soulsync/tests/discovery/test_sync_database_only_matcher.py
BoulderBadgeDad cf2b4d7d38 Playlist sync: honor the DURABLE manual match, not just the volatile cache
Find & Add was being forgotten on the next auto-sync. It persists two ways — a fast
sync_match_cache override AND a durable manual_library_match (#787) that survives a
rescan — but BOTH sync matchers (services.sync_service._find_track_in_media_server and
the DB-only fallback) only consulted the volatile cache. A library rescan wipes that
cache, so the next 'replace' auto-sync re-matched the track from scratch and the user
had to Find & Add it again.

Both matchers now fall back to the durable manual match when the cache misses
(self-healing a stale library id via the stored file path), exactly like the compare
view already does via resolve_durable_match_server_id. So a Find & Add pairing sticks
across rescans + auto-syncs. Seam tests: cache-wiped→durable hit, stale-id self-heal,
no-match→fuzzy fall-through.
2026-06-21 18:15:20 -07:00

105 lines
4.9 KiB
Python

"""Regression: the database-only sync matcher must accept candidate_pool.
sync_service calls _find_track_in_media_server(track, candidate_pool=...). When
no media server is connected, discovery/sync patches in a database-only matcher.
That override dropped the candidate_pool kwarg, so every Spotify sync failed with
"unexpected keyword argument 'candidate_pool'". These pin the contract.
"""
from __future__ import annotations
import asyncio
import inspect
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import core.discovery.sync as sync_mod
def test_matcher_signature_accepts_candidate_pool():
sig = inspect.signature(sync_mod._database_only_find_track)
assert 'candidate_pool' in sig.parameters
def _run(track, **kw):
fake_db = MagicMock()
fake_db.read_sync_match_cache.return_value = None
fake_db.find_manual_library_match_by_source_track_id.return_value = None
fake_db.check_track_exists.return_value = (None, 0.0)
fake_cm = MagicMock()
fake_cm.get_active_media_server.return_value = "plex"
with patch("database.music_database.MusicDatabase", return_value=fake_db), \
patch("config.settings.config_manager", fake_cm), \
patch("core.artists.map.get_current_profile_id", return_value=1):
return asyncio.run(sync_mod._database_only_find_track(track, **kw))
def test_called_with_candidate_pool_no_match():
track = SimpleNamespace(name="Song", artists=["Artist"], id="sp1")
# The exact call sync_service makes — must not raise TypeError.
assert _run(track, candidate_pool={}) == (None, 0.0)
def test_returns_match_when_db_has_it():
track = SimpleNamespace(name="HUMBLE.", artists=["Kendrick Lamar"], id="sp2")
fake_db = MagicMock()
fake_db.read_sync_match_cache.return_value = None
fake_db.find_manual_library_match_by_source_track_id.return_value = None
fake_db.check_track_exists.return_value = (SimpleNamespace(id="t1", title="HUMBLE."), 0.95)
fake_cm = MagicMock()
fake_cm.get_active_media_server.return_value = "plex"
with patch("database.music_database.MusicDatabase", return_value=fake_db), \
patch("config.settings.config_manager", fake_cm), \
patch("core.artists.map.get_current_profile_id", return_value=1):
match, conf = asyncio.run(sync_mod._database_only_find_track(track, candidate_pool={}))
assert conf == 0.95 and match.id == "t1"
# ── durable manual match (#787) survives a rescan that wipes sync_match_cache ──
# (#895 follow-up: Find & Add was forgotten on the next auto-sync after a library
# scan, because the matcher only consulted the volatile cache.)
def _run_with_db(track, fake_db):
fake_cm = MagicMock()
fake_cm.get_active_media_server.return_value = "plex"
with patch("database.music_database.MusicDatabase", return_value=fake_db), \
patch("config.settings.config_manager", fake_cm), \
patch("core.artists.map.get_current_profile_id", return_value=1):
return asyncio.run(sync_mod._database_only_find_track(track, candidate_pool={}))
def test_durable_match_used_when_volatile_cache_is_empty():
track = SimpleNamespace(name="Valió la Pena - Salsa Version", artists=["Marc Anthony"], id="sp16")
dt = SimpleNamespace(id="t99", title="Valió la Pena (Salsa Version)")
db = MagicMock()
db.read_sync_match_cache.return_value = None # cache wiped by a rescan
db.check_track_exists.return_value = (None, 0.0) # fuzzy would FAIL
db.find_manual_library_match_by_source_track_id.return_value = {
"library_track_id": "t99", "library_file_path": "/m/x.flac"}
db.get_track_by_id.side_effect = lambda i: dt if str(i) == "t99" else None
match, conf = _run_with_db(track, db)
assert conf == 1.0 and match.id == "t99" # manual pick honored, not re-matched
def test_durable_match_self_heals_a_stale_library_id():
track = SimpleNamespace(name="X", artists=["Y"], id="sp1")
dt = SimpleNamespace(id="newid", title="X")
db = MagicMock()
db.read_sync_match_cache.return_value = None
db.check_track_exists.return_value = (None, 0.0)
db.find_manual_library_match_by_source_track_id.return_value = {
"library_track_id": "staleid", "library_file_path": "/m/x.flac"}
db.get_track_by_id.side_effect = lambda i: dt if str(i) == "newid" else None # stale id misses
db.find_track_id_by_file_path.return_value = "newid" # re-resolve via path
match, conf = _run_with_db(track, db)
assert conf == 1.0 and match.id == "newid"
db.find_track_id_by_file_path.assert_called_once_with("/m/x.flac")
def test_no_durable_match_falls_through_to_fuzzy():
track = SimpleNamespace(name="X", artists=["Y"], id="sp1")
db = MagicMock()
db.read_sync_match_cache.return_value = None
db.find_manual_library_match_by_source_track_id.return_value = None
db.check_track_exists.return_value = (None, 0.0)
assert _run_with_db(track, db) == (None, 0.0)