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.
This commit is contained in:
parent
ad0eda3575
commit
0df1d55ed6
2 changed files with 193 additions and 2 deletions
|
|
@ -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}")
|
||||
|
|
|
|||
108
tests/sync/test_reresolve_live_plex.py
Normal file
108
tests/sync/test_reresolve_live_plex.py
Normal file
|
|
@ -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
|
||||
Loading…
Reference in a new issue