#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:
parent
f2f0f5d849
commit
e7814e0acf
3 changed files with 81 additions and 18 deletions
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue