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:
BoulderBadgeDad 2026-06-22 08:47:24 -07:00
parent ad0eda3575
commit 0df1d55ed6
2 changed files with 193 additions and 2 deletions

View file

@ -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}")

View 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