From 465a77f01da2691eecbaef08a55bb8e5c9bda0d2 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Sun, 28 Jun 2026 21:20:06 -0700 Subject: [PATCH] playlist export: Spotify/Deezer export job + endpoint (#945, increment 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ties the resolver + write clients into a working backend, reusing the ListenBrainz export's resolve→push→store-target shape: - _run_service_export(job, db, playlist_id, title, service, client, resolve_fn): resolves the mirrored playlist's tracks to their stored service track IDs (id_key='service_track_id'), guards "nothing matched", pushes via the injected write client, and stores the returned playlist id as the export target so a re-export updates in place (idempotent, like LB #903). Deps injected → unit-testable without a DB or live service. - _run_playlist_export dispatches mode in {spotify, deezer} to it (builds the real client + service resolver); the existing download/push (ListenBrainz/JSPF) flow is untouched. - POST /api/playlists//export/service/ — distinct path so it can't collide with the existing /export/listenbrainz route; validates the target, starts the background job, returns {job_id} polled via the shared status endpoint. 5 orchestration tests (fake db/client/resolve_fn): success stores target + passes ids in order, no-match → error with no push, client None → not-connected error, push failure surfaces the client's error and stores nothing, re-export passes the existing target id. ruff clean. Last piece: the modal options (Sync to Spotify / Deezer, gated on auth, unmatched count surfaced). --- tests/test_service_playlist_export.py | 75 ++++++++++++++++++++++ web_server.py | 91 +++++++++++++++++++++++++++ 2 files changed, 166 insertions(+) create mode 100644 tests/test_service_playlist_export.py diff --git a/tests/test_service_playlist_export.py b/tests/test_service_playlist_export.py new file mode 100644 index 00000000..07570a5b --- /dev/null +++ b/tests/test_service_playlist_export.py @@ -0,0 +1,75 @@ +"""_run_service_export orchestration (#945): resolve mirrored tracks → service IDs → push → +store the target for idempotent re-export. Deps injected (fake db/client/resolve_fn) so this +needs no DB or live Spotify/Deezer.""" + +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 _resolve(mapping): + return lambda artist, title: (mapping.get(title), 'library' if mapping.get(title) else None) + + +def test_success_stores_target(): + job = {} + db = _FakeDB([{'artist': 'A', 'title': 'X'}, {'artist': 'A', 'title': 'Y'}]) + client = _FakeClient({'success': True, 'playlist_id': 'pl-1', 'added': 2}) + ws._run_service_export(job, db, 5, 'My PL', 'spotify', client, _resolve({'X': 'sx', 'Y': 'sy'})) + assert job['phase'] == 'done' + assert client.calls[0] == ('My PL', ['sx', 'sy'], None) + assert db.set_calls == [(5, 'spotify', 'pl-1')] + + +def test_no_matched_ids_errors_no_push(): + job = {} + client = _FakeClient({'success': True}) + ws._run_service_export(job, _FakeDB([{'artist': 'A', 'title': 'X'}]), 5, 'PL', 'deezer', + client, _resolve({})) + 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([{'artist': 'A', 'title': 'X'}]), 5, 'PL', 'spotify', + None, _resolve({'X': 'sx'})) + assert job['phase'] == 'error' and 'not connected' in job['error'] + + +def test_push_failure_surfaces_error_no_target_store(): + job = {} + db = _FakeDB([{'artist': 'A', 'title': 'X'}]) + client = _FakeClient({'success': False, 'error': 'Reconnect Spotify'}) + ws._run_service_export(job, db, 5, 'PL', 'spotify', client, _resolve({'X': 'sx'})) + assert job['phase'] == 'error' and job['error'] == 'Reconnect Spotify' + assert db.set_calls == [] + + +def test_reexport_passes_existing_target(): + job = {} + db = _FakeDB([{'artist': 'A', 'title': 'X'}], existing='pl-old') + client = _FakeClient({'success': True, 'playlist_id': 'pl-old', 'added': 1}) + ws._run_service_export(job, db, 5, 'PL', 'deezer', client, _resolve({'X': 'dz'})) + assert client.calls[0][2] == 'pl-old' diff --git a/web_server.py b/web_server.py index 18292261..413dbbe5 100644 --- a/web_server.py +++ b/web_server.py @@ -27741,9 +27741,68 @@ _playlist_export_jobs = {} _playlist_export_jobs_lock = threading.Lock() +def _run_service_export(job, db, playlist_id, title, service, client, resolve_fn): + """Export a mirrored playlist back to a streaming service (Spotify/Deezer, #945). + + Reuses the same resolve→push→store-target machinery the ListenBrainz export uses, + just with a service track-ID resolver and a service write client. Deps are injected + so the orchestration (resolve, unmatched-guard, push, idempotent target store, error + paths) is unit-testable without a DB or live service. + """ + from core.exports.playlist_export import resolve_playlist_tracks + + tracks = db.get_mirrored_playlist_tracks(int(playlist_id)) + job['total'] = len(tracks) + job['phase'] = 'resolving' + + def on_progress(done, total, stats): + job['done'] = done + job['stats'] = dict(stats) + + out = resolve_playlist_tracks(tracks, resolve_fn, on_progress=on_progress, + id_key='service_track_id') + job['stats'] = out['stats'] + ids = [r['service_track_id'] for r in out['resolved'] if r.get('service_track_id')] + if not ids: + job['phase'] = 'error' + job['error'] = f"No tracks matched a {service.title()} ID — nothing to export" + return + if client is None: + job['phase'] = 'error' + job['error'] = f"{service.title()} is not connected" + return + + job['phase'] = 'pushing' + # Re-export updates the same service playlist in place (idempotent), mirroring the + # ListenBrainz #903 fix — the target ID is stored per (playlist, service). + existing = db.get_playlist_export_target(int(playlist_id), service) + res = client.create_or_update_playlist(title, ids, existing_id=existing) + job['push'] = res + if res.get('success'): + if res.get('playlist_id'): + db.set_playlist_export_target(int(playlist_id), service, str(res['playlist_id'])) + job['phase'] = 'done' + else: + job['phase'] = 'error' + job['error'] = res.get('error') or f"{service.title()} push failed" + + def _run_playlist_export(job_id, playlist_id, title, mode): job = _playlist_export_jobs[job_id] try: + # Service export (#945) — resolve to Spotify/Deezer track IDs and push. + if mode in ('spotify', 'deezer'): + from core.exports.export_sources import build_service_resolve_fn + db = get_database() + if mode == 'spotify': + client = get_spotify_client() + else: + from core.deezer_download_client import DeezerDownloadClient + client = DeezerDownloadClient() + _run_service_export(job, db, playlist_id, title, mode, client, + build_service_resolve_fn(mode)) + return + from core.exports.export_sources import build_resolve_fn from core.exports.playlist_export import resolve_playlist_tracks from core.exports.jspf_export import build_jspf @@ -27816,6 +27875,38 @@ def start_playlist_export_listenbrainz(playlist_id): return jsonify({"success": False, "error": str(e)}), 500 +@app.route('/api/playlists//export/service/', methods=['POST']) +def start_playlist_export_service(playlist_id, service): + """Export a mirrored playlist back to a streaming service (#945). + + ``service`` ∈ {spotify, deezer}. Creates a service-owned playlist (or updates the + one a prior export created) from the library tracks' stored service IDs. Returns + {job_id} to poll via the shared export-status endpoint.""" + try: + service = (service or '').lower() + if service not in ('spotify', 'deezer'): + return jsonify({"success": False, "error": f"Unsupported export target: {service}"}), 400 + db = get_database() + meta = db.get_mirrored_playlist(int(playlist_id)) or {} + title = (meta.get('name') or meta.get('title') or 'SoulSync Export').strip() or 'SoulSync Export' + + import uuid + job_id = uuid.uuid4().hex + with _playlist_export_jobs_lock: + _playlist_export_jobs[job_id] = { + 'job_id': job_id, 'playlist_id': str(playlist_id), 'title': title, + 'mode': service, 'phase': 'starting', 'done': 0, 'total': 0, + 'stats': {}, 'error': None, + } + t = threading.Thread(target=_run_playlist_export, + args=(job_id, playlist_id, title, service), daemon=True) + t.start() + return jsonify({"success": True, "job_id": job_id}) + except Exception as e: + logger.error(f"Service playlist export start failed ({service}): {e}") + return jsonify({"success": False, "error": str(e)}), 500 + + @app.route('/api/playlists/export/status/', methods=['GET']) def playlist_export_status(job_id): """Live status for an export job (polled by the mirrored-playlist card)."""