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()
|
_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):
|
def _run_playlist_export(job_id, playlist_id, title, mode):
|
||||||
job = _playlist_export_jobs[job_id]
|
job = _playlist_export_jobs[job_id]
|
||||||
try:
|
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.export_sources import build_resolve_fn
|
||||||
from core.exports.playlist_export import resolve_playlist_tracks
|
from core.exports.playlist_export import resolve_playlist_tracks
|
||||||
from core.exports.jspf_export import build_jspf
|
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
|
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'])
|
@app.route('/api/playlists/export/status/<job_id>', methods=['GET'])
|
||||||
def playlist_export_status(job_id):
|
def playlist_export_status(job_id):
|
||||||
"""Live status for an export job (polled by the mirrored-playlist card)."""
|
"""Live status for an export job (polled by the mirrored-playlist card)."""
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue