Second cluster. Two more sets of byte-identical per-source bodies: cancel_<source>_sync (Tidal, Deezer, Qobuz, Spotify-Public, iTunes-Link, YouTube, ListenBrainz) -> core.discovery.endpoints.cancel_sync(states, key, *, label, not_found_message, sync_lock, sync_states, active_sync_workers). Returns (payload, status_code); a thin web_server glue (_cancel_source_sync) wires the sync-infra globals + jsonify. Caller passes the resolved key (ListenBrainz transforms via _lb_state_key) and the exact 404 string (iTunes-Link uses "iTunes Link not found"). delete_<source>_playlist (Tidal, Deezer, Qobuz, Spotify-Public) -> delete_playlist_state(states, key, *, label, not_found_message), wired via _delete_source_playlist. Intentionally left with their own bodies (genuinely divergent, not 1:1): - Beatport cancel (cancels a stored sync_future, no message, warning log). - iTunes-Link / YouTube / ListenBrainz / Beatport deletes (different success messages, info-log wording, playlist-name extraction, /remove route, chart key). Tests: +11 in tests/discovery/test_discovery_endpoints.py covering cancel (404, active-worker cancel + state revert, worker-absent, no-sync-in-progress, label in message, exception->500) and delete (404, future cancel + removal, no/falsy future, exception->500 leaves state). Full discovery suite: 162 passed. web_server.py: -216 lines.
159 lines
6.3 KiB
Python
159 lines
6.3 KiB
Python
"""Generic, source-agnostic helpers for the playlist-discovery route layer.
|
|
|
|
The discovery/sync endpoints in ``web_server.py`` were copy-pasted once per
|
|
source (Tidal, Deezer, Qobuz, Spotify-public, iTunes-link, YouTube,
|
|
ListenBrainz, Beatport). The per-source copies differ only by a source label
|
|
string and which ``<source>_discovery_states`` global they read. This module
|
|
lifts the source-agnostic pieces into importable, unit-testable helpers so the
|
|
route functions become thin wrappers — exactly preserving behavior (1:1).
|
|
|
|
Each helper is lifted verbatim from its web_server.py counterpart; any
|
|
per-source quirk that genuinely differs (e.g. Beatport's distinct result
|
|
shape) is intentionally NOT routed through here and stays in its own function.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
from typing import Any, Dict, List, Tuple
|
|
|
|
from utils.logging_config import get_logger
|
|
|
|
logger = get_logger("discovery.endpoints")
|
|
|
|
|
|
def convert_results_to_spotify_tracks(
|
|
discovery_results: List[Dict[str, Any]],
|
|
source_label: str,
|
|
) -> List[Dict[str, Any]]:
|
|
"""Convert a source's discovery results into the Spotify-track dicts the
|
|
sync pipeline expects.
|
|
|
|
Lifted verbatim from the per-source ``convert_<source>_results_to_spotify_tracks``
|
|
functions (and the already-generic ``_convert_link_results_to_spotify_tracks``),
|
|
which were byte-identical apart from the ``source_label`` used in the log
|
|
line. Two input shapes are supported, matching the originals exactly:
|
|
|
|
- ``spotify_data`` (manual-fix shape): copied through, preserving optional
|
|
``track_number`` / ``disc_number``.
|
|
- ``spotify_track`` + ``status_class == 'found'`` (auto-discovery shape):
|
|
rebuilt from the flat ``spotify_*`` fields.
|
|
|
|
Any result matching neither shape is skipped, identical to the originals.
|
|
|
|
NOTE: Beatport deliberately does NOT use this — its converter coerces
|
|
artist objects to strings and emits a different track shape (``source``
|
|
field, album dict), so it keeps its own implementation.
|
|
"""
|
|
spotify_tracks: List[Dict[str, Any]] = []
|
|
|
|
for result in discovery_results:
|
|
# Support both data formats: spotify_data (manual fixes) and individual
|
|
# fields (automatic discovery).
|
|
if result.get('spotify_data'):
|
|
spotify_data = result['spotify_data']
|
|
track = {
|
|
'id': spotify_data['id'],
|
|
'name': spotify_data['name'],
|
|
'artists': spotify_data['artists'],
|
|
'album': spotify_data['album'],
|
|
'duration_ms': spotify_data.get('duration_ms', 0),
|
|
}
|
|
if spotify_data.get('track_number'):
|
|
track['track_number'] = spotify_data['track_number']
|
|
if spotify_data.get('disc_number'):
|
|
track['disc_number'] = spotify_data['disc_number']
|
|
spotify_tracks.append(track)
|
|
elif result.get('spotify_track') and result.get('status_class') == 'found':
|
|
spotify_tracks.append({
|
|
'id': result.get('spotify_id', 'unknown'),
|
|
'name': result.get('spotify_track', 'Unknown Track'),
|
|
'artists': [result.get('spotify_artist', 'Unknown Artist')] if result.get('spotify_artist') else ['Unknown Artist'],
|
|
'album': result.get('spotify_album', 'Unknown Album'),
|
|
'duration_ms': 0,
|
|
})
|
|
|
|
logger.info(f"Converted {len(spotify_tracks)} {source_label} matches to Spotify tracks for sync")
|
|
return spotify_tracks
|
|
|
|
|
|
def cancel_sync(
|
|
states: Dict[str, Any],
|
|
key: str,
|
|
*,
|
|
label: str,
|
|
not_found_message: str,
|
|
sync_lock: Any,
|
|
sync_states: Dict[str, Any],
|
|
active_sync_workers: Dict[str, Any],
|
|
) -> Tuple[Dict[str, Any], int]:
|
|
"""Cancel an in-progress sync for one discovery playlist.
|
|
|
|
1:1 lift of the byte-identical ``cancel_<source>_sync`` bodies (Tidal,
|
|
Deezer, Qobuz, Spotify-Public, iTunes-Link, YouTube, ListenBrainz). The
|
|
caller passes the already-resolved state key (ListenBrainz transforms it
|
|
via ``_lb_state_key`` first), the source ``label``, the exact 404 message
|
|
(iTunes-Link uses "iTunes Link not found", not "... playlist not found"),
|
|
and the shared sync infrastructure (so this stays free of web_server
|
|
globals / Flask).
|
|
|
|
Returns ``(payload_dict, status_code)``; the caller wraps in ``jsonify``.
|
|
|
|
Beatport is NOT routed here — it cancels a stored ``sync_future`` and
|
|
returns a different payload.
|
|
"""
|
|
try:
|
|
if key not in states:
|
|
return {"error": not_found_message}, 404
|
|
|
|
state = states[key]
|
|
state['last_accessed'] = time.time()
|
|
sync_playlist_id = state.get('sync_playlist_id')
|
|
|
|
if sync_playlist_id:
|
|
with sync_lock:
|
|
sync_states[sync_playlist_id] = {"status": "cancelled"}
|
|
if sync_playlist_id in active_sync_workers:
|
|
del active_sync_workers[sync_playlist_id]
|
|
|
|
state['phase'] = 'discovered'
|
|
state['sync_playlist_id'] = None
|
|
state['sync_progress'] = {}
|
|
|
|
return {"success": True, "message": f"{label} sync cancelled"}, 200
|
|
except Exception as e:
|
|
logger.error(f"Error cancelling {label} sync: {e}")
|
|
return {"error": str(e)}, 500
|
|
|
|
|
|
def delete_playlist_state(
|
|
states: Dict[str, Any],
|
|
key: str,
|
|
*,
|
|
label: str,
|
|
not_found_message: str,
|
|
) -> Tuple[Dict[str, Any], int]:
|
|
"""Delete a discovery playlist's state entry, cancelling any active
|
|
discovery first.
|
|
|
|
1:1 lift of the byte-identical ``delete_<source>_playlist`` bodies
|
|
(Tidal, Deezer, Qobuz, Spotify-Public). Returns ``(payload, status_code)``.
|
|
|
|
The iTunes-Link / YouTube / ListenBrainz / Beatport deletes intentionally
|
|
keep their own bodies — they differ in success message, info-log wording,
|
|
name extraction, and/or key transform.
|
|
"""
|
|
try:
|
|
if key not in states:
|
|
return {"error": not_found_message}, 404
|
|
|
|
state = states[key]
|
|
if 'discovery_future' in state and state['discovery_future']:
|
|
state['discovery_future'].cancel()
|
|
del states[key]
|
|
|
|
logger.info(f"Deleted {label} playlist state: {key}")
|
|
return {"success": True, "message": "Playlist deleted"}, 200
|
|
except Exception as e:
|
|
logger.error(f"Error deleting {label} playlist: {e}")
|
|
return {"error": str(e)}, 500
|