Compare commits

...
Author SHA1 Message Date
byrongamatos c5d8396b56 Merge remote-tracking branch 'origin/main' into feat/mb-alias-scoring
# Conflicts:
#	tests/test_mb_enrichment.py
#	tests/test_mb_match.py
2026-07-05 01:11:16 +02:00
18c4e229e1 feat(enrichment): loose MusicBrainz search fallback (find aliased/romanized artists) (#771)
* feat(enrichment): loose MusicBrainz search fallback (find aliased artists)

The MB text search used a strict field-phrase query
(`recording:"<title>" 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>
2026-07-05 01:09:48 +02:00
byrongamatos 39e2e8d100 Merge remote-tracking branch 'origin/feat/mb-loose-search-fallback' into feat/mb-alias-scoring 2026-07-05 01:07:22 +02:00
byrongamatosandClaude Opus 4.8 fb354f9c38 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>
2026-07-05 01:04:52 +02:00
byrongamatos 7915f94ab6 Merge remote-tracking branch 'origin/feat/mb-loose-search-fallback' into feat/mb-alias-scoring
# Conflicts:
#	server.py
2026-07-05 00:37:08 +02:00
byrongamatos 51085048aa Merge remote-tracking branch 'origin/main' into feat/mb-loose-search-fallback
# Conflicts:
#	lib/mb_match.py
#	server.py
2026-07-05 00:30:04 +02:00
ChrisBeWithYouandClaude Opus 4.8 c49871484f feat(enrichment): alias-aware scoring (auto-confirm non-Latin-primary artists)
Builds on the loose-search fallback: that surfaces a recording stored under a
Japanese primary name (大橋純子) via its romanized alias, but the SCORER still
compared the reference ("Junko Ohashi") against the primary only → artist
similarity 0 → below the auto floor, so it could only ever be a manual
candidate, never an auto-fill.

- mb_match: `cand_artist_sim` takes the best similarity over the candidate's
  primary name AND its `artist_aliases`; score_candidate + classify use it.
- server: `_mb_artist_aliases(id)` fetches an artist's aliases (one throttled
  lookup, process-cached — a one-artist discography costs ONE request) and
  `_alias_enrich` attaches them ONLY to promising near-misses (title agrees,
  primary artist doesn't) so a normal pass spends zero extra requests. Wired
  into both the auto-matcher (_enrich_one) and the manual search proxy.

Verified live: "Junko Ohashi / Telephone Number" → 大橋純子 candidate goes from
score 0.5 (loose-only) to 1.0 (auto-confirmable), ranked #1; "AC/DC / Highway
to Hell" unchanged at 1.0 with no alias lookup.

Stacks on #771 (feat/mb-loose-search-fallback).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 15:35:34 -05:00
ChrisBeWithYouandClaude Opus 4.8 117d260723 feat(enrichment): loose MusicBrainz search fallback (find aliased artists)
The MB text search used a strict field-phrase query
(`recording:"<title>" 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>
2026-07-04 15:03:26 -05:00
4 changed files with 292 additions and 12 deletions
+48 -4
View File
@@ -144,12 +144,31 @@ def _duration_int(v):
return None 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: def score_candidate(song: dict, cand: dict) -> float:
"""Combined confidence that MusicBrainz candidate `cand` is the song the """Combined confidence that MusicBrainz candidate `cand` is the song the
chart transcribes. 0.5*artist + 0.5*title, plus small year/duration chart transcribes. 0.5*artist + 0.5*title, plus small year/duration
corroboration bonuses, capped at 1.0. Missing fields score 0 on their corroboration bonuses, capped at 1.0. Missing fields score 0 on their
half — classify() separately refuses to auto-match without both.""" 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")) title_sim = similarity(song.get("title"), cand.get("title"))
score = 0.5 * artist_sim + 0.5 * title_sim score = 0.5 * artist_sim + 0.5 * title_sim
sy, cy = _year_int(song.get("year")), _year_int(cand.get("year")) 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: if auto_min is None:
auto_min = AUTO_MIN 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")) title_sim = similarity(song.get("title"), cand.get("title"))
if (score >= auto_min and artist_sim >= AUTO_ARTIST_MIN if (score >= auto_min and artist_sim >= AUTO_ARTIST_MIN
and title_sim >= AUTO_TITLE_MIN): and title_sim >= AUTO_TITLE_MIN):
@@ -234,12 +253,37 @@ def _lucene_escape_phrase(s: str) -> str:
_LIVE_GROUP_RE = re.compile(r"[(\[][^)\]]*\blive\b[^)\]]*[)\]]", re.IGNORECASE) _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 — """Lucene query for /ws/2/recording. Built from the DENOISED fields —
the noise we strip (author credits, "(Live)", "(v2)") would otherwise 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) t = denoise(title)
a = denoise(artist) 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 = [] parts = []
if t: if t:
parts.append('recording:"%s"' % _lucene_escape_phrase(t)) parts.append('recording:"%s"' % _lucene_escape_phrase(t))
+108 -8
View File
@@ -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]: def _mb_search_recordings(artist, title, limit: int = 12) -> list[dict]:
"""Text search (tier 24): denoised Lucene query over /recording. The query """Text search (tier 24): denoised Lucene query over /recording. The strict
now drops live-only recordings and our ranker rewards the studio take, so a 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 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) query = mb_match.build_recording_query(artist, title)
if not query: cands: list[dict] = []
return [] if query:
body = _mb_http_get("recording", {"query": query, "limit": limit}) body = _mb_http_get("recording", {"query": query, "limit": limit})
return mb_match.parse_search_response(body or {}) 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) ────────────── # ── AcoustID audio fingerprinting (content-based identification) ──────────────
@@ -6529,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}$") _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: def _manifest_exact_ids(filename: str) -> dict:
"""Optional `mbid`/`isrc` from the pack manifest — the spec's additive """Optional `mbid`/`isrc` from the pack manifest — the spec's additive
identity keys. Feature-detected: packs published before that spec identity keys. Feature-detected: packs published before that spec
@@ -6941,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]) cand=field_filter(cands[0]) if field_filter else cands[0])
return 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 best = ranked[0] if ranked else None
tier = mb_match.classify(ref, best, best["score"], auto_min=auto_min) if best else "none" tier = mb_match.classify(ref, best, best["score"], auto_min=auto_min) if best else "none"
if tier == "auto": if tier == "auto":
@@ -7836,6 +7929,13 @@ def api_enrichment_search(artist: str = "", title: str = "", limit: int = 8,
if duration and duration > 0 and not ref.get("duration"): if duration and duration > 0 and not ref.get("duration"):
ref = dict(ref) ref = dict(ref)
ref["duration"] = duration 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)} return {"candidates": mb_match.rank_candidates(ref, cands)}
+88
View File
@@ -97,6 +97,94 @@ 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
# ── 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) ────────────────── # ── offline safety (the pytest-never-hits-network contract) ──────────────────
def test_offline_default_skips_matching(server, monkeypatch): def test_offline_default_skips_matching(server, monkeypatch):
+48
View File
@@ -189,6 +189,54 @@ def test_build_recording_query_escapes_and_handles_missing_artist():
assert "artist:" not in q 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)"
# ── 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 ────────────────────────────────────────────── # ── MusicBrainz response parsing ──────────────────────────────────────────────
MB_DOC = { MB_DOC = {