diff --git a/tests/metadata/test_metadata_discography.py b/tests/metadata/test_metadata_discography.py index 65ac0624..a5109dbb 100644 --- a/tests/metadata/test_metadata_discography.py +++ b/tests/metadata/test_metadata_discography.py @@ -731,3 +731,26 @@ def test_get_artist_discography_prefers_source_specific_artist_ids(monkeypatch): ) ] assert deezer.album_calls == [] + + +def test_get_artist_detail_discography_splits_eps_from_singles(monkeypatch): + """#877: get_artist_detail_discography must put EPs in their OWN bucket (not + lumped into singles). The Download Discography modal now reads this split, so + its EPs filter has cards to act on and stays in sync with Artist Detail.""" + spotify = _FakeSourceClient( + album_results=[ + _album("a1", "An Album", "2024-01-01", album_type="album"), + _album("e1", "An EP", "2024-02-01", album_type="ep"), + _album("s1", "A Single", "2024-03-01", album_type="single"), + ] + ) + clients = {"deezer": _FakeSourceClient(), "spotify": spotify, "itunes": _FakeSourceClient()} + monkeypatch.setattr(metadata_registry, "get_primary_source", lambda spotify_client_factory=None: "deezer") + monkeypatch.setattr(metadata_registry, "get_source_priority", lambda primary: [primary, "spotify", "itunes"]) + monkeypatch.setattr(metadata_registry, "get_client_for_source", lambda source, **kwargs: clients.get(source)) + + result = metadata_discography.get_artist_detail_discography("artist-1", "Artist One", MetadataLookupOptions()) + + assert [r["id"] for r in result["albums"]] == ["a1"] + assert [r["id"] for r in result["eps"]] == ["e1"] + assert [r["id"] for r in result["singles"]] == ["s1"] diff --git a/web_server.py b/web_server.py index fd53c71a..e53a2711 100644 --- a/web_server.py +++ b/web_server.py @@ -9114,7 +9114,11 @@ def get_artist_discography(artist_id): effective_override_source = 'spotify' from core.metadata.lookup import MetadataLookupOptions - from core.metadata_service import get_artist_discography as _get_artist_discography + # #877: use the artist-DETAIL discography so the Download Discography modal + # gets the SAME release-type split (albums / eps / singles) the Artist + # Detail view shows — EPs were being lumped into singles before, leaving + # the modal's EPs toggle dead. + from core.metadata.discography import get_artist_detail_discography as _get_artist_discography # Server-side per-source ID resolution. Look up the library row # by ANY of the IDs the frontend might send: library DB id, @@ -9192,6 +9196,7 @@ def get_artist_discography(artist_id): ) album_list = discography['albums'] + eps_list = discography.get('eps', []) singles_list = discography['singles'] active_source = discography['source'] source_priority = discography['source_priority'] @@ -9303,6 +9308,7 @@ def get_artist_discography(artist_id): return jsonify({ "albums": album_list, + "eps": eps_list, "singles": singles_list, "source": active_source or (source_priority[0] if source_priority else "unknown"), "artist_info": artist_info, diff --git a/webui/static/library.js b/webui/static/library.js index cd88e264..48f347f8 100644 --- a/webui/static/library.js +++ b/webui/static/library.js @@ -1891,16 +1891,12 @@ function createReleaseCard(release) { // Store mutable reference so stream updates propagate to click handler card._releaseData = release; - // Tag card for content-type filtering - const livePattern = /\b(live)\b|\(live[^)]*\)|\[live[^]]*\]/i; - const compilationPattern = /\b(greatest hits|best of|collection|anthology|essential)\b/i; - const featuredPattern = /\(?\bfeat\.?\s|\bft\.?\s|\bfeaturing\b/i; - const isLive = livePattern.test(release.title || '') || (release.album_type === 'compilation' && livePattern.test(release.title || '')); - const isCompilation = (release.album_type === 'compilation') || compilationPattern.test(release.title || ''); - const isFeatured = featuredPattern.test(release.title || ''); - card.setAttribute("data-is-live", isLive ? "true" : "false"); - card.setAttribute("data-is-compilation", isCompilation ? "true" : "false"); - card.setAttribute("data-is-featured", isFeatured ? "true" : "false"); + // Tag card for content-type filtering (shared classifier — #877, so Artist + // Detail and the Download Discography modal never drift apart). + const cc = _classifyReleaseContent(release); + card.setAttribute("data-is-live", cc.isLive ? "true" : "false"); + card.setAttribute("data-is-compilation", cc.isCompilation ? "true" : "false"); + card.setAttribute("data-is-featured", cc.isFeatured ? "true" : "false"); // Background image — use data-bg-src for IntersectionObserver lazy loading // (observeLazyBackgrounds is called by the caller after appending the grid). @@ -2518,8 +2514,8 @@ async function openDiscographyModal() { const data = await res.json(); if (!data.error) { - discography = { albums: data.albums || [], singles: data.singles || [] }; - if (discography.albums.length > 0 || discography.singles.length > 0) { + discography = { albums: data.albums || [], eps: data.eps || [], singles: data.singles || [] }; + if (discography.albums.length > 0 || discography.eps.length > 0 || discography.singles.length > 0) { artistsPageState.artistDiscography = discography; artistsPageState.sourceOverride = data.source || artistsPageState.sourceOverride || null; // Use metadata source ID for the modal (needed for download API calls) @@ -2569,6 +2565,9 @@ async function openDiscographyModal() { + + +
@@ -2605,21 +2604,36 @@ async function openDiscographyModal() { function _esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; } +// #877: single source of truth for content-type classification, shared by the +// Artist Detail cards and the Download Discography modal so they can't drift. +function _classifyReleaseContent(release) { + const t = (release && (release.title || release.name)) || ''; + const livePattern = /\b(live)\b|\(live[^)]*\)|\[live[^\]]*\]/i; + const compilationPattern = /\b(greatest hits|best of|collection|anthology|essential)\b/i; + const featuredPattern = /\(?\bfeat\.?\s|\bft\.?\s|\bfeaturing\b/i; + return { + isLive: livePattern.test(t), + isCompilation: (release && release.album_type === 'compilation') || compilationPattern.test(t), + isFeatured: featuredPattern.test(t), + }; +} + function _renderDiscogCard(release, index, completionData) { const comp = completionData?.albums?.find(c => c.id === release.id) || completionData?.singles?.find(c => c.id === release.id); const status = comp?.status || 'unknown'; const isOwned = status === 'completed'; const isPartial = status === 'partial' || status === 'nearly_complete'; const year = release.release_date ? release.release_date.substring(0, 4) : ''; - const tracks = release.total_tracks || 0; + const tracks = release.total_tracks || release.track_count || 0; const img = release.image_url || ''; + const cc = _classifyReleaseContent(release); const checked = !isOwned; const statusClass = isOwned ? 'owned' : isPartial ? 'partial' : ''; const statusIcon = isOwned ? '✓' : isPartial ? '◐' : ''; const albumName = release.name || release.title || ''; return ` -