From 45121dd80739eb8db3e3add81192a6f4f827528f Mon Sep 17 00:00:00 2001 From: zebra Date: Mon, 8 Jun 2026 21:06:39 -0700 Subject: [PATCH] Plan smarter Lidarr matching via exact MBID lookup Drop fuzzy difflib scoring: MusicBrainz resolves track->album release-group MBID, Lidarr album/lookup?term=mbid: returns the exact album. Live-verified against the user's Lidarr. Co-Authored-By: Claude Opus 4.8 --- .../2026-06-08-smarter-lidarr-matching.md | 514 ++++++++++++++++++ ...26-06-08-smarter-lidarr-matching-design.md | 62 ++- 2 files changed, 546 insertions(+), 30 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-08-smarter-lidarr-matching.md diff --git a/docs/superpowers/plans/2026-06-08-smarter-lidarr-matching.md b/docs/superpowers/plans/2026-06-08-smarter-lidarr-matching.md new file mode 100644 index 0000000..38a3e84 --- /dev/null +++ b/docs/superpowers/plans/2026-06-08-smarter-lidarr-matching.md @@ -0,0 +1,514 @@ +# Smarter Lidarr Matching Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `musicfetch.lidarr_search` resolve a Shazam-style `Artist - Track` to the correct album by asking MusicBrainz for the studio album's release-group MBID, then doing an **exact** Lidarr lookup (`album/lookup?term=mbid:`) — so the noninteractive API picks the real album (Daft Punk *Discovery*) instead of junk (Pignickel novelty), with **no fuzzy ranking system**. + +**Architecture:** All changes are in the single-file `musicfetch` binary (the shared search used by both the CLI picker and the REST API). New helpers `_split_query` and `musicbrainz_best_album`, plus a rewritten `lidarr_search` with small lookup helpers and tiered fallbacks. Tests import the binary as a module via the existing `server.mf` loader (which registers it in `sys.modules` as `musicfetch_core`). + +**Tech Stack:** Python 3.10+, stdlib `time`, `requests` (already a dep), pytest with `monkeypatch`. No new dependencies. Live-validated against MusicBrainz + the user's Lidarr 3.1.0 — `album/lookup?term=mbid:48117b90-a16e-34ca-a514-19c702df1158` returns exactly `Discovery — Daft Punk`. + +--- + +## Context for the implementer + +`musicfetch` is an executable Python file (no `.py` ext) at the repo root. Relevant existing pieces: +- `Hit` dataclass: fields `source, kind, title, artist, album, year, thumbnail, payload`. +- `_album_to_hit(album)` → `Hit(source="lidarr", kind="album", ..., payload={"album": album})`. The raw Lidarr album dict carries `foreignAlbumId` (MusicBrainz release-group MBID) and `releaseDate`. +- `_artist_to_hit(artist)` → `Hit(source="lidarr", kind="artist", ...)`. +- `lidarr_get(path, params=None, timeout=15)` → GET helper, raises on HTTP error. +- `API_KEY`, `dbg(...)`, `err(...)`, module-level `requests`, `from requests.exceptions import RequestException, Timeout`. +- Current `lidarr_search(query, limit)` at lines ~129-162 trusts `/api/v1/search` ordering then falls back to `/album/lookup` + `/artist/lookup`. **This is what we replace.** + +**Why MusicBrainz is still required:** Lidarr has no track-search endpoint; `album/lookup` only matches albums/artists. Shazam gives `Artist - Track`, and the track name won't match the album title in Lidarr. MusicBrainz recording search maps track → album, and gives us the release-group MBID that Lidarr's `mbid:` lookup resolves exactly. No scoring needed. + +**Don't break callers:** `lidarr_search(query, limit) -> list[Hit]` signature stays identical. `build_combined_hits` and the API depend on it returning `[]` on failure (so the YouTube fallback works). + +**Tests access the binary like this** (top of each new test module): +```python +import server.mf # noqa: F401 — loads musicfetch and registers musicfetch_core in sys.modules +import musicfetch_core as mf +``` +Set `mf.API_KEY` via `monkeypatch.setattr(mf, "API_KEY", "testkey")` where needed. + +**One import to add** to the top imports block of `musicfetch` (Task 2): `import time`. + +--- + +### Task 1: Query splitter `_split_query` + +**Files:** +- Modify: `musicfetch` (add `_split_query` just above `lidarr_search`) +- Test: `tests/test_lidarr_match.py` + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_lidarr_match.py`: +```python +import server.mf # noqa: F401 — loads musicfetch, registers musicfetch_core in sys.modules +import musicfetch_core as mf + + +def test_split_query_with_dash(): + assert mf._split_query("Daft Punk - Discovery") == ("Daft Punk", "Discovery") + + +def test_split_query_no_dash(): + assert mf._split_query("Daft Punk") == ("Daft Punk", None) + + +def test_split_query_splits_on_first_dash_only(): + assert mf._split_query("A - B - C") == ("A", "B - C") + + +def test_split_query_strips_whitespace(): + assert mf._split_query(" Daft Punk - Discovery ") == ("Daft Punk", "Discovery") +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_lidarr_match.py -v` +Expected: FAIL — `AttributeError: module 'musicfetch_core' has no attribute '_split_query'` + +- [ ] **Step 3: Add the implementation** + +In `musicfetch`, immediately above `def lidarr_search(`: +```python +def _split_query(query: str) -> tuple[str, Optional[str]]: + """Split a Shazam-style 'Artist - Track' on the first ' - '. + Returns (artist, track) or (term, None) when there is no separator.""" + if " - " in query: + left, right = query.split(" - ", 1) + return left.strip(), right.strip() + return query.strip(), None +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_lidarr_match.py -v` +Expected: PASS (4 passed) + +- [ ] **Step 5: Commit** + +```bash +git add musicfetch tests/test_lidarr_match.py +git commit -m "feat(lidarr): add Artist - Track query splitter" +``` + +--- + +### Task 2: MusicBrainz track→album resolver + +**Files:** +- Modify: `musicfetch` (add `import time` to top imports; add MB constants + `_mb_rate_limit`, `_mb_artist_credit`, `musicbrainz_best_album` above `lidarr_search`) +- Test: `tests/test_musicbrainz.py` + +The release-group selection prefers studio albums (`primary-type == "Album"` with no `secondary-types`), choosing the earliest dated one, skipping Single/Compilation/Live. Verified live: for "Daft Punk / Harder Better Faster Stronger" MB returns a Single, Compilations, Live albums, and the studio **Discovery** (mbid `48117b90-a16e-34ca-a514-19c702df1158`). + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_musicbrainz.py`: +```python +import server.mf # noqa: F401 +import musicfetch_core as mf + + +class _FakeResp: + def __init__(self, payload): + self._payload = payload + def raise_for_status(self): + pass + def json(self): + return self._payload + + +# Trimmed real-shaped MB recording response. +MB_PAYLOAD = { + "recordings": [ + { + "artist-credit": [{"name": "Daft Punk"}], + "releases": [ + {"date": "2001", + "release-group": {"id": "single-mbid", "title": "Harder, Better, Faster, Stronger", + "primary-type": "Single", "secondary-types": []}}, + {"date": "2002", + "release-group": {"id": "comp-mbid", "title": "Musique, Vol. 1", + "primary-type": "Album", "secondary-types": ["Compilation"]}}, + {"date": "2001", + "release-group": {"id": "48117b90-a16e-34ca-a514-19c702df1158", + "title": "Discovery", "primary-type": "Album", + "secondary-types": []}}, + ], + } + ] +} + + +def test_picks_studio_album_over_single_and_comp(monkeypatch): + monkeypatch.setattr(mf.requests, "get", lambda *a, **k: _FakeResp(MB_PAYLOAD)) + monkeypatch.setattr(mf.time, "sleep", lambda *_: None) + out = mf.musicbrainz_best_album("Daft Punk", "Harder Better Faster Stronger") + assert out["album_title"] == "Discovery" + assert out["artist"] == "Daft Punk" + assert out["year"] == "2001" + assert out["rg_mbid"] == "48117b90-a16e-34ca-a514-19c702df1158" + + +def test_returns_none_on_empty(monkeypatch): + monkeypatch.setattr(mf.requests, "get", lambda *a, **k: _FakeResp({"recordings": []})) + monkeypatch.setattr(mf.time, "sleep", lambda *_: None) + assert mf.musicbrainz_best_album("Nobody", "Nothing") is None + + +def test_returns_none_on_exception(monkeypatch): + def boom(*a, **k): + raise mf.requests.exceptions.RequestException("network down") + monkeypatch.setattr(mf.requests, "get", boom) + monkeypatch.setattr(mf.time, "sleep", lambda *_: None) + assert mf.musicbrainz_best_album("Daft Punk", "Discovery") is None + + +def test_falls_back_to_any_releasegroup_when_no_studio(monkeypatch): + payload = {"recordings": [{"artist-credit": [{"name": "X"}], "releases": [ + {"date": "2010", "release-group": {"id": "live1", "title": "Live Thing", + "primary-type": "Album", "secondary-types": ["Live"]}}, + ]}]} + monkeypatch.setattr(mf.requests, "get", lambda *a, **k: _FakeResp(payload)) + monkeypatch.setattr(mf.time, "sleep", lambda *_: None) + out = mf.musicbrainz_best_album("X", "Y") + assert out["album_title"] == "Live Thing" + + +def test_first_artist_credit_only(monkeypatch): + payload = {"recordings": [{"artist-credit": [{"name": "SLVMLORD"}, {"name": "Travis Bradley"}], + "releases": [{"date": "2025", + "release-group": {"id": "x", "title": "Album X", + "primary-type": "Album", + "secondary-types": []}}]}]} + monkeypatch.setattr(mf.requests, "get", lambda *a, **k: _FakeResp(payload)) + monkeypatch.setattr(mf.time, "sleep", lambda *_: None) + out = mf.musicbrainz_best_album("SLVMLORD", "Under My Skin") + assert out["artist"] == "SLVMLORD" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_musicbrainz.py -v` +Expected: FAIL — `AttributeError: ... 'musicbrainz_best_album'` + +- [ ] **Step 3: Add the implementation** + +Add `import time` to the top imports block of `musicfetch` (with `import json`, `import os`, etc.). Then add above `lidarr_search`: +```python +MUSICBRAINZ_URL = "https://musicbrainz.org/ws/2" +MB_HEADERS = {"User-Agent": "musicfetch/2.0 (https://github.com/; personal music fetcher)"} +_mb_last_call = 0.0 + + +def _mb_rate_limit(): + """Courtesy ~1 req/sec to MusicBrainz.""" + global _mb_last_call + elapsed = time.time() - _mb_last_call + if elapsed < 1.0: + time.sleep(1.0 - elapsed) + _mb_last_call = time.time() + + +def _mb_artist_credit(credit) -> str: + """First credited artist name only (ignore featured/secondary).""" + if credit and isinstance(credit, list) and isinstance(credit[0], dict): + return credit[0].get("name") or (credit[0].get("artist") or {}).get("name", "") + return "" + + +def musicbrainz_best_album(artist: str, track: str, timeout: int = 8) -> Optional[dict]: + """Resolve 'artist - track' to its best studio album via MusicBrainz. + Returns {album_title, artist, year, rg_mbid} or None. Never raises.""" + query = f'artist:"{artist}" AND recording:"{track}"' + try: + _mb_rate_limit() + resp = requests.get( + f"{MUSICBRAINZ_URL}/recording", + params={"query": query, "fmt": "json", "limit": 10}, + headers=MB_HEADERS, timeout=timeout, + ) + resp.raise_for_status() + data = resp.json() + except Exception as e: # noqa: BLE001 — degrade to fallback on any failure + dbg(f"MusicBrainz lookup failed: {e}") + return None + + # candidate = (is_studio, date_sortkey, title, artist, year, mbid) + candidates = [] + for rec in data.get("recordings", []): + rec_artist = _mb_artist_credit(rec.get("artist-credit")) + for rel in rec.get("releases", []): + rg = rel.get("release-group") or {} + title = rg.get("title") or rel.get("title") or "" + if not title: + continue + mbid = rg.get("id") or "" + primary = rg.get("primary-type") or "" + secondary = rg.get("secondary-types") or [] + date = rel.get("date") or rg.get("first-release-date") or "" + is_studio = primary == "Album" and not secondary + candidates.append((is_studio, date or "9999", title, rec_artist, date[:4], mbid)) + + if not candidates: + return None + pool = [c for c in candidates if c[0]] or candidates + pool.sort(key=lambda c: c[1]) # earliest date first + _, _, title, art, year, mbid = pool[0] + dbg(f"MusicBrainz resolved '{artist} - {track}' -> '{title}' ({year}) mbid={mbid}") + return {"album_title": title, "artist": art or artist, "year": year, "rg_mbid": mbid} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_musicbrainz.py -v` +Expected: PASS (5 passed) + +- [ ] **Step 5: Commit** + +```bash +git add musicfetch tests/test_musicbrainz.py +git commit -m "feat(lidarr): MusicBrainz track-to-album resolver" +``` + +--- + +### Task 3: Rewrite `lidarr_search` for MBID-exact lookup + +**Files:** +- Modify: `musicfetch` (replace `lidarr_search`; add `_lidarr_album_candidates`, `_lidarr_artist_candidates`, `_fallback_lookup`, `_universal_search`) +- Test: `tests/test_lidarr_search.py` + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_lidarr_search.py`: +```python +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}" # exact MBID lookup, not fuzzy + 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" # bare term -> artist first + 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 [] # album/lookup + artist/lookup empty + monkeypatch.setattr(mf, "lidarr_get", fake_get) + hits = mf.lidarr_search("Daft Punk - Discovery", 10) + assert hits and hits[0].album == "Discovery" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_lidarr_search.py -v` +Expected: FAIL (current `lidarr_search` ignores MB / `mbid:` lookup) + +- [ ] **Step 3: Replace `lidarr_search` and add helpers** + +In `musicfetch`, replace the entire existing `def lidarr_search(...)` body (lines ~129-162) with the following, adding the helpers below it: +```python +def lidarr_search(query: str, limit: int) -> list[Hit]: + """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: + 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"])) + except RequestException as e: + dbg(f"/api/v1/search failed: {e}") + return hits[:limit] +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pytest tests/test_lidarr_search.py -v` +Expected: PASS (6 passed) + +- [ ] **Step 5: Run the full suite** + +Run: `pytest -q` +Expected: all green (prior 27 + new split/musicbrainz/lidarr-search tests), and `python3 -m py_compile musicfetch` clean. + +- [ ] **Step 6: Commit** + +```bash +git add musicfetch tests/test_lidarr_search.py +git commit -m "feat(lidarr): exact MBID album lookup via MusicBrainz resolution" +``` + +--- + +### Task 4: Live verification against the user's Lidarr + +**Files:** none (manual verification by the controller, not a subagent). + +- [ ] **Step 1: Read-only check — `lidarr_search` resolves the real album** + +No mutation; confirms the MB → `mbid:` exact lookup end-to-end: +```bash +cd /home/zhering/Documents/musicfetch +env LIDARR_URL=http://10.2.1.16:8686 LIDARR_API_KEY=49cf02acb4c7436b842df2150056d468 \ + python3 -c "import server.mf, musicfetch_core as mf; \ + hits=mf.lidarr_search('Daft Punk - Harder Better Faster Stronger', 5); \ + print([(h.artist, h.album, h.payload['album'].get('foreignAlbumId')) for h in hits[:3]])" +``` +Expected: first hit `('Daft Punk', 'Discovery', '48117b90-a16e-34ca-a514-19c702df1158')`. + +- [ ] **Step 2: Spot-check a second track** (different artist), e.g.: +```bash +env LIDARR_URL=http://10.2.1.16:8686 LIDARR_API_KEY=49cf02acb4c7436b842df2150056d468 \ + python3 -c "import server.mf, musicfetch_core as mf; \ + print([(h.artist,h.album) for h in mf.lidarr_search('Tame Impala - The Less I Know The Better',3)])" +``` +Expected: top hit is the album containing that track (e.g. *Currents*), not a single/compilation. + +- [ ] **Step 3: (Optional, mutating) full /fetch** — only with user approval, since it adds the artist+album to their Lidarr. Start the API (`env MUSICFETCH_API_KEY=… LIDARR_URL=http://10.2.1.16:8686 LIDARR_API_KEY=… MUSICFETCH_ROOT=/media/music python3 -m uvicorn server.app:app --port 6769`), `POST /fetch?q=...&source=lidarr`, observe job + Lidarr UI, then clean up any added test artist via `DELETE /api/v1/artist/?deleteFiles=false`. + +--- + +## Self-Review + +**Spec coverage:** +- Shared `lidarr_search` rewrite, same signature → Task 3. ✅ +- MusicBrainz resolver w/ studio release-group selection + first-artist credit → Task 2. ✅ +- `mbid:` exact Lidarr lookup (no fuzzy scoring) → Task 3. ✅ +- Query split → Task 1. ✅ +- Fallback tiers (MB miss → `_fallback_lookup` → `/api/v1/search`; returns [] on total failure / no key) → Task 3 (`test_mb_miss_falls_back_to_lookup`, `test_last_resort_universal_search`, `test_no_api_key_returns_empty`). ✅ +- Year enrichment from MB → Task 3 (`test_year_enriched_from_musicbrainz`). ✅ +- YouTube-fallback preserved (signature unchanged; `[]` on failure) → guaranteed + `test_no_api_key_returns_empty`. ✅ +- Single-term artist-first ordering → Task 3 (`test_single_term_is_artist_first`). ✅ +- Out-of-scope (difflib scoring removed; metadata/quality-profile hardening raised separately) intentionally excluded. + +**Placeholder scan:** None — all code and test bodies complete; real MBID/JSON baked in. + +**Type consistency:** `lidarr_search(query, limit) -> list[Hit]` unchanged. `musicbrainz_best_album` returns `{album_title, artist, year, rg_mbid}` — keys identical across Task 2 (definition) and Task 3 (consumes `mb["rg_mbid"]`, `mb["year"]`) and tests. `_split_query -> (str, Optional[str])` consistent. `_lidarr_album_candidates`/`_lidarr_artist_candidates`/`_fallback_lookup(query, limit, artist_first)`/`_universal_search(query, limit)` signatures consistent between Task 3 definition and call sites. `_album_to_hit` payload `{"album": {...}}` with `foreignAlbumId` matches the assertions in Task 3. diff --git a/docs/superpowers/specs/2026-06-08-smarter-lidarr-matching-design.md b/docs/superpowers/specs/2026-06-08-smarter-lidarr-matching-design.md index ec409b5..d924f64 100644 --- a/docs/superpowers/specs/2026-06-08-smarter-lidarr-matching-design.md +++ b/docs/superpowers/specs/2026-06-08-smarter-lidarr-matching-design.md @@ -31,7 +31,12 @@ good matches first. Resolve `Artist - Track` → album via MusicBrainz. - **Track-first semantics** (`Artist - Track`): the right side is treated as a track to resolve to its album. (YouTube path already handles exact tracks; this makes Lidarr the accurate album/discography source.) -- **Scoring** with stdlib `difflib` (no new dependency). +- **No fuzzy scoring.** Live-verified that Lidarr's `album/lookup` accepts a + direct MusicBrainz id: `term=mbid:` (also `term=lidarr:`) + returns **exactly one** album. So we resolve the album's MBID via MusicBrainz and + ask Lidarr for that exact MBID — no difflib, no ranking heuristics. The only + selection is deterministic release-group type-filtering inside the MusicBrainz + step (prefer studio Album over single/comp/live). - **YouTube fallback preserved** exactly as today (see below). ## Architecture @@ -43,28 +48,26 @@ musicfetch ├── _split_query(query) -> (left, right|None) # split on first " - " ├── musicbrainz_best_album(artist, track) -> dict|None │ # MB recording search -> best release-group {album_title, artist, year, rg_mbid} -├── _similar(a, b) -> float # difflib ratio, casefolded -├── _score_album_hit(hit, want_artist, want_album, rg_mbid) -> float -└── lidarr_search(query, limit) -> list[Hit] # REWRITTEN: scored, best-first +├── _lidarr_album_candidates(term) / _lidarr_artist_candidates(term) -> list[Hit] +├── _universal_search(query, limit) -> list[Hit] # /api/v1/search last resort +└── lidarr_search(query, limit) -> list[Hit] # REWRITTEN: MBID-exact + fallbacks ``` ### Data flow 1. **`Artist - Track` query:** - a. `musicbrainz_best_album(artist, track)` → album candidate (title, artist, - year, release-group MBID). - b. Lidarr `GET /api/v1/album/lookup?term=" "` → map to `Hit`s. - c. Score each: `0.7*_similar(want_artist, hit.artist) + 0.3*_similar(want_album, - hit.album)`, plus a strong bonus (e.g. +0.5) if `hit.payload.album.foreignAlbumId - == rg_mbid`. Sort desc. - d. Enrich `Hit.year` from MB when the Lidarr hit lacks one. -2. **Single-term query (no ` - `):** Lidarr `/album/lookup` + `/artist/lookup` - with the raw term; score each against the whole query (artist hits scored on - artist name, album hits on artist+album); merge, sort desc. -3. **Fallbacks (never regress):** if MB times out / returns nothing, skip to step - 2 using `(artist, track)` recombined as the term. If `/album/lookup` and - `/artist/lookup` both fail, fall back to the existing `/api/v1/search` path. - `lidarr_search` returns `[]` only when everything fails or the key is missing. + a. `musicbrainz_best_album(artist, track)` → `{album_title, artist, year, rg_mbid}`. + b. Lidarr `GET /api/v1/album/lookup?term=mbid:` → 0 or 1 exact album → `Hit`. + c. Enrich `Hit.year` from MB when the Lidarr hit lacks one. Return it. +2. **Single-term query (no ` - `):** `_fallback_lookup` — artist-first concatenation + of `/artist/lookup` + `/album/lookup` for the raw term (a bare term is most often + an artist). No scoring; the interactive picker / noninteractive top-pick consume + the order. +3. **Fallbacks (never regress):** if MusicBrainz misses or the exact MBID lookup + returns nothing, use `_fallback_lookup(query)` (album-first there, since a dash + query named an album/track). If `/album/lookup` and `/artist/lookup` both yield + nothing, fall back to the existing `/api/v1/search`. `lidarr_search` returns `[]` + only when everything fails or the key is missing. ### MusicBrainz client details @@ -95,27 +98,26 @@ This feature does not alter fallback behavior: - All MB and Lidarr HTTP calls are wrapped; exceptions/timeouts are caught and degrade to the next fallback tier. `lidarr_search` never raises. - Empty/garbled MB JSON → treated as no match. -- Existing `DEBUG` logging extended to show MB query, chosen release-group, and - top scored candidates. +- Existing `DEBUG` logging extended to show MB query and chosen release-group. ## Testing Unit tests (mock `requests`, no live network): - `musicbrainz_best_album`: from canned MB JSON, picks studio Album over a single - and a compilation; picks earliest among Albums; returns `None` on empty. -- `_similar` / `_score_album_hit`: real *Discovery* by Daft Punk outscores the - *Pignickel* novelty for query `Daft Punk - Discovery`-style candidates; MBID - match bonus dominates. + and a compilation; picks earliest among Albums; falls back to any release-group + when no studio exists; returns `None` on empty/exception. - `_split_query`: `"A - B"` → `("A","B")`; no dash → `("A", None)`; only first ` - ` splits. -- Fallback: MB failure → Lidarr lookup path; lookup failure → `/search`. +- `lidarr_search`: `Artist - Track` resolves via MB then does an `mbid:` exact + lookup returning the real album (year enriched from MB); MB miss → fallback + lookup; fallback empty → `/api/v1/search`; no key → `[]`. Manual live check (end of implementation): with the API pointed at the user's -Lidarr (`10.2.1.16:8686`), `POST /fetch?q=Daft Punk - Harder Better Faster -Stronger&source=lidarr` resolves to **Discovery** by Daft Punk (not a single, -compilation, or novelty), and the interactive-release flow proceeds. +Lidarr (`10.2.1.16:8686`), `lidarr_search("Daft Punk - Harder Better Faster +Stronger")` resolves to **Discovery** by Daft Punk (the exact MBID +`48117b90-a16e-34ca-a514-19c702df1158`), not a single/compilation/novelty. ## Out of Scope (YAGNI) -Caching MB responses, multi-track/album disambiguation UI, configurable scoring -weights, fuzzy artist aliasing beyond difflib, MB cover-art lookup. +Caching MB responses, multi-track/album disambiguation UI, fuzzy similarity +scoring (eliminated by the `mbid:` exact lookup), MB cover-art lookup.