diff --git a/services/sync_service.py b/services/sync_service.py index 172dbd5d..c1213f1a 100644 --- a/services/sync_service.py +++ b/services/sync_service.py @@ -28,6 +28,71 @@ def _artist_name(artist) -> str: return name return str(artist) if artist is not None else '' + +def _plex_track_file(plex_track) -> str: + """Best-effort file path of a live Plex track object (media[0].parts[0].file).""" + try: + return plex_track.media[0].parts[0].file or '' + except Exception: + return '' + + +def reresolve_manual_match_live_plex(cache_db, media_client, m, *, profile_id, + source_track_id, server_source): + """Re-resolve a manual match whose stored Plex ratingKey went stale. + + Plex re-keys tracks on a metadata refresh/optimize, and the SoulSync DB id is + itself that old ratingKey — so until a SoulSync rescan, BOTH the stored + ``library_track_id`` and the file-path self-heal land on the same dead key and + ``fetchItem`` 404s. The only live source of truth is Plex, so search it by the + matched track's metadata, disambiguate by the stored file path (so the user's + EXACT chosen track wins when several versions exist), heal the stored id, and + return the current live Plex track. Best-effort: returns the live track or + None, never raises — a manual match must never be dropped on a transient miss. + """ + try: + title = (m.get('source_title') or '').strip() + artist = (m.get('source_artist') or '').strip() + file_path = (m.get('library_file_path') or '').strip() + if not title: + return None + results = media_client.search_tracks(title, artist, limit=15) or [] + if not results: + return None + + import os as _os + want_base = _os.path.basename(file_path.replace('\\', '/')) if file_path else '' + chosen = None + if want_base: + for t in results: + live = getattr(t, '_original_plex_track', None) + p = _plex_track_file(live) if live is not None else '' + if p and _os.path.basename(p.replace('\\', '/')) == want_base: + chosen = t + break + if chosen is None: + chosen = results[0] # search_tracks already ranks artist→title; best effort + live = getattr(chosen, '_original_plex_track', None) + if live is None or not hasattr(live, 'ratingKey'): + return None + + # Heal the stored id (+ the cache, done by the caller) so the next sync is + # fast and doesn't repeat the live search. + try: + cache_db.save_manual_library_match( + profile_id, m.get('source') or 'spotify', str(source_track_id), str(live.ratingKey), + source_title=m.get('source_title'), source_artist=m.get('source_artist'), + source_album=m.get('source_album'), + server_source=server_source, + library_file_path=file_path or _plex_track_file(live), + ) + except Exception as _heal_err: + logger.debug("manual-match heal (save) failed: %s", _heal_err) + return live + except Exception: + return None + + @dataclass class SyncResult: playlist_name: str @@ -643,15 +708,33 @@ class PlaylistSyncService: # Self-heals a stale library id via the stored file path. try: from core.artists.map import get_current_profile_id + _profile_id = get_current_profile_id() m = cache_db.find_manual_library_match_by_source_track_id( - get_current_profile_id(), str(spotify_id), active_server) + _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) + # Plex re-keys tracks on a metadata refresh, and the SoulSync + # DB id IS that ratingKey — so both lookups above can land on + # the same stale key and 404. Re-resolve against LIVE Plex by + # the matched track's metadata so a manual match is NEVER + # dropped or silently re-matched by the fuzzy path below. + if not actual_track and server_type == "plex": + actual_track = reresolve_manual_match_live_plex( + cache_db, media_client, m, + profile_id=_profile_id, source_track_id=spotify_id, + server_source=active_server) + if actual_track and spotify_id: + try: + cache_db.save_sync_match_cache( + spotify_id, original_title, _artist_name(spotify_track.artists[0]) if spotify_track.artists else '', + active_server, actual_track.ratingKey, getattr(actual_track, 'title', original_title), 1.0) + except Exception as _cache_err: + logger.debug("sync cache heal failed: %s", _cache_err) if actual_track: - logger.debug(f"Durable manual match hit: '{original_title}' → {m.get('library_track_id')}") + logger.info(f"Durable manual match honored for '{original_title}' → {getattr(actual_track, 'ratingKey', 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}") diff --git a/tests/sync/test_reresolve_live_plex.py b/tests/sync/test_reresolve_live_plex.py new file mode 100644 index 00000000..5de3bf49 --- /dev/null +++ b/tests/sync/test_reresolve_live_plex.py @@ -0,0 +1,108 @@ +"""Plex re-keys tracks on a metadata refresh, so a durable manual match's stored +ratingKey (and the SoulSync DB id, which IS that ratingKey) can both go stale at +once — every DB-side lookup lands on the same dead key and fetchItem 404s, so the +manually-matched track gets dropped on sync (wolf39us). The fix re-resolves the +match against LIVE Plex by the matched track's metadata, disambiguated by the +stored file path so the user's EXACT chosen track wins, and heals the stored id. +""" + +from __future__ import annotations + +from services.sync_service import reresolve_manual_match_live_plex + + +class _PlexTrack: + def __init__(self, rating_key, file): + self.ratingKey = rating_key + self.title = f"track-{rating_key}" + + class _Part: + def __init__(self, f): self.file = f + class _Media: + def __init__(self, f): self.parts = [_Part(f)] + self.media = [_Media(file)] + + +class _TrackInfo: + def __init__(self, plex_track): + self._original_plex_track = plex_track + + +class _MediaClient: + def __init__(self, results): + self._results = results + self.calls = [] + def search_tracks(self, title, artist, limit=15): + self.calls.append((title, artist)) + return self._results + + +class _CacheDb: + def __init__(self): + self.healed = [] + def save_manual_library_match(self, profile_id, source, source_track_id, library_track_id, **meta): + self.healed.append({"id": library_track_id, "source_track_id": source_track_id, **meta}) + return True + + +_MATCH = { + "source": "spotify", "source_title": "It's the End of the World", + "source_artist": "R.E.M.", "source_album": "Document", + "library_file_path": "/music/REM/Document/05 - Its the End.flac", + "library_track_id": 39161, # stale +} + + +def test_picks_the_track_matching_the_stored_file_path(): + # Two live candidates (a different version + the real one); the stored file + # path must select the user's exact track, with its CURRENT ratingKey. + wrong = _TrackInfo(_PlexTrack(50001, "/music/REM/Live/05 - Its the End (Live).flac")) + right = _TrackInfo(_PlexTrack(39167, "/music/REM/Document/05 - Its the End.flac")) + mc = _MediaClient([wrong, right]) + db = _CacheDb() + live = reresolve_manual_match_live_plex( + db, mc, _MATCH, profile_id=1, source_track_id="sp1", server_source="plex") + assert live is not None and live.ratingKey == 39167 # current key, not stale 39161 + assert mc.calls == [("It's the End of the World", "R.E.M.")] + # healed the stored id to the fresh ratingKey + assert db.healed and db.healed[0]["id"] == "39167" + assert db.healed[0]["source_track_id"] == "sp1" + + +def test_basename_match_handles_server_vs_local_path(): + # stored path is a local path; the Plex part.file is a container path — same basename. + m = dict(_MATCH, library_file_path="D:\\Music\\REM\\05 - Its the End.flac") + right = _TrackInfo(_PlexTrack(39167, "/data/Music/REM/05 - Its the End.flac")) + live = reresolve_manual_match_live_plex( + _CacheDb(), _MediaClient([right]), m, profile_id=1, source_track_id="sp1", server_source="plex") + assert live.ratingKey == 39167 + + +def test_no_file_match_falls_back_to_top_result(): + only = _TrackInfo(_PlexTrack(40000, "/music/somewhere/else.flac")) + live = reresolve_manual_match_live_plex( + _CacheDb(), _MediaClient([only]), _MATCH, profile_id=1, source_track_id="sp1", server_source="plex") + assert live.ratingKey == 40000 # never drop a manual match — best-effort top hit + + +def test_no_results_returns_none_and_does_not_heal(): + db = _CacheDb() + assert reresolve_manual_match_live_plex( + db, _MediaClient([]), _MATCH, profile_id=1, source_track_id="sp1", server_source="plex") is None + assert db.healed == [] + + +def test_missing_title_returns_none(): + m = dict(_MATCH, source_title="") + assert reresolve_manual_match_live_plex( + _CacheDb(), _MediaClient([_TrackInfo(_PlexTrack(1, "/x.flac"))]), m, + profile_id=1, source_track_id="sp1", server_source="plex") is None + + +def test_never_raises_on_a_broken_media_client(): + class _Boom: + def search_tracks(self, *a, **k): + raise RuntimeError("plex down") + # a transient Plex error must not bubble — the caller falls through, not crash. + assert reresolve_manual_match_live_plex( + _CacheDb(), _Boom(), _MATCH, profile_id=1, source_track_id="sp1", server_source="plex") is None