Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion src/app/music_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
)
Expand All @@ -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,
Expand Down
101 changes: 94 additions & 7 deletions src/app/services/music.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,20 +93,44 @@ 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.
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

for music in Music.objects.filter(album=album).select_related("item"):
direct_genres = _music_item_direct_genres(album)
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 != list(album.genres or []):
item.genres = list(album.genres or [])
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 []):
item.implied_genres = list(album.implied_genres or [])
Expand All @@ -115,20 +139,72 @@ 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."""
"""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 []

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:
if direct_genres and item.genres != direct_genres:
item.genres = direct_genres
update_fields.append("genres")
if item.implied_genres != implied_genres:
Expand All @@ -137,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


Expand Down
28 changes: 26 additions & 2 deletions src/app/services/music_scrobble.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,10 @@
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,
store_matched_genres,
sync_artist_discography,
sync_music_item_genres_from_album,
)
Expand Down Expand Up @@ -160,6 +162,10 @@ 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. 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()

Expand Down Expand Up @@ -235,6 +241,23 @@ 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)

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


Expand Down Expand Up @@ -1629,8 +1652,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():
Expand Down
13 changes: 13 additions & 0 deletions src/templates/app/components/detail_music_album.html
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,19 @@ <h3 class="text-sm font-semibold text-[var(--color-text-muted)]">{% translate "T

</div>

{% if album_genres %}
<div {% detail_section_attrs user "music_album" "sidebar" "genres" %}>
<h2 class="text-xl font-bold mb-4 mt-6">{% translate "Genres" %}</h2>
<div class="bg-[var(--color-surface)] p-4 rounded-lg text-center md:text-start">
<div class="flex flex-wrap gap-2 justify-center md:justify-start">
{% for genre in album_genres %}
<span class="inline-flex h-6 items-center rounded-full border border-[var(--color-genre-badge-border)] bg-[var(--color-genre-badge-bg)] px-2.5 text-xs font-medium text-[var(--color-genre-badge-text)]">{{ genre }}</span>
{% endfor %}
</div>
</div>
</div>
{% endif %}

<div {% detail_section_attrs user "music_album" "sidebar" "collection" %}>
<h2 class="text-xl font-bold mb-4 mt-6">{% translate "Collection" %}</h2>
<div class="bg-[var(--color-surface)] p-4 rounded-lg text-center md:text-start">
Expand Down
13 changes: 13 additions & 0 deletions src/templates/app/components/detail_music_artist.html
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,19 @@ <h3 class="text-sm font-semibold text-[var(--color-text-muted)]">{% translate "R

</div>

{% if genre_chips %}
<div {% detail_section_attrs user "music_artist" "sidebar" "genres" %}>
<h2 class="text-xl font-bold mb-4 mt-6">{% translate "Genres" %}</h2>
<div class="bg-[var(--color-surface)] p-4 rounded-lg text-center md:text-start">
<div class="flex flex-wrap gap-2 justify-center md:justify-start">
{% for genre in genre_chips %}
<span class="inline-flex h-6 items-center rounded-full border border-[var(--color-genre-badge-border)] bg-[var(--color-genre-badge-bg)] px-2.5 text-xs font-medium text-[var(--color-genre-badge-text)]">{{ genre }}</span>
{% endfor %}
</div>
</div>
</div>
{% endif %}

<div {% detail_section_attrs user "music_artist" "sidebar" "collection" %}>
<h2 class="text-xl font-bold mb-4 mt-6">{% translate "Collection" %}</h2>
<div class="bg-[var(--color-surface)] p-4 rounded-lg text-center md:text-start">
Expand Down
14 changes: 14 additions & 0 deletions src/templates/app/episode_details.html
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,20 @@ <h1 class="text-3xl font-bold cursor-pointer hover:text-indigo-500 transition-co
{# Your Notes — same component the movie/season pages use, pointed at the watch that carries the note. #}
{# Rendered even with no notes yet: the empty section is the anchor episode_save's OOB swap replaces. #}
<div class="flex flex-col">
{% if season_metadata.genres %}
<div {% detail_section_attrs user "episode" "content" "genres" %}>
{% url 'medialist' media_type=parent_media_type as genre_list_url %}
<h2 class="text-xl font-bold mb-4">{% translate "Genres" %}</h2>
<div class="bg-[var(--color-surface)] p-4 rounded-lg text-center md:text-start mb-8">
<div class="flex flex-wrap gap-2 justify-center md:justify-start">
{% for genre in season_metadata.genres %}
<a href="{{ genre_list_url }}?genre={{ genre|urlencode }}"
class="inline-flex h-6 items-center rounded-full border border-[var(--color-genre-badge-border)] bg-[var(--color-genre-badge-bg)] px-2.5 text-xs font-medium text-[var(--color-genre-badge-text)] hover:bg-[var(--color-genre-badge-hover-bg)] transition-colors duration-200">{% translate genre %}</a>
{% endfor %}
</div>
</div>
</div>
{% endif %}
{% if show_notes %}
<div {% detail_section_attrs user "episode" "content" "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 %}
Expand Down
11 changes: 11 additions & 0 deletions src/templates/app/podcast_show_detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,17 @@ <h1 class="text-3xl font-bold mb-4 text-center md:text-start">{{ show.title }}</
<p class="text-[var(--color-text-muted)] mb-4 text-center md:text-start">{% blocktranslate trimmed with value_1=show.author %}by {{ value_1 }}{% endblocktranslate %}</p>
{% endif %}

{% if show.genres %}
<div class="mb-6" {% detail_section_attrs user "podcast" "sidebar" "genres" %}>
<h2 class="text-xl font-bold mb-4">{% translate "Genres" %}</h2>
<div class="flex flex-wrap gap-2 justify-center md:justify-start">
{% for genre in show.genres %}
<span class="inline-flex h-6 items-center rounded-full border border-[var(--color-genre-badge-border)] bg-[var(--color-genre-badge-bg)] px-2.5 text-xs font-medium text-[var(--color-genre-badge-text)]">{{ genre }}</span>
{% endfor %}
</div>
</div>
{% endif %}

{% if show.website_url %}
<p class="mb-4 text-center md:text-start">
<a href="{{ show.website_url }}"
Expand Down
3 changes: 3 additions & 0 deletions src/users/appearance.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ def _zone(label, *sections):
"zones": {
"content": _zone(
"Content",
("genres", "Genres"),
("notes", "Notes"),
("cast", "Cast"),
("crew", "Crew"),
Expand Down Expand Up @@ -184,6 +185,7 @@ def _zone(label, *sections):
"sidebar": _zone(
"Information column",
("details", "Details"),
("genres", "Genres"),
("collection", "Collection"),
),
"content": _zone(
Expand All @@ -197,6 +199,7 @@ def _zone(label, *sections):
"sidebar": _zone(
"Information column",
("details", "Details"),
("genres", "Genres"),
("collection", "Collection"),
),
"content": _zone(
Expand Down
Loading