From 18f72a56267b7bde2aa0807bc013147a3c519472 Mon Sep 17 00:00:00 2001 From: zebra Date: Mon, 8 Jun 2026 23:22:11 -0700 Subject: [PATCH] feat(lidarr): exact MBID album lookup via MusicBrainz resolution Co-Authored-By: Claude Sonnet 4.6 --- musicfetch | 76 +++++++++++++++++++++++---------- tests/test_lidarr_search.py | 83 +++++++++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+), 22 deletions(-) create mode 100644 tests/test_lidarr_search.py diff --git a/musicfetch b/musicfetch index 36dc52d..b5f38c8 100755 --- a/musicfetch +++ b/musicfetch @@ -200,38 +200,70 @@ def _split_query(query: str) -> tuple[str, Optional[str]]: def lidarr_search(query: str, limit: int) -> list[Hit]: - """Universal search via /api/v1/search; fall back to album+artist lookup.""" + """Return Lidarr hits, best match first. Resolves 'Artist - Track' to an + album's MusicBrainz release-group MBID, then does an exact Lidarr lookup + (term=mbid:) — no fuzzy ranking. Falls back so it never raises and + returns [] only on total failure / missing key.""" if not API_KEY: err("LIDARR_API_KEY not set — skipping Lidarr search.") return [] + + artist, right = _split_query(query) + + if right: + mb = musicbrainz_best_album(artist, right) + if mb and mb["rg_mbid"]: + hits = _lidarr_album_candidates(f"mbid:{mb['rg_mbid']}") + for h in hits: + if not h.year and mb["year"]: + h.year = mb["year"] + if hits: + return hits[:limit] + # MusicBrainz miss / no exact album → plain lookup (album-first: a dash + # query named an album/track). + return _fallback_lookup(query, limit, artist_first=False) + + # Bare term is most often an artist. + return _fallback_lookup(query, limit, artist_first=True) + + +def _lidarr_album_candidates(term: str) -> list[Hit]: + try: + return [_album_to_hit(a) for a in lidarr_get("/api/v1/album/lookup", params={"term": term})] + except RequestException as e: + dbg(f"album/lookup failed: {e}") + return [] + + +def _lidarr_artist_candidates(term: str) -> list[Hit]: + try: + return [_artist_to_hit(a) for a in lidarr_get("/api/v1/artist/lookup", params={"term": term})] + except RequestException as e: + dbg(f"artist/lookup failed: {e}") + return [] + + +def _fallback_lookup(query: str, limit: int, artist_first: bool) -> list[Hit]: + """Plain album + artist lookups (no scoring); /search as last resort.""" + albums = _lidarr_album_candidates(query) + artists = _lidarr_artist_candidates(query) + hits = (artists + albums) if artist_first else (albums + artists) + if hits: + return hits[:limit] + return _universal_search(query, limit) + + +def _universal_search(query: str, limit: int) -> list[Hit]: + """Last resort: Lidarr's fuzzy /search (unranked).""" hits: list[Hit] = [] try: - results = lidarr_get("/api/v1/search", params={"term": query}) - for item in results: - # /search returns objects with 'foreignId' and either 'album' or 'artist'. + for item in lidarr_get("/api/v1/search", params={"term": query}): if item.get("album"): hits.append(_album_to_hit(item["album"])) elif item.get("artist"): hits.append(_artist_to_hit(item["artist"])) - if hits: - return hits[:limit] - dbg("/api/v1/search returned nothing useful; trying lookup endpoints.") - except Timeout: - err("Lidarr universal search timed out.") except RequestException as e: - dbg(f"/api/v1/search unavailable ({e}); falling back to lookup endpoints.") - - # Fallback: album lookup then artist lookup. - try: - for album in lidarr_get("/api/v1/album/lookup", params={"term": query}): - hits.append(_album_to_hit(album)) - except RequestException as e: - dbg(f"album/lookup failed: {e}") - try: - for artist in lidarr_get("/api/v1/artist/lookup", params={"term": query}): - hits.append(_artist_to_hit(artist)) - except RequestException as e: - dbg(f"artist/lookup failed: {e}") + dbg(f"/api/v1/search failed: {e}") return hits[:limit] diff --git a/tests/test_lidarr_search.py b/tests/test_lidarr_search.py new file mode 100644 index 0000000..c4a5fd6 --- /dev/null +++ b/tests/test_lidarr_search.py @@ -0,0 +1,83 @@ +import server.mf # noqa: F401 +import musicfetch_core as mf + +DISCOVERY_MBID = "48117b90-a16e-34ca-a514-19c702df1158" + +DISCOVERY_ALBUM = {"title": "Discovery", "artist": {"artistName": "Daft Punk"}, + "releaseDate": "2001-01-01", "foreignAlbumId": DISCOVERY_MBID} + + +def test_artist_track_uses_mbid_exact_lookup(monkeypatch): + monkeypatch.setattr(mf, "API_KEY", "testkey") + monkeypatch.setattr(mf, "musicbrainz_best_album", + lambda artist, track: {"album_title": "Discovery", "artist": "Daft Punk", + "year": "2001", "rg_mbid": DISCOVERY_MBID}) + seen = {} + + def fake_get(path, params=None, timeout=15): + seen["term"] = (params or {}).get("term") + if path == "/api/v1/album/lookup" and seen["term"] == f"mbid:{DISCOVERY_MBID}": + return [DISCOVERY_ALBUM] + return [] + monkeypatch.setattr(mf, "lidarr_get", fake_get) + + hits = mf.lidarr_search("Daft Punk - Harder Better Faster Stronger", 10) + assert seen["term"] == f"mbid:{DISCOVERY_MBID}" + assert hits[0].album == "Discovery" + assert hits[0].artist == "Daft Punk" + assert hits[0].payload["album"]["foreignAlbumId"] == DISCOVERY_MBID + + +def test_year_enriched_from_musicbrainz(monkeypatch): + monkeypatch.setattr(mf, "API_KEY", "testkey") + monkeypatch.setattr(mf, "musicbrainz_best_album", + lambda artist, track: {"album_title": "Discovery", "artist": "Daft Punk", + "year": "2001", "rg_mbid": DISCOVERY_MBID}) + no_year = [{"title": "Discovery", "artist": {"artistName": "Daft Punk"}, + "releaseDate": "", "foreignAlbumId": DISCOVERY_MBID}] + monkeypatch.setattr(mf, "lidarr_get", + lambda path, params=None, timeout=15: no_year if path == "/api/v1/album/lookup" else []) + hits = mf.lidarr_search("Daft Punk - Discovery", 10) + assert hits[0].year == "2001" + + +def test_no_api_key_returns_empty(monkeypatch): + monkeypatch.setattr(mf, "API_KEY", "") + assert mf.lidarr_search("Daft Punk - Discovery", 10) == [] + + +def test_mb_miss_falls_back_to_lookup(monkeypatch): + monkeypatch.setattr(mf, "API_KEY", "testkey") + monkeypatch.setattr(mf, "musicbrainz_best_album", lambda artist, track: None) + monkeypatch.setattr(mf, "lidarr_get", + lambda path, params=None, timeout=15: [DISCOVERY_ALBUM] if path == "/api/v1/album/lookup" else []) + hits = mf.lidarr_search("Daft Punk - Discovery", 10) + assert hits[0].album == "Discovery" + + +def test_single_term_is_artist_first(monkeypatch): + monkeypatch.setattr(mf, "API_KEY", "testkey") + + def fake_get(path, params=None, timeout=15): + if path == "/api/v1/artist/lookup": + return [{"artistName": "Daft Punk"}] + if path == "/api/v1/album/lookup": + return [DISCOVERY_ALBUM] + return [] + monkeypatch.setattr(mf, "lidarr_get", fake_get) + hits = mf.lidarr_search("Daft Punk", 10) + assert hits[0].kind == "artist" + assert hits[0].artist == "Daft Punk" + + +def test_last_resort_universal_search(monkeypatch): + monkeypatch.setattr(mf, "API_KEY", "testkey") + monkeypatch.setattr(mf, "musicbrainz_best_album", lambda artist, track: None) + + def fake_get(path, params=None, timeout=15): + if path == "/api/v1/search": + return [{"album": DISCOVERY_ALBUM}] + return [] + monkeypatch.setattr(mf, "lidarr_get", fake_get) + hits = mf.lidarr_search("Daft Punk - Discovery", 10) + assert hits and hits[0].album == "Discovery"