diff --git a/lib/mb_match.py b/lib/mb_match.py index 67df7eb..f4fe07e 100644 --- a/lib/mb_match.py +++ b/lib/mb_match.py @@ -144,12 +144,31 @@ def _duration_int(v): return None +def cand_artist_sim(song: dict, cand: dict) -> float: + """Best artist similarity between the song's reference artist and the + candidate's PRIMARY name OR any of its `artist_aliases` (romanized/alternate + names). MusicBrainz stores many artists under a non-Latin primary name + (大橋純子) with the romanized form ("Junko Ohashi") only as an alias, so a + reference typed/derived in romaji scores 0 against the primary but 1.0 + against the alias. The caller (server) attaches `artist_aliases` only for + promising near-misses, so this is a plain max when they're present and the + original single comparison when they're not.""" + best = similarity(song.get("artist"), cand.get("artist"), artist=True) + for alias in cand.get("artist_aliases") or []: + if best >= 1.0: + break + s = similarity(song.get("artist"), alias, artist=True) + if s > best: + best = s + return best + + def score_candidate(song: dict, cand: dict) -> float: """Combined confidence that MusicBrainz candidate `cand` is the song the chart transcribes. 0.5*artist + 0.5*title, plus small year/duration corroboration bonuses, capped at 1.0. Missing fields score 0 on their half — classify() separately refuses to auto-match without both.""" - artist_sim = similarity(song.get("artist"), cand.get("artist"), artist=True) + artist_sim = cand_artist_sim(song, cand) title_sim = similarity(song.get("title"), cand.get("title")) score = 0.5 * artist_sim + 0.5 * title_sim sy, cy = _year_int(song.get("year")), _year_int(cand.get("year")) @@ -180,7 +199,7 @@ def classify(song: dict, cand: dict, score: float, auto_min: float | None = None """ if auto_min is None: auto_min = AUTO_MIN - artist_sim = similarity(song.get("artist"), cand.get("artist"), artist=True) + artist_sim = cand_artist_sim(song, cand) title_sim = similarity(song.get("title"), cand.get("title")) if (score >= auto_min and artist_sim >= AUTO_ARTIST_MIN and title_sim >= AUTO_TITLE_MIN): diff --git a/server.py b/server.py index 5fab183..da34a25 100644 --- a/server.py +++ b/server.py @@ -6544,6 +6544,78 @@ _MBID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f _ISRC_RE = re.compile(r"^[A-Z]{2}[A-Z0-9]{3}[0-9]{7}$") +# ── Alias-aware scoring ─────────────────────────────────────────────────────── +# MusicBrainz stores many artists under a non-Latin PRIMARY name (大橋純子) with +# the romanized form ("Junko Ohashi") only as an ALIAS. A recording search +# returns the primary name in its artist-credit, never the aliases — so scoring +# a romanized reference against the primary gives 0 and the match can't confirm. +# We fetch the artist's aliases (one throttled lookup, process-cached) and hand +# them to the scorer, but ONLY for a promising near-miss (title already agrees, +# artist doesn't) so a normal pass spends no extra requests. +_ALIAS_ENRICH_MAX = 3 # cap alias lookups per song/search (each is ≤1/s) +_artist_alias_cache: dict[str, list[str]] = {} + + +def _mb_artist_aliases(artist_id: str) -> list[str]: + """Romanized/alternate names for a MusicBrainz artist, process-cached (an + artist recurs across a whole discography, so a library of one artist costs + ONE lookup). Returns [] for an unknown/aliasless artist. Raises + EnrichTransportError on a network failure so the caller pauses the pass + (nothing is cached on failure → retried next pass).""" + aid = str(artist_id or "") + if aid in _artist_alias_cache: + return _artist_alias_cache[aid] + if not _MBID_RE.match(aid): + return [] + body = _mb_http_get(f"artist/{aid}", {"inc": "aliases"}) + names: list[str] = [] + if body: + sort_name = str(body.get("sort-name") or "").strip() + if sort_name: + names.append(sort_name) # often the romanized form for JP artists + for al in body.get("aliases") or []: + if isinstance(al, dict) and al.get("name"): + names.append(str(al["name"])) + seen: set[str] = set() + out: list[str] = [] + for n in names: + k = n.casefold() + if k and k not in seen: + seen.add(k) + out.append(n) + out = out[:12] + _artist_alias_cache[aid] = out + return out + + +def _alias_enrich(ref: dict, cands: list[dict]) -> None: + """Attach `artist_aliases` in place to candidates that look like the + non-Latin-primary case — title agrees with the reference but the primary + artist doesn't — so the scorer can confirm them via a romanized alias. + Bounded by _ALIAS_ENRICH_MAX + the process cache; a no-op when the + reference has no artist or nothing is aliasable.""" + ref_artist = (ref.get("artist") or "").strip() + if not ref_artist: + return + spent = 0 + for c in cands: + if spent >= _ALIAS_ENRICH_MAX: + break + if not isinstance(c, dict) or c.get("artist_aliases") is not None: + continue + aid = c.get("artist_id") + if not aid: + continue + # Only spend a lookup on a promising near-miss: the title already + # matches, but the primary artist doesn't (that's the alias signature). + if mb_match.similarity(ref.get("title"), c.get("title")) < mb_match.AUTO_TITLE_MIN: + continue + if mb_match.similarity(ref_artist, c.get("artist"), artist=True) >= mb_match.AUTO_ARTIST_MIN: + continue + c["artist_aliases"] = _mb_artist_aliases(aid) # cached; attach [] to avoid refetch + spent += 1 + + def _manifest_exact_ids(filename: str) -> dict: """Optional `mbid`/`isrc` from the pack manifest — the spec's additive identity keys. Feature-detected: packs published before that spec @@ -6956,7 +7028,13 @@ def _enrich_one(row: dict, auto_min: float | None = None, field_filter=None, cand=field_filter(cands[0]) if field_filter else cands[0]) return - ranked = mb_match.rank_candidates(ref, _mb_search_recordings(ref.get("artist"), ref.get("title"))) + cands = _mb_search_recordings(ref.get("artist"), ref.get("title")) + # Alias-enrich promising near-misses (title agrees, primary artist doesn't) + # so a non-Latin-primary artist can confirm via its romanized alias, then + # rank once with the aliases in hand. `ref` carries any filename-derived + # artist seed, so alias scoring runs against the searched identity. + _alias_enrich(ref, cands) + ranked = mb_match.rank_candidates(ref, cands) best = ranked[0] if ranked else None tier = mb_match.classify(ref, best, best["score"], auto_min=auto_min) if best else "none" if tier == "auto": @@ -7851,6 +7929,13 @@ def api_enrichment_search(artist: str = "", title: str = "", limit: int = 8, if duration and duration > 0 and not ref.get("duration"): ref = dict(ref) ref["duration"] = duration + # Alias-enrich so a non-Latin-primary artist (大橋純子) ranks by its + # romanized alias against the typed query ("Junko Ohashi") instead of + # sinking to the bottom with a 0 artist score. + try: + _alias_enrich(ref, cands) + except EnrichTransportError: + pass # aliases are a ranking nicety here; fall back to primary-name scoring return {"candidates": mb_match.rank_candidates(ref, cands)} diff --git a/tests/test_mb_enrichment.py b/tests/test_mb_enrichment.py index a39105f..43c42df 100644 --- a/tests/test_mb_enrichment.py +++ b/tests/test_mb_enrichment.py @@ -134,6 +134,57 @@ def test_search_does_not_retry_when_strict_hits(server, monkeypatch): assert len(calls) == 1 +# ── alias-aware scoring (non-Latin-primary artists) ────────────────────────── + +_AID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + + +def test_artist_aliases_fetched_and_cached(server, monkeypatch): + calls = [] + + def fake(path, params): + calls.append(path) + return {"sort-name": "Ohashi, Junko", + "aliases": [{"name": "Junko Ohashi"}, {"name": "大橋 純子"}]} + + monkeypatch.setattr(server, "_mb_http_get", fake) + names = server._mb_artist_aliases(_AID) + assert "Junko Ohashi" in names and "Ohashi, Junko" in names + server._mb_artist_aliases(_AID) # cached → no second request + assert len(calls) == 1 + + +def test_artist_aliases_rejects_bad_id(server, monkeypatch): + def boom(path, params): + raise AssertionError("must not fetch for a non-UUID id") + + monkeypatch.setattr(server, "_mb_http_get", boom) + assert server._mb_artist_aliases("not-a-uuid") == [] + + +def test_enrich_auto_matches_japanese_primary_via_alias(server, monkeypatch): + # A pack whose (romanized) artist MB stores under a Japanese primary name. + _put(server, "x.sloppak", title="Telephone Number", artist="Junko Ohashi") + + def _routed(path, params): + if path.startswith("artist/"): # alias lookup + return {"sort-name": "Ohashi, Junko", + "aliases": [{"name": "Junko Ohashi"}]} + q = params.get("query", "") + if q.startswith("recording:"): # strict phrase → nothing + return {"recordings": []} + return {"recordings": [mb_doc(rid="rec-jp", title="Telephone Number", + artist="大橋純子", artist_id=_AID)]} # loose hit + + monkeypatch.setattr(server, "_mb_http_get", _routed) + monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True) + server._background_enrich() + row = server.meta_db.get_enrichment("x.sloppak") + # The romanized alias lifts the artist over the auto floor → auto-confirmed. + assert row["match_state"] == "matched" + assert row["mb_recording_id"] == "rec-jp" + + # ── 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 6baa4f6..61a2421 100644 --- a/tests/test_mb_match.py +++ b/tests/test_mb_match.py @@ -216,6 +216,27 @@ def test_build_recording_query_loose_keeps_live_for_live_charts(): assert loose == "(highway to hell) AND (ac dc)" +# ── alias-aware artist scoring ──────────────────────────────────────────────── + +def test_cand_artist_sim_uses_aliases(): + song = {"artist": "Junko Ohashi", "title": "Telephone Number"} + # primary is the Japanese name → romanized reference scores 0… + assert m.cand_artist_sim(song, {"artist": "大橋純子"}) == 0.0 + # …but a romanized alias confirms it + assert m.cand_artist_sim( + song, {"artist": "大橋純子", "artist_aliases": ["Ohashi Junko", "Junko Ohashi"]}) == 1.0 + + +def test_alias_lifts_candidate_to_auto(): + song = {"artist": "Junko Ohashi", "title": "Telephone Number"} + jp = {"artist": "大橋純子", "title": "Telephone Number"} + # Without the alias: title matches but the artist floor fails → never auto. + assert m.classify(song, jp, m.score_candidate(song, jp)) != "auto" + # With the romanized alias attached: artist clears the floor → auto. + jp_alias = dict(jp, artist_aliases=["Junko Ohashi"]) + assert m.classify(song, jp_alias, m.score_candidate(song, jp_alias)) == "auto" + + # ── MusicBrainz response parsing ────────────────────────────────────────────── MB_DOC = {