Fix #785: file/CSV playlists fail to match (raw 'Artist - Title' titles)

#768 added canonical_source_track to the live-sync matcher and the playlist
editor reconcile, but NOT to the two paths that actually run for file/CSV
mirrored playlists: the discovery worker (core/discovery/playlist.py) and the
DB-only matcher (core/discovery/sync.py). YouTube playlists are cleaned at
ingest, so they matched; file playlists fed the raw 'Arctic Monkeys - Do I
Wanna Know?' title into search+scoring and never matched the library's clean
'Do I Wanna Know?' → reported missing / shown as 'extra'.

Add a conservative canonical best-of to both: score with the raw title AND the
canonicalized one, keep the better. canonical_source_track only strips an
'<artist> - ' prefix when it equals the artist, so it can only add a candidate.

Tests: _canonical_best_score seam (file-style match / clean title scored once /
keeps original when better).
This commit is contained in:
BoulderBadgeDad 2026-06-04 21:13:23 -07:00
parent c3ec36c989
commit 6cb753e7a1
3 changed files with 86 additions and 4 deletions

View file

@ -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 "<artist> - " 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

View file

@ -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:

View file

@ -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)