Merge pull request #597 from Nezreka/fix/cross-script-artist-alias-coverage

Cross-script artist aliases: include canonical name + non-strict fall…
This commit is contained in:
BoulderBadgeDad 2026-05-14 13:16:31 -07:00 committed by GitHub
commit 1fc4c4313b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 365 additions and 57 deletions

View file

@ -428,54 +428,52 @@ class MusicBrainzService:
return []
# Tier 3: live MB lookup. Search → fetch by MBID → cache.
try:
results = self.mb_client.search_artist(artist_name, limit=3)
except Exception as e:
logger.debug("lookup_artist_aliases: search_artist(%r) raised: %s", artist_name, e)
self._save_to_cache('artist_aliases', artist_name, None, None, {'aliases': []}, 0)
return []
if not results:
self._save_to_cache('artist_aliases', artist_name, None, None, {'aliases': []}, 0)
return []
# Score each result: combined of name-similarity + MB's own
# relevance. Score range 0.0-1.0.
scored = []
for result in results:
mb_name = result.get('name', '')
mb_score = result.get('score', 0)
sim = self._calculate_similarity(artist_name, mb_name)
combined = (sim * 0.7) + (mb_score / 100 * 0.3)
mbid = result.get('id')
if mbid:
scored.append((combined, mbid))
# Issue #586 — strict search queries `artist:"..."` only and
# MISSES alias / sortname indexes. When MB's canonical name is
# the non-Latin form (e.g. `Дмитрий Яблонский`), the user's
# Latin input ("Dmitry Yablonsky") finds nothing under strict.
# Fall back to non-strict (bare query, hits alias + sortname
# indexes) when strict returns empty OR all results fail the
# trust gate.
scored = self._search_and_score_artists(artist_name, strict=True)
if not scored or self._best_score(scored) < 0.85:
non_strict = self._search_and_score_artists(artist_name, strict=False)
if non_strict and (not scored or self._best_score(non_strict) > self._best_score(scored)):
scored = non_strict
if not scored:
self._save_to_cache('artist_aliases', artist_name, None, None, {'aliases': []}, 0)
return []
scored.sort(key=lambda x: -x[0])
best_score, best_mbid = scored[0]
best_score, best_mbid, best_mb_score = scored[0]
# Strict trust threshold: real matches for distinctive cross-
# script artists (the user-reported case) score >= 0.95.
# Anything below 0.85 is ambiguous and not worth the false-
# positive risk of pulling in aliases for the wrong artist.
if best_score < 0.85:
# Trust gate. Two ways to pass:
# 1. Combined score >= 0.85 (the historical strict bar that
# catches same-script matches)
# 2. MB's OWN score is very high (>= 95) AND the result is
# unambiguous (top result clearly leads). Bridges the
# cross-script case where local similarity is near zero
# ("Dmitry Yablonsky" vs "Дмитрий Яблонский" sim ~0)
# but MB's index found a high-confidence match.
passes_combined = best_score >= 0.85
passes_mb_only = best_mb_score >= 95 and (
len(scored) < 2 or (scored[0][2] - scored[1][2]) >= 5
)
if not (passes_combined or passes_mb_only):
logger.debug(
"lookup_artist_aliases: best match for %r below trust "
"threshold (score=%.2f)", artist_name, best_score,
"threshold (combined=%.2f, mb_score=%d)",
artist_name, best_score, best_mb_score,
)
self._save_to_cache('artist_aliases', artist_name, None, None, {'aliases': []}, 0)
return []
# Ambiguity detection: when 2+ results both score high (within
# 0.1 of the best), the search hit multiple distinct artists
# with similar names ("John Smith" returning 10 different
# John Smiths all at score 100). Pulling aliases for one of
# them could produce wrong matches. Skip + cache empty.
if len(scored) >= 2 and (scored[0][0] - scored[1][0]) < 0.1:
# 0.1 of the best combined), the search hit multiple distinct
# artists with similar names. Pulling aliases for one could
# produce wrong matches. Skip + cache empty.
if len(scored) >= 2 and (scored[0][0] - scored[1][0]) < 0.1 and not passes_mb_only:
logger.debug(
"lookup_artist_aliases: ambiguous match for %r — top "
"two results within 0.1 (%.2f / %.2f). Skipping alias lookup.",
@ -491,6 +489,40 @@ class MusicBrainzService:
)
return aliases
def _search_and_score_artists(self, artist_name: str, strict: bool):
"""Search MB for an artist and score each result.
Returns a list of (combined_score, mbid, raw_mb_score) tuples.
Combined score: 70% local similarity + 30% MB's own relevance
score (0..1). raw_mb_score preserved separately so the trust
gate can prefer high-MB-score results in cross-script cases
where local similarity is near zero.
Returns empty list on any failure.
"""
try:
results = self.mb_client.search_artist(artist_name, limit=3, strict=strict)
except Exception as e:
logger.debug(
"lookup_artist_aliases: search_artist(%r, strict=%s) raised: %s",
artist_name, strict, e,
)
return []
scored = []
for result in results or []:
mb_name = result.get('name', '')
mb_score = result.get('score', 0)
sim = self._calculate_similarity(artist_name, mb_name)
combined = (sim * 0.7) + (mb_score / 100 * 0.3)
mbid = result.get('id')
if mbid:
scored.append((combined, mbid, mb_score))
return scored
@staticmethod
def _best_score(scored):
return max((s[0] for s in scored), default=0.0) if scored else 0.0
def fetch_artist_aliases(self, mbid: str) -> list:
"""Fetch the alias list for an artist from MusicBrainz.
@ -499,6 +531,14 @@ class MusicBrainzService:
artist record. Pull them so SoulSync can recognise that
`澤野弘之` and `Hiroyuki Sawano` refer to the same artist.
Issue #586 — for some artists MB's CANONICAL `name` is the
non-Latin spelling (e.g. `Дмитрий Яблонский`) while the
Latin spelling lives in `aliases` but the inverse also
happens, where the Latin canonical name has the Cyrillic in
aliases. Either way the canonical `name` and `sort-name` are
themselves valid alternate spellings for matching purposes,
so include them alongside the explicit alias entries.
Returns the deduplicated list of alias `name` strings. Returns
empty list (NOT None) on any failure caller should treat
empty as "no aliases available, fall back to direct match" so
@ -514,24 +554,38 @@ class MusicBrainzService:
return []
if not data:
return []
raw_aliases = data.get('aliases') or []
seen = set()
cleaned = []
def _add(value):
if not isinstance(value, str):
return
text = value.strip()
if not text:
return
key = text.lower()
if key in seen:
return
seen.add(key)
cleaned.append(text)
# Canonical name + sort-name treated as aliases for matching —
# they're the strongest cross-script bridge when MB's
# canonical spelling differs from the user's input.
_add(data.get('name'))
_add(data.get('sort-name'))
# MB returns each alias as a dict with `name`, `sort-name`,
# `locale`, `primary`, `type`, etc. We only care about the
# display name — that's what `actual` artist strings will
# match against.
seen = set()
cleaned = []
for entry in raw_aliases:
# match against. Also pull alias sort-name when present
# (some entries have a different sortable form).
for entry in data.get('aliases') or []:
if not isinstance(entry, dict):
continue
name = (entry.get('name') or '').strip()
if not name:
continue
key = name.lower()
if key in seen:
continue
seen.add(key)
cleaned.append(name)
_add(entry.get('name'))
_add(entry.get('sort-name'))
return cleaned
def update_artist_aliases(self, artist_id: int, aliases: list) -> None:

View file

@ -0,0 +1,243 @@
"""Cross-script artist alias lookup — issue #586.
The verifier's alias path was missing the Cyrillic spelling for
"Dmitry Yablonsky" because:
1. ``fetch_artist_aliases`` only read ``data['aliases']`` and ignored
the canonical ``name`` / ``sort-name`` fields. When MB's canonical
name IS the cross-script form, the Latin spelling never made it
into the alias output (and vice-versa).
2. ``lookup_artist_aliases`` ran search in strict mode only, which
queries ``artist:"..."`` and skips alias / sortname indexes. Cross-
script searches found nothing under strict.
3. The trust gate weighted local similarity 70%, so cross-script
matches scored ~0.30 even when MB's own confidence was 100, getting
rejected as low-confidence.
These tests pin all three fixes plus the original Hiroyuki Sawano
case from #442 (regression guard).
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from core.musicbrainz_service import MusicBrainzService
@pytest.fixture
def service():
"""Build a MusicBrainzService with mocked client + DB. The instance
is built bypassing __init__ so we don't need real config / DB."""
svc = MusicBrainzService.__new__(MusicBrainzService)
svc.mb_client = MagicMock()
svc._calculate_similarity = lambda a, b: _simple_sim(a, b)
svc.get_artist_aliases = MagicMock(return_value=[])
svc._check_cache = MagicMock(return_value=None)
svc._save_to_cache = MagicMock()
return svc
def _simple_sim(a: str, b: str) -> float:
"""Tiny stub similarity — exact match=1.0 else 0.0. Cross-script
pairs naturally fall into 0.0 since no characters overlap."""
if not a or not b:
return 0.0
return 1.0 if a.lower() == b.lower() else 0.0
# ──────────────────────────────────────────────────────────────────────
# fetch_artist_aliases — canonical name + sort-name now included
# ──────────────────────────────────────────────────────────────────────
def test_fetch_aliases_includes_canonical_name(service):
service.mb_client.get_artist.return_value = {
'name': 'Дмитрий Яблонский',
'sort-name': 'Yablonsky, Dmitry',
'aliases': [
{'name': 'Dmitry Yablonsky', 'sort-name': 'Yablonsky, Dmitry'},
],
}
aliases = service.fetch_artist_aliases('mbid-yablonsky')
# Canonical name MUST be present so cross-script matching works
# whichever direction the canonical form points.
assert 'Дмитрий Яблонский' in aliases
assert 'Dmitry Yablonsky' in aliases
# Sort-name covered too
assert 'Yablonsky, Dmitry' in aliases
def test_fetch_aliases_dedupes_canonical_against_alias_entry(service):
# MB sometimes lists the canonical name as ALSO an alias entry.
# No duplicate output.
service.mb_client.get_artist.return_value = {
'name': 'Hiroyuki Sawano',
'sort-name': 'Sawano, Hiroyuki',
'aliases': [
{'name': 'Hiroyuki Sawano', 'sort-name': 'Sawano, Hiroyuki'},
{'name': '澤野弘之'},
],
}
aliases = service.fetch_artist_aliases('mbid-sawano')
assert aliases.count('Hiroyuki Sawano') == 1
assert '澤野弘之' in aliases
def test_fetch_aliases_handles_missing_canonical_gracefully(service):
service.mb_client.get_artist.return_value = {
'aliases': [{'name': 'Dmitry Yablonsky'}],
}
aliases = service.fetch_artist_aliases('mbid-x')
assert aliases == ['Dmitry Yablonsky']
def test_fetch_aliases_returns_empty_on_no_data(service):
service.mb_client.get_artist.return_value = None
assert service.fetch_artist_aliases('mbid-x') == []
def test_fetch_aliases_returns_empty_on_exception(service):
service.mb_client.get_artist.side_effect = RuntimeError('boom')
assert service.fetch_artist_aliases('mbid-x') == []
# ──────────────────────────────────────────────────────────────────────
# lookup_artist_aliases — strict + non-strict fallback
# ──────────────────────────────────────────────────────────────────────
def test_lookup_falls_back_to_non_strict_when_strict_returns_nothing(service):
# Strict search returns nothing (typical cross-script case).
# Non-strict hits the alias index and finds the artist.
def search(name, limit, strict):
if strict:
return []
return [{'id': 'mbid-yab', 'name': 'Дмитрий Яблонский', 'score': 100}]
service.mb_client.search_artist.side_effect = search
service.mb_client.get_artist.return_value = {
'name': 'Дмитрий Яблонский',
'aliases': [{'name': 'Dmitry Yablonsky'}],
}
aliases = service.lookup_artist_aliases('Dmitry Yablonsky')
assert 'Дмитрий Яблонский' in aliases
assert 'Dmitry Yablonsky' in aliases
# Confirm both modes were attempted
calls = service.mb_client.search_artist.call_args_list
assert any(call.kwargs.get('strict') is True for call in calls) or any(
len(call.args) >= 3 and call.args[2] is True for call in calls
)
def test_lookup_falls_back_to_non_strict_when_strict_score_too_low(service):
# Strict returns a low-confidence match (cross-script — local sim
# near 0). Non-strict hits a stronger match via alias index.
def search(name, limit, strict):
if strict:
return [{'id': 'mbid-other', 'name': 'Some Other Artist', 'score': 30}]
return [{'id': 'mbid-yab', 'name': 'Дмитрий Яблонский', 'score': 100}]
service.mb_client.search_artist.side_effect = search
service.mb_client.get_artist.return_value = {
'name': 'Дмитрий Яблонский',
'aliases': [{'name': 'Dmitry Yablonsky'}],
}
aliases = service.lookup_artist_aliases('Dmitry Yablonsky')
assert 'Дмитрий Яблонский' in aliases
# ──────────────────────────────────────────────────────────────────────
# Trust gate — MB-score-only escape for cross-script
# ──────────────────────────────────────────────────────────────────────
def test_trust_gate_passes_on_high_mb_score_even_with_zero_local_sim(service):
# The cross-script case: local sim ~0, MB score 100, single
# unambiguous result → should now pass.
service.mb_client.search_artist.return_value = [
{'id': 'mbid-yab', 'name': 'Дмитрий Яблонский', 'score': 100},
]
service.mb_client.get_artist.return_value = {
'name': 'Дмитрий Яблонский',
'aliases': [{'name': 'Dmitry Yablonsky'}],
}
aliases = service.lookup_artist_aliases('Dmitry Yablonsky')
assert 'Дмитрий Яблонский' in aliases
assert 'Dmitry Yablonsky' in aliases
def test_trust_gate_rejects_low_mb_score_low_local_sim(service):
# Low confidence on both axes — must NOT pull aliases (false-
# positive risk: pulling random artist's aliases).
service.mb_client.search_artist.return_value = [
{'id': 'mbid-other', 'name': 'Some Random Artist', 'score': 40},
]
aliases = service.lookup_artist_aliases('Dmitry Yablonsky')
assert aliases == []
service.mb_client.get_artist.assert_not_called()
def test_trust_gate_rejects_when_two_high_mb_scores_tie(service):
# Two artists named the same with score 100 → ambiguous → skip
# even with MB-only escape (the unambiguity check still gates).
service.mb_client.search_artist.return_value = [
{'id': 'mbid-a', 'name': 'John Smith', 'score': 100},
{'id': 'mbid-b', 'name': 'John Smith', 'score': 100},
]
aliases = service.lookup_artist_aliases('John Smith')
assert aliases == []
def test_trust_gate_passes_combined_score_when_local_sim_strong(service):
# Same-script case from #442 — local sim high. Should still pass
# (no regression on the existing path).
service.mb_client.search_artist.return_value = [
{'id': 'mbid-saw', 'name': 'Hiroyuki Sawano', 'score': 100},
]
service.mb_client.get_artist.return_value = {
'name': 'Hiroyuki Sawano',
'aliases': [{'name': '澤野弘之'}],
}
aliases = service.lookup_artist_aliases('Hiroyuki Sawano')
assert '澤野弘之' in aliases
# ──────────────────────────────────────────────────────────────────────
# End-to-end — reporter scenario via artist_names_match
# ──────────────────────────────────────────────────────────────────────
def test_yablonsky_reporter_scenario_end_to_end(service):
"""Issue #586 exact case: expected 'Dmitry Yablonsky', actual
'Русская филармония, Дмитрий Яблонский', MB returns artist with
canonical name in Cyrillic and Latin in aliases. Strict search
finds nothing; non-strict finds the artist with high MB score."""
def search(name, limit, strict):
if strict:
return []
return [{'id': 'mbid-yab', 'name': 'Дмитрий Яблонский', 'score': 100}]
service.mb_client.search_artist.side_effect = search
service.mb_client.get_artist.return_value = {
'name': 'Дмитрий Яблонский',
'sort-name': 'Yablonsky, Dmitry',
'aliases': [{'name': 'Dmitry Yablonsky'}],
}
aliases = service.lookup_artist_aliases('Dmitry Yablonsky')
# Must include the Cyrillic canonical so artist_names_match can
# bridge the credit-split actual.
assert 'Дмитрий Яблонский' in aliases
# Verify the full bridge with the real artist_names_match helper.
from core.matching.artist_aliases import artist_names_match
matched, score = artist_names_match(
'Dmitry Yablonsky',
'Русская филармония, Дмитрий Яблонский',
aliases=aliases,
)
assert matched is True
assert score >= 0.6

View file

@ -81,10 +81,13 @@ class TestFetchArtistAliases:
def test_extracts_alias_names_from_mb_response(self, service):
"""Reporter's case 1 shape: MB returns aliases for Hiroyuki
Sawano including the Japanese kanji form. Extract the `name`
from each alias entry."""
from each alias entry. Issue #586 — also include the canonical
``name`` and ``sort-name`` from the artist record itself, plus
per-alias ``sort-name`` when present, for cross-script bridging."""
service.mb_client.get_artist.return_value = {
'id': '60d2ea34-1912-425f-bf9c-fc544e4448cd',
'name': 'Hiroyuki Sawano',
'sort-name': 'Sawano, Hiroyuki',
'aliases': [
{'name': '澤野弘之', 'sort-name': '澤野弘之', 'locale': 'ja', 'primary': True},
{'name': 'SawanoHiroyuki', 'sort-name': 'SawanoHiroyuki', 'locale': None},
@ -94,10 +97,11 @@ class TestFetchArtistAliases:
aliases = service.fetch_artist_aliases('60d2ea34-1912-425f-bf9c-fc544e4448cd')
assert 'Hiroyuki Sawano' in aliases # canonical name
assert 'Sawano, Hiroyuki' in aliases # canonical sort-name (also matches alias sort-name)
assert '澤野弘之' in aliases
assert 'SawanoHiroyuki' in aliases
assert 'Sawano Hiroyuki' in aliases
assert len(aliases) == 3
def test_dedup_case_insensitive(self, service):
"""Same name with different casing should collapse — MB
@ -125,14 +129,15 @@ class TestFetchArtistAliases:
aliases = service.fetch_artist_aliases('mbid-x')
assert aliases == ['Real Name']
def test_missing_aliases_key_returns_empty(self, service):
"""MB artist record might not have any aliases. Returns []
not raises."""
def test_missing_aliases_key_returns_canonical_name_only(self, service):
"""MB artist record without an aliases array still returns the
canonical name (post-#586). Pre-fix this returned [] which
meant cross-script bridging was impossible."""
service.mb_client.get_artist.return_value = {
'id': 'mbid-x',
'name': 'Some Artist',
}
assert service.fetch_artist_aliases('mbid-x') == []
assert service.fetch_artist_aliases('mbid-x') == ['Some Artist']
def test_aliases_null_returns_empty(self, service):
"""MB sometimes returns `aliases: null` instead of empty array."""
@ -476,14 +481,19 @@ class TestLookupArtistAliasesMultiTier:
assert aliases == []
def test_no_search_results_returns_empty(self, service):
"""Artist not found on MB — empty return, cached so we
don't re-search the same name forever."""
"""Artist not found on MB under either strict or non-strict
search empty return, cached so we don't re-search the same
name forever. Issue #586: strict-then-non-strict means TWO
search calls per uncached lookup; the empty cache prevents
further calls on the next invocation."""
service.mb_client.search_artist.return_value = []
aliases = service.lookup_artist_aliases('NeverHeardOf')
assert aliases == []
# Second call should hit cache, not re-search
# First lookup: strict + non-strict fallback = 2 calls.
assert service.mb_client.search_artist.call_count == 2
# Second call should hit cache, not re-search at all.
service.lookup_artist_aliases('NeverHeardOf')
assert service.mb_client.search_artist.call_count == 1
assert service.mb_client.search_artist.call_count == 2
def test_low_confidence_match_skipped(self, service):
"""Search returned something but the name similarity is too

View file

@ -3416,6 +3416,7 @@ const WHATS_NEW = {
'2.5.2': [
// --- May 13, 2026 — 2.5.2 release ---
{ date: 'May 13, 2026 — 2.5.2 release' },
{ title: 'Cross-Script Artist Aliases: Cyrillic / Kanji Canonical Names Now Bridge', desc: 'github issue #586 (follow-up to #442): "Dmitry Yablonsky" tracks were quarantining as audio mismatch — file identified as "Русская филармония, Дмитрий Яблонский" (4% artist similarity) — even though the Cyrillic spelling is just the russian transliteration. three layered bugs in the alias resolution chain. fix 1: `fetch_artist_aliases` only read `data.aliases` and IGNORED the artist record\'s canonical `name` and `sort-name`. for artists where the canonical form is the cross-script spelling (e.g. MB stores "Дмитрий Яблонский" as canonical, "Dmitry Yablonsky" as alias — or vice versa), the missing direction never made it into the alias list. now both canonical name + sort-name are included alongside the explicit aliases (deduped). fix 2: `lookup_artist_aliases` ran search in strict mode only (`artist:"..."` lucene query), which skips MB\'s alias and sortname indexes. cross-script searches found nothing under strict. now falls back to non-strict (bare query, hits all indexes) when strict returns empty OR all results fail the trust gate. fix 3: trust gate weighted local similarity 70% — cross-script pairs have similarity ~0 → combined score ~0.30 → below the 0.85 threshold → cached as empty even when MB\'s own confidence was 100. new escape: when MB score is ≥ 95 AND the result is unambiguous (top result clearly leads), accept regardless of local similarity. covers cases where MB definitely knows the right artist but our local sim collapses to zero. existing #442 same-script path (Hiroyuki Sawano ↔ 澤野弘之) still passes via combined-score path. 12 new tests pin every layer + the exact reporter scenario end-to-end via `artist_names_match`. existing alias tests updated to reflect canonical-name inclusion + 2-call strict+non-strict pattern.', page: 'downloads' },
{ title: 'MTV Unplugged & Live Albums No Longer False-Quarantine', desc: 'github issue #589: tracks from live / unplugged / concert albums (MTV Unplugged, Live At Wembley, etc) consistently failed AcoustID verification with "Version mismatch: expected (live) but file is (original)". two upstream bugs fed into the false positive — the AcoustID gate itself was correctly catching the wrong file Tidal had selected. fix 1: album-scoped library check at `core/downloads/master.py` was scoring "Shy Away (MTV Unplugged Live)" (source) vs "Shy Away" (local DB) with raw string similarity → ~0.3 → marked missing → re-downloaded even though user already owned it. new pure helper `core/matching/album_context_title.py:strip_redundant_album_suffix` strips suffixes whose tokens are fully subsumed by the album context (live/unplugged/acoustic/session markers + tolerated noise + album-title words). only fires inside the album-confirmed scope so global matching elsewhere is unchanged. fix 2: `core/tidal_download_client.py` qualifier filter only ran on FALLBACK searches — primary search returned all results unfiltered, so a query for "Shy Away (MTV Unplugged Live)" could accept the studio cut if Tidal ranked it first. now applies to both primary and fallback. fix 3: qualifier check now inspects both `track.name` AND `track.album.name` — for concert / unplugged releases the live signal often lives in the album title, not the track. AcoustID version-mismatch gate left intact (still correctly catches genuinely-wrong files). 19 tests on the album-context helper + 13 tests on the tidal qualifier helper pin every shape: MTV Unplugged variants, dash-style suffixes, brackets, year tolerance, plural-form markers, anti-regression cases (instrumental/remix on a studio album must NOT be stripped), defensive non-dict / missing-album inputs.', page: 'downloads' },
{ title: 'Deezer: Contributing Artist Tagging Now Consistent', desc: 'github issue #588: contributors tagging worked for some tracks but silently dropped them for others — most reproducibly for tracks whose ALBUM was fetched before the per-track post-process ran. trace: `core/deezer_client.py:get_track_details` cache check used `track_position` as the "full payload" sentinel, but BOTH `/track/<id>` AND `/album/<id>/tracks` set that field. only `/track/<id>` sets the `contributors` array. when album-tracks data hit the cache first, `get_track_details` returned the partial record → `_build_enhanced_track` found no contributors → the metadata-source contributors-upgrade silently fell back to single-artist. fix: lifted cache-validity to a pure helper `_is_full_track_payload` that requires BOTH `track_position` AND `contributors` key presence (empty list `[]` is valid — single-artist tracks fetched via `/track/<id>` carry it explicitly). partial cache hits now fall through to a fresh `/track/<id>` fetch. 11 boundary tests pin every shape: full payload, single-artist with empty contributors list, partial album-tracks shape, search-result shape, none/non-dict, cache-hit/cache-miss/api-failure paths.', page: 'downloads' },
{ title: 'Server Playlists: Find & Add Now Persists As A Permanent Match', desc: 'github issue #585: when a spotify track name had a versioned suffix not present in the local file (e.g. "Iron Man - 2012 - Remaster" vs "Iron Man") the auto-matcher missed the pair. user could click Find & Add to manually pick the right local file — that worked, file got added to the plex playlist — but the source spotify track stayed in Missing while the added file showed up under Extra, because the matcher had no record of the user-confirmed pairing. on the next sync the source track would re-quarantine and try to download all over again. fix: every Find & Add selection now writes a `(spotify_track_id → server_track_id)` override into `sync_match_cache` at confidence=1.0. the matching algorithm runs an override pass BEFORE the existing exact and fuzzy passes, so any user-confirmed pair short-circuits straight to "matched" without going through normalization at all. covers every kind of mismatch — dash-suffix remasters, covers / karaoke versions, alt masters, cross-language titles, typo\'d local files, anything. logic lifted to `core/sync/match_overrides.py` (pure helpers `resolve_match_overrides` + `record_manual_match`). 18 boundary tests pin: cache-hit pairs, cache-miss falls through, stale-cache (server track removed) handled gracefully, two sources pointing at same server track (UNIQUE-violation defense), str/int id coercion, partial cache hits, defensive against non-dict inputs and DB exceptions. legacy entries without `source_track_id` (non-mirrored playlists) just skip the override path. works across plex / jellyfin / navidrome.', page: 'sync' },