diff --git a/lib/mb_match.py b/lib/mb_match.py index 2efb233..044af09 100644 --- a/lib/mb_match.py +++ b/lib/mb_match.py @@ -198,12 +198,29 @@ def _lucene_escape_phrase(s: str) -> str: return s.replace("\\", "\\\\").replace('"', '\\"') -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. + return " AND ".join("(%s)" % g for g in (t, a) if g) parts = [] if t: parts.append('recording:"%s"' % _lucene_escape_phrase(t)) diff --git a/server.py b/server.py index 94f282f..de97eb5 100644 --- a/server.py +++ b/server.py @@ -6236,12 +6236,27 @@ def _mb_http_get(path: str, params: dict) -> dict | None: def _mb_search_recordings(artist, title, limit: int = 8) -> list[dict]: - """Text search (tier 2–4): denoised Lucene query over /recording.""" + """Text search (tier 2–4): denoised Lucene query over /recording. + + Runs the strict field-phrase query first (high precision, unchanged); 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 def _mb_lookup_recording(mbid: str) -> dict | None: 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 b2912c9..9191929 100644 --- a/tests/test_mb_match.py +++ b/tests/test_mb_match.py @@ -160,6 +160,21 @@ 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 (unchanged) locks to the *primary* artist/title phrase. + assert m.build_recording_query("Junko Ohashi", "Telephone Number") == \ + 'recording:"telephone number" AND artist:"junko ohashi"' + # The loose form has no field scoping and no phrases, so MusicBrainz also + # searches artist ALIASES — rescues non-Latin-primary artists (大橋純子). + loose = m.build_recording_query("Junko Ohashi", "Telephone Number", loose=True) + assert loose == "(telephone number) AND (junko ohashi)" + 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)" + + # ── MusicBrainz response parsing ────────────────────────────────────────────── MB_DOC = {