Playlist sync: honor the DURABLE manual match, not just the volatile cache

Find & Add was being forgotten on the next auto-sync. It persists two ways — a fast
sync_match_cache override AND a durable manual_library_match (#787) that survives a
rescan — but BOTH sync matchers (services.sync_service._find_track_in_media_server and
the DB-only fallback) only consulted the volatile cache. A library rescan wipes that
cache, so the next 'replace' auto-sync re-matched the track from scratch and the user
had to Find & Add it again.

Both matchers now fall back to the durable manual match when the cache misses
(self-healing a stale library id via the stored file path), exactly like the compare
view already does via resolve_durable_match_server_id. So a Find & Add pairing sticks
across rescans + auto-syncs. Seam tests: cache-wiped→durable hit, stale-id self-heal,
no-match→fuzzy fall-through.
This commit is contained in:
BoulderBadgeDad 2026-06-21 18:15:20 -07:00
parent b6cb244cbd
commit cf2b4d7d38
3 changed files with 133 additions and 28 deletions

View file

@ -173,6 +173,32 @@ async def _database_only_find_track(spotify_track, candidate_pool=None):
logger.debug("sync match cache fast-path failed: %s", e)
# --- End cache fast-path ---
# Durable manual library match (#787) — survives a library rescan (the
# sync_match_cache above does not), so a user's Find & Add pairing keeps
# sticking across auto-syncs instead of being re-matched from scratch (#895
# follow-up). Self-heals a stale library id via the stored file path.
if spotify_id:
try:
from core.artists.map import get_current_profile_id
m = db.find_manual_library_match_by_source_track_id(
get_current_profile_id(), str(spotify_id), active_server)
if m:
lib_id = m.get('library_track_id')
dt = db.get_track_by_id(lib_id) if lib_id is not None else None
if not dt and m.get('library_file_path'):
new_id = db.find_track_id_by_file_path(m['library_file_path'])
dt = db.get_track_by_id(new_id) if new_id else None
if dt:
class DatabaseTrackDurable:
def __init__(self, db_t):
self.ratingKey = db_t.id
self.title = db_t.title
self.id = db_t.id
logger.debug(f"Durable manual match hit: '{original_title}'{lib_id}")
return DatabaseTrackDurable(dt), 1.0
except Exception as e:
logger.debug("durable manual match fast-path failed: %s", e)
# Try each artist
for artist in spotify_track.artists:
if isinstance(artist, str):

View file

@ -599,38 +599,63 @@ class PlaylistSyncService:
spotify_id = getattr(spotify_track, 'id', '') or ''
active_server = config_manager.get_active_media_server()
# --- Sync match cache fast-path ---
# --- User-confirmed match fast-path (Find & Add / manual match) ---
if spotify_id:
cache_db = MusicDatabase()
def _materialize(server_track_id):
"""Turn a stored library track id into the actual server item the
sync needs (DB row for Jellyfin/Navidrome/SoulSync, Plex fetchItem)."""
if server_track_id is None:
return None
dbt = cache_db.get_track_by_id(server_track_id)
if not dbt:
return None
if server_type in ("jellyfin", "navidrome", "soulsync"):
class DbTrackFromCache:
def __init__(self, db_t):
self.ratingKey = db_t.id
self.title = db_t.title
self.id = db_t.id
return DbTrackFromCache(dbt)
try:
at = media_client.server.fetchItem(int(server_track_id))
return at if (at and hasattr(at, 'ratingKey')) else None
except Exception:
return None
# 1) Volatile sync_match_cache — fast, but wiped on every library rescan.
try:
cache_db = MusicDatabase()
cached = cache_db.read_sync_match_cache(spotify_id, active_server)
if cached:
server_track_id = cached['server_track_id']
db_track_check = cache_db.get_track_by_id(server_track_id)
if db_track_check:
if server_type in ("jellyfin", "navidrome", "soulsync"):
class DbTrackFromCache:
def __init__(self, db_t):
self.ratingKey = db_t.id
self.title = db_t.title
self.id = db_t.id
actual_track = DbTrackFromCache(db_track_check)
else:
try:
actual_track = media_client.server.fetchItem(int(server_track_id))
if not (actual_track and hasattr(actual_track, 'ratingKey')):
actual_track = None
except Exception:
actual_track = None
if actual_track:
logger.debug(f"Sync cache hit: '{original_title}' → server track {server_track_id}")
return actual_track, cached['confidence']
logger.debug(f"Sync cache stale for '{original_title}' — track {server_track_id} gone")
actual_track = _materialize(cached['server_track_id'])
if actual_track:
logger.debug(f"Sync cache hit: '{original_title}' → server track {cached['server_track_id']}")
return actual_track, cached['confidence']
logger.debug(f"Sync cache stale for '{original_title}' — track {cached['server_track_id']} gone")
except Exception as cache_err:
logger.debug(f"Sync cache lookup error: {cache_err}")
# --- End cache fast-path ---
# 2) Durable manual library match (#787) — SURVIVES a rescan (the cache
# above does not). Without this, a user's Find & Add pairing is
# re-matched from scratch on the next auto-sync after a library scan,
# so they have to Find & Add the same track again (#895 follow-up).
# Self-heals a stale library id via the stored file path.
try:
from core.artists.map import get_current_profile_id
m = cache_db.find_manual_library_match_by_source_track_id(
get_current_profile_id(), str(spotify_id), active_server)
if m:
actual_track = _materialize(m.get('library_track_id'))
if not actual_track and m.get('library_file_path'):
new_id = cache_db.find_track_id_by_file_path(m['library_file_path'])
actual_track = _materialize(new_id)
if actual_track:
logger.debug(f"Durable manual match hit: '{original_title}'{m.get('library_track_id')}")
return actual_track, 1.0
except Exception as durable_err:
logger.debug(f"Durable manual match lookup error: {durable_err}")
# --- End match fast-path ---
# Try each artist (same as modal logic)
for artist in spotify_track.artists:

View file

@ -24,11 +24,13 @@ def test_matcher_signature_accepts_candidate_pool():
def _run(track, **kw):
fake_db = MagicMock()
fake_db.read_sync_match_cache.return_value = None
fake_db.find_manual_library_match_by_source_track_id.return_value = None
fake_db.check_track_exists.return_value = (None, 0.0)
fake_cm = MagicMock()
fake_cm.get_active_media_server.return_value = "plex"
with patch("database.music_database.MusicDatabase", return_value=fake_db), \
patch("config.settings.config_manager", fake_cm):
patch("config.settings.config_manager", fake_cm), \
patch("core.artists.map.get_current_profile_id", return_value=1):
return asyncio.run(sync_mod._database_only_find_track(track, **kw))
@ -42,10 +44,62 @@ def test_returns_match_when_db_has_it():
track = SimpleNamespace(name="HUMBLE.", artists=["Kendrick Lamar"], id="sp2")
fake_db = MagicMock()
fake_db.read_sync_match_cache.return_value = None
fake_db.find_manual_library_match_by_source_track_id.return_value = None
fake_db.check_track_exists.return_value = (SimpleNamespace(id="t1", title="HUMBLE."), 0.95)
fake_cm = MagicMock()
fake_cm.get_active_media_server.return_value = "plex"
with patch("database.music_database.MusicDatabase", return_value=fake_db), \
patch("config.settings.config_manager", fake_cm):
patch("config.settings.config_manager", fake_cm), \
patch("core.artists.map.get_current_profile_id", return_value=1):
match, conf = asyncio.run(sync_mod._database_only_find_track(track, candidate_pool={}))
assert conf == 0.95 and match.id == "t1"
# ── durable manual match (#787) survives a rescan that wipes sync_match_cache ──
# (#895 follow-up: Find & Add was forgotten on the next auto-sync after a library
# scan, because the matcher only consulted the volatile cache.)
def _run_with_db(track, fake_db):
fake_cm = MagicMock()
fake_cm.get_active_media_server.return_value = "plex"
with patch("database.music_database.MusicDatabase", return_value=fake_db), \
patch("config.settings.config_manager", fake_cm), \
patch("core.artists.map.get_current_profile_id", return_value=1):
return asyncio.run(sync_mod._database_only_find_track(track, candidate_pool={}))
def test_durable_match_used_when_volatile_cache_is_empty():
track = SimpleNamespace(name="Valió la Pena - Salsa Version", artists=["Marc Anthony"], id="sp16")
dt = SimpleNamespace(id="t99", title="Valió la Pena (Salsa Version)")
db = MagicMock()
db.read_sync_match_cache.return_value = None # cache wiped by a rescan
db.check_track_exists.return_value = (None, 0.0) # fuzzy would FAIL
db.find_manual_library_match_by_source_track_id.return_value = {
"library_track_id": "t99", "library_file_path": "/m/x.flac"}
db.get_track_by_id.side_effect = lambda i: dt if str(i) == "t99" else None
match, conf = _run_with_db(track, db)
assert conf == 1.0 and match.id == "t99" # manual pick honored, not re-matched
def test_durable_match_self_heals_a_stale_library_id():
track = SimpleNamespace(name="X", artists=["Y"], id="sp1")
dt = SimpleNamespace(id="newid", title="X")
db = MagicMock()
db.read_sync_match_cache.return_value = None
db.check_track_exists.return_value = (None, 0.0)
db.find_manual_library_match_by_source_track_id.return_value = {
"library_track_id": "staleid", "library_file_path": "/m/x.flac"}
db.get_track_by_id.side_effect = lambda i: dt if str(i) == "newid" else None # stale id misses
db.find_track_id_by_file_path.return_value = "newid" # re-resolve via path
match, conf = _run_with_db(track, db)
assert conf == 1.0 and match.id == "newid"
db.find_track_id_by_file_path.assert_called_once_with("/m/x.flac")
def test_no_durable_match_falls_through_to_fuzzy():
track = SimpleNamespace(name="X", artists=["Y"], id="sp1")
db = MagicMock()
db.read_sync_match_cache.return_value = None
db.find_manual_library_match_by_source_track_id.return_value = None
db.check_track_exists.return_value = (None, 0.0)
assert _run_with_db(track, db) == (None, 0.0)