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 = {