diff --git a/core/discovery/sync.py b/core/discovery/sync.py index aef0acaf..cee281d6 100644 --- a/core/discovery/sync.py +++ b/core/discovery/sync.py @@ -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): diff --git a/services/sync_service.py b/services/sync_service.py index b48aa051..172dbd5d 100644 --- a/services/sync_service.py +++ b/services/sync_service.py @@ -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: diff --git a/tests/discovery/test_sync_database_only_matcher.py b/tests/discovery/test_sync_database_only_matcher.py index b28b63d2..d987e788 100644 --- a/tests/discovery/test_sync_database_only_matcher.py +++ b/tests/discovery/test_sync_database_only_matcher.py @@ -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)