diff --git a/core/discovery/playlist.py b/core/discovery/playlist.py index 9761310d..db841d72 100644 --- a/core/discovery/playlist.py +++ b/core/discovery/playlist.py @@ -40,6 +40,30 @@ from typing import Any, Callable logger = logging.getLogger(__name__) +def _canonical_best_score(deps, title, artist, duration_ms, results): + """Score search results against the source track, trying the canonicalized + title/artist too and keeping the better confidence (#785). + + YouTube playlists have their "Artist - Title" / channel decoration stripped + at ingest, but file/CSV-imported playlists keep raw titles — so a track + titled "Arctic Monkeys - Do I Wanna Know?" scored verbatim against the + library's "Do I Wanna Know?" never matched. canonical_source_track is + conservative (only strips an " - " prefix when it equals the + artist), so this can only ADD a better candidate, never weaken a match. + Returns (match, confidence).""" + match, confidence, _ = deps.discovery_score_candidates(title, artist, duration_ms, results) + try: + from core.text.source_title import canonical_source_track + canon_title, canon_artist = canonical_source_track(title or '', artist or '') + except Exception: + return match, confidence + if (canon_title, canon_artist) != (title, artist): + alt_match, alt_conf, _ = deps.discovery_score_candidates(canon_title, canon_artist, duration_ms, results) + if alt_match and alt_conf > confidence: + return alt_match, alt_conf + return match, confidence + + @dataclass class PlaylistDiscoveryDeps: """Bundle of cross-cutting deps the playlist discovery worker needs.""" @@ -237,8 +261,8 @@ def run_playlist_discovery_worker(playlists, automation_id=None, deps: PlaylistD if not results: continue - match, confidence, _ = deps.discovery_score_candidates( - track_name, artist_name, duration_ms, results + match, confidence = _canonical_best_score( + deps, track_name, artist_name, duration_ms, results ) if match and confidence > best_confidence: @@ -259,8 +283,8 @@ def run_playlist_discovery_worker(playlists, automation_id=None, deps: PlaylistD else: extended = itunes_client_instance.search_tracks(query, limit=50) if extended: - match, confidence, _ = deps.discovery_score_candidates( - track_name, artist_name, duration_ms, extended + match, confidence = _canonical_best_score( + deps, track_name, artist_name, duration_ms, extended ) if match and confidence > best_confidence: best_confidence = confidence diff --git a/core/discovery/sync.py b/core/discovery/sync.py index 4fd447ce..82ff0614 100644 --- a/core/discovery/sync.py +++ b/core/discovery/sync.py @@ -188,6 +188,22 @@ async def _database_only_find_track(spotify_track, candidate_pool=None): server_source=active_server ) + if not (db_track and confidence >= 0.80): + # #785: file/CSV playlists keep raw "Artist - Title" titles (unlike + # YouTube, cleaned at ingest), which don't match the clean library + # title. Retry with the canonical form (best-of, conservative). + try: + from core.text.source_title import canonical_source_track + _canon_title, _canon_artist = canonical_source_track(original_title, artist_name) + if (_canon_title, _canon_artist) != (original_title, artist_name): + _alt_track, _alt_conf = db.check_track_exists( + _canon_title, _canon_artist, + confidence_threshold=0.80, server_source=active_server) + if _alt_track and _alt_conf > confidence: + db_track, confidence = _alt_track, _alt_conf + except Exception as _canon_err: + logger.debug("canonical retry failed: %s", _canon_err) + if db_track and confidence >= 0.80: logger.info(f"Database match: '{db_track.title}' (confidence: {confidence:.2f})") if spotify_id: diff --git a/tests/discovery/test_discovery_playlist.py b/tests/discovery/test_discovery_playlist.py index 0c6cfea3..3c5de2a6 100644 --- a/tests/discovery/test_discovery_playlist.py +++ b/tests/discovery/test_discovery_playlist.py @@ -496,3 +496,45 @@ def test_multi_playlist_aggregates_grand_total(): # All 3 tracks discovered → 3 extra_data writes assert len(deps._db.extra_data_writes) == 3 + + +# --------------------------------------------------------------------------- +# _canonical_best_score — #785: file/CSV playlists keep raw "Artist - Title" +# titles (YouTube is cleaned at ingest); the worker must try the canonical form. +# --------------------------------------------------------------------------- + +from types import SimpleNamespace # noqa: E402 + + +def test_canonical_best_score_matches_file_style_title(): + # Raw "Artist - Title" scores low; canonical "Title" scores high → take it. + def score(title, artist, dur, results): + if title == 'Do I Wanna Know?': + return ('MATCH', 0.95, None) + return (None, 0.2, None) + deps = SimpleNamespace(discovery_score_candidates=score) + match, conf = dp._canonical_best_score( + deps, 'Arctic Monkeys - Do I Wanna Know?', 'Arctic Monkeys', 0, ['r']) + assert match == 'MATCH' + assert conf == 0.95 + + +def test_canonical_best_score_clean_title_scored_once(): + calls = [] + def score(title, artist, dur, results): + calls.append(title) + return ('M', 0.9, None) + deps = SimpleNamespace(discovery_score_candidates=score) + match, conf = dp._canonical_best_score(deps, 'Do I Wanna Know?', 'Arctic Monkeys', 0, ['r']) + assert (match, conf) == ('M', 0.9) + assert calls == ['Do I Wanna Know?'] # canonical == original → no second score + + +def test_canonical_best_score_keeps_original_when_better(): + # Best-of: if the raw title actually scores higher, keep it. + def score(title, artist, dur, results): + return ('CANON', 0.6, None) if title == 'Do I Wanna Know?' else ('ORIG', 0.9, None) + deps = SimpleNamespace(discovery_score_candidates=score) + match, conf = dp._canonical_best_score( + deps, 'Arctic Monkeys - Do I Wanna Know?', 'Arctic Monkeys', 0, ['r']) + assert (match, conf) == ('ORIG', 0.9)