Surface metadata source on Import album results (#681)

Import album search silently fell through to the next source in
METADATA_SOURCE_PRIORITY when the configured primary returned zero
matches — intentional behavior shared with the auto-import worker
(see core/auto_import_worker.py:1316). With MusicBrainz selected and
a query MB couldn't resolve, users saw Deezer cards with no indication
their primary was bypassed.

Backend now echoes `primary_source` on /api/import/search/albums,
/api/import/search/tracks, and /api/import/staging/suggestions.
Frontend renders a per-card 'via {source}' badge when the served
source differs from the primary, plus a banner above the grid when
every card came from a fallback source. Fallback semantics unchanged.

Also collapses an inline duplicate of _renderSuggestionCard inside
importPageSearchAlbum into a single shared renderer.

Regression test pins the contract: response carries primary_source +
per-album source when the chain falls back.
pull/685/head
Broque Thomas 2 days ago
parent 94129d3099
commit eba7f61e04

@ -217,7 +217,12 @@ def staging_hints(runtime: ImportRouteRuntime) -> tuple[Dict[str, Any], int]:
def staging_suggestions() -> tuple[Dict[str, Any], int]: def staging_suggestions() -> tuple[Dict[str, Any], int]:
"""Return cached import suggestions and readiness state.""" """Return cached import suggestions and readiness state."""
cache = get_import_suggestions_cache() cache = get_import_suggestions_cache()
return {"success": True, "suggestions": cache["suggestions"], "ready": cache["built"]}, 200 return {
"success": True,
"suggestions": cache["suggestions"],
"ready": cache["built"],
"primary_source": _get_primary_source(),
}, 200
def search_albums(runtime: ImportRouteRuntime, query: str, limit: int = 12) -> tuple[Dict[str, Any], int]: def search_albums(runtime: ImportRouteRuntime, query: str, limit: int = 12) -> tuple[Dict[str, Any], int]:
@ -228,11 +233,12 @@ def search_albums(runtime: ImportRouteRuntime, query: str, limit: int = 12) -> t
return {"success": False, "error": "Missing query parameter"}, 400 return {"success": False, "error": "Missing query parameter"}, 400
limit = min(int(limit), 50) limit = min(int(limit), 50)
if runtime.get_primary_source() == "hydrabase" and runtime.hydrabase_worker and runtime.dev_mode_enabled: primary_source = runtime.get_primary_source()
if primary_source == "hydrabase" and runtime.hydrabase_worker and runtime.dev_mode_enabled:
runtime.hydrabase_worker.enqueue(query, "albums") runtime.hydrabase_worker.enqueue(query, "albums")
albums = runtime.search_import_albums(query, limit=limit) albums = runtime.search_import_albums(query, limit=limit)
return {"success": True, "albums": albums}, 200 return {"success": True, "albums": albums, "primary_source": primary_source}, 200
except Exception as exc: except Exception as exc:
runtime.logger.error("Error searching albums for import: %s", exc) runtime.logger.error("Error searching albums for import: %s", exc)
return {"success": False, "error": str(exc)}, 500 return {"success": False, "error": str(exc)}, 500
@ -362,11 +368,12 @@ def search_tracks(runtime: ImportRouteRuntime, query: str, limit: int = 10) -> t
return {"success": False, "error": "Missing query parameter"}, 400 return {"success": False, "error": "Missing query parameter"}, 400
limit = min(int(limit), 30) limit = min(int(limit), 30)
if runtime.get_primary_source() == "hydrabase" and runtime.hydrabase_worker and runtime.dev_mode_enabled: primary_source = runtime.get_primary_source()
if primary_source == "hydrabase" and runtime.hydrabase_worker and runtime.dev_mode_enabled:
runtime.hydrabase_worker.enqueue(query, "tracks") runtime.hydrabase_worker.enqueue(query, "tracks")
tracks = runtime.search_import_tracks(query, limit=limit) tracks = runtime.search_import_tracks(query, limit=limit)
return {"success": True, "tracks": tracks}, 200 return {"success": True, "tracks": tracks, "primary_source": primary_source}, 200
except Exception as exc: except Exception as exc:
runtime.logger.error("Error searching tracks for import: %s", exc) runtime.logger.error("Error searching tracks for import: %s", exc)
return {"success": False, "error": str(exc)}, 500 return {"success": False, "error": str(exc)}, 500

@ -207,6 +207,7 @@ def test_staging_suggestions_returns_cache_payload(monkeypatch):
"get_import_suggestions_cache", "get_import_suggestions_cache",
lambda: {"suggestions": [{"album": "Album"}], "built": True}, lambda: {"suggestions": [{"album": "Album"}], "built": True},
) )
monkeypatch.setattr(import_routes, "_get_primary_source", lambda: "deezer")
payload, status = staging_suggestions() payload, status = staging_suggestions()
@ -215,6 +216,7 @@ def test_staging_suggestions_returns_cache_payload(monkeypatch):
"success": True, "success": True,
"suggestions": [{"album": "Album"}], "suggestions": [{"album": "Album"}],
"ready": True, "ready": True,
"primary_source": "deezer",
} }
@ -303,7 +305,11 @@ def test_search_albums_enqueues_hydrabase_and_caps_limit():
payload, status = search_albums(runtime, " Album ", 99) payload, status = search_albums(runtime, " Album ", 99)
assert status == 200 assert status == 200
assert payload == {"success": True, "albums": [{"id": "album-1"}]} assert payload == {
"success": True,
"albums": [{"id": "album-1"}],
"primary_source": "hydrabase",
}
assert worker.enqueued == [("Album", "albums")] assert worker.enqueued == [("Album", "albums")]
assert calls == [("Album", 50)] assert calls == [("Album", 50)]
@ -315,6 +321,28 @@ def test_search_albums_requires_query():
assert payload == {"success": False, "error": "Missing query parameter"} assert payload == {"success": False, "error": "Missing query parameter"}
def test_search_albums_exposes_primary_source_when_chain_falls_back():
# Pins github issue #681: when the primary source returns nothing and the
# silent fallback chain (intentional, see core/auto_import_worker.py:1316)
# serves results from a different source, the response must carry both
# `primary_source` (what the user configured) and per-album `source`
# (what actually served the result) so the UI can warn the user.
runtime = ImportRouteRuntime(
get_primary_source=lambda: "musicbrainz",
search_import_albums=lambda query, limit: [
{"id": "deezer-1", "name": "Album", "source": "deezer"},
],
logger=_FakeLogger(),
)
payload, status = search_albums(runtime, "Weapons of Mass Destruction", 12)
assert status == 200
assert payload["success"] is True
assert payload["primary_source"] == "musicbrainz"
assert payload["albums"][0]["source"] == "deezer"
def test_search_tracks_enqueues_hydrabase_and_caps_limit(): def test_search_tracks_enqueues_hydrabase_and_caps_limit():
worker = _FakeHydrabaseWorker() worker = _FakeHydrabaseWorker()
calls = [] calls = []
@ -329,7 +357,11 @@ def test_search_tracks_enqueues_hydrabase_and_caps_limit():
payload, status = search_tracks(runtime, " Track ", 99) payload, status = search_tracks(runtime, " Track ", 99)
assert status == 200 assert status == 200
assert payload == {"success": True, "tracks": [{"id": "track-1"}]} assert payload == {
"success": True,
"tracks": [{"id": "track-1"}],
"primary_source": "hydrabase",
}
assert worker.enqueued == [("Track", "tracks")] assert worker.enqueued == [("Track", "tracks")]
assert calls == [("Track", 30)] assert calls == [("Track", 30)]

@ -3414,6 +3414,8 @@ function closeHelperSearch() {
// release time and add a real `date:` line at the top of the version block. // release time and add a real `date:` line at the top of the version block.
const WHATS_NEW = { const WHATS_NEW = {
'2.5.9': [ '2.5.9': [
{ date: 'Unreleased — dev cycle' },
{ title: 'Import search: show when results came from the fallback source', desc: 'if you picked MusicBrainz (or Discogs / iTunes / etc.) as your primary metadata source but the Import album search ended up serving Deezer cards, you had no idea — the chain silently fell through when the primary returned nothing. now each card shows a small "via Deezer" label when the source differs from your primary, and a banner above the grid spells it out when all results came from the fallback. backend behavior unchanged.' },
{ date: 'May 21, 2026 — 2.5.9 release' }, { date: 'May 21, 2026 — 2.5.9 release' },
{ title: 'Now-playing modal: lyrics panel', desc: 'new lyrics panel below the player controls in the expanded now-playing modal. fetches from LRClib via /api/lyrics/fetch, but prefers the local .lrc / .txt sidecar files SoulSync drops next to your audio during post-processing so downloaded tracks show lyrics instantly with zero network. synced LRC (timestamped) highlights the active line and auto-scrolls it into the middle of the viewport on every audio timeupdate; plain text renders without highlighting. status chip shows whether the result came back Synced or Plain. panel is collapsed by default — click the Lyrics header to expand. cached per track so revisiting a track doesn\'t refetch.' }, { title: 'Now-playing modal: lyrics panel', desc: 'new lyrics panel below the player controls in the expanded now-playing modal. fetches from LRClib via /api/lyrics/fetch, but prefers the local .lrc / .txt sidecar files SoulSync drops next to your audio during post-processing so downloaded tracks show lyrics instantly with zero network. synced LRC (timestamped) highlights the active line and auto-scrolls it into the middle of the viewport on every audio timeupdate; plain text renders without highlighting. status chip shows whether the result came back Synced or Plain. panel is collapsed by default — click the Lyrics header to expand. cached per track so revisiting a track doesn\'t refetch.' },
{ title: 'Now-playing modal: View Artist closes the modal first', desc: 'tapping View Artist on the expanded media player now closes the now-playing modal before navigating, so the artist page is actually visible instead of sitting under a modal you\'d have to manually dismiss. click is a no-op when no artist_id is attached to the current track.' }, { title: 'Now-playing modal: View Artist closes the modal first', desc: 'tapping View Artist on the expanded media player now closes the now-playing modal before navigating, so the artist page is actually visible instead of sitting under a modal you\'d have to manually dismiss. click is a no-op when no artist_id is attached to the current track.' },
@ -3502,6 +3504,7 @@ const VERSION_MODAL_SECTIONS = [
"Jellyfin full refresh repairs older media databases before importing tracks, so stale schemas no longer drop every track row", "Jellyfin full refresh repairs older media databases before importing tracks, so stale schemas no longer drop every track row",
"Cache maintenance and full refresh retry transient SQLite disk I/O errors instead of failing the whole job on the first attempt", "Cache maintenance and full refresh retry transient SQLite disk I/O errors instead of failing the whole job on the first attempt",
"Album Completeness rejects same-title releases from the wrong artist before importing anything", "Album Completeness rejects same-title releases from the wrong artist before importing anything",
"Import album search now labels each card with the source that served it and warns when results came from a fallback instead of your primary",
], ],
usage_note: "version 2.5.9 focuses on safer matching and making the new release-based sources usable without disturbing existing source flows", usage_note: "version 2.5.9 focuses on safer matching and making the new release-based sources usable without disturbing existing source flows",
}, },

@ -589,7 +589,8 @@ async function importPageMatchAutoGroup(groupIdx) {
importPageState._autoGroupFilePaths = group.file_paths; importPageState._autoGroupFilePaths = group.file_paths;
// Render results — user picks the right album // Render results — user picks the right album
grid.innerHTML = data.albums.map(a => _renderSuggestionCard(a)).join(''); const banner = _renderImportFallbackBanner(data.albums, data.primary_source);
grid.innerHTML = banner + data.albums.map(a => _renderSuggestionCard(a, data.primary_source)).join('');
} else { } else {
grid.innerHTML = '<div style="color:#888;text-align:center;padding:20px;">No albums found — try searching manually</div>'; grid.innerHTML = '<div style="color:#888;text-align:center;padding:20px;">No albums found — try searching manually</div>';
} }
@ -625,28 +626,51 @@ async function importPageLoadSuggestions() {
} }
section.style.display = ''; section.style.display = '';
grid.innerHTML = data.suggestions.map(a => _renderSuggestionCard(a)).join(''); const banner = _renderImportFallbackBanner(data.suggestions, data.primary_source);
grid.innerHTML = banner + data.suggestions.map(a => _renderSuggestionCard(a, data.primary_source)).join('');
} catch (err) { } catch (err) {
// Network error or server not ready — fail silently // Network error or server not ready — fail silently
console.warn('Failed to load import suggestions:', err); console.warn('Failed to load import suggestions:', err);
} }
} }
function _renderSuggestionCard(a) { function _renderSuggestionCard(a, primarySource) {
// Cache the album lookup so importPageSelectAlbum can pull source + // Cache the album lookup so importPageSelectAlbum can pull source +
// name + artist on click (the onclick can only carry the ID string // name + artist on click (the onclick can only carry the ID string
// — see github issue #524 root cause). // — see github issue #524 root cause).
importPageState._albumLookup[a.id] = { importPageState._albumLookup[a.id] = {
id: a.id, name: a.name || '', artist: a.artist || '', source: a.source || '', id: a.id, name: a.name || '', artist: a.artist || '', source: a.source || '',
}; };
// Surface the served source when it differs from the user's configured
// primary — the search route silently falls through to the next source
// in METADATA_SOURCE_PRIORITY when the primary returns nothing
// (intentional design, see core/auto_import_worker.py:1316). Without
// this badge the user has no idea their MusicBrainz / Discogs choice
// got bypassed (github issue #681).
const sourceBadge = (a.source && primarySource && a.source !== primarySource)
? `<div class="import-page-album-card-source">via ${_esc((SOURCE_LABELS[a.source] || {}).text || a.source)}</div>`
: '';
return `<div class="import-page-album-card" onclick="importPageSelectAlbum('${_escAttr(a.id)}')"> return `<div class="import-page-album-card" onclick="importPageSelectAlbum('${_escAttr(a.id)}')">
<img src="${a.image_url || '/static/placeholder-album.png'}" alt="${_escAttr(a.name)}" loading="lazy" onerror="this.src='/static/placeholder-album.png'"> <img src="${a.image_url || '/static/placeholder-album.png'}" alt="${_escAttr(a.name)}" loading="lazy" onerror="this.src='/static/placeholder-album.png'">
<div class="import-page-album-card-title" title="${_escAttr(a.name)}">${_esc(a.name)}</div> <div class="import-page-album-card-title" title="${_escAttr(a.name)}">${_esc(a.name)}</div>
<div class="import-page-album-card-artist" title="${_escAttr(a.artist)}">${_esc(a.artist)}</div> <div class="import-page-album-card-artist" title="${_escAttr(a.artist)}">${_esc(a.artist)}</div>
<div class="import-page-album-card-meta">${a.total_tracks} tracks · ${a.release_date ? a.release_date.substring(0, 4) : ''}</div> <div class="import-page-album-card-meta">${a.total_tracks} tracks · ${a.release_date ? a.release_date.substring(0, 4) : ''}</div>
${sourceBadge}
</div>`; </div>`;
} }
function _renderImportFallbackBanner(albums, primarySource) {
if (!primarySource || !albums || !albums.length) return '';
const allFallback = albums.every(a => a.source && a.source !== primarySource);
if (!allFallback) return '';
const servedSource = albums[0].source;
const primaryLabel = (SOURCE_LABELS[primarySource] || {}).text || primarySource;
const servedLabel = (SOURCE_LABELS[servedSource] || {}).text || servedSource;
// Neutral wording — covers both live-search fallback (primary returned 0)
// and cache-stale suggestions (primary changed since cache was built).
return `<div class="import-page-fallback-banner">Showing ${_esc(servedLabel)} results — not from your primary source (${_esc(primaryLabel)}).</div>`;
}
// --- Album Tab: Search --- // --- Album Tab: Search ---
async function importPageSearchAlbum() { async function importPageSearchAlbum() {
@ -666,20 +690,8 @@ async function importPageSearchAlbum() {
grid.innerHTML = '<div style="color:#888;text-align:center;padding:20px;">No albums found</div>'; grid.innerHTML = '<div style="color:#888;text-align:center;padding:20px;">No albums found</div>';
return; return;
} }
grid.innerHTML = data.albums.map(a => { const banner = _renderImportFallbackBanner(data.albums, data.primary_source);
// Cache album lookup so the click handler can include source grid.innerHTML = banner + data.albums.map(a => _renderSuggestionCard(a, data.primary_source)).join('');
// + name + artist on the match POST (see #524).
importPageState._albumLookup[a.id] = {
id: a.id, name: a.name || '', artist: a.artist || '', source: a.source || '',
};
return `
<div class="import-page-album-card" onclick="importPageSelectAlbum('${_escAttr(a.id)}')">
<img src="${a.image_url || '/static/placeholder-album.png'}" alt="${_escAttr(a.name)}" loading="lazy" onerror="this.src='/static/placeholder-album.png'">
<div class="import-page-album-card-title" title="${_escAttr(a.name)}">${_esc(a.name)}</div>
<div class="import-page-album-card-artist" title="${_escAttr(a.artist)}">${_esc(a.artist)}</div>
<div class="import-page-album-card-meta">${a.total_tracks} tracks · ${a.release_date ? a.release_date.substring(0, 4) : ''}</div>
</div>`;
}).join('');
document.getElementById('import-page-album-clear-btn').classList.remove('hidden'); document.getElementById('import-page-album-clear-btn').classList.remove('hidden');
} catch (err) { } catch (err) {
grid.innerHTML = `<div style="color:#ef4444;text-align:center;padding:20px;">Error: ${err.message}</div>`; grid.innerHTML = `<div style="color:#ef4444;text-align:center;padding:20px;">Error: ${err.message}</div>`;

@ -40320,6 +40320,25 @@ div.artist-hero-badge {
margin-top: 4px; margin-top: 4px;
} }
.import-page-album-card-source {
font-size: 10px;
color: rgba(255, 200, 100, 0.75);
margin-top: 2px;
font-style: italic;
}
.import-page-fallback-banner {
grid-column: 1 / -1;
padding: 10px 14px;
margin-bottom: 12px;
background: rgba(255, 200, 100, 0.08);
border: 1px solid rgba(255, 200, 100, 0.25);
border-radius: 8px;
color: rgba(255, 220, 170, 0.9);
font-size: 12px;
line-height: 1.4;
}
/* Album hero (selected album) */ /* Album hero (selected album) */
.import-page-album-hero { .import-page-album-hero {
display: flex; display: flex;

Loading…
Cancel
Save