Commit graph

12 commits

Author SHA1 Message Date
BoulderBadgeDad
177a4d8d05 #868: disambiguate same-name artists by owned-catalog overlap during enrichment
Enrichment matched artists by NAME ONLY (0.85 gate), so for a common name
('Rone' has ~5 artists) it stored whichever the source ranked first — often the
wrong one, which then drove a wrong/sparse library 'Standard' discography while
'Enhanced' (the real owned albums) showed the full set.

Fix — use the decisive signal the library already has (the albums you OWN):
- worker_utils: pick_artist_by_catalog() + catalog_overlap_score() +
  owned_album_titles()/release_titles(). When 2+ candidates clear the name gate,
  fetch each one's catalog and choose the one overlapping the owned albums; falls
  back to the current best-by-name pick when there's nothing to disambiguate or
  no overlap (so the common single-candidate path makes no extra API calls).
- Wired into Spotify (covers Spotify-Free, same client), iTunes, Deezer (now
  multi-candidate search_artists + get_artist_info store), and MusicBrainz
  (match_artist gains owned_titles; release-groups as the catalog).

Re-match path (#868):
- build_reset_query now also clears the stored source-ID column for artist/album
  item resets — previously a 're-match' only nulled match_status, so the worker's
  existing-id short-circuit re-confirmed the WRONG id and never re-resolved. Tracks
  excluded (ids live in tags, not a column).
- MusicBrainz also self-corrects its 90-day name->mbid cache: match_artist bypasses
  a cached mbid whose catalog has ZERO overlap with the owned albums, so a re-match
  isn't blocked by a stale wrong cache entry.

Tests: shared selector (9), per-worker disambiguation for all 4 sources + MB
backward-compat + MB cache-revalidation (8), reset-clears-id (2). 99 worker/
enrichment tests green.
2026-06-13 14:57:17 -07:00
BoulderBadgeDad
142a1aaf38 Cover art: a numeric difference is a different release — Vol.4 stops wearing Vol.4.5's cover
Sokhi (continued from #806): volume-numbered series ('B小町 …キャラクター
ソングCD Vol.2' / 'Vol.2.5' / 'Vol.4' / 'Vol.4.5') got each other's art from
both normal downloads and the retag tool. Two distinct holes, one principle:

1. The art picker's _album_matches validates by significant-token SUBSET —
   built to tolerate '(Deluxe)'/'- Remastered' suffixes. CJK strips out of
   the normalizer entirely, so Vol.4 → {b,tv,cd,vol,4}, a clean subset of
   Vol.4.5's {b,tv,cd,vol,4,5}: the wrong volume validated as "the same
   album with a suffix". Affected every fuzzy art source (iTunes, Deezer,
   AudioDB, Spotify) in downloads, retag, and the missing-art repair.

2. MusicBrainz match_release scores by string similarity — Vol.4 vs Vol.4.5
   is 0.973, so the wrong volume could win the match outright, and its MBID
   then feeds Cover Art Archive with NO downstream validation (CAA is
   MBID-keyed, trusted by design). With Sokhi's MB metadata source this is
   the likely path in his logs (his release-group 404s push re-matching).

The shared rule (core.text.title_match.numeric_tokens_differ): digit-bearing
tokens must be IDENTICAL between the two titles. A number on one side only —
volume, part, sequel, remaster year — is a different release, never a
suffix. '1989' vs '1989 (Deluxe)' still matches (digits shared); 'Album' vs
'Album 2' now rejects (sequels!). Art picker rejects outright (falls through
to next source / the download's own art — the designed cost of a false
reject); MB matcher halves the candidate's confidence, landing it below the
70 gate while the exact-volume result is untouched.

Tests: helper truth table, the exact reported pairs through _album_matches,
and match_release end-to-end (wrong volume alone → no match beats a wrong
MBID; exact volume beats near-identical wrong one despite lower MB score).
828 matching/metadata + 301 musicbrainz/retag/artwork tests pass.
2026-06-07 10:21:23 -07:00
dev
6cb5d455f9 MusicBrainz: alias trust-gate evaluates MB-score leader, not combined leader
The cross-script alias bridge (#442/#586) silently returned [] for some
artists ("Sawano Hiroyuki"). Root cause: the mb-only escape — built exactly
for the case where local string similarity is ~0 (romaji↔kanji) but MB's own
score is decisive — inspected scored[0], the COMBINED-score leader. When an
unrelated same-script decoy outranks the real artist on combined score (decoy:
sim 0.82 + mb_score 83 → combined 0.82, just under the 0.85 bar; real '澤野弘之':
sim 0 + mb_score 100 → combined 0.30, sorted last), the gate saw the decoy's
mb_score 83 (< 95), failed both paths, and cached an empty alias result.
Verification then scored the kanji artist 0% against the romaji expected name
and quarantined every correct file.

Evaluate the MB-SCORE leader independently of combined ranking for the mb-only
escape, and pull aliases from whichever entity actually passed (combined leader
for the combined path, MB-score leader for the mb-only path). The unambiguity
check now compares the top two raw MB scores. Same-script and single-result
paths are unchanged (regression-guarded by the existing #442/#586 tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 16:44:14 +02:00
Broque Thomas
0aa18b0180 Cross-script artist aliases: include canonical name + non-strict fallback
Closes #586. Follow-up to #442 — Cyrillic / kanji canonical names
weren't bridging cross-script comparisons. Reporter case: "Dmitry
Yablonsky" tracks quarantined as audio mismatch with file identified
as "Русская филармония, Дмитрий Яблонский" (4% artist sim) even
though the Cyrillic spelling is just the Russian transliteration.

Codex diagnosed three layered bugs in the alias resolution chain.
This fixes all three.

Bug 1 — fetch_artist_aliases ignores canonical name + sort-name

`core/musicbrainz_service.py:fetch_artist_aliases` only read
`data['aliases']`. For artists where MB's canonical `name` IS the
cross-script form (and the Latin spelling lives only in aliases —
or vice versa), the missing direction never made it into the
returned list. Fix: include both `data['name']` and `data['sort-name']`
alongside the explicit alias entries (deduped, also pulls each
alias entry's sort-name when present).

Bug 2 — lookup_artist_aliases ran search in strict mode only

Strict mode queries `artist:"..."` only and skips MB's alias and
sortname indexes. Cross-script searches found nothing under strict
because the user's Latin input never matches a Cyrillic canonical
name in the artist index. Fix: lifted the search-and-score logic
to a private helper `_search_and_score_artists(name, strict=)` and
fall back to non-strict when strict returns empty OR all results
fail the trust gate. Non-strict (bare query) hits all indexes.

Bug 3 — trust gate weighted local similarity 70%

Combined score = local_sim * 0.7 + mb_score/100 * 0.3. Cross-script
pairs have local sim ~0 → combined ~0.30 → below the 0.85 threshold
→ cached as empty even when MB's own confidence was 100. Fix: added
an MB-only escape — when MB score is >= 95 AND the result is
unambiguous (top result's MB score leads the runner-up by >= 5),
accept regardless of local similarity. The existing combined-score
path stays intact for same-script matches (#442 Hiroyuki Sawano
case still passes via that path).

12 new tests pin every layer:
- fetch_artist_aliases canonical-name inclusion + dedup against
  alias entries + missing-canonical handling + exception path
- strict-then-non-strict fallback (empty-strict + low-strict-score)
- trust gate MB-only escape + low-confidence rejection + ambiguity
  rejection (two artists same MB score) + same-script regression
- end-to-end reporter scenario with the real `artist_names_match`
  helper proving the bridge works for "Русская филармония, Дмитрий
  Яблонский" vs expected "Dmitry Yablonsky"

Existing alias tests in `test_artist_alias_service.py` updated to
reflect: canonical name now appears in `fetch_artist_aliases`
output, lookup makes 2 search calls (strict + non-strict fallback)
on first cache miss instead of 1.

Full suite: 3065 passed.
2026-05-14 13:07:15 -07:00
Broque Thomas
bc34d39ce9 Tighten alias-lookup trust + add ambiguity gate + diagnostic log
Cin pre-review pass on the false-positive risk. Three tightenings:

# 1. Bumped MB-search trust threshold from 0.6 → 0.85

`MusicBrainzService.lookup_artist_aliases` previously trusted any
MB search match scoring ≥ 0.6 combined (name-similarity + MB
relevance). For distinctive cross-script artists the user-reported
case targets (Hiroyuki Sawano, Сергей Лазарев, etc.) real matches
score ~1.0 — well above 0.85. The 0.6 floor was loose enough to
let in moderate matches for ambiguous names, risking aliases for
the wrong artist getting cached + applied.

Bumped to 0.85. Tighter without rejecting any of the legit
cross-script cases the PR is for.

# 2. Ambiguity gate — skip when results within 0.1 of best

When MB search returns multiple results all scoring high (within
0.1 of the best), the artist name is ambiguous — common name with
multiple distinct artists ("John Smith" returning 10 different
John Smiths). Pulling aliases for any one of them risks the wrong
artist's data bridging incorrectly to a file's tag.

Added explicit ambiguity detection: when 2+ results within 0.1,
skip alias lookup entirely + cache empty. Matches Cin's
"explicit > implicit" — the prior code just picked the highest
score blindly.

# 3. Diagnostic log when alias rescues a comparison

When the alias path triggers a PASS that direct similarity would
have FAILed, emit an INFO log: `Artist alias rescued comparison:
expected='X' vs actual='Y' (direct sim=0.00, alias 'Z' →
score=1.00)`.

Lets future bug reports trace which alias triggered which decision.
Doesn't change behavior — visibility only. Logs ONLY the rescue
case, not happy-path direct matches (no log spam).

# Tests added (5)

`test_artist_alias_service.py` (+3):
- `test_moderate_confidence_match_now_skipped_strict_threshold`
- `test_ambiguous_results_skipped`
- `test_unambiguous_high_confidence_match_succeeds`

`test_acoustid_verification_aliases.py` (+3):
- `test_alias_rescue_emits_info_log` — direct-fail + alias-pass
  emits INFO log
- `test_no_log_when_direct_match_succeeds` — happy path quiet
- `test_no_log_when_alias_doesnt_help` — failed path also quiet

# Test infrastructure note

Logging tests use a directly-attached `ListHandler` on
`soulsync.acoustid.verification` (the actual logger name —
dot-separated by `get_logger`), NOT pytest's caplog. Same pattern
as the prior watchdog-test fix — caplog is intermittently flaky
in full-suite runs for soulsync namespace loggers. An owned
handler sidesteps both issues.

# Verification

- 85/85 matching tests pass (+5 from prior commit)
- 2543 full suite passes (+6 from prior, +85 PR-total)
- Ruff clean
- Reporter's Japanese + Russian regression tests still pass —
  legit cross-script case (sim ≈ 1.0) clears the new 0.85
  threshold easily
2026-05-10 17:38:03 -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
59587162cd Add metadata cache maintenance and health monitoring
Cache maintenance:
- Input validation rejects junk entities (Unknown Artist, empty names)
  from being cached, with exemptions for synthetic entries (_features,
  _tracks suffixes)
- CacheEvictorJob expanded to 4 phases: TTL eviction, junk cleanup,
  orphaned search cleanup, MusicBrainz failed lookup cleanup
- MusicBrainz null results now expire after 30 days (was 90) so failed
  lookups get retried sooner

Cache health UI:
- Polished modal accessible from Dashboard "Cache Health" button and
  repair dashboard health bar
- Shows health status banner (healthy/fair/poor), stat cards, source
  breakdown with colored progress bars, type pills, and metrics table
- Repair dashboard shows compact bar with health dot indicator
2026-03-30 07:40:20 -07:00
Broque Thomas
f991d02f0c Fix MusicBrainz release matching preferring standard over deluxe
When searching for "Playing the Angel (Deluxe)", MusicBrainz would
match the standard release because its higher popularity score
outweighed the lower title similarity. Now match_release extracts
version qualifiers (Deluxe, Remastered, etc.) from both the query
and each result, applying a +10 bonus for exact qualifier match,
+5 for partial, and -5 penalty when the query has a qualifier but
the result doesn't. This ensures deluxe downloads get the deluxe
MBID, preventing Navidrome from splitting albums.
2026-03-21 10:48:35 -07:00
Broque Thomas
87b39634a0 Fix MusicBrainz recording matches with wrong titles & add MBID mismatch repair job
Add minimum 60% title similarity gate to match_recording() — prevents
artist bonus + MB score from pushing unrelated titles past the confidence
threshold (e.g. "Sweet Surrender" matching "Answers" by same artist).

New MBID Mismatch Detector repair job reads embedded MusicBrainz recording
IDs from audio files, verifies them against the MusicBrainz API, and flags
tracks where the MBID points to a different song. Fix action strips the bad
MBID tag so media servers like Navidrome fall back to correct file tags.
2026-03-16 07:52:00 -07:00
Broque Thomas
d08a2e91a2 feat: embed MusicBrainz, Spotify/iTunes IDs, ISRC, and merged genres into audio file tags
Enrich downloaded audio files with external identifiers and improved genre metadata in a single post-processing write. During metadata enhancement, the app now looks up the MusicBrainz recording and artist MBIDs, retrieves the ISRC and MusicBrainz genres from a follow-up detail lookup, merges them with Spotify's artist-level genres (deduplicated, capped at 5), and embeds everything alongside the Spotify/iTunes track, artist, and album IDs. All MusicBrainz API calls are serialized through the existing global rate limiter, making concurrent download workers safe without needing to pause the background worker. Includes a database migration adding Spotify/iTunes ID columns to the library tables.
2026-02-05 21:26:19 -08:00
Broque Thomas
cee5590718 feat(ui): add MusicBrainz enrichment status UI with real-time monitoring
MusicBrainz library enrichment with real-time
status monitoring and manual control.
Features:
- Status icon button in dashboard header with glassmorphic design
- Animated loading spinner during active enrichment
- Hover tooltip showing:
  - Worker status (Running/Paused/Idle)
  - Currently processing item
  - Artist matching progress with percentage
- Click-to-toggle pause/resume functionality
- Auto-polling every 2 seconds for live updates
Backend Changes:
- Added GET /api/musicbrainz/status endpoint
- Added POST /api/musicbrainz/pause endpoint
- Added POST /api/musicbrainz/resume endpoint
- Worker tracks current_item for UI display
- get_stats() returns enhanced status data
Frontend Changes:
- New MusicBrainz button component with tooltip
- Premium CSS styling with animations
- JavaScript polling and state management
- Positioned tooltip below button with centered arrow
Files Modified:
- web_server.py: API endpoints and worker initialization
- core/musicbrainz_worker.py: current_item tracking
- webui/index.html: Button and tooltip structure
- webui/static/style.css: Complete styling (240 lines)
- webui/static/script.js: Polling and interaction logic (115 lines)
2026-02-03 15:20:04 -08:00