[music] Tracks can have multiple artists
All checks were successful
build & deploy / test (push) Successful in 1m54s
build & deploy / build-and-deploy (push) Successful in 33s

This commit is contained in:
2026-05-28 23:32:09 -04:00
parent 17aed1191d
commit 22956c7c7f
15 changed files with 231 additions and 84 deletions

View File

@ -93,7 +93,7 @@ fetching and simple saving.
:LOGBOOK: :LOGBOOK:
CLOCK: [2025-07-09 Wed 09:55]--[2025-07-09 Wed 10:15] => 0:20 CLOCK: [2025-07-09 Wed 09:55]--[2025-07-09 Wed 10:15] => 0:20
:END: :END:
* Backlog [30/47] :vrobbler:project:personal: * Backlog [32/47] :vrobbler:project:personal:
** TODO [#C] Add sentiment parsing for Scrobbles with notes :vrobbler:project:scrobbles:sentiment: ** TODO [#C] Add sentiment parsing for Scrobbles with notes :vrobbler:project:scrobbles:sentiment:
:PROPERTIES: :PROPERTIES:
:ID: 37781d6a-f3b0-48b2-bf98-33c2c791cf85 :ID: 37781d6a-f3b0-48b2-bf98-33c2c791cf85
@ -475,7 +475,7 @@ to GMT to save it in the database.
It would be nice to not duplicate comments that exist on a task when it's first scrobbled. It would be nice to not duplicate comments that exist on a task when it's first scrobbled.
** TODO [#A] Fix various artist album problem with Superwolves (track with multiple artists) :vrobbler:project:music:bug:artists: ** DONE [#A] Fix various artist album problem with Superwolves (track with multiple artists) :vrobbler:project:music:bug:artists:
:PROPERTIES: :PROPERTIES:
:ID: 590bc038-745f-710b-8272-4d8a3d2efa01 :ID: 590bc038-745f-710b-8272-4d8a3d2efa01
:END: :END:
@ -496,10 +496,11 @@ or at least make it optional, and then require a M2M between Track and Artist.
Then this one would be a Track by both `Matt Sweeney` and `Bonnie "Prince" Then this one would be a Track by both `Matt Sweeney` and `Bonnie "Prince"
Billy` Billy`
** TODO [#A] Move imported eBird CSV files to processed/ directory on WebDAV :webdav:ebird:importers: ** DONE [#A] Move imported eBird CSV files to processed/ directory on WebDAV :webdav:ebird:importers:
:PROPERTIES: :PROPERTIES:
:ID: 445e1253-d353-4b55-b1d8-39d0a0dcdd34 :ID: 445e1253-d353-4b55-b1d8-39d0a0dcdd34
:END: :END:
- File: ~vrobbler/apps/scrobbles/importers/webdav.py~ (line 439) - File: ~vrobbler/apps/scrobbles/importers/webdav.py~ (line 439)
- Same pattern as the GPX importer: after importing a =.csv= file from - Same pattern as the GPX importer: after importing a =.csv= file from
WebDAV, move it to =var/ebird/processed/= with a timestamp appended. WebDAV, move it to =var/ebird/processed/= with a timestamp appended.

View File

@ -143,9 +143,10 @@ def test_mopidy_track_webhook_creates_track_and_scrobble(
album = Album.objects.create(name="Sublime", album_artist=artist) album = Album.objects.create(name="Sublime", album_artist=artist)
track = Track.objects.create( track = Track.objects.create(
title="Same in the End", title="Same in the End",
artist=artist, artist_fk=artist,
base_run_time_seconds=60, base_run_time_seconds=60,
) )
track.artists.add(artist)
track.albums.add(album) track.albums.add(album)
mock_artist_fc.return_value = artist mock_artist_fc.return_value = artist
@ -181,9 +182,10 @@ def test_jellyfin_track_webhook_creates_track_and_scrobble(
album = Album.objects.create(name="Emotion", album_artist=artist) album = Album.objects.create(name="Emotion", album_artist=artist)
track = Track.objects.create( track = Track.objects.create(
title="Emotion", title="Emotion",
artist=artist, artist_fk=artist,
base_run_time_seconds=60, base_run_time_seconds=60,
) )
track.artists.add(artist)
track.albums.add(album) track.albums.add(album)
mock_artist_fc.return_value = artist mock_artist_fc.return_value = artist
@ -225,9 +227,10 @@ def test_mopidy_track_webhook_stores_raw_data(
album = Album.objects.create(name="Sublime", album_artist=artist) album = Album.objects.create(name="Sublime", album_artist=artist)
track = Track.objects.create( track = Track.objects.create(
title="Same in the End", title="Same in the End",
artist=artist, artist_fk=artist,
base_run_time_seconds=60, base_run_time_seconds=60,
) )
track.artists.add(artist)
track.albums.add(album) track.albums.add(album)
mock_artist_fc.return_value = artist mock_artist_fc.return_value = artist
@ -265,10 +268,11 @@ def test_mopidy_track_webhook_stores_album_id(
album = Album.objects.create(name="Sublime", album_artist=artist) album = Album.objects.create(name="Sublime", album_artist=artist)
track = Track.objects.create( track = Track.objects.create(
title="Same in the End", title="Same in the End",
artist=artist, artist_fk=artist,
album=album, album=album,
base_run_time_seconds=60, base_run_time_seconds=60,
) )
track.artists.add(artist)
mock_artist_fc.return_value = artist mock_artist_fc.return_value = artist
mock_track_fc.return_value = track mock_track_fc.return_value = track
@ -303,9 +307,10 @@ def test_jellyfin_track_webhook_stores_raw_data(
album = Album.objects.create(name="Emotion", album_artist=artist) album = Album.objects.create(name="Emotion", album_artist=artist)
track = Track.objects.create( track = Track.objects.create(
title="Emotion", title="Emotion",
artist=artist, artist_fk=artist,
base_run_time_seconds=60, base_run_time_seconds=60,
) )
track.artists.add(artist)
track.albums.add(album) track.albums.add(album)
mock_artist_fc.return_value = artist mock_artist_fc.return_value = artist
@ -347,11 +352,11 @@ def test_jellyfin_track_webhook_stores_album_id(
album = Album.objects.create(name="Emotion", album_artist=artist) album = Album.objects.create(name="Emotion", album_artist=artist)
track = Track.objects.create( track = Track.objects.create(
title="Emotion", title="Emotion",
artist=artist, artist_fk=artist,
album=album, album=album,
base_run_time_seconds=60, base_run_time_seconds=60,
) )
track.albums.add(album) track.artists.add(artist)
mock_artist_fc.return_value = artist mock_artist_fc.return_value = artist
mock_track_fc.return_value = track mock_track_fc.return_value = track

View File

@ -104,8 +104,8 @@ def build_charts(
media_config = { media_config = {
"artist": { "artist": {
"filter": Q(track__isnull=False) & Q(track__artist__isnull=False), "filter": Q(track__isnull=False) & Q(track__artists__isnull=False),
"values": "track__artist", "values": "track__artists",
"annotate": Count("id", distinct=True), "annotate": Count("id", distinct=True),
}, },
"album": { "album": {

View File

@ -1,7 +1,5 @@
from django.contrib import admin from django.contrib import admin
from music.models import Album, Artist, Track
from music.models import Artist, Album, Track
from scrobbles.admin import ScrobbleInline from scrobbles.admin import ScrobbleInline
@ -54,12 +52,12 @@ class TrackAdmin(admin.ModelAdmin):
"artist", "artist",
"musicbrainz_id", "musicbrainz_id",
) )
raw_id_fields = ("artist", "albums", "album") raw_id_fields = ("artist_fk", "artists", "albums", "album")
list_filter = ("album", "artist")
search_fields = ("title",) search_fields = ("title",)
ordering = ("-created",) ordering = ("-created",)
filter_horizontal = [ filter_horizontal = [
"albums", "albums",
"artists",
] ]
inlines = [ inlines = [
ScrobbleInline, ScrobbleInline,

View File

@ -149,7 +149,7 @@ def live_charts(
def artist_scrobble_count(artist_id: int, filter: str = "today") -> int: def artist_scrobble_count(artist_id: int, filter: str = "today") -> int:
return Scrobble.objects.filter(track__artist=artist_id).count() return Scrobble.objects.filter(track__artists=artist_id).count()
def live_tv_charts( def live_tv_charts(
@ -158,7 +158,7 @@ def live_tv_charts(
limit: int = 15, limit: int = 15,
) -> QuerySet: ) -> QuerySet:
from django.db.models import OuterRef, Subquery from django.db.models import OuterRef, Subquery
from videos.models import Video, Series from videos.models import Series, Video
now = timezone.now() now = timezone.now()
tzinfo = now.tzinfo tzinfo = now.tzinfo
@ -215,7 +215,7 @@ def live_youtube_channel_charts(
limit: int = 15, limit: int = 15,
) -> QuerySet: ) -> QuerySet:
from django.db.models import OuterRef, Subquery from django.db.models import OuterRef, Subquery
from videos.models import Video, Channel from videos.models import Channel, Video
now = timezone.now() now = timezone.now()
tzinfo = now.tzinfo tzinfo = now.tzinfo

View File

@ -1,3 +1,5 @@
from taggit.serializers import TaggitSerializer, TagListSerializerField
from music.models import Album, Artist, Track from music.models import Album, Artist, Track
from rest_framework import serializers from rest_framework import serializers
@ -14,7 +16,25 @@ class AlbumSerializer(serializers.HyperlinkedModelSerializer):
fields = "__all__" fields = "__all__"
class TrackSerializer(serializers.HyperlinkedModelSerializer): class TrackSerializer(TaggitSerializer, serializers.HyperlinkedModelSerializer):
tags = TagListSerializerField()
genre = TagListSerializerField()
class Meta: class Meta:
model = Track model = Track
fields = "__all__" fields = [
"url",
"id",
"uuid",
"title",
"artist_fk",
"artists",
"album",
"albums",
"musicbrainz_id",
"genre",
"tags",
"base_run_time_seconds",
"created",
"modified",
]

View File

@ -0,0 +1,32 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("music", "0031_alter_track_genre"),
]
operations = [
migrations.RenameField(
model_name="track",
old_name="artist",
new_name="artist_fk",
),
migrations.AlterField(
model_name="track",
name="artist_fk",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=models.DO_NOTHING,
related_name="+",
to="music.artist",
),
),
migrations.AddField(
model_name="track",
name="artists",
field=models.ManyToManyField(to="music.artist"),
),
]

View File

@ -0,0 +1,27 @@
from django.db import migrations
def backfill_artist_fk_to_artists(apps, schema_editor):
Track = apps.get_model("music", "Track")
TrackArtist = Track.artists.through
TrackArtist.objects.bulk_create(
[
TrackArtist(track_id=r["id"], artist_id=r["artist_fk_id"])
for r in Track.objects.filter(artist_fk__isnull=False).values("id", "artist_fk_id")
],
ignore_conflicts=True,
)
class Migration(migrations.Migration):
dependencies = [
("music", "0032_track_artist_fk_and_artists"),
]
operations = [
migrations.RunPython(
backfill_artist_fk_to_artists,
reverse_code=migrations.RunPython.noop,
),
]

View File

@ -124,7 +124,7 @@ class Artist(TimeStampedModel):
def charts(self): def charts(self):
from scrobbles.models import ChartRecord from scrobbles.models import ChartRecord
return ChartRecord.objects.filter(track__artist=self).order_by("-year") return ChartRecord.objects.filter(track__artists=self).order_by("-year")
def scrape_allmusic(self, force=False) -> None: def scrape_allmusic(self, force=False) -> None:
if not self.allmusic_id or force: if not self.allmusic_id or force:
@ -422,7 +422,8 @@ class Album(TimeStampedModel):
self.artists.add(new_artist) self.artists.add(new_artist)
if not new_artist: if not new_artist:
for t in self.track_set.all(): for t in self.track_set.all():
self.artists.add(t.artist) for a in t.artists.all():
self.artists.add(a)
if not self.cover_image or self.cover_image == "default-image-replace-me": if not self.cover_image or self.cover_image == "default-image-replace-me":
self.fetch_artwork() self.fetch_artwork()
self.fix_album_artist() self.fix_album_artist()
@ -573,7 +574,10 @@ class Album(TimeStampedModel):
class Track(ScrobblableMixin): class Track(ScrobblableMixin):
COMPLETION_PERCENT = getattr(settings, "MUSIC_COMPLETION_PERCENT", 100) COMPLETION_PERCENT = getattr(settings, "MUSIC_COMPLETION_PERCENT", 100)
artist = models.ForeignKey(Artist, on_delete=models.DO_NOTHING) artist_fk = models.ForeignKey(
Artist, on_delete=models.DO_NOTHING, **BNULL, related_name="+"
)
artists = models.ManyToManyField(Artist)
albums = models.ManyToManyField(Album, related_name="tracks") albums = models.ManyToManyField(Album, related_name="tracks")
album = models.ForeignKey(Album, on_delete=models.DO_NOTHING, **BNULL) album = models.ForeignKey(Album, on_delete=models.DO_NOTHING, **BNULL)
musicbrainz_id = models.CharField(max_length=255, **BNULL) musicbrainz_id = models.CharField(max_length=255, **BNULL)
@ -581,6 +585,10 @@ class Track(ScrobblableMixin):
class Meta: class Meta:
unique_together = [["album", "musicbrainz_id"]] unique_together = [["album", "musicbrainz_id"]]
@property
def artist(self):
return self.artists.first() or self.artist_fk
def __str__(self): def __str__(self):
return f"{self.title} by {self.artist}" return f"{self.title} by {self.artist}"
@ -615,8 +623,9 @@ class Track(ScrobblableMixin):
@property @property
def primary_image_url(self) -> str: def primary_image_url(self) -> str:
url = "" url = ""
if self.artist.thumbnail: primary_artist = self.artist
url = self.artist.thumbnail_medium.url if primary_artist and primary_artist.thumbnail:
url = primary_artist.thumbnail_medium.url
if self.primary_album and self.primary_album.cover_image: if self.primary_album and self.primary_album.cover_image:
url = self.primary_album.cover_image_medium.url url = self.primary_album.cover_image_medium.url
return url return url
@ -625,6 +634,7 @@ class Track(ScrobblableMixin):
def find_or_create( def find_or_create(
cls, cls,
title: str = "", title: str = "",
artist_names: list[str] | None = None,
artist_name: str = "", artist_name: str = "",
album_name: str = "", album_name: str = "",
run_time_seconds: int | None = None, run_time_seconds: int | None = None,
@ -638,25 +648,40 @@ class Track(ScrobblableMixin):
name name
Optionally, we can update any found artists with overwrite.""" Optionally, we can update any found artists with overwrite."""
from music.utils import parse_artist_names
if artist_names is None and artist_name:
artist_names = parse_artist_names(artist_name)
if not artist_names:
artist_names = []
album = None album = None
if album_name: if album_name:
logger.info(f"Looking up album for: {album_name}") logger.info(f"Looking up album for: {album_name}")
album = Album.find_or_create(name=album_name, artist_name=artist_name) first_name = artist_names[0] if artist_names else ""
artist = album.album_artist album = Album.find_or_create(name=album_name, artist_name=first_name)
else:
artist = Artist.find_or_create(artist_name, track_name=title) artist_objs = []
if not artist: for name in artist_names:
artist = Artist.find_or_create(artist_name) artist = Artist.find_or_create(name, track_name=title)
if artist:
artist_objs.append(artist)
track = None
if artist_objs:
track = cls.objects.filter(title=title, artists__in=artist_objs).first()
lookup_keys = {"title": title, "artist": artist}
if run_time_seconds:
lookup_keys["base_run_time_seconds"] = run_time_seconds
logger.info(f"Looking up track using: {lookup_keys}")
track = cls.objects.filter(**lookup_keys).first()
if not track: if not track:
track = cls.objects.filter(title=title, artist=artist).first() track = cls.objects.filter(title=title).first()
if not track:
track, _ = cls.objects.get_or_create(title=title, artist=artist) if not track:
track = cls(title=title)
track.save()
track.refresh_from_db()
if artist_objs:
track.artists.add(*artist_objs)
if album: if album:
track.albums.add(album) track.albums.add(album)
@ -666,17 +691,18 @@ class Track(ScrobblableMixin):
track.base_run_time_seconds = run_time_seconds track.base_run_time_seconds = run_time_seconds
track.musicbrainz_id = mbid track.musicbrainz_id = mbid
else: else:
artist_name_str = " & ".join(artist_names) if artist_names else ""
logger.info( logger.info(
f"Enriching track {track}", f"Enriching track {track}",
extra={ extra={
"title": title, "title": title,
"artist_name": artist_name, "artist_name": artist_name_str,
"track_id": track.id, "track_id": track.id,
}, },
) )
try: try:
mbid, length = get_recording_mbid_exact( mbid, length = get_recording_mbid_exact(
title, artist_name, album_name title, artist_name_str, album_name
) )
except Exception: except Exception:
print("No musicbrainz result found, cannot enrich") print("No musicbrainz result found, cannot enrich")
@ -688,6 +714,4 @@ class Track(ScrobblableMixin):
return track return track
def fix_metadata(self, force_update=False): def fix_metadata(self, force_update=False): ...
...

View File

@ -1,5 +1,5 @@
from datetime import datetime
import logging import logging
from datetime import datetime
import musicbrainzngs import musicbrainzngs
from dateutil.parser import parse from dateutil.parser import parse

View File

@ -15,12 +15,26 @@ def clean_artist_name(name: str) -> str:
name = re.split(" w. ", name, flags=re.IGNORECASE)[0].strip() name = re.split(" w. ", name, flags=re.IGNORECASE)[0].strip()
if " featuring " in name.lower(): if " featuring " in name.lower():
name = re.split(" featuring ", name, flags=re.IGNORECASE)[0].strip() name = re.split(" featuring ", name, flags=re.IGNORECASE)[0].strip()
# if " & " in name.lower() and "of the wand" not in name.lower():
# name = re.split("&", name, flags=re.IGNORECASE)[0].strip()
return name return name
def parse_artist_names(name: str) -> list[str]:
"""Split a combined artist string (e.g. 'A & B') into individual names.
First strips feat./featuring/w. prefixes, then splits on ' & ' to
support collaboration-style credits like 'Matt Sweeney & Bonnie Prince Billy'.
"""
name = clean_artist_name(name)
if " & " in name.lower():
return [
part.strip()
for part in re.split(r"\s+&\s+", name, flags=re.IGNORECASE)
if part.strip()
]
return [name]
def get_or_create_various_artists() -> "Artist": def get_or_create_various_artists() -> "Artist":
from music.models import Artist from music.models import Artist
@ -35,19 +49,19 @@ def deduplicate_tracks(commit=False) -> int:
from music.models import Track from music.models import Track
duplicates = ( duplicates = (
Track.objects.values("artist", "title") Track.objects.values("title")
.annotate(dup_count=models.Count("id")) .annotate(dup_count=models.Count("id"))
.filter(dup_count__gt=1) .filter(dup_count__gt=1)
) )
query = models.Q() query = models.Q()
for dup in duplicates: for dup in duplicates:
query |= models.Q(artist=dup["artist"], title=dup["title"]) query |= models.Q(title=dup["title"])
duplicate_tracks = Track.objects.filter(query) duplicate_tracks = Track.objects.filter(query)
for b in duplicate_tracks: for b in duplicate_tracks:
tracks = Track.objects.filter(artist=b.artist, title=b.title) tracks = Track.objects.filter(title=b.title)
first = tracks.first() first = tracks.first()
for other in tracks.exclude(id=first.id): for other in tracks.exclude(id=first.id):
print("Moving scrobbles for", other.id, " to ", first.id) print("Moving scrobbles for", other.id, " to ", first.id)
@ -74,7 +88,7 @@ def condense_albums(commit: bool = False):
for track in Track.objects.all(): for track in Track.objects.all():
albums_to_add = [] albums_to_add = []
duplicates = ( duplicates = (
Track.objects.filter(title=track.title, artist=track.artist) Track.objects.filter(title=track.title)
.exclude(id=track.id) .exclude(id=track.id)
.exclude(id__in=processed_ids) .exclude(id__in=processed_ids)
) )
@ -88,7 +102,7 @@ def condense_albums(commit: bool = False):
track.albums.add(dup_track.album) track.albums.add(dup_track.album)
# Find out if this track appears more than once # Find out if this track appears more than once
duplicates = Track.objects.filter(title=track.title, artist=track.artist) duplicates = Track.objects.filter(title=track.title)
if duplicates.count() > 1: if duplicates.count() > 1:
logger.info(f"Track appears more than once, condensing: {track}") logger.info(f"Track appears more than once, condensing: {track}")

View File

@ -1,10 +1,9 @@
from charts.models import ChartRecord
from django.db.models import Count from django.db.models import Count
from django.views import generic from django.views import generic
from music.models import Album, Artist, Track from music.models import Album, Artist, Track
from charts.models import ChartRecord
from scrobbles.stats import get_scrobble_count_qs from scrobbles.stats import get_scrobble_count_qs
from scrobbles.views import ScrobbleableDetailView, ScrobbleableListView
from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView
class TrackListView(ScrobbleableListView): class TrackListView(ScrobbleableListView):
@ -64,7 +63,7 @@ class ArtistDetailView(generic.DetailView):
from scrobbles.models import Scrobble from scrobbles.models import Scrobble
context_data["recent_scrobbles"] = ( context_data["recent_scrobbles"] = (
Scrobble.objects.filter(track__artist=artist) Scrobble.objects.filter(track__artists=artist)
.select_related("track", "track__album") .select_related("track", "track__album")
.order_by("-timestamp")[:100] .order_by("-timestamp")[:100]
) )

View File

@ -1,8 +1,8 @@
import csv import csv
import tempfile import tempfile
from scrobbles.models import Scrobble
from django.db.models import Q from django.db.models import Q
from scrobbles.models import Scrobble
def export_scrobbles(start_date=None, end_date=None, format="AS"): def export_scrobbles(start_date=None, end_date=None, format="AS"):

View File

@ -564,7 +564,7 @@ class ScrobbleQuerySet(models.QuerySet):
return self.select_related("user").prefetch_related( return self.select_related("user").prefetch_related(
"video", "video",
"track", "track",
"track__artist", "track__artist_fk",
"podcast_episode", "podcast_episode",
"podcast_episode__podcast", "podcast_episode__podcast",
"sport_event", "sport_event",

View File

@ -226,7 +226,7 @@ class RecentScrobbleList(ListView):
# Get user's home scrobble limit (default 20) # Get user's home scrobble limit (default 20)
home_limit = 20 home_limit = 20
if hasattr(user, 'profile') and user.profile.home_scrobble_limit: if hasattr(user, "profile") and user.profile.home_scrobble_limit:
home_limit = user.profile.home_scrobble_limit home_limit = user.profile.home_scrobble_limit
today = timezone.localtime(timezone.now()) today = timezone.localtime(timezone.now())
@ -612,9 +612,7 @@ class KoReaderImportCreateView(LoginRequiredMixin, JsonableResponseMixin, Create
return HttpResponseRedirect(self.request.META.get("HTTP_REFERER")) return HttpResponseRedirect(self.request.META.get("HTTP_REFERER"))
class ScaleCSVImportCreateView( class ScaleCSVImportCreateView(LoginRequiredMixin, JsonableResponseMixin, CreateView):
LoginRequiredMixin, JsonableResponseMixin, CreateView
):
model = ScaleCSVImport model = ScaleCSVImport
fields = ["csv_file"] fields = ["csv_file"]
template_name = "scrobbles/upload_form.html" template_name = "scrobbles/upload_form.html"
@ -623,17 +621,13 @@ class ScaleCSVImportCreateView(
def form_valid(self, form): def form_valid(self, form):
self.object = form.save(commit=False) self.object = form.save(commit=False)
self.object.user = self.request.user self.object.user = self.request.user
self.object.original_filename = ( self.object.original_filename = form.cleaned_data["csv_file"].name
form.cleaned_data["csv_file"].name
)
self.object.save() self.object.save()
self.object.process() self.object.process()
return HttpResponseRedirect(self.request.META.get("HTTP_REFERER")) return HttpResponseRedirect(self.request.META.get("HTTP_REFERER"))
class TrailGPXImportCreateView( class TrailGPXImportCreateView(LoginRequiredMixin, JsonableResponseMixin, CreateView):
LoginRequiredMixin, JsonableResponseMixin, CreateView
):
model = TrailGPXImport model = TrailGPXImport
fields = ["gpx_file"] fields = ["gpx_file"]
template_name = "scrobbles/upload_form.html" template_name = "scrobbles/upload_form.html"
@ -852,7 +846,9 @@ def scrobble_start(request, uuid):
book=media_obj, book=media_obj,
user_id=user_id, user_id=user_id,
) )
.filter(Q(long_play_complete=False) | Q(long_play_complete__isnull=True)) .filter(
Q(long_play_complete=False) | Q(long_play_complete__isnull=True)
)
.filter(log__page_end__isnull=False) .filter(log__page_end__isnull=False)
.order_by("-timestamp") .order_by("-timestamp")
.first() .first()
@ -1328,6 +1324,7 @@ class ScrobbleCalendarView(LoginRequiredMixin, TemplateView):
def _day_color(self, month_index, day_num, total_days): def _day_color(self, month_index, day_num, total_days):
import colorsys import colorsys
hue = (month_index - 1) * 30 / 360 hue = (month_index - 1) * 30 / 360
lightness = 0.80 + (day_num / total_days) * 0.15 lightness = 0.80 + (day_num / total_days) * 0.15
r, g, b = colorsys.hls_to_rgb(hue, lightness, 0.5) r, g, b = colorsys.hls_to_rgb(hue, lightness, 0.5)
@ -1355,7 +1352,9 @@ class ScrobbleCalendarView(LoginRequiredMixin, TemplateView):
if media_type_filter and media_type_filter in self.CALENDAR_MEDIA_TYPES: if media_type_filter and media_type_filter in self.CALENDAR_MEDIA_TYPES:
active_types = [media_type_filter] active_types = [media_type_filter]
else: else:
active_types = [t for t in self.CALENDAR_MEDIA_TYPES if t not in self.DEFAULT_EXCLUDE] active_types = [
t for t in self.CALENDAR_MEDIA_TYPES if t not in self.DEFAULT_EXCLUDE
]
scrobbles = ( scrobbles = (
Scrobble.objects.filter( Scrobble.objects.filter(
@ -1364,12 +1363,23 @@ class ScrobbleCalendarView(LoginRequiredMixin, TemplateView):
timestamp__date__lte=month_end, timestamp__date__lte=month_end,
media_type__in=active_types, media_type__in=active_types,
) )
.select_related("task", "birding_location", "food", "trail", "video_game", "book", "mood", "video", "board_game") .select_related(
"task",
"birding_location",
"food",
"trail",
"video_game",
"book",
"mood",
"video",
"board_game",
)
.order_by("timestamp") .order_by("timestamp")
) )
from django.db.models import Count, Q from django.db.models import Count, Q
from django.db.models.functions import TruncDate from django.db.models.functions import TruncDate
total_by_day = dict( total_by_day = dict(
Scrobble.objects.filter( Scrobble.objects.filter(
user=self.request.user, user=self.request.user,
@ -1377,7 +1387,11 @@ class ScrobbleCalendarView(LoginRequiredMixin, TemplateView):
timestamp__date__lte=month_end, timestamp__date__lte=month_end,
) )
.exclude(Q(media_type="GeoLocation") & Q(geo_location__title__isnull=True)) .exclude(Q(media_type="GeoLocation") & Q(geo_location__title__isnull=True))
.annotate(local_date=TruncDate("timestamp", tzinfo=timezone.get_current_timezone())) .annotate(
local_date=TruncDate(
"timestamp", tzinfo=timezone.get_current_timezone()
)
)
.values("local_date") .values("local_date")
.annotate(count=Count("id")) .annotate(count=Count("id"))
.values_list("local_date", "count") .values_list("local_date", "count")
@ -1403,17 +1417,27 @@ class ScrobbleCalendarView(LoginRequiredMixin, TemplateView):
{ {
"uuid": scrobble.uuid, "uuid": scrobble.uuid,
"emoji": self.MEDIA_EMOJI.get(scrobble.media_type, "📌"), "emoji": self.MEDIA_EMOJI.get(scrobble.media_type, "📌"),
"title": str(scrobble.media_obj) if scrobble.media_obj else scrobble.media_type, "title": (
str(scrobble.media_obj)
if scrobble.media_obj
else scrobble.media_type
),
"media_type": scrobble.media_type, "media_type": scrobble.media_type,
} }
) )
calendar_days.append({ calendar_days.append(
"day": day_num, {
"scrobbles": day_scrobbles, "day": day_num,
"total_count": total_by_day.get(datetime(year, month, day_num).date(), 0), "scrobbles": day_scrobbles,
"is_today": year == today.year and month == today.month and day_num == today.day, "total_count": total_by_day.get(
"color": self._day_color(month, day_num, total_days), datetime(year, month, day_num).date(), 0
}) ),
"is_today": year == today.year
and month == today.month
and day_num == today.day,
"color": self._day_color(month, day_num, total_days),
}
)
ctx.update( ctx.update(
{ {
@ -1430,7 +1454,10 @@ class ScrobbleCalendarView(LoginRequiredMixin, TemplateView):
"day_names": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"], "day_names": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
"month_color": month_color, "month_color": month_color,
"active_filter": media_type_filter or "", "active_filter": media_type_filter or "",
"media_types": [{"name": mt, "emoji": self.MEDIA_EMOJI.get(mt, "📌")} for mt in self.CALENDAR_MEDIA_TYPES], "media_types": [
{"name": mt, "emoji": self.MEDIA_EMOJI.get(mt, "📌")}
for mt in self.CALENDAR_MEDIA_TYPES
],
} }
) )
return ctx return ctx
@ -1441,7 +1468,7 @@ class ScrobbleSearchView(LoginRequiredMixin, TemplateView):
MEDIA_FIELDS = { MEDIA_FIELDS = {
"Video": ["video__title", "video__overview"], "Video": ["video__title", "video__overview"],
"Track": ["track__title", "track__artist__name", "track__album__name"], "Track": ["track__title", "track__artists__name", "track__album__name"],
"PodcastEpisode": ["podcast_episode__title", None], "PodcastEpisode": ["podcast_episode__title", None],
"Book": ["book__title", "book__summary"], "Book": ["book__title", "book__summary"],
"Paper": ["paper__title", None], "Paper": ["paper__title", None],