From eba7f61e0464832c52d6ac5522e8ee02a04a6e08 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sat, 23 May 2026 16:22:17 -0700 Subject: [PATCH] Surface metadata source on Import album results (#681) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- core/imports/routes.py | 17 +++++++---- tests/imports/test_import_routes.py | 36 ++++++++++++++++++++-- webui/static/helper.js | 3 ++ webui/static/stats-automations.js | 46 ++++++++++++++++++----------- webui/static/style.css | 19 ++++++++++++ 5 files changed, 97 insertions(+), 24 deletions(-) diff --git a/core/imports/routes.py b/core/imports/routes.py index 57df3c7b..edd38f08 100644 --- a/core/imports/routes.py +++ b/core/imports/routes.py @@ -217,7 +217,12 @@ def staging_hints(runtime: ImportRouteRuntime) -> tuple[Dict[str, Any], int]: def staging_suggestions() -> tuple[Dict[str, Any], int]: """Return cached import suggestions and readiness state.""" 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]: @@ -228,11 +233,12 @@ def search_albums(runtime: ImportRouteRuntime, query: str, limit: int = 12) -> t return {"success": False, "error": "Missing query parameter"}, 400 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") 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: runtime.logger.error("Error searching albums for import: %s", exc) 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 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") 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: runtime.logger.error("Error searching tracks for import: %s", exc) return {"success": False, "error": str(exc)}, 500 diff --git a/tests/imports/test_import_routes.py b/tests/imports/test_import_routes.py index 83c57350..cfc7d8ce 100644 --- a/tests/imports/test_import_routes.py +++ b/tests/imports/test_import_routes.py @@ -207,6 +207,7 @@ def test_staging_suggestions_returns_cache_payload(monkeypatch): "get_import_suggestions_cache", lambda: {"suggestions": [{"album": "Album"}], "built": True}, ) + monkeypatch.setattr(import_routes, "_get_primary_source", lambda: "deezer") payload, status = staging_suggestions() @@ -215,6 +216,7 @@ def test_staging_suggestions_returns_cache_payload(monkeypatch): "success": True, "suggestions": [{"album": "Album"}], "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) 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 calls == [("Album", 50)] @@ -315,6 +321,28 @@ def test_search_albums_requires_query(): 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(): worker = _FakeHydrabaseWorker() calls = [] @@ -329,7 +357,11 @@ def test_search_tracks_enqueues_hydrabase_and_caps_limit(): payload, status = search_tracks(runtime, " Track ", 99) 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 calls == [("Track", 30)] diff --git a/webui/static/helper.js b/webui/static/helper.js index 045c5802..36c8389c 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3414,6 +3414,8 @@ function closeHelperSearch() { // release time and add a real `date:` line at the top of the version block. const WHATS_NEW = { '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' }, { 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.' }, @@ -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", "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", + "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", }, diff --git a/webui/static/stats-automations.js b/webui/static/stats-automations.js index 06772113..74872897 100644 --- a/webui/static/stats-automations.js +++ b/webui/static/stats-automations.js @@ -589,7 +589,8 @@ async function importPageMatchAutoGroup(groupIdx) { importPageState._autoGroupFilePaths = group.file_paths; // 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 { grid.innerHTML = '
-