From 6e17e4ce0d578a92bda5eba769b9dc3962e28f1a Mon Sep 17 00:00:00 2001 From: Colin Powell Date: Fri, 3 Mar 2023 02:29:15 -0500 Subject: [PATCH] Fix chart rank and periods --- vrobbler/apps/music/views.py | 35 ++++--- vrobbler/apps/profiles/utils.py | 57 ++++++++--- ...ord_period_end_chartrecord_period_start.py | 23 +++++ vrobbler/apps/scrobbles/models.py | 30 ++++++ vrobbler/apps/scrobbles/stats.py | 98 ++++++++++++++++++- vrobbler/apps/scrobbles/tasks.py | 12 ++- vrobbler/templates/music/album_list.html | 6 +- vrobbler/templates/music/artist_detail.html | 12 +-- vrobbler/templates/music/artist_list.html | 6 +- vrobbler/templates/music/track_list.html | 4 +- 10 files changed, 237 insertions(+), 46 deletions(-) create mode 100644 vrobbler/apps/scrobbles/migrations/0024_chartrecord_period_end_chartrecord_period_start.py 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 @@ + - {% for album in object_list %} - - + + {% 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 %} - - - - + + + + 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 @@
Scrobbles Album ArtistScrobbles
{{album}}{{album.artist}} {{album.scrobbles.count}}{{album}}{{album.primary_artist}}
{{rank}}#1{{track.title}}{{track.album}}{{track.scrobble_count}}#{{track.0}}{{track.1.title}}{{track.1.album}}{{track.1.scrobble_count}}
- +
- - + {% for artist in object_list %} - - + {% 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 @@
Artist ScrobblesAll timeArtist
{{artist}} {{artist.scrobbles.count}}{{artist}}
+ - {% for track in object_list %} + - {% endfor %}
Scrobbles Track ArtistScrobbles
{{track.scrobble_set.count}} {{track}} {{track.artist}}{{track.scrobble_set.count}}