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:
parent
b6cb244cbd
commit
cf2b4d7d38
3 changed files with 133 additions and 28 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue