diff --git a/PROJECT.org b/PROJECT.org index 667683f..90244fe 100644 --- a/PROJECT.org +++ b/PROJECT.org @@ -93,7 +93,7 @@ fetching and simple saving. :LOGBOOK: CLOCK: [2025-07-09 Wed 09:55]--[2025-07-09 Wed 10:15] => 0:20 :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: :PROPERTIES: :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. -** 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: :ID: 590bc038-745f-710b-8272-4d8a3d2efa01 :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" 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: :ID: 445e1253-d353-4b55-b1d8-39d0a0dcdd34 :END: + - File: ~vrobbler/apps/scrobbles/importers/webdav.py~ (line 439) - Same pattern as the GPX importer: after importing a =.csv= file from WebDAV, move it to =var/ebird/processed/= with a timestamp appended. diff --git a/tests/scrobbles_tests/test_views.py b/tests/scrobbles_tests/test_views.py index 534fe17..cd1d772 100644 --- a/tests/scrobbles_tests/test_views.py +++ b/tests/scrobbles_tests/test_views.py @@ -143,9 +143,10 @@ def test_mopidy_track_webhook_creates_track_and_scrobble( album = Album.objects.create(name="Sublime", album_artist=artist) track = Track.objects.create( title="Same in the End", - artist=artist, + artist_fk=artist, base_run_time_seconds=60, ) + track.artists.add(artist) track.albums.add(album) 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) track = Track.objects.create( title="Emotion", - artist=artist, + artist_fk=artist, base_run_time_seconds=60, ) + track.artists.add(artist) track.albums.add(album) 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) track = Track.objects.create( title="Same in the End", - artist=artist, + artist_fk=artist, base_run_time_seconds=60, ) + track.artists.add(artist) track.albums.add(album) 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) track = Track.objects.create( title="Same in the End", - artist=artist, + artist_fk=artist, album=album, base_run_time_seconds=60, ) + track.artists.add(artist) mock_artist_fc.return_value = artist 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) track = Track.objects.create( title="Emotion", - artist=artist, + artist_fk=artist, base_run_time_seconds=60, ) + track.artists.add(artist) track.albums.add(album) 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) track = Track.objects.create( title="Emotion", - artist=artist, + artist_fk=artist, album=album, base_run_time_seconds=60, ) - track.albums.add(album) + track.artists.add(artist) mock_artist_fc.return_value = artist mock_track_fc.return_value = track diff --git a/vrobbler/apps/charts/utils.py b/vrobbler/apps/charts/utils.py index 140ef5d..6db9eb6 100644 --- a/vrobbler/apps/charts/utils.py +++ b/vrobbler/apps/charts/utils.py @@ -104,8 +104,8 @@ def build_charts( media_config = { "artist": { - "filter": Q(track__isnull=False) & Q(track__artist__isnull=False), - "values": "track__artist", + "filter": Q(track__isnull=False) & Q(track__artists__isnull=False), + "values": "track__artists", "annotate": Count("id", distinct=True), }, "album": { diff --git a/vrobbler/apps/music/admin.py b/vrobbler/apps/music/admin.py index 3244258..6b5d78f 100644 --- a/vrobbler/apps/music/admin.py +++ b/vrobbler/apps/music/admin.py @@ -1,7 +1,5 @@ from django.contrib import admin - -from music.models import Artist, Album, Track - +from music.models import Album, Artist, Track from scrobbles.admin import ScrobbleInline @@ -54,12 +52,12 @@ class TrackAdmin(admin.ModelAdmin): "artist", "musicbrainz_id", ) - raw_id_fields = ("artist", "albums", "album") - list_filter = ("album", "artist") + raw_id_fields = ("artist_fk", "artists", "albums", "album") search_fields = ("title",) ordering = ("-created",) filter_horizontal = [ "albums", + "artists", ] inlines = [ ScrobbleInline, diff --git a/vrobbler/apps/music/aggregators.py b/vrobbler/apps/music/aggregators.py index a021328..b72d85f 100644 --- a/vrobbler/apps/music/aggregators.py +++ b/vrobbler/apps/music/aggregators.py @@ -149,7 +149,7 @@ def live_charts( 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( @@ -158,7 +158,7 @@ def live_tv_charts( limit: int = 15, ) -> QuerySet: from django.db.models import OuterRef, Subquery - from videos.models import Video, Series + from videos.models import Series, Video now = timezone.now() tzinfo = now.tzinfo @@ -215,7 +215,7 @@ def live_youtube_channel_charts( limit: int = 15, ) -> QuerySet: from django.db.models import OuterRef, Subquery - from videos.models import Video, Channel + from videos.models import Channel, Video now = timezone.now() tzinfo = now.tzinfo diff --git a/vrobbler/apps/music/api/serializers.py b/vrobbler/apps/music/api/serializers.py index 9730dd4..a358b60 100644 --- a/vrobbler/apps/music/api/serializers.py +++ b/vrobbler/apps/music/api/serializers.py @@ -1,3 +1,5 @@ +from taggit.serializers import TaggitSerializer, TagListSerializerField + from music.models import Album, Artist, Track from rest_framework import serializers @@ -14,7 +16,25 @@ class AlbumSerializer(serializers.HyperlinkedModelSerializer): fields = "__all__" -class TrackSerializer(serializers.HyperlinkedModelSerializer): +class TrackSerializer(TaggitSerializer, serializers.HyperlinkedModelSerializer): + tags = TagListSerializerField() + genre = TagListSerializerField() + class Meta: model = Track - fields = "__all__" + fields = [ + "url", + "id", + "uuid", + "title", + "artist_fk", + "artists", + "album", + "albums", + "musicbrainz_id", + "genre", + "tags", + "base_run_time_seconds", + "created", + "modified", + ] diff --git a/vrobbler/apps/music/migrations/0032_track_artist_fk_and_artists.py b/vrobbler/apps/music/migrations/0032_track_artist_fk_and_artists.py new file mode 100644 index 0000000..4bad8d4 --- /dev/null +++ b/vrobbler/apps/music/migrations/0032_track_artist_fk_and_artists.py @@ -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"), + ), + ] diff --git a/vrobbler/apps/music/migrations/0033_backfill_track_artists.py b/vrobbler/apps/music/migrations/0033_backfill_track_artists.py new file mode 100644 index 0000000..a1d9b21 --- /dev/null +++ b/vrobbler/apps/music/migrations/0033_backfill_track_artists.py @@ -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, + ), + ] diff --git a/vrobbler/apps/music/models.py b/vrobbler/apps/music/models.py index 40e972b..db8c270 100644 --- a/vrobbler/apps/music/models.py +++ b/vrobbler/apps/music/models.py @@ -124,7 +124,7 @@ class Artist(TimeStampedModel): def charts(self): 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: if not self.allmusic_id or force: @@ -422,7 +422,8 @@ class Album(TimeStampedModel): self.artists.add(new_artist) if not new_artist: 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": self.fetch_artwork() self.fix_album_artist() @@ -573,7 +574,10 @@ class Album(TimeStampedModel): class Track(ScrobblableMixin): 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") album = models.ForeignKey(Album, on_delete=models.DO_NOTHING, **BNULL) musicbrainz_id = models.CharField(max_length=255, **BNULL) @@ -581,6 +585,10 @@ class Track(ScrobblableMixin): class Meta: unique_together = [["album", "musicbrainz_id"]] + @property + def artist(self): + return self.artists.first() or self.artist_fk + def __str__(self): return f"{self.title} by {self.artist}" @@ -615,8 +623,9 @@ class Track(ScrobblableMixin): @property def primary_image_url(self) -> str: url = "" - if self.artist.thumbnail: - url = self.artist.thumbnail_medium.url + primary_artist = self.artist + if primary_artist and primary_artist.thumbnail: + url = primary_artist.thumbnail_medium.url if self.primary_album and self.primary_album.cover_image: url = self.primary_album.cover_image_medium.url return url @@ -625,6 +634,7 @@ class Track(ScrobblableMixin): def find_or_create( cls, title: str = "", + artist_names: list[str] | None = None, artist_name: str = "", album_name: str = "", run_time_seconds: int | None = None, @@ -638,25 +648,40 @@ class Track(ScrobblableMixin): name 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 if album_name: logger.info(f"Looking up album for: {album_name}") - album = Album.find_or_create(name=album_name, artist_name=artist_name) - artist = album.album_artist - else: - artist = Artist.find_or_create(artist_name, track_name=title) - if not artist: - artist = Artist.find_or_create(artist_name) + first_name = artist_names[0] if artist_names else "" + album = Album.find_or_create(name=album_name, artist_name=first_name) + + artist_objs = [] + for name in artist_names: + 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: - track = cls.objects.filter(title=title, artist=artist).first() - if not track: - track, _ = cls.objects.get_or_create(title=title, artist=artist) + track = cls.objects.filter(title=title).first() + + if not track: + track = cls(title=title) + track.save() + track.refresh_from_db() + + if artist_objs: + track.artists.add(*artist_objs) if album: track.albums.add(album) @@ -666,17 +691,18 @@ class Track(ScrobblableMixin): track.base_run_time_seconds = run_time_seconds track.musicbrainz_id = mbid else: + artist_name_str = " & ".join(artist_names) if artist_names else "" logger.info( f"Enriching track {track}", extra={ "title": title, - "artist_name": artist_name, + "artist_name": artist_name_str, "track_id": track.id, }, ) try: mbid, length = get_recording_mbid_exact( - title, artist_name, album_name + title, artist_name_str, album_name ) except Exception: print("No musicbrainz result found, cannot enrich") @@ -688,6 +714,4 @@ class Track(ScrobblableMixin): return track - def fix_metadata(self, force_update=False): - - ... + def fix_metadata(self, force_update=False): ... diff --git a/vrobbler/apps/music/musicbrainz.py b/vrobbler/apps/music/musicbrainz.py index 1f8f869..8eec8a7 100644 --- a/vrobbler/apps/music/musicbrainz.py +++ b/vrobbler/apps/music/musicbrainz.py @@ -1,5 +1,5 @@ -from datetime import datetime import logging +from datetime import datetime import musicbrainzngs from dateutil.parser import parse diff --git a/vrobbler/apps/music/utils.py b/vrobbler/apps/music/utils.py index 34f7a8c..08fe2dc 100644 --- a/vrobbler/apps/music/utils.py +++ b/vrobbler/apps/music/utils.py @@ -15,12 +15,26 @@ def clean_artist_name(name: str) -> str: name = re.split(" w. ", name, flags=re.IGNORECASE)[0].strip() if " featuring " in name.lower(): 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 +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": from music.models import Artist @@ -35,19 +49,19 @@ def deduplicate_tracks(commit=False) -> int: from music.models import Track duplicates = ( - Track.objects.values("artist", "title") + Track.objects.values("title") .annotate(dup_count=models.Count("id")) .filter(dup_count__gt=1) ) query = models.Q() 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) 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() for other in tracks.exclude(id=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(): albums_to_add = [] duplicates = ( - Track.objects.filter(title=track.title, artist=track.artist) + Track.objects.filter(title=track.title) .exclude(id=track.id) .exclude(id__in=processed_ids) ) @@ -88,7 +102,7 @@ def condense_albums(commit: bool = False): track.albums.add(dup_track.album) # 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: logger.info(f"Track appears more than once, condensing: {track}") diff --git a/vrobbler/apps/music/views.py b/vrobbler/apps/music/views.py index 68e4ca3..3908aff 100644 --- a/vrobbler/apps/music/views.py +++ b/vrobbler/apps/music/views.py @@ -1,10 +1,9 @@ +from charts.models import ChartRecord from django.db.models import Count from django.views import generic from music.models import Album, Artist, Track -from charts.models import ChartRecord from scrobbles.stats import get_scrobble_count_qs - -from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView +from scrobbles.views import ScrobbleableDetailView, ScrobbleableListView class TrackListView(ScrobbleableListView): @@ -64,7 +63,7 @@ class ArtistDetailView(generic.DetailView): from scrobbles.models import Scrobble context_data["recent_scrobbles"] = ( - Scrobble.objects.filter(track__artist=artist) + Scrobble.objects.filter(track__artists=artist) .select_related("track", "track__album") .order_by("-timestamp")[:100] ) diff --git a/vrobbler/apps/scrobbles/export.py b/vrobbler/apps/scrobbles/export.py index 7ca6568..2a74ee0 100644 --- a/vrobbler/apps/scrobbles/export.py +++ b/vrobbler/apps/scrobbles/export.py @@ -1,8 +1,8 @@ import csv import tempfile -from scrobbles.models import Scrobble from django.db.models import Q +from scrobbles.models import Scrobble def export_scrobbles(start_date=None, end_date=None, format="AS"): diff --git a/vrobbler/apps/scrobbles/models.py b/vrobbler/apps/scrobbles/models.py index 8ff6def..3e4c70c 100644 --- a/vrobbler/apps/scrobbles/models.py +++ b/vrobbler/apps/scrobbles/models.py @@ -564,7 +564,7 @@ class ScrobbleQuerySet(models.QuerySet): return self.select_related("user").prefetch_related( "video", "track", - "track__artist", + "track__artist_fk", "podcast_episode", "podcast_episode__podcast", "sport_event", diff --git a/vrobbler/apps/scrobbles/views.py b/vrobbler/apps/scrobbles/views.py index f171b60..e2f539e 100644 --- a/vrobbler/apps/scrobbles/views.py +++ b/vrobbler/apps/scrobbles/views.py @@ -226,7 +226,7 @@ class RecentScrobbleList(ListView): # Get user's home scrobble limit (default 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 today = timezone.localtime(timezone.now()) @@ -612,9 +612,7 @@ class KoReaderImportCreateView(LoginRequiredMixin, JsonableResponseMixin, Create return HttpResponseRedirect(self.request.META.get("HTTP_REFERER")) -class ScaleCSVImportCreateView( - LoginRequiredMixin, JsonableResponseMixin, CreateView -): +class ScaleCSVImportCreateView(LoginRequiredMixin, JsonableResponseMixin, CreateView): model = ScaleCSVImport fields = ["csv_file"] template_name = "scrobbles/upload_form.html" @@ -623,17 +621,13 @@ class ScaleCSVImportCreateView( def form_valid(self, form): self.object = form.save(commit=False) self.object.user = self.request.user - self.object.original_filename = ( - form.cleaned_data["csv_file"].name - ) + self.object.original_filename = form.cleaned_data["csv_file"].name self.object.save() self.object.process() return HttpResponseRedirect(self.request.META.get("HTTP_REFERER")) -class TrailGPXImportCreateView( - LoginRequiredMixin, JsonableResponseMixin, CreateView -): +class TrailGPXImportCreateView(LoginRequiredMixin, JsonableResponseMixin, CreateView): model = TrailGPXImport fields = ["gpx_file"] template_name = "scrobbles/upload_form.html" @@ -852,7 +846,9 @@ def scrobble_start(request, uuid): book=media_obj, 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) .order_by("-timestamp") .first() @@ -1328,6 +1324,7 @@ class ScrobbleCalendarView(LoginRequiredMixin, TemplateView): def _day_color(self, month_index, day_num, total_days): import colorsys + hue = (month_index - 1) * 30 / 360 lightness = 0.80 + (day_num / total_days) * 0.15 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: active_types = [media_type_filter] 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 = ( Scrobble.objects.filter( @@ -1364,12 +1363,23 @@ class ScrobbleCalendarView(LoginRequiredMixin, TemplateView): timestamp__date__lte=month_end, 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") ) from django.db.models import Count, Q from django.db.models.functions import TruncDate + total_by_day = dict( Scrobble.objects.filter( user=self.request.user, @@ -1377,7 +1387,11 @@ class ScrobbleCalendarView(LoginRequiredMixin, TemplateView): timestamp__date__lte=month_end, ) .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") .annotate(count=Count("id")) .values_list("local_date", "count") @@ -1403,17 +1417,27 @@ class ScrobbleCalendarView(LoginRequiredMixin, TemplateView): { "uuid": scrobble.uuid, "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, } ) - calendar_days.append({ - "day": day_num, - "scrobbles": day_scrobbles, - "total_count": total_by_day.get(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), - }) + calendar_days.append( + { + "day": day_num, + "scrobbles": day_scrobbles, + "total_count": total_by_day.get( + 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( { @@ -1430,7 +1454,10 @@ class ScrobbleCalendarView(LoginRequiredMixin, TemplateView): "day_names": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"], "month_color": month_color, "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 @@ -1441,7 +1468,7 @@ class ScrobbleSearchView(LoginRequiredMixin, TemplateView): MEDIA_FIELDS = { "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], "Book": ["book__title", "book__summary"], "Paper": ["paper__title", None],