soulsync/tests/sync/test_reresolve_live_plex.py
BoulderBadgeDad 0df1d55ed6 Sync: re-resolve a manual match against live Plex when its stored key went stale
wolf39us: a Find & Add manual match got dropped on auto-sync (60->57) with a 404
fetching the stored Plex ratingKey. Root cause: Plex re-keys tracks on a metadata
refresh, and the SoulSync DB id IS that ratingKey — so until a SoulSync rescan BOTH
the durable match's library_track_id AND the file-path self-heal (which reads the
same DB) land on the dead key, fetchItem 404s, the track falls through to fuzzy
(also the dead key) and is dropped.

Fix: when both DB-side lookups miss on Plex, re-resolve the manual match against
LIVE Plex by the matched track's metadata, disambiguated by the stored file path
so the user's EXACT chosen track wins among multiple versions; return the current
live track and heal the stored id + sync cache. A manual match is now never dropped
or silently re-matched — it 'always overrides' as asked. Scoped to Plex (DB-backed
servers materialize off the DB and don't have this re-key problem).

Seam tests: file-path picks the right version over a live alternate, basename
fallback for server-vs-local paths, no-file-match falls back to top hit (never
drop), no results/empty title -> None, and a broken client never raises.
2026-06-22 08:47:24 -07:00

108 lines
4.4 KiB
Python

"""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