"""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 ``_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__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__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__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