소스 검색

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.
Colin Powell 2 년 전
부모
커밋
eed344ae46

+ 27 - 5
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

+ 21 - 0
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/<slug:slug>/',
+        views.TrackDetailView.as_view(),
+        name='track_detail',
+    ),
+    path('artists/', views.ArtistListView.as_view(), name='artist_list'),
+    path(
+        'artists/<slug:slug>/',
+        views.ArtistDetailView.as_view(),
+        name='artist_detail',
+    ),
+]

+ 33 - 0
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

+ 88 - 0
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,
+            },
+        ),
+    ]

+ 26 - 0
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,
+            ),
+        ),
+    ]

+ 79 - 2
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)

+ 115 - 0
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)

+ 1 - 1
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 = [

+ 1 - 0
vrobbler/settings.py

@@ -83,6 +83,7 @@ INSTALLED_APPS = [
     "music",
     "podcasts",
     "sports",
+    "mathfilters",
     "rest_framework",
     "allauth",
     "allauth.account",

+ 18 - 0
vrobbler/templates/base_detail.html

@@ -0,0 +1,18 @@
+{% extends "base.html" %}
+
+{% block content %}
+<main class="col-md-4 ms-sm-auto col-lg-10 px-md-4">
+    <div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
+        <h1 class="h2">{% block title %}{% endblock %} </h1>
+        <div class="btn-toolbar mb-2 mb-md-0">
+            <div class="btn-group me-2">
+                <button type="button" class="btn btn-sm btn-outline-secondary">Export</button>
+            </div>
+        </div>
+    </div>
+
+    <div class="container">
+        {% block details %}{% endblock %}
+    </div>
+</main>
+{% endblock %}

+ 18 - 0
vrobbler/templates/base_list.html

@@ -0,0 +1,18 @@
+{% extends "base.html" %}
+
+{% block content %}
+<main class="col-md-4 ms-sm-auto col-lg-10 px-md-4">
+    <div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
+        <h1 class="h2">{% block title %}{% endblock %} </h1>
+        <div class="btn-toolbar mb-2 mb-md-0">
+            <div class="btn-group me-2">
+                <button type="button" class="btn btn-sm btn-outline-secondary">Export</button>
+            </div>
+        </div>
+    </div>
+
+    <div class="container">
+        {% block lists %}{% endblock %}
+    </div>
+</main>
+{% endblock %}

+ 11 - 0
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 %}
+<dl style="width: 130px; float: left; margin-right:10px;">
+    <dd><img src="{{album.cover_image.url}}" width=120 height=120 /></dd>
+</dl>
+{% endfor %}
+{% endblock %}

+ 72 - 0
vrobbler/templates/music/artist_detail.html

@@ -0,0 +1,72 @@
+{% extends "base_detail.html" %}
+{% load mathfilters %}
+
+{% block title %}{{object.name}}{% endblock %}
+
+{% block details %}
+
+<div class="row">
+    {% for album in artist.album_set.all %}
+    {% if album.cover_image %}
+    <p style="width:150px; float:left;"><img src="{{album.cover_image.url}}" width=150 height=150 /></p>
+    {% endif %}
+    {% endfor %}
+</div>
+<div class="row">
+    <p>{{artist.scrobbles.count}} scrobbles</p>
+    <div class="col-md">
+        <h3>Top tracks</h3>
+        <div class="table-responsive">
+            <table class="table table-striped table-sm">
+            <thead>
+                <tr>
+                    <th scope="col">Rank</th>
+                    <th scope="col">Track</th>
+                    <th scope="col">Count</th>
+                    <th scope="col"></th>
+                </tr>
+            </thead>
+            <tbody>
+                {% for track in object.tracks %}
+                <tr>
+                    <td>{{rank}}#1</td>
+                    <td>{{track.title}}</td>
+                    <td>{{track.scrobble_count}}</td>
+                    <td>
+                        <div class="progress-bar" style="margin-right:5px;">
+                            <span class="progress-bar-fill" style="width: {{track.scrobble_count|mul:10}}%;"></span>
+                        </div>
+                    </td>
+                </tr>
+                {% endfor %}
+            </tbody>
+            </table>
+        </div>
+    </div>
+</div>
+<div class="row">
+    <div class="col-md">
+        <h3>Last scrobbles</h3>
+        <div class="table-responsive">
+            <table class="table table-striped table-sm">
+            <thead>
+                <tr>
+                    <th scope="col">Date</th>
+                    <th scope="col">Track</th>
+                    <th scope="col">Album</th>
+                </tr>
+            </thead>
+            <tbody>
+                {% for scrobble in object.scrobbles %}
+                <tr>
+                    <td>{{scrobble.timestamp}}</td>
+                    <td>{{scrobble.track.title}}</td>
+                    <td>{{scrobble.track.album.name}}</td>
+                </tr>
+                {% endfor %}
+            </tbody>
+            </table>
+        </div>
+    </div>
+</div>
+{% endblock %}

+ 30 - 0
vrobbler/templates/music/artist_list.html

@@ -0,0 +1,30 @@
+{% extends "base_list.html" %}
+
+{% block title %}Artists{% endblock %}
+
+{% block lists %}
+<div class="row">
+    <div class="col-md">
+        <div class="table-responsive">
+            <table class="table table-striped table-sm">
+            <thead>
+                <tr>
+                    <th scope="col">Artist</th>
+                    <th scope="col">Scrobbles</th>
+                    <th scope="col">All time</th>
+                </tr>
+            </thead>
+            <tbody>
+                {% for artist in object_list %}
+                <tr>
+                    <td><a href="{{artist.get_absolute_url}}">{{artist}}</a></td>
+                    <td>{{artist.scrobbles.count}}</td>
+                    <td></td>
+                </tr>
+                {% endfor %}
+            </tbody>
+            </table>
+        </div>
+    </div>
+</div>
+{% endblock %}

+ 13 - 0
vrobbler/templates/music/track_detail.html

@@ -0,0 +1,13 @@
+{% extends "base_detail.html" %}
+
+{% block title %}{{object.title}}{% endblock %}
+
+{% block details %}
+<h2>Last scrobbles</h2>
+{% for scrobble in object.scrobble_set.all  %}
+<ul>
+    <li>{{scrobble.timestamp|date:"d M Y h:m"}} - <img src="{{object.album.cover_image.url}}" width=25 height=25 /> - {{object}}</li>
+</ul>
+{% endfor %}
+
+{% endblock %}

+ 12 - 0
vrobbler/templates/music/track_list.html

@@ -0,0 +1,12 @@
+{% extends "base_list.html" %}
+
+{% block title %}Tracks{% endblock %}
+
+{% block lists %}
+<h2>All time</h2>
+{% for track in object_list %}
+<ul>
+    <li><a href="{{track.get_absolute_url}}">{{track}}</a></li>
+</ul>
+{% endfor %}
+{% endblock %}

+ 2 - 1
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"),
 ]