diff --git a/core/musicbrainz_service.py b/core/musicbrainz_service.py index 20f24023..d9085300 100644 --- a/core/musicbrainz_service.py +++ b/core/musicbrainz_service.py @@ -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: diff --git a/tests/matching/test_artist_alias_lookup_586.py b/tests/matching/test_artist_alias_lookup_586.py new file mode 100644 index 00000000..97c9f03d --- /dev/null +++ b/tests/matching/test_artist_alias_lookup_586.py @@ -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 diff --git a/tests/matching/test_artist_alias_service.py b/tests/matching/test_artist_alias_service.py index 2e2f1a6a..a895f942 100644 --- a/tests/matching/test_artist_alias_service.py +++ b/tests/matching/test_artist_alias_service.py @@ -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 diff --git a/webui/static/helper.js b/webui/static/helper.js index 7f5b4052..304ecee2 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -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/` AND `/album//tracks` set that field. only `/track/` 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/` carry it explicitly). partial cache hits now fall through to a fresh `/track/` 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' },