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 %} + +{% 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"), ]