feat(lidarr): exact MBID album lookup via MusicBrainz resolution
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
76
musicfetch
76
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:<id>) — 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]
|
||||
|
||||
|
||||
|
||||
83
tests/test_lidarr_search.py
Normal file
83
tests/test_lidarr_search.py
Normal file
@@ -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"
|
||||
Reference in New Issue
Block a user