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:
parent
c3ec36c989
commit
6cb753e7a1
3 changed files with 86 additions and 4 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue