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.
This commit is contained in:
2023-01-30 18:29:18 -05:00
parent 41570dc2f9
commit eed344ae46
17 changed files with 567 additions and 9 deletions

View File

@ -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

View File

@ -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',
),
]

View File

@ -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

View File

@ -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,
},
),
]

View File

@ -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,
),
),
]

View File

@ -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)

View File

@ -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)

View File

@ -1,7 +1,7 @@
from django.urls import path
from videos import views
app_name = 'scrobbles'
app_name = 'videos'
urlpatterns = [

View File

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

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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"),
]