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.
108 lines
4.4 KiB
Python
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
|