Commit graph

4 commits

Author SHA1 Message Date
Broque Thomas
7066233c37 Wire alias-aware artist match into AcoustID verifier — fixes #442
This is the user-visible commit. The reporter's exact two cases
(Japanese kanji, Russian Cyrillic) now pass verification instead of
being quarantined.

# What changed

Verifier's three artist-similarity sites now route through the
shared `core.matching.artist_aliases.artist_names_match` helper
instead of raw `_similarity`:

- `_find_best_title_artist_match` (per-recording scoring at the
  best-match stage)
- Secondary scan when title matches but best-match's artist doesn't
  (line ~355 pre-fix)
- Final fallback scan over all recordings (line ~403 pre-fix)

Aliases for the expected artist are resolved ONCE at the top of
`verify_audio_file` via `_resolve_expected_artist_aliases`, which
calls the new `MusicBrainzService.lookup_artist_aliases` chain
(library DB → cache → live MB). Single resolution per verification
regardless of how many AcoustID recordings come back — pinned by
test.

New helper `_alias_aware_artist_sim(expected, actual, aliases)`
wraps the pure helper with the verifier's normaliser
(`_similarity`) and threshold (`ARTIST_MATCH_THRESHOLD`). Returns a
single float so existing threshold-comparison code paths keep their
shape — minimal diff.

# Reporter's cases — verified

Case 1 (issue #442 verbatim):
    File:     YAMANAIAME by 澤野弘之
    Expected: YAMANAIAME by Hiroyuki Sawano
    Pre-fix:  Quarantined (artist=0%)
    Post-fix: PASS (alias '澤野弘之' resolved from MB)

Case 2 (issue #442 verbatim):
    File:     On the Other Side by Sergey Lazarev
    Expected: On the other side by Сергей Лазарев
    Pre-fix:  Quarantined (artist=7%)
    Post-fix: PASS (alias 'Sergey Lazarev' resolved from MB)

Both reproduced as regression tests with stubbed MB service.

# Backward compat

Three test cases pin that no-aliases / failure paths preserve
pre-fix behaviour exactly:

- Clear artist mismatch (different artist, same script) still
  FAILs — aliases bridge synonyms, not unrelated artists.
- Exact title + artist match still PASSes regardless of aliases.
- MB service raise → verifier completes with direct similarity
  (treats failure as "no aliases available" — same as pre-fix).

Also covers manual import: the import-modal "Search for Match"
flow goes through the same verifier, so the reporter's complaint
that "manual import simply throws them back in quarantine again"
is fixed by the same change.

# Tests added (11)

`tests/matching/test_acoustid_verification_aliases.py`:
- `_alias_aware_artist_sim`: alias bridges score ↑, no-aliases
  falls back, aliases don't mask genuine mismatches
- `_find_best_title_artist_match` accepts + uses aliases
- Reporter's case 1 (Japanese) end-to-end
- Reporter's case 2 (Russian) end-to-end
- Backward compat: no-aliases mismatch still fails, exact match
  still passes, MB-service-raise doesn't break verification
- Performance: alias lookup fires ONCE per verification regardless
  of recording count

# Verification

- 11 new verifier tests pass
- 31 prior service tests pass
- 28 prior helper tests pass
- 294 matching + imports tests pass total (no regression)
- Ruff clean
2026-05-10 16:33:54 -07:00
Broque Thomas
15244f24cf Live MB lookup for un-enriched artists with cache
Previous commit only populated `artists.aliases` for artists the MB
worker had enriched. But the AcoustID verifier (next commit) needs
aliases for ANY expected artist — including:

- Artists not yet in the user's library (first download)
- Artists in the library where MB enrichment hasn't run yet
- Artists where MB enrichment ran but found no MBID (NULL aliases)

This commit adds a multi-tier resolution helper that fills those
gaps without thrashing the MB API.

# Multi-tier resolution

`lookup_artist_aliases(artist_name) -> list[str]`:

1. **Library DB** (fast path): existing `get_artist_aliases` lookup
   by name. No network. Most common path once the worker has
   enriched everything.
2. **Cache** (existing `musicbrainz_cache` table, entity_type=
   `artist_aliases`): a prior live lookup for this name. Empty
   cache hit is respected (don't re-query when MB previously had
   nothing).
3. **Live MB**: search artist by name → pick highest-confidence
   match (combined name-similarity + MB relevance) → fetch aliases
   for that MBID → cache the result.

Always returns a list (possibly empty), never raises. Empty result
on any tier means "no alternate spellings found, fall back to
direct match" — identical to the pre-fix behaviour.

# Threshold gate

Live lookup only trusts the MB search result when combined
similarity score >= 0.6. Below that, we'd be guessing at the wrong
artist — searching `John Smith` returns multiple John Smiths and
pulling aliases for one of them could mismatch. Cache the empty
result so we don't keep re-searching the same low-confidence name.

# Performance contract

Critical for the verifier path: 100 quarantine candidates with the
same expected artist must NOT trigger 100 MB API calls. Cache hit
on second + subsequent calls per unique artist name. Verified by
test pinning the call counts.

# Tests added (8)

- Tier 1 library DB hit — no MB API call fired
- Tier 3 live MB lookup → search → fetch → returns aliases
- Tier 2 cache hit on second call — no re-query
- Empty input → empty return + no API call
- Network failure on search → empty + cached so we don't retry
- No search results → empty + cached
- Low-confidence match (sim < 0.6) skipped — defends against
  picking the wrong artist
- Library row exists but aliases NULL → falls through to live
  lookup (defends against the half-enriched state)

# Verification

- 31/31 service tests pass (8 new + 23 prior)
- Ruff clean
2026-05-10 16:25:30 -07:00
Broque Thomas
48d848bb74 MB worker populates artists.aliases on enrichment
Issue #442 — MusicBrainz exposes alternate-spelling aliases (Japanese
kanji `澤野弘之` for `Hiroyuki Sawano`, Cyrillic `Сергей Лазарев` for
`Sergey Lazarev`, etc.) on every artist record. SoulSync's MB
enrichment worker had access to this data via `get_artist(mbid,
includes=['aliases'])` but wasn't reading or persisting it.

This commit wires the alias fetch into the worker's existing
artist-match path, persists to the new `artists.aliases` column
added in the prior commit, and adds a verifier-friendly read-by-
name lookup so the AcoustID verifier (next commit) can resolve
aliases without an MB round-trip when the artist is in the library.

# New service methods

- `fetch_artist_aliases(mbid) -> list[str]` — calls
  `mb_client.get_artist(mbid, includes=['aliases'])`, parses the
  alias array, dedupes case-insensitively. Returns empty list on
  any failure (missing key, network error, malformed response) so
  transient MB outages never trigger stricter quarantine decisions
  than the pre-fix behaviour. Empty mbid → no API call.

- `update_artist_aliases(artist_id, aliases)` — persists as JSON
  array to `artists.aliases`. Idempotent — overwrites prior value.
  Empty list clears the column. None artist_id is a no-op.

- `get_artist_aliases(artist_name) -> list[str]` — reads back by
  artist NAME (not id), case-insensitive. Used by the verifier
  where the expected artist comes from track metadata — there's no
  library row id at quarantine time. Returns empty list for unknown
  artists, missing data, or corrupt JSON (defensive against legacy
  rows).

# Worker integration

`MusicBrainzWorker._process_item` artist branch:
- After `update_artist_mbid` succeeds, fetch aliases for the matched
  MBID and persist via `update_artist_aliases`.
- Best-effort: alias fetch wrapped in try/except, failure logs at
  debug level, doesn't regress the match outcome.
- No alias call when the artist didn't match an MBID (nothing to
  enrich).

# Tests (23)

- `fetch_artist_aliases`: extracts names from MB response,
  case-insensitive dedup, skips empty/null entries, missing-key
  fallback, network failure → empty, empty mbid no API call,
  verifies `inc=aliases` request param.
- `update_artist_aliases`: persists as JSON, idempotent overwrite,
  empty list clears column, None id is no-op.
- `get_artist_aliases`: returns aliases for known artist,
  case-insensitive lookup, empty for unknown artist / no-aliases
  row, handles corrupt JSON + non-list shape gracefully.
- Worker integration: matched artist triggers fetch + persist,
  no alias call when not matched, alias-fetch failure doesn't
  break the match outcome.

# Verification

- 23/23 new tests pass
- Ruff clean
2026-05-10 16:22:23 -07:00
Broque Thomas
235ada7e0f Add pure artist-name comparison helper with alias awareness
Issue #442 — files tagged with one spelling of an artist's name
(Japanese kanji `澤野弘之`) get quarantined when SoulSync expects the
romanized spelling (`Hiroyuki Sawano`). Raw similarity comparison
scored 0% across scripts. MusicBrainz exposes alternate-spelling
aliases on every artist record but the verifier never consulted
them.

This commit adds the pure helper that does the alias-aware
comparison. No I/O, no DB access, no network. Caller supplies the
aliases (looked up from library DB or live MB by later commits in
this PR). Default threshold matches the verifier's existing
`ARTIST_MATCH_THRESHOLD` (0.6) so wiring this in preserves current
pass/fail semantics on the no-alias path.

# API

```
artist_names_match(expected, actual, *, aliases=None,
                   threshold=0.6, similarity=None)
    -> (matched: bool, best_score: float)
```

- Direct compare first (fast path + baseline score)
- If below threshold, score each alias against `actual`
- First alias to clear threshold → match
- Returns the best score across all candidates so callers can log
  the score they made the decision on

```
best_alias_match(expected, actual, aliases=None, *, similarity=None)
    -> (winner: Optional[str], best_score: float)
```

Companion helper for callers that want to surface WHICH alias
triggered the match (debug logs, UI explanations). No threshold —
purely informative.

# Architectural choices

- **Pure function**: no I/O. Caller (verifier, future matching-engine
  consumers) owns alias lookup strategy + threshold tuning.
- **Custom similarity callable**: lets the verifier pass its
  parenthetical-stripping normaliser without this module having to
  know about it. Defaults to lowercase + SequenceMatcher (matches
  the verifier's existing behaviour).
- **Defensive coercion**: aliases input handles None entries, empty
  strings, non-string types, sets, tuples, lists — caller may feed
  raw MB response data without cleaning first.
- **Backward compat**: `aliases=None` or empty → behaves identically
  to a plain similarity check. Paths not yet wired up to alias lookup
  see no behaviour change.

# Tests (28)

- Direct compare (no aliases): exact / case / whitespace / fuzzy /
  different
- Cross-script with aliases: Japanese ↔ romanized (reporter's case 1),
  Cyrillic ↔ Latin (reporter's case 2), symmetric direction, no-match
  fallthrough so aliases don't mask genuine mismatches
- Aliases input handling: None, empty, set, tuple, None-entries,
  non-string entries
- Threshold: default matches verifier's 0.6, custom stricter, custom
  looser
- Custom similarity: applies to both direct + alias compare
- Best-alias-match introspection
- Backward compat parametrised across 5 cases

# What this commit does NOT do

This is the helper module + tests only. Subsequent commits in this
PR populate aliases (MB worker), provide live MB lookup with cache
for un-enriched artists, and wire the helper into the AcoustID
verifier where the quarantine decision actually fires.
2026-05-10 16:08:38 -07:00