diff --git a/vrobbler/apps/music/views.py b/vrobbler/apps/music/views.py
index eb6fd53..6b0885f 100644
--- a/vrobbler/apps/music/views.py
+++ b/vrobbler/apps/music/views.py
@@ -1,5 +1,4 @@
-from datetime import timedelta
-from django.utils import timezone
+from django.db.models import Count
from django.views import generic
from music.models import Album, Artist, Track
from scrobbles.models import ChartRecord
@@ -22,7 +21,6 @@ class TrackDetailView(generic.DetailView):
def get_context_data(self, **kwargs):
context_data = super().get_context_data(**kwargs)
-
context_data['charts'] = ChartRecord.objects.filter(
track=self.object, rank__in=[1, 2, 3]
)
@@ -34,7 +32,12 @@ class ArtistListView(generic.ListView):
paginate_by = 100
def get_queryset(self):
- return super().get_queryset().order_by("name")
+ return (
+ super()
+ .get_queryset()
+ .annotate(scrobble_count=Count('track__scrobble'))
+ .order_by("-scrobble_count")
+ )
def get_context_data(self, *, object_list=None, **kwargs):
context_data = super().get_context_data(
@@ -50,6 +53,17 @@ class ArtistDetailView(generic.DetailView):
def get_context_data(self, **kwargs):
context_data = super().get_context_data(**kwargs)
+ artist = context_data['object']
+ rank = 1
+ tracks_ranked = []
+ scrobbles = artist.tracks.first().scrobble_count
+ for track in artist.tracks:
+ if scrobbles > track.scrobble_count:
+ rank += 1
+ tracks_ranked.append((rank, track))
+ scrobbles = track.scrobble_count
+
+ context_data['tracks_ranked'] = tracks_ranked
context_data['charts'] = ChartRecord.objects.filter(
artist=self.object, rank__in=[1, 2, 3]
)
@@ -58,17 +72,14 @@ class ArtistDetailView(generic.DetailView):
class AlbumListView(generic.ListView):
model = Album
- paginate_by = 50
def get_queryset(self):
- return super().get_queryset().order_by("name")
-
- def get_context_data(self, *, object_list=None, **kwargs):
- context_data = super().get_context_data(
- object_list=object_list, **kwargs
+ return (
+ super()
+ .get_queryset()
+ .annotate(scrobble_count=Count('track__scrobble'))
+ .order_by("-scrobble_count")
)
- context_data['view'] = self.request.GET.get('view')
- return context_data
class AlbumDetailView(generic.DetailView):
diff --git a/vrobbler/apps/profiles/utils.py b/vrobbler/apps/profiles/utils.py
index 15866f3..7649e79 100644
--- a/vrobbler/apps/profiles/utils.py
+++ b/vrobbler/apps/profiles/utils.py
@@ -1,23 +1,18 @@
-import datetime
-
import pytz
from django.conf import settings
from django.utils import timezone
+import calendar
+from datetime import datetime, timedelta
# need to translate to a non-naive timezone, even if timezone == settings.TIME_ZONE, so we can compare two dates
def to_user_timezone(date, profile):
timezone = profile.timezone if profile.timezone else settings.TIME_ZONE
- return date.replace(tzinfo=pytz.timezone(settings.TIME_ZONE)).astimezone(
- pytz.timezone(timezone)
- )
+ return date.astimezone(pytz.timezone(timezone))
-def to_system_timezone(date, profile):
- timezone = profile.timezone if profile.timezone else settings.TIME_ZONE
- return date.replace(tzinfo=pytz.timezone(timezone)).astimezone(
- pytz.timezone(settings.TIME_ZONE)
- )
+def to_system_timezone(date):
+ return date.astimezone(pytz.timezone(settings.TIME_ZONE))
def now_user_timezone(profile):
@@ -25,9 +20,39 @@ def now_user_timezone(profile):
return timezone.localtime(timezone.now())
-def now_system_timezone():
- return (
- datetime.datetime.now()
- .replace(tzinfo=pytz.timezone(settings.TIME_ZONE))
- .astimezone(pytz.timezone(settings.TIME_ZONE))
- )
+def start_of_day(dt, profile) -> datetime:
+ """Get the start of the day in the profile's timezone"""
+ timezone = profile.timezone if profile.timezone else settings.TIME_ZONE
+ tzinfo = pytz.timezone(timezone)
+ return datetime.combine(dt, datetime.min.time(), tzinfo)
+
+
+def end_of_day(dt, profile) -> datetime:
+ """Get the start of the day in the profile's timezone"""
+ timezone = profile.timezone if profile.timezone else settings.TIME_ZONE
+ tzinfo = pytz.timezone(timezone)
+ return datetime.combine(dt, datetime.max.time(), tzinfo)
+
+
+def start_of_week(dt, profile) -> datetime:
+ # TODO allow profile to set start of week
+ return start_of_day(dt, profile) - timedelta(dt.weekday())
+
+
+def end_of_week(dt, profile) -> datetime:
+ # TODO allow profile to set start of week
+ return start_of_week(dt, profile) + timedelta(days=6)
+
+
+def start_of_month(dt, profile) -> datetime:
+ return start_of_day(dt, profile).replace(day=1)
+
+
+def end_of_month(dt, profile) -> datetime:
+ next_month = end_of_day(dt, profile).replace(day=28) + timedelta(days=4)
+ # subtracting the number of the current day brings us back one month
+ return next_month - timedelta(days=next_month.day)
+
+
+def start_of_year(dt, profile) -> datetime:
+ return start_of_day(dt, profile).replace(month=1, day=1)
diff --git a/vrobbler/apps/scrobbles/migrations/0024_chartrecord_period_end_chartrecord_period_start.py b/vrobbler/apps/scrobbles/migrations/0024_chartrecord_period_end_chartrecord_period_start.py
new file mode 100644
index 0000000..ae92991
--- /dev/null
+++ b/vrobbler/apps/scrobbles/migrations/0024_chartrecord_period_end_chartrecord_period_start.py
@@ -0,0 +1,23 @@
+# Generated by Django 4.1.5 on 2023-03-03 00:43
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('scrobbles', '0023_alter_audioscrobblertsvimport_options_and_more'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='chartrecord',
+ name='period_end',
+ field=models.DateTimeField(blank=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='chartrecord',
+ name='period_start',
+ field=models.DateTimeField(blank=True, null=True),
+ ),
+ ]
diff --git a/vrobbler/apps/scrobbles/models.py b/vrobbler/apps/scrobbles/models.py
index 757d6ff..236ba47 100644
--- a/vrobbler/apps/scrobbles/models.py
+++ b/vrobbler/apps/scrobbles/models.py
@@ -1,4 +1,5 @@
import calendar
+import datetime
import logging
from uuid import uuid4
@@ -15,6 +16,14 @@ from scrobbles.utils import check_scrobble_for_finish
from sports.models import SportEvent
from videos.models import Series, Video
+from vrobbler.apps.profiles.utils import (
+ end_of_day,
+ end_of_month,
+ end_of_week,
+ start_of_day,
+ start_of_month,
+ start_of_week,
+)
from vrobbler.apps.scrobbles.stats import build_charts
logger = logging.getLogger(__name__)
@@ -62,6 +71,7 @@ class BaseFileImportMixin(TimeStampedModel):
from scrobbles.models import Scrobble
if not self.process_log:
+
logger.warning("No lines in process log found to undo")
return
@@ -256,6 +266,26 @@ class ChartRecord(TimeStampedModel):
series = models.ForeignKey(Series, on_delete=models.DO_NOTHING, **BNULL)
artist = models.ForeignKey(Artist, on_delete=models.DO_NOTHING, **BNULL)
track = models.ForeignKey(Track, on_delete=models.DO_NOTHING, **BNULL)
+ period_start = models.DateTimeField(**BNULL)
+ period_end = models.DateTimeField(**BNULL)
+
+ def save(self, *args, **kwargs):
+ profile = self.user.profile
+
+ if self.week:
+ # set start and end to start and end of week
+ period = datetime.date.fromisocalendar(self.year, self.week, 1)
+ self.period_start = start_of_week(period, profile)
+ self.period_start = end_of_week(period, profile)
+ if self.day:
+ period = datetime.datetime(self.year, self.month, self.day)
+ self.period_start = start_of_day(period, profile)
+ self.period_end = end_of_day(period, profile)
+ if self.month and not self.day:
+ period = datetime.datetime(self.year, self.month, 1)
+ self.period_start = start_of_month(period, profile)
+ self.period_end = end_of_month(period, profile)
+ super(ChartRecord, self).save(*args, **kwargs)
@property
def media_obj(self):
diff --git a/vrobbler/apps/scrobbles/stats.py b/vrobbler/apps/scrobbles/stats.py
index 718fca8..6660700 100644
--- a/vrobbler/apps/scrobbles/stats.py
+++ b/vrobbler/apps/scrobbles/stats.py
@@ -6,8 +6,11 @@ from typing import Optional
import pytz
from django.apps import apps
from django.conf import settings
-from django.db.models import Count, Q, ExpressionWrapper, OuterRef, Subquery
+from django.contrib.auth import get_user_model
+from django.db.models import Count, Q
+from django.utils import timezone
+User = get_user_model()
logger = logging.getLogger(__name__)
@@ -102,11 +105,11 @@ def get_scrobble_count_qs(
def build_charts(
+ user: "User",
year: Optional[int] = None,
month: Optional[int] = None,
week: Optional[int] = None,
day: Optional[int] = None,
- user=None,
model_str="Track",
):
ChartRecord = apps.get_model(
@@ -140,3 +143,94 @@ def build_charts(
ChartRecord.objects.bulk_create(
chart_records, ignore_conflicts=True, batch_size=500
)
+
+
+def build_yesterdays_charts_for_user(user: "User", model_str="Track") -> None:
+ """Given a user calculate needed charts."""
+ ChartRecord = apps.get_model(
+ app_label='scrobbles', model_name='ChartRecord'
+ )
+ tz = pytz.timezone(settings.TIME_ZONE)
+ if user and user.is_authenticated:
+ tz = pytz.timezone(user.profile.timezone)
+ now = timezone.now().astimezone(tz)
+ yesterday = now - timedelta(days=1)
+ logger.info(
+ f"Generating charts for yesterday ({yesterday.date()}) for {user}"
+ )
+
+ # Always build yesterday's chart
+ ChartRecord.build(
+ user,
+ year=yesterday.year,
+ month=yesterday.month,
+ day=yesterday.day,
+ model_str=model_str,
+ )
+ now_week = now.isocalendar()[1]
+ yesterday_week = now.isocalendar()[1]
+ if now_week != yesterday_week:
+ logger.info(
+ f"New weekly charts for {yesterday.year}-{yesterday_week} for {user}"
+ )
+ ChartRecord.build(
+ user,
+ year=yesterday.year,
+ month=yesterday_week,
+ model_str=model_str,
+ )
+ # If the month has changed, build charts
+ if now.month != yesterday.month:
+ logger.info(
+ f"New monthly charts for {yesterday.year}-{yesterday.month} for {user}"
+ )
+ ChartRecord.build(
+ user,
+ year=yesterday.year,
+ month=yesterday.month,
+ model_str=model_str,
+ )
+ # If the year has changed, build charts
+ if now.year != yesterday.year:
+ logger.info(f"New annual charts for {yesterday.year} for {user}")
+ ChartRecord.build(user, year=yesterday.year, model_str=model_str)
+
+
+def build_missing_charts_for_user(user: "User", model_str="Track") -> None:
+ """"""
+ ChartRecord = apps.get_model(
+ app_label='scrobbles', model_name='ChartRecord'
+ )
+ Scrobble = apps.get_model(app_label='scrobbles', model_name='Scrobble')
+
+ logger.info(f"Generating historical charts for {user}")
+ tz = pytz.timezone(settings.TIME_ZONE)
+ if user and user.is_authenticated:
+ tz = pytz.timezone(user.profile.timezone)
+ now = timezone.now().astimezone(tz)
+
+ first_scrobble = (
+ Scrobble.objects.filter(user=user, played_to_completion=True)
+ .order_by('created')
+ .first()
+ )
+
+ start_date = first_scrobble.timestamp
+ days_since = (now - start_date).days
+
+ for day_num in range(0, days_since):
+ build_date = start_date + timedelta(days=day_num)
+ logger.info(f"Generating chart batch for {build_date}")
+ ChartRecord.build(user=user, year=build_date.year)
+ ChartRecord.build(
+ user=user, year=build_date.year, week=build_date.isocalendar()[1]
+ )
+ ChartRecord.build(
+ user=user, year=build_date.year, month=build_date.month
+ )
+ ChartRecord.build(
+ user=user,
+ year=build_date.year,
+ month=build_date.month,
+ day=build_date.day,
+ )
diff --git a/vrobbler/apps/scrobbles/tasks.py b/vrobbler/apps/scrobbles/tasks.py
index aa30184..8092420 100644
--- a/vrobbler/apps/scrobbles/tasks.py
+++ b/vrobbler/apps/scrobbles/tasks.py
@@ -1,13 +1,17 @@
import logging
-from celery import shared_task
+from celery import shared_task
+from django.contrib.auth import get_user_model
from scrobbles.models import (
AudioScrobblerTSVImport,
KoReaderImport,
LastFmImport,
)
+from vrobbler.apps.scrobbles.stats import build_yesterdays_charts_for_user
+
logger = logging.getLogger(__name__)
+User = get_user_model()
@shared_task
@@ -35,3 +39,9 @@ def process_koreader_import(import_id):
logger.warn(f"KOReaderImport not found with id {import_id}")
koreader_import.process()
+
+
+@shared_task
+def create_yesterdays_charts():
+ for user in User.objects.all():
+ build_yesterdays_charts_for_user(user)
diff --git a/vrobbler/templates/music/album_list.html b/vrobbler/templates/music/album_list.html
index 842751f..e24d105 100644
--- a/vrobbler/templates/music/album_list.html
+++ b/vrobbler/templates/music/album_list.html
@@ -46,17 +46,17 @@
+ | Scrobbles |
Album |
Artist |
- Scrobbles |
{% for album in object_list %}
- | {{album}} |
- {{album.artist}} |
{{album.scrobbles.count}} |
+ {{album}} |
+ {{album.primary_artist}} |
{% endfor %}
diff --git a/vrobbler/templates/music/artist_detail.html b/vrobbler/templates/music/artist_detail.html
index 586bab1..c0ec310 100644
--- a/vrobbler/templates/music/artist_detail.html
+++ b/vrobbler/templates/music/artist_detail.html
@@ -44,15 +44,15 @@
- {% for track in object.tracks %}
+ {% for track in tracks_ranked %}
- | {{rank}}#1 |
- {{track.title}} |
- {{track.album}} |
- {{track.scrobble_count}} |
+ #{{track.0}} |
+ {{track.1.title}} |
+ {{track.1.album}} |
+ {{track.1.scrobble_count}} |
-
+
|
diff --git a/vrobbler/templates/music/artist_list.html b/vrobbler/templates/music/artist_list.html
index 816298d..6075dc9 100644
--- a/vrobbler/templates/music/artist_list.html
+++ b/vrobbler/templates/music/artist_list.html
@@ -45,17 +45,15 @@
- | Artist |
Scrobbles |
- All time |
+ Artist |
{% for artist in object_list %}
- | {{artist}} |
{{artist.scrobbles.count}} |
- |
+ {{artist}} |
{% endfor %}
diff --git a/vrobbler/templates/music/track_list.html b/vrobbler/templates/music/track_list.html
index 5eaa06d..4f4a3cf 100644
--- a/vrobbler/templates/music/track_list.html
+++ b/vrobbler/templates/music/track_list.html
@@ -24,17 +24,17 @@
+ | Scrobbles |
Track |
Artist |
- Scrobbles |
{% for track in object_list %}
+ | {{track.scrobble_set.count}} |
{{track}} |
{{track.artist}} |
- {{track.scrobble_set.count}} |
{% endfor %}