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:
parent
0db72bf48d
commit
465a77f01d
2 changed files with 166 additions and 0 deletions
75
tests/test_service_playlist_export.py
Normal file
75
tests/test_service_playlist_export.py
Normal 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'
|
||||
|
|
@ -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/<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)."""
|
||||
|
|
|
|||
Loading…
Reference in a new issue