10 Commits

Author SHA1 Message Date
b0e3ce6e6c feat(lidarr): add Artist - Track query splitter 2026-06-08 21:08:02 -07:00
45121dd807 Plan smarter Lidarr matching via exact MBID lookup
Drop fuzzy difflib scoring: MusicBrainz resolves track->album release-group
MBID, Lidarr album/lookup?term=mbid:<id> returns the exact album. Live-verified
against the user's Lidarr.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 21:06:39 -07:00
6687a5a0fc Add design spec for smarter Lidarr matching
Scored best-first lidarr_search with MusicBrainz track->album resolution,
difflib scoring, preserved YouTube fallback. Fixes noninteractive API
picking junk (Pignickel) over the real album.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 20:51:15 -07:00
425a973d85 fix: write single first-artist tag, not doubled/multi-artist
Live end-to-end test surfaced two bugs in youtube tagging:
- `--replace-in-metadata artist .* NAME` matched twice and doubled the
  artist tag (e.g. "SLVMLORDSLVMLORD"). Anchor with ^.*$ to match once.
- Use only the first artist when several are present (SLVMLORD, not
  "SLVMLORD, Travis Bradley, ...") for both the embedded tag and the
  spoken/echoed API messages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 20:39:03 -07:00
9984c162c6 fix(server): return {message} body for request validation errors
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 20:27:00 -07:00
eecf0836f7 fix(server): make .dockerignore effective at repo root, pin yt-dlp in requirements
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 20:23:37 -07:00
809de44e2e feat(server): Dockerfile and compose for the Lidarr stack
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 20:21:14 -07:00
5b6986e01c test(server): cover validation 422s and pick-None 404; tighten message assert
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 20:18:54 -07:00
d4c1b18e58 feat(server): /fetch and /jobs endpoints with async download jobs
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 20:15:28 -07:00
49a45e6270 feat(server): FastAPI app with API-key auth and health check
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 20:09:50 -07:00
14 changed files with 961 additions and 4 deletions

7
.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
__pycache__/
*.pyc
tests/
docs/
.git/
*.md
.claude/

View File

@@ -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:<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:<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:
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/<id>?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.

View File

@@ -0,0 +1,123 @@
# Smarter Lidarr Matching — Design
**Date:** 2026-06-08
**Status:** Approved
## Context & Goal
Live testing of the REST API exposed a real weakness: `musicfetch`'s
`lidarr_search` trusts Lidarr's universal `/api/v1/search` ordering, which is
fuzzy and unranked. A query of `Daft Punk - Discovery` ranked a novelty remix
("Daft Punk's Discovery but it's in the SM64 Soundfont" by *Pignickel*) #1, and
the real *Discovery* by Daft Punk wasn't even top-5. The interactive CLI picker
lets a human work around this; the **API's noninteractive top-pick cannot** and
grabs garbage.
The real input shape is Shazam-style `Artist - Track`. Lidarr only grabs
**albums**, never single tracks, so we must resolve a track to the album that
contains it, then pick the best-matching Lidarr album.
**Goal:** make `lidarr_search` return a **scored, best-first** list of Lidarr
hits so the noninteractive API picks the correct album, and the CLI picker shows
good matches first. Resolve `Artist - Track` → album via MusicBrainz.
## Decisions (confirmed with user)
- **Fix in the shared `musicfetch.lidarr_search`** (not an API-only layer) — both
the CLI picker and the API noninteractive pick benefit; no duplicated logic.
Signature unchanged: `lidarr_search(query, limit) -> list[Hit]` (drop-in).
- **Resolve track → album via MusicBrainz** (the same upstream Lidarr uses).
Lidarr's own track indexing is too weak. One extra HTTP call, no API key.
- **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.)
- **No fuzzy scoring.** Live-verified that Lidarr's `album/lookup` accepts a
direct MusicBrainz id: `term=mbid:<release-group-mbid>` (also `term=lidarr:<mbid>`)
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
All changes live in the `musicfetch` binary (single file). New/changed units:
```
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}
├── _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_title, artist, year, rg_mbid}`.
b. Lidarr `GET /api/v1/album/lookup?term=mbid:<rg_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
- Endpoint: `https://musicbrainz.org/ws/2/recording?query=<lucene>&fmt=json&limit=10`
where lucene = `artist:"<artist>" AND recording:"<track>"`.
- Headers: `User-Agent: musicfetch/2.0 (https://github.com/…)` (MB requires a
descriptive UA). Timeout ~8s. Rate-limit: at most ~1 request/sec (a process-level
min-interval guard; this tool makes one call per fetch so it's effectively a
courtesy delay).
- **Release-group selection** from the returned recordings' releases:
prefer `primary-type == "Album"` with **no** `secondary-types` (excludes
Compilation, Live, Single, Soundtrack); among those choose the earliest
`first-release-date`. Fall back to any release-group if none qualify. Return
`{album_title, artist, year, rg_mbid}` or `None`.
## YouTube Fallback (unchanged, documented)
This feature does not alter fallback behavior:
- **`source=auto` (default):** `build_combined_hits` includes YouTube hits. If
Lidarr times out or returns no results, `lidarr_search` returns `[]` and the top
YouTube hit is picked. If a Lidarr album is picked but has no indexer release,
`actions.perform_fetch` falls through to the top YouTube hit.
- **`source=lidarr`:** lidarr-only by design — **no** YouTube fallback (the
explicit "force Lidarr" switch). Unchanged.
## Error Handling
- 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 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; 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.
- `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`), `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, fuzzy similarity
scoring (eliminated by the `mbid:` exact lookup), MB cover-art lookup.

View File

@@ -126,6 +126,15 @@ def _artist_to_hit(artist: dict) -> Hit:
) )
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
def lidarr_search(query: str, limit: int) -> list[Hit]: def lidarr_search(query: str, limit: int) -> list[Hit]:
"""Universal search via /api/v1/search; fall back to album+artist lookup.""" """Universal search via /api/v1/search; fall back to album+artist lookup."""
if not API_KEY: if not API_KEY:
@@ -474,7 +483,10 @@ def yt_download(url_or_query: str, target_folder: str, quality: str, dry_run: bo
# Override tags from the chosen hit so they don't rely on scraped titles. # Override tags from the chosen hit so they don't rely on scraped titles.
if hit: if hit:
if hit.artist: if hit.artist:
cmd += ["--replace-in-metadata", "artist", ".*", hit.artist] # First artist only; anchored ^.*$ replaces the whole field exactly once
# (a bare .* matches twice and doubles the value).
primary_artist = hit.artist.split(",")[0].strip()
cmd += ["--replace-in-metadata", "artist", "^.*$", primary_artist]
if hit.album: if hit.album:
cmd += ["--parse-metadata", f"{hit.album}:%(album)s"] cmd += ["--parse-metadata", f"{hit.album}:%(album)s"]
if hit.title: if hit.title:

15
server/Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM python:3.12-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY server/requirements.txt /app/server/requirements.txt
RUN pip install --no-cache-dir -r /app/server/requirements.txt
COPY musicfetch /app/musicfetch
COPY server /app/server
EXPOSE 6769
CMD ["sh", "-c", "uvicorn server.app:app --host 0.0.0.0 --port ${MUSICFETCH_PORT:-6769}"]

View File

@@ -13,16 +13,21 @@ def _title(hit) -> str:
return hit.album if hit.kind == "album" else (hit.title or hit.album or hit.artist) return hit.album if hit.kind == "album" else (hit.title or hit.album or hit.artist)
def _primary_artist(hit) -> str:
"""First artist only — ignore featured/secondary artists."""
return (hit.artist.split(",")[0].strip() if hit.artist else "") or "unknown artist"
def started_message(hit) -> str: def started_message(hit) -> str:
return f"Found '{_title(hit)}' by {hit.artist or 'unknown artist'} on {_source_label(hit)}. Downloading now." return f"Found '{_title(hit)}' by {_primary_artist(hit)} on {_source_label(hit)}. Downloading now."
def done_message(hit) -> str: def done_message(hit) -> str:
return f"Finished downloading '{_title(hit)}' by {hit.artist or 'unknown artist'}." return f"Finished downloading '{_title(hit)}' by {_primary_artist(hit)}."
def failed_message(hit) -> str: def failed_message(hit) -> str:
return f"Failed to download '{_title(hit)}' by {hit.artist or 'unknown artist'}." return f"Failed to download '{_title(hit)}' by {_primary_artist(hit)}."
def _yt_path(hit, root: str) -> str: def _yt_path(hit, root: str) -> str:

84
server/app.py Normal file
View File

@@ -0,0 +1,84 @@
"""MusicFetch REST API. Plain HTTP behind an upstream TLS reverse proxy."""
import os
from fastapi import Depends, FastAPI, Header, HTTPException, Query
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from . import actions, jobs, mf
API_KEY = os.environ.get("MUSICFETCH_API_KEY", "")
ROOT = os.environ.get("MUSICFETCH_ROOT", "/media/music")
app = FastAPI(title="MusicFetch API")
def require_key(x_api_key: str = Header(default="")):
if not API_KEY or x_api_key != API_KEY:
raise HTTPException(status_code=401, detail="Invalid API key.")
@app.exception_handler(HTTPException)
async def _http_exc(_req, exc: HTTPException):
# Always return a Siri-speakable {"message": ...} body.
return JSONResponse(status_code=exc.status_code, content={"message": exc.detail})
@app.exception_handler(RequestValidationError)
async def _validation_exc(_req, exc: RequestValidationError):
return JSONResponse(status_code=422, content={"message": "Invalid or missing request parameters."})
@app.get("/health")
def health():
return {"status": "ok"}
def _hit_public(hit) -> dict:
return {"source": hit.source, "kind": hit.kind, "artist": hit.artist,
"album": hit.album, "title": hit.title, "year": hit.year}
def _job_public(job) -> dict:
return {"message": job.message, "job_id": job.id, "status": job.status,
"hit": _hit_public(job.hit) if job.hit is not None else None,
"result": job.result, "error": job.error}
@app.post("/fetch", dependencies=[Depends(require_key)])
def fetch(q: str = Query(..., min_length=1),
quality: str = Query("best"),
source: str = Query("auto")):
if quality not in mf.QUALITY_CHOICES:
raise HTTPException(status_code=422, detail=f"Invalid quality '{quality}'.")
if source not in ("auto", "lidarr", "youtube"):
raise HTTPException(status_code=422, detail=f"Invalid source '{source}'.")
yt_first = source == "youtube"
hits = mf.build_combined_hits(q, 10, yt_first,
lidarr_only=(source == "lidarr"),
yt_only=(source == "youtube"))
if not hits:
raise HTTPException(status_code=404, detail=f"No results found for '{q}'.")
chosen = mf.pick(hits, q, True, yt_first)
if chosen is None:
raise HTTPException(status_code=404, detail=f"No results found for '{q}'.")
job = jobs.create_job(hit=chosen, message=actions.started_message(chosen))
response = _job_public(job) # snapshot "queued" state before background thread starts
jobs.run_job(
job.id,
lambda: actions.perform_fetch(chosen, hits, quality, ROOT),
done_message=actions.done_message(chosen),
fail_message=actions.failed_message(chosen),
)
return response
@app.get("/jobs/{job_id}", dependencies=[Depends(require_key)])
def job_status(job_id: str):
job = jobs.get_job(job_id)
if job is None:
raise HTTPException(status_code=404, detail="No such job.")
return _job_public(job)

25
server/docker-compose.yml Normal file
View File

@@ -0,0 +1,25 @@
services:
musicfetch-api:
build:
context: ..
dockerfile: server/Dockerfile
container_name: musicfetch-api
restart: unless-stopped
ports:
- "6769:6769"
environment:
LIDARR_URL: "http://lidarr:8686"
LIDARR_API_KEY: "${LIDARR_API_KEY}"
MUSICFETCH_API_KEY: "${MUSICFETCH_API_KEY}"
MUSICFETCH_ROOT: "/media/music"
MUSICFETCH_PORT: "6769"
volumes:
- /media/music:/media/music
networks:
- lidarr_net
networks:
lidarr_net:
external: true
# Set to the actual network name of your existing Lidarr stack, e.g.:
# name: media_default

6
server/requirements.txt Normal file
View File

@@ -0,0 +1,6 @@
fastapi
uvicorn[standard]
requests
ytmusicapi
rich
yt-dlp

16
tests/conftest.py Normal file
View File

@@ -0,0 +1,16 @@
import os
import pytest
os.environ.setdefault("MUSICFETCH_API_KEY", "test-key")
@pytest.fixture
def client():
from fastapi.testclient import TestClient
from server.app import app
return TestClient(app)
@pytest.fixture
def auth():
return {"X-API-Key": "test-key"}

View File

@@ -28,6 +28,17 @@ def test_done_message_mentions_title():
assert "Avril Lavigne" in msg assert "Avril Lavigne" in msg
def test_messages_use_only_first_artist():
hit = mf.Hit(source="youtube", kind="track", title="Under My Skin",
artist="SLVMLORD, James John, BobbyGee", album="X", year="",
payload={"videoId": "abc"})
for msg in (actions.started_message(hit), actions.done_message(hit),
actions.failed_message(hit)):
assert "SLVMLORD" in msg
assert "James John" not in msg
assert "BobbyGee" not in msg
def test_perform_youtube_calls_act_youtube(monkeypatch): def test_perform_youtube_calls_act_youtube(monkeypatch):
calls = {} calls = {}
monkeypatch.setattr(mf, "act_youtube", monkeypatch.setattr(mf, "act_youtube",

105
tests/test_api.py Normal file
View File

@@ -0,0 +1,105 @@
import time
import pytest
from server import mf, jobs as jobs_mod
@pytest.fixture(autouse=True)
def _clear_jobs():
jobs_mod.JOBS.clear()
yield
jobs_mod.JOBS.clear()
def _yt_hit():
return mf.Hit(source="youtube", kind="track", title="Together",
artist="Avril Lavigne", album="Under My Skin", year="2004",
payload={"videoId": "abc"})
def test_fetch_returns_job_and_message(client, auth, monkeypatch):
hit = _yt_hit()
monkeypatch.setattr("server.app.mf.build_combined_hits",
lambda q, limit, yt_first, lidarr_only, yt_only: [hit])
monkeypatch.setattr("server.app.mf.pick",
lambda hits, q, noninteractive, yt_first: hits[0])
monkeypatch.setattr("server.app.actions.perform_fetch",
lambda chosen, hits, quality, root: {"path": "/media/music/x", "lidarr_album_id": None})
r = client.post("/fetch", params={"q": "Under My Skin"}, headers=auth)
assert r.status_code == 200
body = r.json()
assert body["status"] == "queued"
assert "Together" in body["message"]
assert body["hit"]["artist"] == "Avril Lavigne"
assert body["job_id"]
def test_fetch_no_hits_returns_404(client, auth, monkeypatch):
monkeypatch.setattr("server.app.mf.build_combined_hits",
lambda q, limit, yt_first, lidarr_only, yt_only: [])
r = client.post("/fetch", params={"q": "zzzz"}, headers=auth)
assert r.status_code == 404
assert "zzzz" in r.json()["message"]
def test_fetch_missing_q_returns_422(client, auth):
r = client.post("/fetch", headers=auth)
assert r.status_code == 422
assert "message" in r.json()
def test_job_lifecycle_done(client, auth, monkeypatch):
hit = _yt_hit()
monkeypatch.setattr("server.app.mf.build_combined_hits",
lambda q, limit, yt_first, lidarr_only, yt_only: [hit])
monkeypatch.setattr("server.app.mf.pick",
lambda hits, q, noninteractive, yt_first: hits[0])
monkeypatch.setattr("server.app.actions.perform_fetch",
lambda chosen, hits, quality, root: {"path": "/media/music/x", "lidarr_album_id": None})
job_id = client.post("/fetch", params={"q": "x"}, headers=auth).json()["job_id"]
end = time.time() + 2
status = None
while time.time() < end:
body = client.get(f"/jobs/{job_id}", headers=auth).json()
status = body["status"]
if status == "done":
break
time.sleep(0.01)
assert status == "done"
assert body["result"]["path"] == "/media/music/x"
assert "Finished" in body["message"]
def test_unknown_job_404(client, auth):
r = client.get("/jobs/deadbeef", headers=auth)
assert r.status_code == 404
assert "message" in r.json()
def test_jobs_requires_key(client):
r = client.get("/jobs/whatever")
assert r.status_code == 401
def test_fetch_invalid_quality_422(client, auth):
r = client.post("/fetch", params={"q": "x", "quality": "bogus"}, headers=auth)
assert r.status_code == 422
assert "message" in r.json()
def test_fetch_invalid_source_422(client, auth):
r = client.post("/fetch", params={"q": "x", "source": "spotify"}, headers=auth)
assert r.status_code == 422
assert "message" in r.json()
def test_fetch_pick_none_returns_404(client, auth, monkeypatch):
hit = _yt_hit()
monkeypatch.setattr("server.app.mf.build_combined_hits",
lambda q, limit, yt_first, lidarr_only, yt_only: [hit])
monkeypatch.setattr("server.app.mf.pick",
lambda hits, q, noninteractive, yt_first: None)
r = client.post("/fetch", params={"q": "x"}, headers=auth)
assert r.status_code == 404
assert "message" in r.json()

16
tests/test_auth.py Normal file
View File

@@ -0,0 +1,16 @@
def test_health_no_auth(client):
r = client.get("/health")
assert r.status_code == 200
assert r.json() == {"status": "ok"}
def test_fetch_requires_key(client):
r = client.post("/fetch", params={"q": "anything"})
assert r.status_code == 401
assert "message" in r.json()
def test_fetch_rejects_wrong_key(client):
r = client.post("/fetch", params={"q": "anything"},
headers={"X-API-Key": "wrong"})
assert r.status_code == 401

View File

@@ -0,0 +1,18 @@
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")