soulsync/tests/test_service_playlist_export.py
BoulderBadgeDad 4743dfd644 playlist export: opt-in confident-search backfill for the unmatched tail (#945, increment 7)
Adds the third resolver stage for tracks the discovery cache + library can't resolve — a live
search of the target service, gated behind a "Match missing tracks" toggle so the API cost is opt-in.

The whole point is coverage WITHOUT the wrong-track risk, so it's a CONFIDENT match, not "search
and grab":
- search_service_track_id(artist, title, search_fn): searches the service, reranks via the existing
  relevance scorer (filter_and_rerank), and returns the top hit's id ONLY if it clears
  BACKFILL_MIN_SCORE (1.2 on the score_track scale). A wrong-artist hit (no 1.5x exact-artist boost,
  caps ~1.0) or a karaoke/cover (x0.05) can't clear the floor → None, and the track is left out
  rather than added wrong. search_fn injected → unit-testable without a live service.
- resolve_service_track_ids gains an optional search_id_fn: cache → library → search. Tallies
  from_search separately.
- _run_service_export builds the search fn from the service's metadata search client only when
  job['backfill'] is set; the endpoint reads `backfill` from the body; the modal adds the toggle and
  the status line shows "(N matched live)".

Store-back of confident matches deferred: a mirrored-only track may have no library row to write to,
so persisting needs the track→library mapping — a follow-up, not correctness.

9 new tests incl. the safety ones: wrong-artist rejected, karaoke/cover rejected, real-over-cover
picked, fail-safe on search error, and the cache→library→search waterfall + toggle wiring (on/off).
28 export/orchestration tests green, 64 script-integrity green, ruff clean.
2026-06-28 23:02:08 -07:00

112 lines
4.5 KiB
Python

"""_run_service_export orchestration (#945): resolve mirrored tracks → service IDs
(discovery cache → library) → push → store the target for idempotent re-export. Deps
injected so this needs no real DB or live Spotify/Deezer."""
import json
import web_server as ws
class _FakeDB:
def __init__(self, tracks, existing=None):
self._tracks, self._existing, self.set_calls = tracks, existing, []
def get_mirrored_playlist_tracks(self, pid):
return self._tracks
def get_playlist_export_target(self, pid, service):
return self._existing
def set_playlist_export_target(self, pid, service, target):
self.set_calls.append((pid, service, target))
class _FakeClient:
def __init__(self, result):
self.result, self.calls = result, []
def create_or_update_playlist(self, title, ids, existing_id=None):
self.calls.append((title, list(ids), existing_id))
return self.result
def _fake_resolver(ids, seen=None):
"""resolve_ids_fn stub returning the given service ids. When ``seen`` is given, records
the search_id_fn it was called with (to assert the backfill toggle wiring)."""
def fn(tracks, service, search_id_fn=None, on_progress=None):
if seen is not None:
seen['search_id_fn'] = search_id_fn
resolved = [{'artist': 'A', 'title': f't{i}', 'service_track_id': s}
for i, s in enumerate(ids)]
matched = sum(1 for s in ids if s)
return {'resolved': resolved,
'stats': {'total': len(ids), 'resolved': matched, 'unmatched': len(ids) - matched}}
return fn
def _discovered(artist, title, service, tid):
return {'artist_name': artist, 'track_name': title,
'extra_data': json.dumps({'discovered': True, 'provider': service,
'matched_data': {'id': tid}})}
def test_success_resolves_from_discovery_cache_and_stores_target():
"""Real resolver: both tracks were discovered to Deezer, so their IDs come straight
from extra_data (the cache) with no DB/API — the gap Boulder spotted."""
job = {}
db = _FakeDB([_discovered('A', 'X', 'deezer', 111), _discovered('A', 'Y', 'deezer', 222)])
client = _FakeClient({'success': True, 'playlist_id': 'pl-1', 'added': 2})
ws._run_service_export(job, db, 5, 'My PL', 'deezer', client) # real resolve_service_track_ids
assert job['phase'] == 'done'
assert client.calls[0] == ('My PL', ['111', '222'], None)
assert db.set_calls == [(5, 'deezer', 'pl-1')]
assert job['stats']['from_cache'] == 2 and job['stats']['unmatched'] == 0
def test_no_match_errors_no_push():
job = {}
client = _FakeClient({'success': True})
ws._run_service_export(job, _FakeDB([{}]), 5, 'PL', 'deezer', client, _fake_resolver([None]))
assert job['phase'] == 'error' and 'nothing to export' in job['error']
assert client.calls == []
def test_client_none_errors():
job = {}
ws._run_service_export(job, _FakeDB([{}]), 5, 'PL', 'spotify', None, _fake_resolver(['sx']))
assert job['phase'] == 'error' and 'not connected' in job['error']
def test_push_failure_surfaces_error_no_target_store():
job = {}
db = _FakeDB([{}])
client = _FakeClient({'success': False, 'error': 'Reconnect Spotify'})
ws._run_service_export(job, db, 5, 'PL', 'spotify', client, _fake_resolver(['sx']))
assert job['phase'] == 'error' and job['error'] == 'Reconnect Spotify'
assert db.set_calls == []
def test_reexport_passes_existing_target():
job = {}
db = _FakeDB([{}], existing='pl-old')
client = _FakeClient({'success': True, 'playlist_id': 'pl-old', 'added': 1})
ws._run_service_export(job, db, 5, 'PL', 'deezer', client, _fake_resolver(['dz']))
assert client.calls[0][2] == 'pl-old'
def test_backfill_off_passes_no_search_fn():
job = {} # no 'backfill' key → off
seen = {}
ws._run_service_export(job, _FakeDB([{}]), 5, 'PL', 'deezer',
_FakeClient({'success': True, 'playlist_id': 'p', 'added': 1}),
_fake_resolver(['dz'], seen))
assert seen['search_id_fn'] is None
def test_backfill_on_wires_search_fn(monkeypatch):
job = {'backfill': True}
seen = {}
monkeypatch.setattr(ws, '_build_service_search_id_fn', lambda service: 'SEARCH_FN')
ws._run_service_export(job, _FakeDB([{}]), 5, 'PL', 'spotify',
_FakeClient({'success': True, 'playlist_id': 'p', 'added': 1}),
_fake_resolver(['sx'], seen))
assert seen['search_id_fn'] == 'SEARCH_FN'