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 name
|
||||||
return str(artist) if artist is not None else ''
|
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
|
@dataclass
|
||||||
class SyncResult:
|
class SyncResult:
|
||||||
playlist_name: str
|
playlist_name: str
|
||||||
|
|
@ -643,15 +708,33 @@ class PlaylistSyncService:
|
||||||
# Self-heals a stale library id via the stored file path.
|
# Self-heals a stale library id via the stored file path.
|
||||||
try:
|
try:
|
||||||
from core.artists.map import get_current_profile_id
|
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(
|
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:
|
if m:
|
||||||
actual_track = _materialize(m.get('library_track_id'))
|
actual_track = _materialize(m.get('library_track_id'))
|
||||||
if not actual_track and m.get('library_file_path'):
|
if not actual_track and m.get('library_file_path'):
|
||||||
new_id = cache_db.find_track_id_by_file_path(m['library_file_path'])
|
new_id = cache_db.find_track_id_by_file_path(m['library_file_path'])
|
||||||
actual_track = _materialize(new_id)
|
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:
|
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
|
return actual_track, 1.0
|
||||||
except Exception as durable_err:
|
except Exception as durable_err:
|
||||||
logger.debug(f"Durable manual match lookup error: {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