From dfcabd32877d706c3148ff39e4e4e75931319fe6 Mon Sep 17 00:00:00 2001 From: crimsonsunset Date: Fri, 25 Sep 2026 09:13:53 -0600 Subject: [PATCH 1/5] Show genres on music artist pages - The artist view already built genre_chips from MusicBrainz, but the template never rendered them. - Register a Genres sidebar section for music_artist so the layout settings can show and reorder it. --- .../app/components/detail_music_artist.html | 13 +++++++++++++ src/users/appearance.py | 1 + 2 files changed, 14 insertions(+) diff --git a/src/templates/app/components/detail_music_artist.html b/src/templates/app/components/detail_music_artist.html index 4efbbc2664..5aa43a5453 100644 --- a/src/templates/app/components/detail_music_artist.html +++ b/src/templates/app/components/detail_music_artist.html @@ -264,6 +264,19 @@

{% translate "R + {% if genre_chips %} +
+

{% translate "Genres" %}

+
+
+ {% for genre in genre_chips %} + {{ genre }} + {% endfor %} +
+
+
+ {% endif %} +

{% translate "Collection" %}

diff --git a/src/users/appearance.py b/src/users/appearance.py index f7bd4186f8..2ea01ce233 100644 --- a/src/users/appearance.py +++ b/src/users/appearance.py @@ -197,6 +197,7 @@ def _zone(label, *sections): "sidebar": _zone( "Information column", ("details", "Details"), + ("genres", "Genres"), ("collection", "Collection"), ), "content": _zone( From fa7889f280eaf202edcc4016e3d572b69feb659e Mon Sep 17 00:00:00 2001 From: crimsonsunset Date: Fri, 25 Sep 2026 09:13:53 -0600 Subject: [PATCH 2/5] Fill music genres on scrobble - After the write transaction, fill an album saved without genres from its MusicBrainz release group, then resync the play. - Plays fall back to the album artist's genres when the album has none. - Store artist genres from scrobbles as names, matching the artist page, instead of raw MusicBrainz genre objects. --- src/app/services/music.py | 31 +++++++++++++++++++++++++----- src/app/services/music_scrobble.py | 21 ++++++++++++++++++-- 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/src/app/services/music.py b/src/app/services/music.py index 9d8f0aa518..5752ee378b 100644 --- a/src/app/services/music.py +++ b/src/app/services/music.py @@ -93,20 +93,38 @@ def _album_implied_genres(direct_genres: list[str]) -> list[str]: ] +def _music_item_direct_genres(album: Album) -> list[str]: + """Return album genres, or the album artist's genres when the album has none.""" + from app.providers import musicbrainz + + if album.genres: + return list(album.genres) + artist_genres = ( + Artist.objects.filter(id=album.artist_id) + .values_list("genres", flat=True) + .first() + ) + return musicbrainz._normalize_musicbrainz_genre_names(artist_genres) + + def _sync_album_music_item_genres(album: Album) -> int: - """Propagate album direct/implied genres to linked music Items.""" + """Propagate album direct/implied genres to linked music Items. + + Direct genres fall back to the album artist's genres when the album has none. + """ update_count = 0 if not album.id: return update_count + direct_genres = _music_item_direct_genres(album) for music in Music.objects.filter(album=album).select_related("item"): item = getattr(music, "item", None) if not item: continue update_fields = [] - if item.genres != list(album.genres or []): - item.genres = list(album.genres or []) + if item.genres != direct_genres: + item.genres = list(direct_genres) update_fields.append("genres") if item.implied_genres != list(album.implied_genres or []): item.implied_genres = list(album.implied_genres or []) @@ -120,12 +138,15 @@ def _sync_album_music_item_genres(album: Album) -> int: def sync_music_item_genres_from_album(item: Item, album: Album | None) -> list[str]: - """Copy album direct/implied genres onto a music Item and save if changed.""" + """Copy album direct/implied genres onto a music Item and save if changed. + + Direct genres fall back to the album artist's genres when the album has none. + """ if not item or not album: return [] update_fields = [] - direct_genres = list(album.genres or []) + direct_genres = _music_item_direct_genres(album) implied_genres = list(album.implied_genres or []) if item.genres != direct_genres: diff --git a/src/app/services/music_scrobble.py b/src/app/services/music_scrobble.py index be4a0ed60c..e53b7ae6da 100644 --- a/src/app/services/music_scrobble.py +++ b/src/app/services/music_scrobble.py @@ -27,6 +27,7 @@ from app.providers import musicbrainz from app.services.music import ( get_artist_hero_image, + populate_album_implied_genres, prefetch_album_covers, refresh_album_cover_art, sync_artist_discography, @@ -160,6 +161,9 @@ def record_music_playback(event: MusicPlaybackEvent) -> Music | None: This resolves canonical metadata (MusicBrainz when possible), ensures Artist/Album/Track/Item existence, and updates the per-user Music row. + An album saved without genres is then filled from its MusicBrainz release + group, outside the write transaction. The play then copies the album's + genres, or the artist's when the album still has none. """ played_at = event.played_at or timezone.now() @@ -235,6 +239,18 @@ def record_music_playback(event: MusicPlaybackEvent) -> Music | None: _maybe_refresh_album_cover(album) _prefetch_missing_covers(artist, force=force_cover_prefetch) + if album and not getattr(event, "defer_cover_prefetch", False): + if not album.genres and album.musicbrainz_release_group_id: + try: + populate_album_implied_genres(album) + except Exception as exc: # pragma: no cover - defensive network guard + logger.debug( + "Failed album genre fill for %s: %s", + album, + exception_summary(exc), + ) + sync_music_item_genres_from_album(item, album) + return music @@ -1629,8 +1645,9 @@ def _sync_artist_metadata(artist: Artist, musicbrainz_id: str, force: bool = Fal updates["country"] = data["country"] if data.get("image"): updates["image"] = data["image"] - if data.get("genres"): - updates["genres"] = data["genres"] + genre_names = [g.get("name") for g in data.get("genres") or [] if g.get("name")] + if genre_names: + updates["genres"] = genre_names changed_fields = [] for field_name, value in updates.items(): From 99ad79b07dd1ab31b62d8ac4a678bf7e38c426d7 Mon Sep 17 00:00:00 2001 From: crimsonsunset Date: Fri, 25 Sep 2026 13:20:34 -0600 Subject: [PATCH 3/5] Show genres on music album and podcast pages Album and podcast show rows already store genres, but those detail pages never rendered them. Register the album sidebar section the same way artist pages already do. --- .../app/components/detail_music_album.html | 13 +++++++++++++ src/templates/app/podcast_show_detail.html | 11 +++++++++++ src/users/appearance.py | 1 + 3 files changed, 25 insertions(+) diff --git a/src/templates/app/components/detail_music_album.html b/src/templates/app/components/detail_music_album.html index b340e2e494..6021851bfb 100644 --- a/src/templates/app/components/detail_music_album.html +++ b/src/templates/app/components/detail_music_album.html @@ -305,6 +305,19 @@

{% translate "T

+ {% if album.genres %} +
+

{% translate "Genres" %}

+
+
+ {% for genre in album.genres %} + {{ genre }} + {% endfor %} +
+
+
+ {% endif %} +

{% translate "Collection" %}

diff --git a/src/templates/app/podcast_show_detail.html b/src/templates/app/podcast_show_detail.html index a240fedd54..a761bb03a8 100644 --- a/src/templates/app/podcast_show_detail.html +++ b/src/templates/app/podcast_show_detail.html @@ -65,6 +65,17 @@

{{ show.title }}{% blocktranslate trimmed with value_1=show.author %}by {{ value_1 }}{% endblocktranslate %}

{% endif %} + {% if show.genres %} +
+

{% translate "Genres" %}

+
+ {% for genre in show.genres %} + {{ genre }} + {% endfor %} +
+
+ {% endif %} + {% if show.website_url %}

Date: Fri, 25 Sep 2026 14:28:13 -0600 Subject: [PATCH 4/5] Show the parent show's genres on episode pages Season metadata already copies the show's genres. The episode page never rendered them. --- src/templates/app/episode_details.html | 14 ++++++++++++++ src/users/appearance.py | 1 + 2 files changed, 15 insertions(+) diff --git a/src/templates/app/episode_details.html b/src/templates/app/episode_details.html index b2dd8bf4b1..ec0351849e 100644 --- a/src/templates/app/episode_details.html +++ b/src/templates/app/episode_details.html @@ -349,6 +349,20 @@

+{% if season_metadata.genres %} + +{% endif %} {% if show_notes %}
{% include "app/components/detail_notes_section.html" with notes_entries=notes_entries notes_entry=notes_entry detail_notes_target_id=episode_notes_modal_target_id detail_notes_modal_url=episode_notes_modal_url detail_return_url=detail_return_url public_notes_view=public_notes_view %} diff --git a/src/users/appearance.py b/src/users/appearance.py index 8b97ab9549..c216786b55 100644 --- a/src/users/appearance.py +++ b/src/users/appearance.py @@ -142,6 +142,7 @@ def _zone(label, *sections): "zones": { "content": _zone( "Content", + ("genres", "Genres"), ("notes", "Notes"), ("cast", "Cast"), ("crew", "Crew"), From c59c4c92a939e9e8471ed94639ad1de39df6a7ef Mon Sep 17 00:00:00 2001 From: crimsonsunset Date: Sat, 26 Sep 2026 00:08:17 -0600 Subject: [PATCH 5/5] Store a genre match on the artist, album, track, and play. - Copy the first non-empty list onto the related rows that are still empty, including after a listen hook writes one. - Show artist genres on the album and artist pages when the album itself has none. --- src/app/music_views.py | 14 +++- src/app/services/music.py | 72 ++++++++++++++++++- src/app/services/music_scrobble.py | 11 ++- .../app/components/detail_music_album.html | 4 +- 4 files changed, 93 insertions(+), 8 deletions(-) diff --git a/src/app/music_views.py b/src/app/music_views.py index 513c064f2b..518953eef4 100644 --- a/src/app/music_views.py +++ b/src/app/music_views.py @@ -226,6 +226,12 @@ def _track_play_history(music_entry): ) return history +def _album_display_genres(album): + """Return album genres, or the artist's when the album has none.""" + if album is None: + return [] + return sync_services._music_item_direct_genres(album) + def _selected_music_release(user, album): """Return the user's valid release preference and its detailed metadata.""" @@ -823,6 +829,10 @@ def _render_music_artist_details(request, artist): genre_chips = [g["name"].title() for g in genres[:6]] elif tags: genre_chips = [t["name"].title() for t in tags[:6]] + else: + from app.providers import musicbrainz + + genre_chips = musicbrainz._normalize_musicbrainz_genre_names(artist.genres) collection_stats = get_artist_collection_stats(request.user, artist) notes_entry = artist_tracker if artist_tracker and artist_tracker.notes else None @@ -1172,11 +1182,12 @@ def _render_music_album_details(request, artist, album): or f"album-{album.id}" ), ).first() + album_genres = _album_display_genres(album) detail_tag_sections = _build_detail_tag_sections( {}, detail_item, request.user, - fallback_genres=album.genres, + fallback_genres=album_genres, fallback_implied_genres=album.implied_genres, genre_list_media_type=MediaTypes.MUSIC.value, ) @@ -1187,6 +1198,7 @@ def _render_music_album_details(request, artist, album): "media_type": MediaTypes.MUSIC.value, "artist": artist or album.artist, "album": album, + "album_genres": album_genres, "album_display_image": album_display_image, "media": { "media_type": MediaTypes.MUSIC.value, diff --git a/src/app/services/music.py b/src/app/services/music.py index 5752ee378b..9bf7046adb 100644 --- a/src/app/services/music.py +++ b/src/app/services/music.py @@ -111,19 +111,25 @@ def _sync_album_music_item_genres(album: Album) -> int: """Propagate album direct/implied genres to linked music Items. Direct genres fall back to the album artist's genres when the album has none. + An empty list does not clear genres already stored on the item. A match on any + of the item, track, album, or artist is copied onto the rows that have none. """ update_count = 0 if not album.id: return update_count direct_genres = _music_item_direct_genres(album) - for music in Music.objects.filter(album=album).select_related("item"): + for music in Music.objects.filter(album=album).select_related( + "item", + "track", + "artist", + ): item = getattr(music, "item", None) if not item: continue update_fields = [] - if item.genres != direct_genres: + if direct_genres and item.genres != direct_genres: item.genres = list(direct_genres) update_fields.append("genres") if item.implied_genres != list(album.implied_genres or []): @@ -133,14 +139,63 @@ def _sync_album_music_item_genres(album: Album) -> int: if update_fields: item.save(update_fields=update_fields) update_count += 1 + store_matched_genres( + artist=album.artist, + album=album, + track=music.track, + item=item, + ) return update_count +def _normalized_genre_names(genres) -> list[str]: + """Return display genre names, dropping blanks.""" + from app.providers import musicbrainz + + return musicbrainz._normalize_musicbrainz_genre_names(genres) + + +def store_matched_genres( + *, + artist: Artist | None = None, + album: Album | None = None, + track: Track | None = None, + item: Item | None = None, +) -> list[str]: + """Copy the first non-empty genre list onto related rows that have none. + + Album, catalog item, track, then artist. A row that already has genres is left + as it is. + """ + rows = [album, item, track, artist] + chosen = [] + for row in rows: + if row is None: + continue + names = _normalized_genre_names(getattr(row, "genres", None)) + if names: + chosen = names + break + if not chosen: + return [] + + for row in rows: + if row is None or not row.pk: + continue + if _normalized_genre_names(row.genres): + continue + row.genres = list(chosen) + row.save(update_fields=["genres"]) + return chosen + + def sync_music_item_genres_from_album(item: Item, album: Album | None) -> list[str]: """Copy album direct/implied genres onto a music Item and save if changed. Direct genres fall back to the album artist's genres when the album has none. + An empty album does not clear genres already stored on the item. Whatever list + is present is then copied onto the album, track, and artist when those are empty. """ if not item or not album: return [] @@ -149,7 +204,7 @@ def sync_music_item_genres_from_album(item: Item, album: Album | None) -> list[s direct_genres = _music_item_direct_genres(album) implied_genres = list(album.implied_genres or []) - if item.genres != direct_genres: + if direct_genres and item.genres != direct_genres: item.genres = direct_genres update_fields.append("genres") if item.implied_genres != implied_genres: @@ -158,6 +213,17 @@ def sync_music_item_genres_from_album(item: Item, album: Album | None) -> list[s if update_fields: item.save(update_fields=update_fields) + + music = ( + Music.objects.filter(item=item, album=album).select_related("track").first() + ) + track = music.track if music is not None and music.track_id else None + store_matched_genres( + artist=album.artist, + album=album, + track=track, + item=item, + ) return update_fields diff --git a/src/app/services/music_scrobble.py b/src/app/services/music_scrobble.py index e53b7ae6da..578a7698e0 100644 --- a/src/app/services/music_scrobble.py +++ b/src/app/services/music_scrobble.py @@ -30,6 +30,7 @@ populate_album_implied_genres, prefetch_album_covers, refresh_album_cover_art, + store_matched_genres, sync_artist_discography, sync_music_item_genres_from_album, ) @@ -162,8 +163,9 @@ def record_music_playback(event: MusicPlaybackEvent) -> Music | None: This resolves canonical metadata (MusicBrainz when possible), ensures Artist/Album/Track/Item existence, and updates the per-user Music row. An album saved without genres is then filled from its MusicBrainz release - group, outside the write transaction. The play then copies the album's - genres, or the artist's when the album still has none. + group, outside the write transaction. The play then copies the album's genres, or the artist's when the album still + has none. After that, any genre list found on the album, item, track, or + artist is stored on the others that are still empty. """ played_at = event.played_at or timezone.now() @@ -251,6 +253,11 @@ def record_music_playback(event: MusicPlaybackEvent) -> Music | None: ) sync_music_item_genres_from_album(item, album) + for row in (item, album, track, artist): + if row is not None and row.pk: + row.refresh_from_db(fields=["genres"]) + store_matched_genres(artist=artist, album=album, track=track, item=item) + return music diff --git a/src/templates/app/components/detail_music_album.html b/src/templates/app/components/detail_music_album.html index 6021851bfb..2775dc6ca2 100644 --- a/src/templates/app/components/detail_music_album.html +++ b/src/templates/app/components/detail_music_album.html @@ -305,12 +305,12 @@

{% translate "T

- {% if album.genres %} + {% if album_genres %}

{% translate "Genres" %}

- {% for genre in album.genres %} + {% for genre in album_genres %} {{ genre }} {% endfor %}