Boulder: "all 50 tracks are discovered to Deezer already — it's not using any of that." Right — the export only checked tracks.deezer_id (library) and ignored the IDs discovery already resolved and stored in each mirrored track's extra_data. So tracks that were discovered+downloaded but not separately enriched showed as "not on Deezer" and got dropped. Adds a per-track waterfall for service export: - service_id_from_extra_data(track, service): the id discovery already matched, read from extra_data.matched_data.id — FREE (no API call) and reliable (it's the same id used to mirror the track). Trusted only when discovered ON the target service (provider == service); a wing_it_fallback (low-confidence guess) does NOT match here, so it falls through rather than risk a wrong track in the export. - resolve_service_track_ids(tracks, service): cache (extra_data) → library stored id → unmatched. Reports from_cache / from_library / unmatched. _run_service_export now uses this instead of the artist/title MBID-style resolver. For Boulder's playlist this means all 50 resolve straight from the cache — full coverage, zero API calls. (A live confident-search backfill for the genuinely-missing remainder is the optional next step, gated + thresholded.) 9 new tests: extra_data id only when provider matches + wing_it excluded + bad-json/not-discovered guards, the cache→library→unmatched waterfall with stat tallies, and _run_service_export resolving straight from the cache end-to-end. 49 export tests green, ruff clean.
90 lines
3.5 KiB
Python
90 lines
3.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):
|
|
"""resolve_ids_fn stub returning the given service ids as the resolved set."""
|
|
def fn(tracks, service, on_progress=None):
|
|
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'
|