From 18c4e229e169cf4a20d27b8815cafbf41c74a070 Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou Date: Sat, 4 Jul 2026 18:09:48 -0500 Subject: [PATCH] feat(enrichment): loose MusicBrainz search fallback (find aliased/romanized artists) (#771) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(enrichment): loose MusicBrainz search fallback (find aliased artists) The MB text search used a strict field-phrase query (`recording:"" AND artist:"<artist>"`). A field phrase only matches MusicBrainz's *primary* artist/title — it never searches ALIASES — so a recording stored under a non-Latin primary name (大橋純子) whose romanized form ("Junko Ohashi") is only an alias returns ZERO results, even though MB has it. Whole swaths of a community library (e.g. romanized J-pop / city-pop charts) were unsearchable. - `build_recording_query(..., loose=True)` drops the field scoping + phrases for plain AND-ed term groups (`(telephone number) AND (junko ohashi)`), which searches the whole document incl. aliases. - `_mb_search_recordings` runs the strict query first (unchanged, high precision) and only on an EMPTY result retries once with the loose query — so mainstream matches are untouched and the extra throttled request is spent only on a miss. Results are re-scored by rank_candidates, so recall goes up without lowering match quality (auto-accept still needs the per-field floors). Verified live: "Junko Ohashi / Telephone Number" and "Anri / Windy Summer" (both 0 under the strict query) now surface the real records; "AC/DC / Highway to Hell" still hits strict at score 1.0 with no loose retry. Follow-up (separate): alias-aware SCORING so these can auto-confirm, not just appear as manual candidates. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(enrichment): keep live exclusion in the loose search fallback The loose fallback dropped the strict path's -secondarytype:Live filter, so a studio chart whose strict query missed could fall back to — and, since score_candidate doesn't penalize live takes, auto-confirm — a live-only recording. Apply the same live gate to the loose query (skipped only when the source title is itself a live take). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: byrongamatos <xasiklas@gmail.com> --- lib/mb_match.py | 29 +++++++++++++++++++++++++++-- server.py | 29 ++++++++++++++++++++++------- tests/test_mb_enrichment.py | 37 +++++++++++++++++++++++++++++++++++++ tests/test_mb_match.py | 27 +++++++++++++++++++++++++++ 4 files changed, 113 insertions(+), 9 deletions(-) diff --git a/lib/mb_match.py b/lib/mb_match.py index 9230961..67df7eb 100644 --- a/lib/mb_match.py +++ b/lib/mb_match.py @@ -234,12 +234,37 @@ def _lucene_escape_phrase(s: str) -> str: _LIVE_GROUP_RE = re.compile(r"[(\[][^)\]]*\blive\b[^)\]]*[)\]]", re.IGNORECASE) -def build_recording_query(artist, title) -> str: +def build_recording_query(artist, title, *, loose: bool = False) -> str: """Lucene query for /ws/2/recording. Built from the DENOISED fields — the noise we strip (author credits, "(Live)", "(v2)") would otherwise - poison the search server's own scoring.""" + poison the search server's own scoring. + + ``loose=True`` drops the field-scoped quoted PHRASES for plain AND-ed + term groups (``(telephone number) AND (junko ohashi)``). The point: + a field phrase like ``artist:"Junko Ohashi"`` only matches MusicBrainz's + *primary* artist name — it never searches ALIASES — so a recording stored + under a non-Latin primary (大橋純子) whose romanized name is only an alias + is invisible to the strict query. A loose term query searches the whole + document, aliases included, and surfaces it. Lower precision by design: it + is a FALLBACK for when the strict query returns nothing, and its results + are re-scored by ``rank_candidates`` (and, for auto-match, gated by the + per-field floors), so noise never auto-applies.""" t = denoise(title) a = denoise(artist) + if loose: + # denoise() already reduced each field to lowercase [a-z0-9 and] tokens + # (punctuation → spaces, diacritics stripped, & → "and"), so no + # Lucene-special character survives to need escaping. Group each field's + # terms and require both groups. + q = " AND ".join("(%s)" % g for g in (t, a) if g) + # Keep the SAME live exclusion as the strict path: the loose query is + # lower-precision, and score_candidate doesn't penalize a live take, so + # without this a studio chart whose strict query missed could fall back + # to — and auto-confirm — a live-only recording. Skipped only when the + # source title is itself a live take (mirrors the strict path). + if q and not _LIVE_GROUP_RE.search(str(title or "")): + q += " AND -secondarytype:Live" + return q parts = [] if t: parts.append('recording:"%s"' % _lucene_escape_phrase(t)) diff --git a/server.py b/server.py index 9e670f8..5fab183 100644 --- a/server.py +++ b/server.py @@ -6320,15 +6320,30 @@ def _mb_http_get(path: str, params: dict) -> dict | None: def _mb_search_recordings(artist, title, limit: int = 12) -> list[dict]: - """Text search (tier 2–4): denoised Lucene query over /recording. The query - now drops live-only recordings and our ranker rewards the studio take, so a + """Text search (tier 2–4): denoised Lucene query over /recording. The strict + query drops live-only recordings and the ranker rewards the studio take, so a slightly larger default result set gives the re-ranker room to surface the - canonical version (one request per song regardless of limit).""" + canonical version. + + Runs the strict field-phrase query first (high precision); if it finds + nothing, retries ONCE with a loose term query. The strict phrase only matches + MusicBrainz's *primary* artist/title, so a recording stored under a non-Latin + primary name (大橋純子) whose romanized form ("Junko Ohashi") is only an alias + is invisible to it — the loose query searches aliases and rescues it. The + retry spends a second throttled request only on a miss; results are re-scored + by rank_candidates, so the looser recall doesn't lower match quality + (auto-accept still needs the per-field floors).""" query = mb_match.build_recording_query(artist, title) - if not query: - return [] - body = _mb_http_get("recording", {"query": query, "limit": limit}) - return mb_match.parse_search_response(body or {}) + cands: list[dict] = [] + if query: + body = _mb_http_get("recording", {"query": query, "limit": limit}) + cands = mb_match.parse_search_response(body or {}) + if not cands: + loose = mb_match.build_recording_query(artist, title, loose=True) + if loose and loose != query: + body = _mb_http_get("recording", {"query": loose, "limit": limit}) + cands = mb_match.parse_search_response(body or {}) + return cands # ── AcoustID audio fingerprinting (content-based identification) ────────────── diff --git a/tests/test_mb_enrichment.py b/tests/test_mb_enrichment.py index dd988af..a39105f 100644 --- a/tests/test_mb_enrichment.py +++ b/tests/test_mb_enrichment.py @@ -97,6 +97,43 @@ def mb_doc(rid="rec-1", title="Thunderstruck", artist="AC/DC", artist_id="art-1" } +# ── strict-then-loose search fallback ──────────────────────────────────────── + +def test_search_falls_back_to_loose_when_strict_is_empty(server, monkeypatch): + """The strict field-phrase query misses a non-Latin-primary artist; the + loose retry (no field scoping) searches aliases and finds it.""" + calls = [] + + def _routed(path, params): + q = params.get("query", "") + calls.append(q) + if q.startswith("recording:"): # strict phrase → nothing + return {"recordings": []} + return {"recordings": [mb_doc(rid="rec-x", title="Telephone Number")]} + + monkeypatch.setattr(server, "_mb_http_get", _routed) + cands = server._mb_search_recordings("Junko Ohashi", "Telephone Number") + assert len(cands) == 1 + assert len(calls) == 2 # strict first, then the loose retry + assert calls[0].startswith("recording:") # strict is the field-phrase form + assert "artist:" not in calls[1] and '"' not in calls[1] # loose retry + + +def test_search_does_not_retry_when_strict_hits(server, monkeypatch): + """A strict hit must not spend a second (throttled) request on the loose + query.""" + calls = [] + + def _routed(path, params): + calls.append(params.get("query", "")) + return {"recordings": [mb_doc()]} + + monkeypatch.setattr(server, "_mb_http_get", _routed) + cands = server._mb_search_recordings("AC/DC", "Thunderstruck") + assert len(cands) == 1 + assert len(calls) == 1 + + # ── offline safety (the pytest-never-hits-network contract) ────────────────── def test_offline_default_skips_matching(server, monkeypatch): diff --git a/tests/test_mb_match.py b/tests/test_mb_match.py index 7f0da98..6baa4f6 100644 --- a/tests/test_mb_match.py +++ b/tests/test_mb_match.py @@ -189,6 +189,33 @@ def test_build_recording_query_escapes_and_handles_missing_artist(): assert "artist:" not in q +def test_build_recording_query_loose_drops_field_phrases(): + # The strict form locks to the *primary* artist/title phrase (and drops + # live-only recordings — the chart isn't a live take). + assert m.build_recording_query("Junko Ohashi", "Telephone Number") == \ + 'recording:"telephone number" AND artist:"junko ohashi" AND -secondarytype:Live' + # The loose form has no field scoping and no phrases, so MusicBrainz also + # searches artist ALIASES — rescues non-Latin-primary artists (大橋純子) — + # but keeps the same live exclusion (a studio chart must not fall back to a + # live-only recording). + loose = m.build_recording_query("Junko Ohashi", "Telephone Number", loose=True) + assert loose == "(telephone number) AND (junko ohashi) AND -secondarytype:Live" + assert "artist:" not in loose and '"' not in loose + + +def test_build_recording_query_loose_missing_artist(): + assert m.build_recording_query("", "Fantasy", loose=True) == \ + "(fantasy) AND -secondarytype:Live" + + +def test_build_recording_query_loose_keeps_live_for_live_charts(): + # A live chart's loose fallback must NOT exclude live recordings (same gate + # as the strict path) — else its only correct recording is filtered out. + loose = m.build_recording_query("AC/DC", "Highway to Hell (Live at Donington)", loose=True) + assert "-secondarytype:Live" not in loose + assert loose == "(highway to hell) AND (ac dc)" + + # ── MusicBrainz response parsing ────────────────────────────────────────────── MB_DOC = {