Find & Add on the playlist-sync page only wrote sync_match_cache, which is DELETEd wholesale after every DB scan — so the source->library pairing (and the user's manual matches) reverted to 'extra'/red-dot on the next shallow scan. The three match stores (sync_match_cache, manual_library_track_matches, discovery extra_data) were disconnected and all pointed at tracks.id, which a rescan re-keys (esp. Jellyfin/Navidrome GUIDs). Unify the match so it's one durable fact, recorded once, honored everywhere: - Find & Add also writes a durable manual_library_track_matches row (one-way; the manual-match tool has no playlist to act on, so no reverse). Carries the library file path. - New library_file_path column (idempotent migration) + find_track_id_by_file_path: re-resolve a stale library_track_id after a rescan re-keys the track, and self-heal the row. - The sync compare display's override lookup now falls back to the durable manual match (resolve_durable_match_server_id) when sync_match_cache misses — so the pairing persists across a scan instead of reverting to a red dot. Purely additive: only adds matches when the cache returns nothing. Tests: durable resolver (valid / stale-reresolve+self-heal / no-match / not-in- playlist / missing-methods), file_path persistence + find_track_id_by_file_path.
278 lines
12 KiB
Python
278 lines
12 KiB
Python
from unittest.mock import MagicMock
|
|
|
|
from core.sync.match_overrides import (
|
|
record_manual_match,
|
|
resolve_durable_match_server_id,
|
|
resolve_match_overrides,
|
|
)
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────
|
|
# resolve_match_overrides — pre-pair source→server from cache
|
|
# ──────────────────────────────────────────────────────────────────────
|
|
|
|
def test_empty_inputs_return_empty_dict():
|
|
assert resolve_match_overrides([], [], lambda _id: None) == {}
|
|
assert resolve_match_overrides([{"source_track_id": "x"}], [], lambda _id: "y") == {}
|
|
assert resolve_match_overrides([], [{"id": "y"}], lambda _id: None) == {}
|
|
|
|
|
|
def test_single_cache_hit_returns_pair():
|
|
sources = [{"source_track_id": "spotify-iron-man", "name": "Iron Man - 2012 - Remaster"}]
|
|
servers = [{"id": 5001, "title": "Iron Man"}]
|
|
cache = {"spotify-iron-man": 5001}
|
|
result = resolve_match_overrides(sources, servers, lambda sid: cache.get(sid))
|
|
assert result == {0: 0}
|
|
|
|
|
|
def test_multiple_overrides_resolve_correctly():
|
|
sources = [
|
|
{"source_track_id": "iron"},
|
|
{"source_track_id": "para"},
|
|
{"source_track_id": "war"},
|
|
]
|
|
servers = [
|
|
{"id": 5001, "title": "Iron Man"},
|
|
{"id": 5002, "title": "Paranoid"},
|
|
{"id": 5003, "title": "War Pigs"},
|
|
]
|
|
cache = {"iron": 5001, "para": 5002, "war": 5003}
|
|
result = resolve_match_overrides(sources, servers, lambda sid: cache.get(sid))
|
|
assert result == {0: 0, 1: 1, 2: 2}
|
|
|
|
|
|
def test_source_without_track_id_skipped():
|
|
sources = [
|
|
{"source_track_id": "iron", "name": "Iron Man"},
|
|
{"name": "Paranoid"}, # no source_track_id (e.g. legacy / non-mirrored)
|
|
]
|
|
servers = [{"id": 5001, "title": "Iron Man"}, {"id": 5002, "title": "Paranoid"}]
|
|
cache = {"iron": 5001}
|
|
result = resolve_match_overrides(sources, servers, lambda sid: cache.get(sid))
|
|
assert result == {0: 0}
|
|
|
|
|
|
def test_cache_miss_skipped():
|
|
sources = [{"source_track_id": "iron"}, {"source_track_id": "para"}]
|
|
servers = [{"id": 5001, "title": "Iron Man"}, {"id": 5002, "title": "Paranoid"}]
|
|
result = resolve_match_overrides(sources, servers, lambda sid: None)
|
|
assert result == {}
|
|
|
|
|
|
def test_stale_cache_pointing_at_missing_server_track_skipped():
|
|
# User cached a match → file got deleted from server → server_tracks
|
|
# no longer has 5001 → don't pair, fall through to normal matching.
|
|
sources = [{"source_track_id": "iron"}]
|
|
servers = [{"id": 9999, "title": "Different Track"}]
|
|
cache = {"iron": 5001} # 5001 no longer exists
|
|
result = resolve_match_overrides(sources, servers, lambda sid: cache.get(sid))
|
|
assert result == {}
|
|
|
|
|
|
def test_server_id_str_int_coercion():
|
|
# Cache might store ints, server_tracks might have str IDs (Plex
|
|
# ratingKey is str). Helper coerces both sides to str.
|
|
sources = [{"source_track_id": "iron"}]
|
|
servers = [{"id": "5001", "title": "Iron Man"}]
|
|
cache = {"iron": 5001} # int from cache
|
|
result = resolve_match_overrides(sources, servers, lambda sid: cache.get(sid))
|
|
assert result == {0: 0}
|
|
|
|
|
|
def test_two_sources_pointing_at_same_server_track_only_first_wins():
|
|
# Defensive — UNIQUE constraint prevents this in production but
|
|
# cache_lookup is injectable so we verify the safety.
|
|
sources = [{"source_track_id": "a"}, {"source_track_id": "b"}]
|
|
servers = [{"id": 5001, "title": "Iron Man"}]
|
|
cache = {"a": 5001, "b": 5001}
|
|
result = resolve_match_overrides(sources, servers, lambda sid: cache.get(sid))
|
|
assert result == {0: 0}
|
|
|
|
|
|
def test_cache_lookup_raising_treated_as_miss():
|
|
sources = [{"source_track_id": "iron"}]
|
|
servers = [{"id": 5001, "title": "Iron Man"}]
|
|
def boom(_sid):
|
|
raise RuntimeError("db down")
|
|
result = resolve_match_overrides(sources, servers, boom)
|
|
assert result == {}
|
|
|
|
|
|
def test_non_dict_source_or_server_skipped():
|
|
sources = [None, "string", {"source_track_id": "iron"}]
|
|
servers = [{"id": 5001, "title": "Iron Man"}]
|
|
cache = {"iron": 5001}
|
|
result = resolve_match_overrides(sources, servers, lambda sid: cache.get(sid))
|
|
# source idx 2 → server idx 0
|
|
assert result == {2: 0}
|
|
|
|
|
|
def test_server_without_id_skipped():
|
|
sources = [{"source_track_id": "iron"}]
|
|
servers = [{"title": "Iron Man"}] # no id
|
|
cache = {"iron": 5001}
|
|
result = resolve_match_overrides(sources, servers, lambda sid: cache.get(sid))
|
|
assert result == {}
|
|
|
|
|
|
def test_partial_cache_hits_only_pair_those():
|
|
sources = [
|
|
{"source_track_id": "iron"},
|
|
{"source_track_id": "para"},
|
|
{"source_track_id": "war"},
|
|
]
|
|
servers = [
|
|
{"id": 5001, "title": "Iron Man"},
|
|
{"id": 5002, "title": "Paranoid"},
|
|
{"id": 5003, "title": "War Pigs"},
|
|
]
|
|
# Only iron + war cached, para falls through to normal matching
|
|
cache = {"iron": 5001, "war": 5003}
|
|
result = resolve_match_overrides(sources, servers, lambda sid: cache.get(sid))
|
|
assert result == {0: 0, 2: 2}
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────
|
|
# record_manual_match — persist user-confirmed pair
|
|
# ──────────────────────────────────────────────────────────────────────
|
|
|
|
def test_record_persists_with_confidence_one():
|
|
db = MagicMock()
|
|
db.save_sync_match_cache.return_value = True
|
|
ok = record_manual_match(
|
|
db,
|
|
source_track_id="spotify-iron-man",
|
|
server_source="plex",
|
|
server_track_id=5001,
|
|
server_track_title="Iron Man",
|
|
source_title="Iron Man - 2012 - Remaster",
|
|
source_artist="Black Sabbath",
|
|
)
|
|
assert ok is True
|
|
db.save_sync_match_cache.assert_called_once()
|
|
kwargs = db.save_sync_match_cache.call_args.kwargs
|
|
assert kwargs["spotify_track_id"] == "spotify-iron-man"
|
|
assert kwargs["server_source"] == "plex"
|
|
assert kwargs["server_track_id"] == 5001
|
|
assert kwargs["server_track_title"] == "Iron Man"
|
|
assert kwargs["confidence"] == 1.0
|
|
assert kwargs["normalized_title"] == "iron man - 2012 - remaster"
|
|
assert kwargs["normalized_artist"] == "black sabbath"
|
|
|
|
|
|
def test_record_returns_false_when_required_fields_missing():
|
|
db = MagicMock()
|
|
assert record_manual_match(db, source_track_id="", server_source="plex", server_track_id=1) is False
|
|
assert record_manual_match(db, source_track_id="x", server_source="", server_track_id=1) is False
|
|
assert record_manual_match(db, source_track_id="x", server_source="plex", server_track_id=None) is False
|
|
db.save_sync_match_cache.assert_not_called()
|
|
|
|
|
|
def test_record_returns_false_when_db_save_returns_false():
|
|
db = MagicMock()
|
|
db.save_sync_match_cache.return_value = False
|
|
assert record_manual_match(db, source_track_id="x", server_source="plex", server_track_id=1) is False
|
|
|
|
|
|
def test_record_swallows_db_exception():
|
|
db = MagicMock()
|
|
db.save_sync_match_cache.side_effect = RuntimeError("db boom")
|
|
assert record_manual_match(db, source_track_id="x", server_source="plex", server_track_id=1) is False
|
|
|
|
|
|
def test_record_returns_false_when_db_lacks_method():
|
|
class NoSaveDB:
|
|
pass
|
|
assert record_manual_match(NoSaveDB(), source_track_id="x", server_source="plex", server_track_id=1) is False
|
|
|
|
|
|
def test_record_handles_empty_optional_strings():
|
|
db = MagicMock()
|
|
db.save_sync_match_cache.return_value = True
|
|
ok = record_manual_match(db, source_track_id="x", server_source="plex", server_track_id=1)
|
|
assert ok is True
|
|
kwargs = db.save_sync_match_cache.call_args.kwargs
|
|
assert kwargs["normalized_title"] == ""
|
|
assert kwargs["normalized_artist"] == ""
|
|
assert kwargs["server_track_title"] == ""
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────
|
|
# resolve_durable_match_server_id — manual match survives a rescan (#787)
|
|
# ──────────────────────────────────────────────────────────────────────
|
|
|
|
class _FakeMatchDB:
|
|
"""Minimal DB stub for the durable-match resolver."""
|
|
def __init__(self, match=None, file_path_id=None):
|
|
self._match = match
|
|
self._file_path_id = file_path_id
|
|
self.saved = []
|
|
|
|
def find_manual_library_match_by_source_track_id(self, profile_id, source_track_id, server_source):
|
|
return dict(self._match) if self._match else None
|
|
|
|
def find_track_id_by_file_path(self, file_path):
|
|
return self._file_path_id
|
|
|
|
def save_manual_library_match(self, profile_id, source, source_track_id, library_track_id, **meta):
|
|
self.saved.append((library_track_id, meta))
|
|
return True
|
|
|
|
|
|
def test_durable_match_returns_id_when_still_valid():
|
|
# Stored library id still present in the server playlist → direct hit.
|
|
db = _FakeMatchDB(match={"library_track_id": "5001", "library_file_path": "/m/a.flac",
|
|
"profile_id": 1, "source": "spotify", "source_track_id": "iron",
|
|
"server_source": "plex"})
|
|
out = resolve_durable_match_server_id(db, 1, "iron", "plex", {"5001", "5002"})
|
|
assert out == "5001"
|
|
assert db.saved == [] # no self-heal needed
|
|
|
|
|
|
def test_durable_match_reresolves_stale_id_via_file_path_and_self_heals():
|
|
# Rescan re-keyed the track: old id 5001 gone, file now lives at id 7777.
|
|
db = _FakeMatchDB(
|
|
match={"library_track_id": "5001", "library_file_path": "/m/a.flac",
|
|
"profile_id": 1, "source": "spotify", "source_track_id": "iron", "server_source": "plex"},
|
|
file_path_id="7777",
|
|
)
|
|
out = resolve_durable_match_server_id(db, 1, "iron", "plex", {"7777", "9000"})
|
|
assert out == "7777" # re-resolved to the current id
|
|
assert db.saved and db.saved[0][0] == "7777" # self-healed the stored id
|
|
|
|
|
|
def test_durable_match_none_when_no_match():
|
|
db = _FakeMatchDB(match=None)
|
|
assert resolve_durable_match_server_id(db, 1, "iron", "plex", {"5001"}) is None
|
|
|
|
|
|
def test_durable_match_none_when_stale_and_file_path_unresolvable():
|
|
# Stale id AND the file path no longer resolves (file moved/removed) → no pair.
|
|
db = _FakeMatchDB(
|
|
match={"library_track_id": "5001", "library_file_path": "/m/gone.flac",
|
|
"profile_id": 1, "source": "spotify", "source_track_id": "iron", "server_source": "plex"},
|
|
file_path_id=None,
|
|
)
|
|
assert resolve_durable_match_server_id(db, 1, "iron", "plex", {"7777"}) is None
|
|
assert db.saved == []
|
|
|
|
|
|
def test_durable_match_none_when_reresolved_id_not_in_playlist():
|
|
# File resolves to a track, but that track isn't in THIS playlist → don't pair.
|
|
db = _FakeMatchDB(
|
|
match={"library_track_id": "5001", "library_file_path": "/m/a.flac",
|
|
"profile_id": 1, "source": "spotify", "source_track_id": "iron", "server_source": "plex"},
|
|
file_path_id="7777",
|
|
)
|
|
assert resolve_durable_match_server_id(db, 1, "iron", "plex", {"1234"}) is None
|
|
|
|
|
|
def test_durable_match_safe_when_db_lacks_methods():
|
|
class Bare:
|
|
pass
|
|
assert resolve_durable_match_server_id(Bare(), 1, "iron", "plex", {"5001"}) is None
|
|
|
|
|
|
def test_durable_match_empty_source_id_returns_none():
|
|
db = _FakeMatchDB(match={"library_track_id": "5001"})
|
|
assert resolve_durable_match_server_id(db, 1, "", "plex", {"5001"}) is None
|