From eed344ae464cf918a94da8b61cfbc0940c130e0f Mon Sep 17 00:00:00 2001 From: Colin Powell Date: Mon, 30 Jan 2023 18:29:18 -0500 Subject: [PATCH] Add the beginnings of charts This commit adds a lot of files, but most of them have no impact on any other code. The thrust here is to start creating chart pages showing which tracks and artists were most played for various time periods. Lots still not working, but we're getting there. --- vrobbler/apps/music/models.py | 32 ++++- vrobbler/apps/music/urls.py | 21 ++++ vrobbler/apps/music/views.py | 33 +++++ .../scrobbles/migrations/0010_chartrecord.py | 88 ++++++++++++++ .../migrations/0011_chartrecord_user.py | 26 ++++ vrobbler/apps/scrobbles/models.py | 81 +++++++++++- vrobbler/apps/scrobbles/stats.py | 115 ++++++++++++++++++ vrobbler/apps/videos/urls.py | 2 +- vrobbler/settings.py | 1 + vrobbler/templates/base_detail.html | 18 +++ vrobbler/templates/base_list.html | 18 +++ vrobbler/templates/music/album_list.html | 11 ++ vrobbler/templates/music/artist_detail.html | 72 +++++++++++ vrobbler/templates/music/artist_list.html | 30 +++++ vrobbler/templates/music/track_detail.html | 13 ++ vrobbler/templates/music/track_list.html | 12 ++ vrobbler/urls.py | 3 +- 17 files changed, 567 insertions(+), 9 deletions(-) create mode 100644 vrobbler/apps/music/urls.py create mode 100644 vrobbler/apps/music/views.py create mode 100644 vrobbler/apps/scrobbles/migrations/0010_chartrecord.py create mode 100644 vrobbler/apps/scrobbles/migrations/0011_chartrecord_user.py create mode 100644 vrobbler/apps/scrobbles/stats.py create mode 100644 vrobbler/templates/base_detail.html create mode 100644 vrobbler/templates/base_list.html create mode 100644 vrobbler/templates/music/album_list.html create mode 100644 vrobbler/templates/music/artist_detail.html create mode 100644 vrobbler/templates/music/artist_list.html create mode 100644 vrobbler/templates/music/track_detail.html create mode 100644 vrobbler/templates/music/track_list.html diff --git a/vrobbler/apps/music/models.py b/vrobbler/apps/music/models.py index 72f67ba..6f24099 100644 --- a/vrobbler/apps/music/models.py +++ b/vrobbler/apps/music/models.py @@ -3,10 +3,10 @@ from typing import Dict, Optional from uuid import uuid4 import musicbrainzngs -from django.apps.config import cached_property from django.conf import settings from django.core.files.base import ContentFile from django.db import models +from django.urls import reverse from django.utils.translation import gettext_lazy as _ from django_extensions.db.models import TimeStampedModel from scrobbles.mixins import ScrobblableMixin @@ -30,6 +30,29 @@ class Artist(TimeStampedModel): def mb_link(self): return f"https://musicbrainz.org/artist/{self.musicbrainz_id}" + def get_absolute_url(self): + return reverse('music:artist_detail', kwargs={'slug': self.uuid}) + + def scrobbles(self): + from scrobbles.models import Scrobble + + return Scrobble.objects.filter( + track__in=self.track_set.all() + ).order_by('-timestamp') + + @property + def tracks(self): + return ( + self.track_set.all() + .annotate(scrobble_count=models.Count('scrobble')) + .order_by('-scrobble_count') + ) + + def charts(self): + from scrobbles.models import ChartRecord + + return ChartRecord.objects.filter(track__artist=self).order_by('-year') + class Album(TimeStampedModel): uuid = models.UUIDField(default=uuid4, editable=False, **BNULL) @@ -135,14 +158,13 @@ class Track(ScrobblableMixin): def __str__(self): return f"{self.title} by {self.artist}" + def get_absolute_url(self): + return reverse('music:track_detail', kwargs={'slug': self.uuid}) + @property def mb_link(self): return f"https://musicbrainz.org/recording/{self.musicbrainz_id}" - @cached_property - def scrobble_count(self): - return self.scrobble_set.filter(in_progress=False).count() - @classmethod def find_or_create( cls, artist_dict: Dict, album_dict: Dict, track_dict: Dict diff --git a/vrobbler/apps/music/urls.py b/vrobbler/apps/music/urls.py new file mode 100644 index 0000000..202749e --- /dev/null +++ b/vrobbler/apps/music/urls.py @@ -0,0 +1,21 @@ +from django.urls import path +from music import views + +app_name = 'music' + + +urlpatterns = [ + path('albums/', views.AlbumListView.as_view(), name='albums_list'), + path("tracks/", views.TrackListView.as_view(), name='tracks_list'), + path( + 'tracks//', + views.TrackDetailView.as_view(), + name='track_detail', + ), + path('artists/', views.ArtistListView.as_view(), name='artist_list'), + path( + 'artists//', + views.ArtistDetailView.as_view(), + name='artist_detail', + ), +] diff --git a/vrobbler/apps/music/views.py b/vrobbler/apps/music/views.py new file mode 100644 index 0000000..aae396b --- /dev/null +++ b/vrobbler/apps/music/views.py @@ -0,0 +1,33 @@ +from django.views import generic +from music.models import Track, Artist, Album +from scrobbles.stats import get_scrobble_count_qs + + +class TrackListView(generic.ListView): + model = Track + + def get_queryset(self): + return get_scrobble_count_qs(user=self.request.user).order_by( + "-scrobble_count" + ) + + +class TrackDetailView(generic.DetailView): + model = Track + slug_field = 'uuid' + + +class ArtistListView(generic.ListView): + model = Artist + + def get_queryset(self): + return super().get_queryset().order_by("name") + + +class ArtistDetailView(generic.DetailView): + model = Artist + slug_field = 'uuid' + + +class AlbumListView(generic.ListView): + model = Album diff --git a/vrobbler/apps/scrobbles/migrations/0010_chartrecord.py b/vrobbler/apps/scrobbles/migrations/0010_chartrecord.py new file mode 100644 index 0000000..3814ac9 --- /dev/null +++ b/vrobbler/apps/scrobbles/migrations/0010_chartrecord.py @@ -0,0 +1,88 @@ +# Generated by Django 4.1.5 on 2023-01-30 17:01 + +from django.db import migrations, models +import django.db.models.deletion +import django_extensions.db.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('music', '0009_alter_track_musicbrainz_id_and_more'), + ('videos', '0006_alter_video_year'), + ('scrobbles', '0009_scrobble_uuid'), + ] + + operations = [ + migrations.CreateModel( + name='ChartRecord', + fields=[ + ( + 'id', + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID', + ), + ), + ( + 'created', + django_extensions.db.fields.CreationDateTimeField( + auto_now_add=True, verbose_name='created' + ), + ), + ( + 'modified', + django_extensions.db.fields.ModificationDateTimeField( + auto_now=True, verbose_name='modified' + ), + ), + ('rank', models.IntegerField()), + ('year', models.IntegerField(default=2023)), + ('month', models.IntegerField(blank=True, null=True)), + ('week', models.IntegerField(blank=True, null=True)), + ('day', models.IntegerField(blank=True, null=True)), + ( + 'artist', + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + to='music.artist', + ), + ), + ( + 'series', + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + to='videos.series', + ), + ), + ( + 'track', + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + to='music.track', + ), + ), + ( + 'video', + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + to='videos.video', + ), + ), + ], + options={ + 'get_latest_by': 'modified', + 'abstract': False, + }, + ), + ] diff --git a/vrobbler/apps/scrobbles/migrations/0011_chartrecord_user.py b/vrobbler/apps/scrobbles/migrations/0011_chartrecord_user.py new file mode 100644 index 0000000..299459c --- /dev/null +++ b/vrobbler/apps/scrobbles/migrations/0011_chartrecord_user.py @@ -0,0 +1,26 @@ +# Generated by Django 4.1.5 on 2023-01-30 17:44 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('scrobbles', '0010_chartrecord'), + ] + + operations = [ + migrations.AddField( + model_name='chartrecord', + name='user', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/vrobbler/apps/scrobbles/models.py b/vrobbler/apps/scrobbles/models.py index 0334201..443b2e0 100644 --- a/vrobbler/apps/scrobbles/models.py +++ b/vrobbler/apps/scrobbles/models.py @@ -1,3 +1,4 @@ +import calendar import logging from datetime import timedelta from uuid import uuid4 @@ -6,17 +7,93 @@ from django.contrib.auth import get_user_model from django.db import models from django.utils import timezone from django_extensions.db.models import TimeStampedModel -from music.models import Track +from music.models import Artist, Track from podcasts.models import Episode from scrobbles.utils import check_scrobble_for_finish from sports.models import SportEvent -from videos.models import Video +from videos.models import Series, Video logger = logging.getLogger(__name__) User = get_user_model() BNULL = {"blank": True, "null": True} +class ChartRecord(TimeStampedModel): + """Sort of like a materialized view for what we could dynamically generate, + but would kill the DB as it gets larger. Collects time-based records + generated by a cron-like archival job + + 1972 by Josh Rouse - #3 in 2023, January + + """ + + user = models.ForeignKey(User, on_delete=models.DO_NOTHING, **BNULL) + rank = models.IntegerField() + year = models.IntegerField(default=timezone.now().year) + month = models.IntegerField(**BNULL) + week = models.IntegerField(**BNULL) + day = models.IntegerField(**BNULL) + video = models.ForeignKey(Video, on_delete=models.DO_NOTHING, **BNULL) + 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) + + @property + def media_obj(self): + media_obj = None + if self.video: + media_obj = self.video + if self.track: + media_obj = self.track + return media_obj + + @property + def month_str(self) -> str: + month_str = "" + if self.month: + month_str = calendar.month_name[self.month] + return month_str + + @property + def day_str(self) -> str: + day_str = "" + if self.day: + day_str = str(self.day) + return day_str + + @property + def week_str(self) -> str: + week_str = "" + if self.week: + week_str = str(self.week) + return "Week " + week_str + + @property + def period(self) -> str: + period = str(self.year) + if self.month: + period = " ".join([self.month_str, period]) + if self.week: + period = " ".join([self.week_str, period]) + if self.day: + period = " ".join([self.day_str, period]) + return period + + @property + def period_type(self) -> str: + period = 'year' + if self.month: + period = 'month' + if self.week: + period = 'week' + if self.day: + period = 'day' + return period + + def __str__(self): + return f"#{self.rank} in {self.period} - {self.media_obj}" + + class Scrobble(TimeStampedModel): uuid = models.UUIDField(editable=False, **BNULL) video = models.ForeignKey(Video, on_delete=models.DO_NOTHING, **BNULL) diff --git a/vrobbler/apps/scrobbles/stats.py b/vrobbler/apps/scrobbles/stats.py new file mode 100644 index 0000000..1640e9a --- /dev/null +++ b/vrobbler/apps/scrobbles/stats.py @@ -0,0 +1,115 @@ +import calendar +import logging +from datetime import datetime, timedelta +from typing import Optional + +import pytz +from django.apps import apps +from django.conf import settings +from django.db.models import Count, Q + + +logger = logging.getLogger(__name__) + + +def get_start_end_dates_by_week(year, week, tz): + d = datetime(year, 1, 1, tzinfo=tz) + if d.weekday() <= 3: + d = d - timedelta(d.weekday()) + else: + d = d + timedelta(7 - d.weekday()) + dlt = timedelta(days=(week - 1) * 7) + return d + dlt, d + dlt + timedelta(days=6) + + +def get_scrobble_count_qs( + year: Optional[int] = None, + month: Optional[int] = None, + week: Optional[int] = None, + day: Optional[int] = None, + user=None, + model_str="Track", +) -> dict[str, int]: + + tz = settings.TIME_ZONE + if user and user.is_authenticated: + tz = pytz.timezone(user.profile.timezone) + + data_model = apps.get_model(app_label='music', model_name='Track') + if model_str == "Video": + data_model = apps.get_model(app_label='videos', model_name='Video') + if model_str == "SportEvent": + data_model = apps.get_model( + app_label='sports', model_name='SportEvent' + ) + + base_qs = data_model.objects.filter( + scrobble__user=user, + scrobble__played_to_completion=True, + ) + + # Returna all media items with scrobble count annotated + if not year: + return base_qs.annotate(scrobble_count=Count("scrobble")).order_by( + "-scrobble_count" + ) + + start = datetime(year, 1, 1, tzinfo=tz) + end = datetime(year, 12, 31, tzinfo=tz) + if month: + end_day = calendar.monthrange(year, month)[1] + start = datetime(year, month, 1, tzinfo=tz) + end = datetime(year, month, end_day, tzinfo=tz) + elif week: + start, end = get_start_end_dates_by_week(year, week, tz) + elif day and month: + start = datetime(year, month, day, 0, 0, tzinfo=tz) + end = datetime(year, month, day, 23, 59, tzinfo=tz) + elif day and not month: + logger.warning('Day provided with month, defaulting ot all-time') + + date_filter = Q( + scrobble__timestamp__gte=start, scrobble__timestamp__lte=end + ) + + return ( + base_qs.annotate( + scrobble_count=Count("scrobble", filter=Q(date_filter)) + ) + .filter(date_filter) + .order_by("-scrobble_count") + ) + + +def build_charts( + 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( + app_label='scrobbles', model_name='ChartRecord' + ) + results = get_scrobble_count_qs(year, month, week, day, user, model_str) + unique_counts = list(set([result.scrobble_count for result in results])) + unique_counts.sort(reverse=True) + ranks = {} + for rank, count in enumerate(unique_counts, start=1): + ranks[count] = rank + + chart_records = [] + for result in results: + chart_record = { + 'year': year, + 'week': week, + 'month': month, + 'day': day, + 'user': user, + } + chart_record['rank'] = ranks[result.scrobble_count] + if model_str == 'Track': + chart_record['track'] = result + chart_records.append(ChartRecord(**chart_record)) + ChartRecord.objects.bulk_create(chart_records, ignore_conflicts=True) diff --git a/vrobbler/apps/videos/urls.py b/vrobbler/apps/videos/urls.py index a4ac804..3686caa 100644 --- a/vrobbler/apps/videos/urls.py +++ b/vrobbler/apps/videos/urls.py @@ -1,7 +1,7 @@ from django.urls import path from videos import views -app_name = 'scrobbles' +app_name = 'videos' urlpatterns = [ diff --git a/vrobbler/settings.py b/vrobbler/settings.py index 5f4b1c2..98d0a3b 100644 --- a/vrobbler/settings.py +++ b/vrobbler/settings.py @@ -83,6 +83,7 @@ INSTALLED_APPS = [ "music", "podcasts", "sports", + "mathfilters", "rest_framework", "allauth", "allauth.account", diff --git a/vrobbler/templates/base_detail.html b/vrobbler/templates/base_detail.html new file mode 100644 index 0000000..034b7b6 --- /dev/null +++ b/vrobbler/templates/base_detail.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

{% block title %}{% endblock %}

+
+
+ +
+
+
+ +
+ {% block details %}{% endblock %} +
+
+{% endblock %} diff --git a/vrobbler/templates/base_list.html b/vrobbler/templates/base_list.html new file mode 100644 index 0000000..f0d989a --- /dev/null +++ b/vrobbler/templates/base_list.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

{% block title %}{% endblock %}

+
+
+ +
+
+
+ +
+ {% block lists %}{% endblock %} +
+
+{% endblock %} diff --git a/vrobbler/templates/music/album_list.html b/vrobbler/templates/music/album_list.html new file mode 100644 index 0000000..a186b41 --- /dev/null +++ b/vrobbler/templates/music/album_list.html @@ -0,0 +1,11 @@ +{% extends "base_list.html" %} + +{% block title %}Albums{% endblock %} + +{% block lists %} +{% for album in object_list %} +
+
+
+{% endfor %} +{% endblock %} diff --git a/vrobbler/templates/music/artist_detail.html b/vrobbler/templates/music/artist_detail.html new file mode 100644 index 0000000..125b4da --- /dev/null +++ b/vrobbler/templates/music/artist_detail.html @@ -0,0 +1,72 @@ +{% extends "base_detail.html" %} +{% load mathfilters %} + +{% block title %}{{object.name}}{% endblock %} + +{% block details %} + +
+ {% for album in artist.album_set.all %} + {% if album.cover_image %} +

+ {% endif %} + {% endfor %} +
+
+

{{artist.scrobbles.count}} scrobbles

+
+

Top tracks

+
+ + + + + + + + + + + {% for track in object.tracks %} + + + + + + + {% endfor %} + +
RankTrackCount
{{rank}}#1{{track.title}}{{track.scrobble_count}} +
+ +
+
+
+
+
+
+
+

Last scrobbles

+
+ + + + + + + + + + {% for scrobble in object.scrobbles %} + + + + + + {% endfor %} + +
DateTrackAlbum
{{scrobble.timestamp}}{{scrobble.track.title}}{{scrobble.track.album.name}}
+
+
+
+{% endblock %} diff --git a/vrobbler/templates/music/artist_list.html b/vrobbler/templates/music/artist_list.html new file mode 100644 index 0000000..d21abb6 --- /dev/null +++ b/vrobbler/templates/music/artist_list.html @@ -0,0 +1,30 @@ +{% extends "base_list.html" %} + +{% block title %}Artists{% endblock %} + +{% block lists %} +
+
+
+ + + + + + + + + + {% for artist in object_list %} + + + + + + {% endfor %} + +
ArtistScrobblesAll time
{{artist}}{{artist.scrobbles.count}}
+
+
+
+{% endblock %} diff --git a/vrobbler/templates/music/track_detail.html b/vrobbler/templates/music/track_detail.html new file mode 100644 index 0000000..117dee7 --- /dev/null +++ b/vrobbler/templates/music/track_detail.html @@ -0,0 +1,13 @@ +{% extends "base_detail.html" %} + +{% block title %}{{object.title}}{% endblock %} + +{% block details %} +

Last scrobbles

+{% for scrobble in object.scrobble_set.all %} +
    +
  • {{scrobble.timestamp|date:"d M Y h:m"}} - - {{object}}
  • +
+{% endfor %} + +{% endblock %} diff --git a/vrobbler/templates/music/track_list.html b/vrobbler/templates/music/track_list.html new file mode 100644 index 0000000..1592e0f --- /dev/null +++ b/vrobbler/templates/music/track_list.html @@ -0,0 +1,12 @@ +{% extends "base_list.html" %} + +{% block title %}Tracks{% endblock %} + +{% block lists %} +

All time

+{% for track in object_list %} + +{% endfor %} +{% endblock %} diff --git a/vrobbler/urls.py b/vrobbler/urls.py index 9c6d2be..2fe84a1 100644 --- a/vrobbler/urls.py +++ b/vrobbler/urls.py @@ -3,8 +3,8 @@ from django.conf import settings from django.conf.urls.static import static from django.contrib import admin from django.urls import include, path -from rest_framework import routers from scrobbles import urls as scrobble_urls +from music import urls as music_urls from videos import urls as video_urls urlpatterns = [ @@ -19,6 +19,7 @@ urlpatterns = [ scrobbles_views.ManualScrobbleView.as_view(), name='imdb-manual-scrobble', ), + path("", include(music_urls, namespace="music")), path("", include(video_urls, namespace="videos")), path("", scrobbles_views.RecentScrobbleList.as_view(), name="home"), ]