playlist export: Spotify/Deezer export job + endpoint (#945, increment 4)

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/<id>/export/service/<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).
This commit is contained in:
BoulderBadgeDad 2026-06-28 21:20:06 -07:00
parent 0db72bf48d
commit 465a77f01d
2 changed files with 166 additions and 0 deletions

View file

@ -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'

View file

@ -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 resolvepushstore-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/<playlist_id>/export/service/<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/<job_id>', methods=['GET'])
def playlist_export_status(job_id):
"""Live status for an export job (polled by the mirrored-playlist card)."""