#877: Download Discography filters mirror Artist Detail (fix dead EPs + add Live/Comp/Featured)

The Download Discography modal exposed only Albums/EPs/Singles, its EPs toggle did
nothing, and Live/Compilations/Featured were missing — so you couldn't fine-filter
a bulk download the way Artist Detail lets you browse.

Root cause: the modal's endpoint (/api/artist/<id>/discography) used the base
get_artist_discography, which lumps EPs into singles, and the modal only read
{albums, singles} — so the EPs bucket was always empty (dead toggle). It also had
no content-type (Live/Compilation/Featured) classification at all.

- Backend: the endpoint now uses get_artist_detail_discography — the SAME split
  Artist Detail uses — and returns a separate `eps` list.
- Frontend: read `eps`; tag each card with data-is-live/compilation/featured via a
  new shared _classifyReleaseContent() (also adopted by the Artist Detail cards so
  the two can't drift); add Live/Compilations/Featured filter buttons; combined
  category+content filtering. The download payload is built from VISIBLE checked
  cards, so every toggle now actually changes what downloads.
- Regression test: get_artist_detail_discography splits an EP into the eps bucket.
This commit is contained in:
BoulderBadgeDad 2026-06-15 21:30:55 -07:00
parent f2f0f5d849
commit e7814e0acf
3 changed files with 81 additions and 18 deletions

View file

@ -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"]

View file

@ -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,

View file

@ -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() {
<button class="discog-filter active" data-type="album" onclick="toggleDiscogFilter(this)">Albums</button>
<button class="discog-filter active" data-type="ep" onclick="toggleDiscogFilter(this)">EPs</button>
<button class="discog-filter active" data-type="single" onclick="toggleDiscogFilter(this)">Singles</button>
<button class="discog-filter active" data-content="live" onclick="toggleDiscogFilter(this)">Live</button>
<button class="discog-filter active" data-content="compilations" onclick="toggleDiscogFilter(this)">Compilations</button>
<button class="discog-filter active" data-content="featured" onclick="toggleDiscogFilter(this)">Featured</button>
</div>
<div class="discog-select-actions">
<button class="discog-select-btn" onclick="discogSelectAll(true)">Select All</button>
@ -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 `
<label class="discog-card ${statusClass}" data-type="${release._type}" style="animation-delay:${index * 0.03}s">
<label class="discog-card ${statusClass}" data-type="${release._type}" data-is-live="${cc.isLive}" data-is-compilation="${cc.isCompilation}" data-is-featured="${cc.isFeatured}" style="animation-delay:${index * 0.03}s">
<input type="checkbox" class="discog-card-cb" data-album-id="${release.id}" data-album-name="${_esc(albumName)}" data-tracks="${tracks}" ${checked ? 'checked' : ''} onchange="_updateDiscogFooterCount()">
<div class="discog-card-art">
${img ? `<img src="${img}" alt="" loading="lazy">` : '<div class="discog-card-art-placeholder">🎵</div>'}
@ -2636,9 +2650,29 @@ function _renderDiscogCard(release, index, completionData) {
function toggleDiscogFilter(btn) {
btn.classList.toggle('active');
const type = btn.dataset.type;
document.querySelectorAll(`.discog-card[data-type="${type}"]`).forEach(card => {
card.style.display = btn.classList.contains('active') ? '' : 'none';
_applyDiscogFilters();
}
// #877: combined category (Albums/EPs/Singles) + content (Live/Compilations/
// Featured) filtering, mirroring the Artist Detail filter logic. A card is
// hidden if its category is off OR any active content exclusion applies — and
// because the download payload is built from VISIBLE checked cards, every
// toggle now actually changes what gets downloaded.
function _applyDiscogFilters() {
const typeActive = {};
document.querySelectorAll('.discog-filter[data-type]').forEach(b => {
typeActive[b.dataset.type] = b.classList.contains('active');
});
const contentActive = {};
document.querySelectorAll('.discog-filter[data-content]').forEach(b => {
contentActive[b.dataset.content] = b.classList.contains('active');
});
document.querySelectorAll('.discog-card').forEach(card => {
let hidden = typeActive[card.getAttribute('data-type')] === false;
if (!hidden && contentActive.live === false && card.getAttribute('data-is-live') === 'true') hidden = true;
if (!hidden && contentActive.compilations === false && card.getAttribute('data-is-compilation') === 'true') hidden = true;
if (!hidden && contentActive.featured === false && card.getAttribute('data-is-featured') === 'true') hidden = true;
card.style.display = hidden ? 'none' : '';
});
_updateDiscogFooterCount();
}