diff --git a/core/discovery/endpoints.py b/core/discovery/endpoints.py new file mode 100644 index 00000000..69dbb0d8 --- /dev/null +++ b/core/discovery/endpoints.py @@ -0,0 +1,738 @@ +"""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 + + +# --- playlist-name accessors ------------------------------------------------- +# The per-source sync-status handlers read the display name three different +# ways. Each is reproduced verbatim so the 1:1 behavior (including which ones +# raise vs. fall back to 'Unknown Playlist') is preserved. + +def playlist_name_attr_or_unknown(state: Dict[str, Any]) -> str: + """Tidal: playlist is an object — use ``.name`` or 'Unknown Playlist'.""" + pl = state.get('playlist') + return pl.name if pl and hasattr(pl, 'name') else 'Unknown Playlist' + + +def playlist_name_strict(state: Dict[str, Any]) -> str: + """Deezer / Qobuz / Spotify-Public / iTunes-Link: strict dict access — + raises (→ 500) if 'playlist' is missing, exactly like the originals.""" + return state['playlist']['name'] + + +def playlist_name_safe(state: Dict[str, Any]) -> str: + """YouTube / ListenBrainz: safe dict access, defaulting to 'Unknown + Playlist'.""" + return state.get('playlist', {}).get('name', 'Unknown Playlist') + + +def playlist_name_obj(state: Dict[str, Any]) -> str: + """Tidal start-sync: playlist is an object — strict ``.name`` (raises if + absent, exactly like the original).""" + return state['playlist'].name + + +def playlist_image_obj(state: Dict[str, Any]) -> str: + """Tidal: ``getattr(playlist, 'image_url', '')`` (object attribute).""" + return getattr(state['playlist'], 'image_url', '') + + +def playlist_image_dict(state: Dict[str, Any]) -> str: + """Deezer/Qobuz/Spotify-Public/YouTube: ``playlist.get('image_url', '')`` + (dict access).""" + return state['playlist'].get('image_url', '') + + +def get_sync_status( + states: Dict[str, Any], + key: str, + *, + not_found_message: str, + error_label: str, + activity_subject: str, + playlist_name_getter, + sync_lock: Any, + sync_states: Dict[str, Any], + add_activity_item, +) -> Tuple[Dict[str, Any], int]: + """Report sync status for one discovery playlist, posting an activity-feed + item when the sync finishes or errors. + + 1:1 lift of the ``get__sync_status`` bodies (Tidal, Deezer, Qobuz, + Spotify-Public, iTunes-Link, YouTube, ListenBrainz). Per-source variation + is captured by the parameters: + + - ``not_found_message`` — the 404 string (iTunes-Link drops "playlist"). + - ``error_label`` — used in the except log ("Error getting sync status"). + - ``activity_subject`` — the activity-feed prefix; note Spotify-Public uses + "Spotify Link playlist" while its error_label is "Spotify Public". + - ``playlist_name_getter`` — one of the accessors above (attr/strict/safe); + the strict one can raise, matching the originals (→ 500). The state's + phase/sync_progress are mutated BEFORE the name is read, so a raising + getter leaves the same partial mutation the original did. + + Beatport is NOT routed here — it returns a different payload (``status`` + not ``sync_status``, includes ``sync_id``, no lock, ``chart`` key). + """ + 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 not sync_playlist_id: + return {"error": "No sync in progress"}, 404 + + with sync_lock: + sync_state = sync_states.get(sync_playlist_id, {}) + + response = { + 'phase': state['phase'], + 'sync_status': sync_state.get('status', 'unknown'), + 'progress': sync_state.get('progress', {}), + 'complete': sync_state.get('status') == 'finished', + 'error': sync_state.get('error'), + } + + if sync_state.get('status') == 'finished': + state['phase'] = 'sync_complete' + state['sync_progress'] = sync_state.get('progress', {}) + playlist_name = playlist_name_getter(state) + add_activity_item("", "Sync Complete", f"{activity_subject} '{playlist_name}' synced successfully", "Now") + elif sync_state.get('status') == 'error': + state['phase'] = 'discovered' + playlist_name = playlist_name_getter(state) + add_activity_item("", "Sync Failed", f"{activity_subject} '{playlist_name}' sync failed", "Now") + + return response, 200 + except Exception as e: + logger.error(f"Error getting {error_label} sync status: {e}") + return {"error": str(e)}, 500 + + +def get_discovery_status( + states: Dict[str, Any], + key: str, + *, + not_found_message: str, + error_label: str, +) -> Tuple[Dict[str, Any], int]: + """Report real-time discovery progress/results for one playlist. + + 1:1 lift of the byte-identical ``get__discovery_status`` bodies. + Unlike sync-status, this shape is identical for ALL eight sources — + Beatport included — so it folds in too. Only the 404 message + (".../discovery not found" vs ".../playlist not found" vs "Beatport chart + not found") and the except-log label vary, both passed in. The caller + resolves the key (ListenBrainz via ``_lb_state_key``). + + Returns ``(payload, status_code)``. + """ + try: + if key not in states: + return {"error": not_found_message}, 404 + + state = states[key] + state['last_accessed'] = time.time() + + return { + 'phase': state['phase'], + 'status': state['status'], + 'progress': state['discovery_progress'], + 'spotify_matches': state['spotify_matches'], + 'spotify_total': state['spotify_total'], + 'results': state['discovery_results'], + 'complete': state['phase'] == 'discovered', + }, 200 + except Exception as e: + logger.error(f"Error getting {error_label} discovery status: {e}") + return {"error": str(e)}, 500 + + +def reset_playlist( + states: Dict[str, Any], + key: str, + *, + label: str, + not_found_message: str, +) -> Tuple[Dict[str, Any], int]: + """Reset a discovery playlist back to the 'fresh' phase, clearing all + discovery/sync data while preserving the original playlist payload. + + 1:1 lift of the byte-identical ``reset__playlist`` bodies + (Tidal, Deezer, Qobuz, Spotify-Public). Returns ``(payload, status_code)``. + + NOT folded in (genuinely divergent): YouTube (status -> 'parsed', no + download_process_id, logs the playlist name, "reset to fresh state"), + ListenBrainz (status -> 'cached', logs playlist title, returns + {"phase": "fresh"}), iTunes-Link (uses state.update, no info log, distinct + message). Those keep their own bodies. + """ + 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() + + state['phase'] = 'fresh' + state['status'] = 'fresh' + state['discovery_results'] = [] + state['discovery_progress'] = 0 + state['spotify_matches'] = 0 + state['sync_playlist_id'] = None + state['converted_spotify_playlist_id'] = None + state['download_process_id'] = None + state['sync_progress'] = {} + state['discovery_future'] = None + state['last_accessed'] = time.time() + + logger.info(f"Reset {label} playlist to fresh: {key}") + return {"success": True, "message": "Playlist reset to fresh phase"}, 200 + except Exception as e: + logger.error(f"Error resetting {label} playlist: {e}") + return {"error": str(e)}, 500 + + +def get_playlist_states( + states: Dict[str, Any], + *, + error_label: str, + info_log_label: str = None, +) -> Tuple[Dict[str, Any], int]: + """Return all stored discovery states for a source as a list for frontend + card hydration (``{"states": [...]}``). + + 1:1 lift of the ``get__playlist_states`` bodies (Tidal, Deezer, + Qobuz, Spotify-Public, iTunes-Link), which build the same per-entry dict. + iTunes-Link is the only one without the "Returning N ..." info log, so + ``info_log_label`` is optional (pass None to suppress it, as iTunes did). + + NOT folded in: the YouTube/ListenBrainz ``get_all_*_playlists`` endpoints — + they return ``{"playlists": [...]}`` (different key + fields: url/created_at, + no discovery_results) and filter mirrored/profile-scoped entries. + """ + try: + result = [] + current_time = time.time() + + for key, state in states.items(): + state['last_accessed'] = current_time + result.append({ + 'playlist_id': key, + 'phase': state['phase'], + 'status': state['status'], + 'discovery_progress': state['discovery_progress'], + 'spotify_matches': state['spotify_matches'], + 'spotify_total': state['spotify_total'], + 'discovery_results': state['discovery_results'], + 'converted_spotify_playlist_id': state.get('converted_spotify_playlist_id'), + 'download_process_id': state.get('download_process_id'), + 'last_accessed': state['last_accessed'], + }) + + if info_log_label: + logger.info(f"Returning {len(result)} stored {info_log_label} playlist states for hydration") + return {"states": result}, 200 + except Exception as e: + logger.error(f"Error getting {error_label} playlist states: {e}") + return {"error": str(e)}, 500 + + +def save_bubble_snapshot( + get_json, + *, + payload_key: str, + no_data_error: str, + snapshot_kind: str, + success_noun: str, + log_subject: str, + log_noun: str, + get_database, + get_current_profile_id, +) -> Tuple[Dict[str, Any], int]: + """Persist a bubble/download snapshot for cross-refresh hydration. + + 1:1 lift of the four structurally-identical snapshot endpoints + (discover_downloads, artist_bubbles, search_bubbles, beatport_bubbles), + which differ only by: + + - ``payload_key`` ('downloads' for discover, 'bubbles' for the rest) and + its ``no_data_error`` message. + - ``snapshot_kind`` — the db.save_bubble_snapshot category. + - ``success_noun`` — fills "Snapshot saved with N ". + - ``log_subject`` / ``log_noun`` — the info ("Saved : N ") + and except ("Error saving ") log lines. + + Returns ``(payload, status_code)``. ``get_json`` is invoked inside the try + like the original ``request.json``. + """ + try: + from datetime import datetime + + data = get_json() + if not data or payload_key not in data: + return {'success': False, 'error': no_data_error}, 400 + + items = data[payload_key] + + db = get_database() + db.save_bubble_snapshot(snapshot_kind, items, profile_id=get_current_profile_id()) + + count = len(items) + logger.info(f"Saved {log_subject}: {count} {log_noun}") + + return { + 'success': True, + 'message': f'Snapshot saved with {count} {success_noun}', + 'timestamp': datetime.now().isoformat(), + }, 200 + except Exception as e: + logger.error(f"Error saving {log_subject}: {e}") + import traceback + traceback.print_exc() + return {'success': False, 'error': str(e)}, 500 + + +def update_playlist_phase( + states: Dict[str, Any], + key: str, + get_json, + *, + not_found_message: str, + error_label: str, + valid_phases: List[str], + apply_extra_fields: bool, +) -> Tuple[Dict[str, Any], int]: + """Update a discovery playlist's phase (used when the modal closes, e.g. to + reset download_complete -> discovered). + + 1:1 lift of the ``update__playlist_phase`` bodies for the five + sources with the identical validation + full-message response (Tidal, + Deezer, Qobuz, Spotify-Public, YouTube). Per-source params: + + - ``valid_phases`` — YouTube's list additionally includes 'parsed'. + - ``apply_extra_fields`` — Deezer/Qobuz/Spotify-Public also persist + download_process_id / converted_spotify_playlist_id from the body; + Tidal/YouTube do NOT (so pass False to keep them 1:1). + - ``not_found_message`` / ``error_label``; ``get_json`` invoked inside the + try like the original ``request.get_json()``. + + Returns ``(payload, status_code)``. + + NOT folded in: iTunes-Link — it uses ``data.get('phase')`` (no separate + "Phase not provided" 400) and returns a no-message payload. + """ + try: + if key not in states: + return {"error": not_found_message}, 404 + + data = get_json() + if not data or 'phase' not in data: + return {"error": "Phase not provided"}, 400 + + new_phase = data['phase'] + if new_phase not in valid_phases: + return {"error": f"Invalid phase. Must be one of: {', '.join(valid_phases)}"}, 400 + + state = states[key] + old_phase = state.get('phase', 'unknown') + state['phase'] = new_phase + state['last_accessed'] = time.time() + + if apply_extra_fields: + if 'download_process_id' in data: + state['download_process_id'] = data['download_process_id'] + if 'converted_spotify_playlist_id' in data: + state['converted_spotify_playlist_id'] = data['converted_spotify_playlist_id'] + + logger.info(f"Updated {error_label} playlist {key} phase: {old_phase} → {new_phase}") + return {"success": True, "message": f"Phase updated to {new_phase}", "old_phase": old_phase, "new_phase": new_phase}, 200 + except Exception as e: + logger.error(f"Error updating {error_label} playlist phase: {e}") + return {"error": str(e)}, 500 + + +def first_artist_str_or_obj(original_track: Dict[str, Any]) -> str: + """Tidal: first artist from an artists list that may hold strings OR + objects ({'name': ...}); '' when empty.""" + artists = original_track.get('artists', []) + if artists: + return artists[0] if isinstance(artists[0], str) else artists[0].get('name', '') + return '' + + +def first_artist_plain(original_track: Dict[str, Any]) -> str: + """Deezer/Qobuz/Spotify-Public: first artist assuming a list of strings; + '' when empty.""" + artists = original_track.get('artists', []) + return artists[0] if artists else '' + + +def update_discovery_match( + states: Dict[str, Any], + get_json, + *, + source_log_label: str, + error_label: str, + original_track_key: str, + original_artist_getter, + join_artist_names, + extract_artist_name, + build_fix_modal_spotify_data, + get_discovery_cache_key, + get_database, + get_active_discovery_source, +) -> Tuple[Dict[str, Any], int]: + """Apply a manually-selected Spotify track to a discovery result (the + fix-modal flow) and persist it to the discovery cache. + + 1:1 lift of the ``update__discovery_match`` bodies for the four + sources with the identical structure (Tidal, Deezer, Qobuz, Spotify-Public). + Per-source pieces are params: + + - ``source_log_label`` (lowercase, e.g. "tidal") for the "Manual match + updated: ..." line; ``error_label`` for the except log. + - ``original_track_key`` — the raw-source track key on the result + ('tidal_track', 'deezer_track', ...). + - ``original_artist_getter`` — Tidal handles string-or-object artists + (``first_artist_str_or_obj``); the rest assume strings + (``first_artist_plain``). + - the web_server helpers (join/extract artist, build_fix_modal_spotify_data, + cache-key, get_database, active-discovery-source) are injected so this + stays free of those globals. + - ``get_json`` is called INSIDE the try (like the original's + ``request.get_json()``) so a malformed body yields the same 500. + + Returns ``(payload, status_code)``. + + NOT folded in: iTunes-Link (saves spotify_data directly via a different + cache signature), YouTube (multi-key original_track fallback), ListenBrainz + (entirely different unmatch-capable structure, no cache write), Beatport. + """ + try: + data = get_json() + identifier = data.get('identifier') + track_index = data.get('track_index') + spotify_track = data.get('spotify_track') + + if not identifier or track_index is None or not spotify_track: + return {'error': 'Missing required fields'}, 400 + + state = states.get(identifier) + if not state: + return {'error': 'Discovery state not found'}, 404 + + if track_index >= len(state['discovery_results']): + return {'error': 'Invalid track index'}, 400 + + result = state['discovery_results'][track_index] + old_status = result.get('status') + + result['status'] = 'Found' + result['status_class'] = 'found' + result['spotify_track'] = spotify_track['name'] + result['spotify_artist'] = join_artist_names(spotify_track['artists']) if isinstance(spotify_track['artists'], list) else extract_artist_name(spotify_track['artists']) + result['spotify_album'] = spotify_track['album'] + result['spotify_id'] = spotify_track['id'] + + duration_ms = spotify_track.get('duration_ms', 0) + if duration_ms: + minutes = duration_ms // 60000 + seconds = (duration_ms % 60000) // 1000 + result['duration'] = f"{minutes}:{seconds:02d}" + else: + result['duration'] = '0:00' + + result['spotify_data'] = build_fix_modal_spotify_data(spotify_track) + result['wing_it_fallback'] = False + result['manual_match'] = True + + if old_status != 'found' and old_status != 'Found': + state['spotify_matches'] = state.get('spotify_matches', 0) + 1 + + logger.info(f"Manual match updated: {source_log_label} - {identifier} - track {track_index}") + logger.info(f" → {result['spotify_artist']} - {result['spotify_track']}") + + try: + original_track = result.get(original_track_key, {}) + original_name = original_track.get('name', spotify_track['name']) + original_artist = original_artist_getter(original_track) + + cache_key = get_discovery_cache_key(original_name, original_artist) + artists_list = spotify_track['artists'] + if isinstance(artists_list, list): + artists_list = [a if isinstance(a, str) else a.get('name', '') for a in artists_list] + image_url = spotify_track.get('image_url') or '' + album_raw = spotify_track.get('album', '') + if isinstance(album_raw, dict): + album_obj = dict(album_raw) + if image_url and not album_obj.get('image_url'): + album_obj['image_url'] = image_url + if image_url and not album_obj.get('images'): + album_obj['images'] = [{'url': image_url}] + else: + album_obj = {'name': album_raw or ''} + if image_url: + album_obj['image_url'] = image_url + album_obj['images'] = [{'url': image_url}] + + matched_data = { + 'id': spotify_track['id'], + 'name': spotify_track['name'], + 'artists': artists_list, + 'album': album_obj, + 'duration_ms': spotify_track.get('duration_ms', 0), + 'image_url': image_url, + 'source': 'spotify', + } + cache_db = get_database() + cache_db.save_discovery_cache_match( + cache_key[0], cache_key[1], get_active_discovery_source(), 1.0, matched_data, + original_name, original_artist + ) + logger.info(f"Manual fix saved to discovery cache: {original_name} by {original_artist}") + except Exception as cache_err: + logger.error(f"Error saving manual fix to discovery cache: {cache_err}") + + return {'success': True, 'result': result}, 200 + except Exception as e: + logger.error(f"Error updating {error_label} discovery match: {e}") + return {'error': str(e)}, 500 + + +def start_sync( + states: Dict[str, Any], + key: str, + *, + sync_id_prefix: str, + not_found_message: str, + not_ready_message: str, + convert_fn, + playlist_name_getter, + playlist_image_getter, + activity_label: str, + error_label: str, + sync_lock: Any, + sync_states: Dict[str, Any], + active_sync_workers: Dict[str, Any], + submit_sync_task, + add_activity_item, +) -> Tuple[Dict[str, Any], int]: + """Kick off a playlist sync from a source's discovered Spotify matches. + + 1:1 lift of the ``start__sync`` bodies for the five sources with + the identical flow (Tidal, Deezer, Qobuz, Spotify-Public, YouTube). The + per-source pieces are parameters: + + - ``sync_id_prefix`` — the ``f"{prefix}_{key}"`` sync id. + - ``convert_fn`` — the source's discovery->spotify-tracks converter. + - ``playlist_name_getter`` / ``playlist_image_getter`` — Tidal reads an + object (``.name`` / ``getattr``), the rest read a dict; lifted as the + ``playlist_name_obj``/``playlist_image_obj`` vs ``playlist_name_strict``/ + ``playlist_image_dict`` accessors. + - ``activity_label`` vs ``error_label`` — these DIFFER for Spotify-Public: + activity says "Spotify Link Sync Started" while logs say "Spotify Public". + - ``submit_sync_task(sync_playlist_id, playlist_name, spotify_tracks, + playlist_image_url) -> Future`` — wraps sync_executor/_run_sync_task/ + get_current_profile_id so this stays free of those globals. + + Returns ``(payload, status_code)``. + + NOT folded in: iTunes-Link (no final info log), ListenBrainz (submits the + task without an image arg), Beatport (extra debug logging, 'chart' key). + """ + try: + if key not in states: + return {"error": not_found_message}, 404 + + state = states[key] + state['last_accessed'] = time.time() + + if state['phase'] not in ['discovered', 'sync_complete', 'download_complete']: + return {"error": not_ready_message}, 400 + + spotify_tracks = convert_fn(state['discovery_results']) + if not spotify_tracks: + return {"error": "No Spotify matches found for sync"}, 400 + + sync_playlist_id = f"{sync_id_prefix}_{key}" + playlist_name = playlist_name_getter(state) + + add_activity_item("", f"{activity_label} Sync Started", f"'{playlist_name}' - {len(spotify_tracks)} tracks", "Now") + + state['phase'] = 'syncing' + state['sync_playlist_id'] = sync_playlist_id + state['sync_progress'] = {} + + with sync_lock: + sync_states[sync_playlist_id] = {"status": "starting", "progress": {}} + + playlist_image_url = playlist_image_getter(state) + future = submit_sync_task(sync_playlist_id, playlist_name, spotify_tracks, playlist_image_url) + active_sync_workers[sync_playlist_id] = future + + logger.info(f"Started {error_label} sync for: {playlist_name} ({len(spotify_tracks)} tracks)") + return {"success": True, "sync_playlist_id": sync_playlist_id}, 200 + except Exception as e: + logger.error(f"Error starting {error_label} sync: {e}") + return {"error": str(e)}, 500 diff --git a/tests/discovery/test_discovery_endpoints.py b/tests/discovery/test_discovery_endpoints.py new file mode 100644 index 00000000..2f1b0ace --- /dev/null +++ b/tests/discovery/test_discovery_endpoints.py @@ -0,0 +1,957 @@ +"""Tests for the lifted, source-agnostic discovery route helpers in +``core.discovery.endpoints``. + +These pin the exact behavior the per-source ``convert__results_to_spotify_tracks`` +functions had in web_server.py, so the lift is provably 1:1. Each input shape +the originals handled is exercised here. +""" + +from __future__ import annotations + +import threading + +from core.discovery.endpoints import ( + convert_results_to_spotify_tracks, + cancel_sync, + delete_playlist_state, + get_sync_status, + playlist_name_strict as _pl_name_strict, + playlist_name_safe as _pl_name_safe, +) + + +class _FakeFuture: + def __init__(self): + self.cancelled = False + + def cancel(self): + self.cancelled = True + + +def _cancel_infra(): + """Fresh sync infra (lock + the two shared dicts) for cancel_sync tests.""" + return { + 'sync_lock': threading.Lock(), + 'sync_states': {}, + 'active_sync_workers': {}, + } + + +# --------------------------------------------------------------------------- +# spotify_data (manual-fix) shape +# --------------------------------------------------------------------------- + +def test_spotify_data_shape_basic(): + results = [{ + 'spotify_data': { + 'id': 'sp1', 'name': 'Song', 'artists': ['A'], 'album': 'Alb', + 'duration_ms': 1234, + } + }] + assert convert_results_to_spotify_tracks(results, 'Tidal') == [{ + 'id': 'sp1', 'name': 'Song', 'artists': ['A'], 'album': 'Alb', + 'duration_ms': 1234, + }] + + +def test_spotify_data_duration_defaults_to_zero(): + results = [{'spotify_data': {'id': 'x', 'name': 'n', 'artists': [], 'album': 'a'}}] + out = convert_results_to_spotify_tracks(results, 'Deezer') + assert out[0]['duration_ms'] == 0 + + +def test_spotify_data_includes_track_and_disc_number_when_present(): + results = [{'spotify_data': { + 'id': 'x', 'name': 'n', 'artists': [], 'album': 'a', + 'track_number': 5, 'disc_number': 2, + }}] + out = convert_results_to_spotify_tracks(results, 'Qobuz') + assert out[0]['track_number'] == 5 + assert out[0]['disc_number'] == 2 + + +def test_spotify_data_omits_track_disc_when_absent_or_falsy(): + # track_number/disc_number of 0 are falsy -> omitted, matching original. + results = [{'spotify_data': { + 'id': 'x', 'name': 'n', 'artists': [], 'album': 'a', + 'track_number': 0, 'disc_number': 0, + }}] + out = convert_results_to_spotify_tracks(results, 'YouTube') + assert 'track_number' not in out[0] + assert 'disc_number' not in out[0] + + +# --------------------------------------------------------------------------- +# spotify_track + status_class == 'found' (auto-discovery) shape +# --------------------------------------------------------------------------- + +def test_auto_discovery_shape_full(): + results = [{ + 'spotify_track': 'Track', 'status_class': 'found', + 'spotify_id': 'id9', 'spotify_artist': 'Artist', 'spotify_album': 'Album', + }] + assert convert_results_to_spotify_tracks(results, 'ListenBrainz') == [{ + 'id': 'id9', 'name': 'Track', 'artists': ['Artist'], 'album': 'Album', + 'duration_ms': 0, + }] + + +def test_auto_discovery_defaults_when_fields_missing(): + results = [{'spotify_track': 'T', 'status_class': 'found'}] + out = convert_results_to_spotify_tracks(results, 'Spotify Public') + assert out == [{ + 'id': 'unknown', 'name': 'T', 'artists': ['Unknown Artist'], + 'album': 'Unknown Album', 'duration_ms': 0, + }] + + +def test_auto_discovery_empty_artist_yields_unknown_artist(): + results = [{ + 'spotify_track': 'T', 'status_class': 'found', 'spotify_artist': '', + }] + out = convert_results_to_spotify_tracks(results, 'Tidal') + assert out[0]['artists'] == ['Unknown Artist'] + + +# --------------------------------------------------------------------------- +# skip / mixed / empty +# --------------------------------------------------------------------------- + +def test_auto_discovery_requires_found_status(): + # spotify_track present but status_class != 'found' -> skipped. + results = [{'spotify_track': 'T', 'status_class': 'not_found'}] + assert convert_results_to_spotify_tracks(results, 'Tidal') == [] + + +def test_result_matching_neither_shape_is_skipped(): + results = [{'irrelevant': True}, {'spotify_track': 'T'}] # 2nd has no status_class + assert convert_results_to_spotify_tracks(results, 'Tidal') == [] + + +def test_mixed_results_preserve_order(): + results = [ + {'spotify_data': {'id': '1', 'name': 'a', 'artists': [], 'album': ''}}, + {'irrelevant': True}, + {'spotify_track': 'b', 'status_class': 'found', 'spotify_id': '2'}, + ] + out = convert_results_to_spotify_tracks(results, 'Tidal') + assert [t['id'] for t in out] == ['1', '2'] + + +def test_empty_input(): + assert convert_results_to_spotify_tracks([], 'Tidal') == [] + + +def test_spotify_data_takes_precedence_over_auto_fields(): + # A result carrying both shapes uses spotify_data (the if-branch wins), + # matching the original if/elif ordering. + results = [{ + 'spotify_data': {'id': 'D', 'name': 'd', 'artists': [], 'album': ''}, + 'spotify_track': 'IGNORED', 'status_class': 'found', 'spotify_id': 'A', + }] + out = convert_results_to_spotify_tracks(results, 'Tidal') + assert out[0]['id'] == 'D' + + +# --------------------------------------------------------------------------- +# cancel_sync +# --------------------------------------------------------------------------- + +def test_cancel_sync_not_found_returns_404(): + body, code = cancel_sync({}, 'missing', label='Tidal', + not_found_message='Tidal playlist not found', **_cancel_infra()) + assert code == 404 + assert body == {"error": "Tidal playlist not found"} + + +def test_cancel_sync_cancels_active_worker_and_reverts_state(): + infra = _cancel_infra() + infra['sync_states']['sp1'] = {"status": "running"} + infra['active_sync_workers']['sp1'] = _FakeFuture() + states = {'pl': {'phase': 'syncing', 'sync_playlist_id': 'sp1', + 'sync_progress': {'done': 1}, 'last_accessed': 0}} + + body, code = cancel_sync(states, 'pl', label='Tidal', + not_found_message='nf', **infra) + + assert code == 200 + assert body == {"success": True, "message": "Tidal sync cancelled"} + # sync marked cancelled, worker removed + assert infra['sync_states']['sp1'] == {"status": "cancelled"} + assert 'sp1' not in infra['active_sync_workers'] + # state reverted + assert states['pl']['phase'] == 'discovered' + assert states['pl']['sync_playlist_id'] is None + assert states['pl']['sync_progress'] == {} + assert states['pl']['last_accessed'] != 0 # touched + + +def test_cancel_sync_worker_absent_from_active_map_is_safe(): + infra = _cancel_infra() # active_sync_workers empty + states = {'pl': {'phase': 'syncing', 'sync_playlist_id': 'sp1'}} + body, code = cancel_sync(states, 'pl', label='Deezer', not_found_message='nf', **infra) + assert code == 200 + assert infra['sync_states']['sp1'] == {"status": "cancelled"} + + +def test_cancel_sync_no_sync_in_progress_still_reverts(): + infra = _cancel_infra() + states = {'pl': {'phase': 'discovered'}} # no sync_playlist_id + body, code = cancel_sync(states, 'pl', label='Qobuz', not_found_message='nf', **infra) + assert code == 200 + assert states['pl']['sync_playlist_id'] is None + assert states['pl']['sync_progress'] == {} + assert infra['sync_states'] == {} # nothing cancelled + + +def test_cancel_sync_label_in_message(): + infra = _cancel_infra() + states = {'pl': {}} + body, _ = cancel_sync(states, 'pl', label='iTunes Link', not_found_message='nf', **infra) + assert body["message"] == "iTunes Link sync cancelled" + + +def test_cancel_sync_exception_returns_500(): + infra = _cancel_infra() + states = {'pl': object()} # not subscriptable -> raises inside try + body, code = cancel_sync(states, 'pl', label='Tidal', not_found_message='nf', **infra) + assert code == 500 + assert "error" in body + + +# --------------------------------------------------------------------------- +# delete_playlist_state +# --------------------------------------------------------------------------- + +def test_delete_not_found_returns_404(): + body, code = delete_playlist_state({}, 'missing', label='Tidal', + not_found_message='Tidal playlist not found') + assert code == 404 + assert body == {"error": "Tidal playlist not found"} + + +def test_delete_cancels_discovery_future_and_removes_state(): + fut = _FakeFuture() + states = {'pl': {'discovery_future': fut}} + body, code = delete_playlist_state(states, 'pl', label='Tidal', not_found_message='nf') + assert code == 200 + assert body == {"success": True, "message": "Playlist deleted"} + assert fut.cancelled is True + assert 'pl' not in states + + +def test_delete_without_discovery_future_still_removes(): + states = {'pl': {'phase': 'discovered'}} # no discovery_future + body, code = delete_playlist_state(states, 'pl', label='Deezer', not_found_message='nf') + assert code == 200 + assert 'pl' not in states + + +def test_delete_falsy_discovery_future_not_cancelled(): + states = {'pl': {'discovery_future': None}} + body, code = delete_playlist_state(states, 'pl', label='Qobuz', not_found_message='nf') + assert code == 200 + assert 'pl' not in states + + +def test_delete_exception_returns_500(): + fut = object() # no .cancel() -> AttributeError inside try + states = {'pl': {'discovery_future': fut}} + body, code = delete_playlist_state(states, 'pl', label='Tidal', not_found_message='nf') + assert code == 500 + assert "error" in body + # state NOT deleted because the exception fired before del + assert 'pl' in states + + +# --------------------------------------------------------------------------- +# playlist-name accessors +# --------------------------------------------------------------------------- + +class _Obj: + def __init__(self, name): + self.name = name + + +def test_name_attr_or_unknown(): + from core.discovery.endpoints import playlist_name_attr_or_unknown as g + assert g({'playlist': _Obj('My PL')}) == 'My PL' + assert g({'playlist': {'name': 'dict'}}) == 'Unknown Playlist' # dict has no .name attr + assert g({}) == 'Unknown Playlist' + assert g({'playlist': None}) == 'Unknown Playlist' + + +def test_name_strict(): + from core.discovery.endpoints import playlist_name_strict as g + assert g({'playlist': {'name': 'X'}}) == 'X' + import pytest + with pytest.raises(KeyError): + g({}) # strict -> raises, matching originals + + +def test_name_safe(): + from core.discovery.endpoints import playlist_name_safe as g + assert g({'playlist': {'name': 'X'}}) == 'X' + assert g({}) == 'Unknown Playlist' + assert g({'playlist': {}}) == 'Unknown Playlist' + + +# --------------------------------------------------------------------------- +# get_sync_status +# --------------------------------------------------------------------------- + +def _activity_recorder(): + calls = [] + + def add_activity_item(user, action, desc, when): + calls.append((user, action, desc, when)) + + return calls, add_activity_item + + +def _status_kwargs(infra, add_activity_item, *, not_found_message='nf', + error_label='Tidal', activity_subject='Tidal playlist', + name_getter=None): + return dict( + not_found_message=not_found_message, error_label=error_label, + activity_subject=activity_subject, + playlist_name_getter=name_getter or _pl_name_safe, + add_activity_item=add_activity_item, + sync_lock=infra['sync_lock'], sync_states=infra['sync_states'], + ) + + +def test_status_not_found(): + infra = _cancel_infra() + calls, add = _activity_recorder() + body, code = get_sync_status({}, 'missing', + **_status_kwargs(infra, add, not_found_message='Tidal playlist not found')) + assert code == 404 and body == {"error": "Tidal playlist not found"} + assert calls == [] + + +def test_status_no_sync_in_progress(): + infra = _cancel_infra() + calls, add = _activity_recorder() + states = {'pl': {'phase': 'discovered'}} # no sync_playlist_id + body, code = get_sync_status(states, 'pl', **_status_kwargs(infra, add)) + assert code == 404 and body == {"error": "No sync in progress"} + + +def test_status_running_returns_shape_without_mutation_or_activity(): + infra = _cancel_infra() + infra['sync_states']['sp1'] = {"status": "running", "progress": {"n": 2}} + calls, add = _activity_recorder() + states = {'pl': {'phase': 'syncing', 'sync_playlist_id': 'sp1'}} + body, code = get_sync_status(states, 'pl', **_status_kwargs(infra, add)) + assert code == 200 + assert body == { + 'phase': 'syncing', 'sync_status': 'running', + 'progress': {"n": 2}, 'complete': False, 'error': None, + } + assert states['pl']['phase'] == 'syncing' # unchanged + assert calls == [] + + +def test_status_finished_sets_complete_and_posts_activity(): + infra = _cancel_infra() + infra['sync_states']['sp1'] = {"status": "finished", "progress": {"done": 9}} + calls, add = _activity_recorder() + states = {'pl': {'phase': 'syncing', 'sync_playlist_id': 'sp1', + 'playlist': {'name': 'Mix'}}} + body, code = get_sync_status(states, 'pl', **_status_kwargs( + infra, add, activity_subject='Spotify Link playlist', + name_getter=_pl_name_strict)) + assert code == 200 + assert body['complete'] is True + assert states['pl']['phase'] == 'sync_complete' + assert states['pl']['sync_progress'] == {"done": 9} + assert calls == [("", "Sync Complete", "Spotify Link playlist 'Mix' synced successfully", "Now")] + + +def test_status_error_reverts_and_posts_failed_activity(): + infra = _cancel_infra() + infra['sync_states']['sp1'] = {"status": "error", "error": "boom"} + calls, add = _activity_recorder() + states = {'pl': {'phase': 'syncing', 'sync_playlist_id': 'sp1', + 'playlist': {'name': 'Mix'}}} + body, code = get_sync_status(states, 'pl', **_status_kwargs( + infra, add, activity_subject='YouTube playlist', name_getter=_pl_name_safe)) + assert code == 200 + assert body['error'] == "boom" + assert states['pl']['phase'] == 'discovered' + assert calls == [("", "Sync Failed", "YouTube playlist 'Mix' sync failed", "Now")] + + +def test_status_strict_getter_missing_playlist_raises_500_after_partial_mutation(): + infra = _cancel_infra() + infra['sync_states']['sp1'] = {"status": "finished", "progress": {}} + calls, add = _activity_recorder() + states = {'pl': {'phase': 'syncing', 'sync_playlist_id': 'sp1'}} # no 'playlist' + body, code = get_sync_status(states, 'pl', **_status_kwargs( + infra, add, error_label='Deezer', name_getter=_pl_name_strict)) + assert code == 500 and "error" in body + # phase was set to sync_complete BEFORE the strict getter raised (1:1). + assert states['pl']['phase'] == 'sync_complete' + assert calls == [] # activity never posted + + +# --------------------------------------------------------------------------- +# get_discovery_status +# --------------------------------------------------------------------------- + +def test_discovery_status_not_found(): + from core.discovery.endpoints import get_discovery_status + body, code = get_discovery_status({}, 'missing', + not_found_message='Tidal discovery not found', + error_label='Tidal') + assert code == 404 and body == {"error": "Tidal discovery not found"} + + +def test_discovery_status_builds_response_and_bumps_access(): + from core.discovery.endpoints import get_discovery_status + state = { + 'phase': 'discovered', 'status': 'done', 'discovery_progress': 100, + 'spotify_matches': 8, 'spotify_total': 10, + 'discovery_results': [{'x': 1}], 'last_accessed': 0, + } + states = {'pl': state} + body, code = get_discovery_status(states, 'pl', + not_found_message='nf', error_label='Tidal') + assert code == 200 + assert body == { + 'phase': 'discovered', 'status': 'done', 'progress': 100, + 'spotify_matches': 8, 'spotify_total': 10, + 'results': [{'x': 1}], 'complete': True, + } + assert state['last_accessed'] != 0 + + +def test_discovery_status_complete_false_when_not_discovered(): + from core.discovery.endpoints import get_discovery_status + state = { + 'phase': 'discovering', 'status': 'running', 'discovery_progress': 40, + 'spotify_matches': 2, 'spotify_total': 10, 'discovery_results': [], + } + body, code = get_discovery_status({'pl': state}, 'pl', + not_found_message='nf', error_label='Deezer') + assert code == 200 and body['complete'] is False + + +def test_discovery_status_missing_field_raises_500(): + from core.discovery.endpoints import get_discovery_status + # state missing 'status' -> strict access raises -> 500 (matches original) + states = {'pl': {'phase': 'x'}} + body, code = get_discovery_status(states, 'pl', + not_found_message='nf', error_label='Beatport') + assert code == 500 and "error" in body + + +# --------------------------------------------------------------------------- +# reset_playlist +# --------------------------------------------------------------------------- + +def test_reset_not_found(): + from core.discovery.endpoints import reset_playlist + body, code = reset_playlist({}, 'missing', label='Tidal', + not_found_message='Tidal playlist not found') + assert code == 404 and body == {"error": "Tidal playlist not found"} + + +def test_reset_clears_state_preserving_playlist_and_cancels_future(): + from core.discovery.endpoints import reset_playlist + fut = _FakeFuture() + state = { + 'playlist': {'name': 'keep me'}, + 'phase': 'discovered', 'status': 'done', + 'discovery_results': [{'x': 1}], 'discovery_progress': 100, + 'spotify_matches': 5, 'sync_playlist_id': 'sp', 'last_accessed': 0, + 'converted_spotify_playlist_id': 'cv', 'download_process_id': 'dp', + 'sync_progress': {'n': 1}, 'discovery_future': fut, + } + states = {'pl': state} + body, code = reset_playlist(states, 'pl', label='Tidal', not_found_message='nf') + + assert code == 200 + assert body == {"success": True, "message": "Playlist reset to fresh phase"} + assert fut.cancelled is True + # cleared + assert state['phase'] == 'fresh' + assert state['status'] == 'fresh' + assert state['discovery_results'] == [] + assert state['discovery_progress'] == 0 + assert state['spotify_matches'] == 0 + assert state['sync_playlist_id'] is None + assert state['converted_spotify_playlist_id'] is None + assert state['download_process_id'] is None + assert state['sync_progress'] == {} + assert state['discovery_future'] is None + assert state['last_accessed'] != 0 + # original playlist payload preserved + assert state['playlist'] == {'name': 'keep me'} + + +def test_reset_without_discovery_future(): + from core.discovery.endpoints import reset_playlist + state = {'phase': 'discovered'} # no discovery_future key + body, code = reset_playlist({'pl': state}, 'pl', label='Deezer', not_found_message='nf') + assert code == 200 + assert state['phase'] == 'fresh' + + +def test_reset_exception_returns_500(): + from core.discovery.endpoints import reset_playlist + fut = object() # .cancel missing -> AttributeError in try + states = {'pl': {'discovery_future': fut}} + body, code = reset_playlist(states, 'pl', label='Qobuz', not_found_message='nf') + assert code == 500 and "error" in body + + +# --------------------------------------------------------------------------- +# get_playlist_states (bulk hydration list) +# --------------------------------------------------------------------------- + +def test_playlist_states_builds_list_and_bumps_access(): + from core.discovery.endpoints import get_playlist_states + s1 = {'phase': 'discovered', 'status': 'done', 'discovery_progress': 100, + 'spotify_matches': 3, 'spotify_total': 5, 'discovery_results': [{'a': 1}], + 'converted_spotify_playlist_id': 'cv', 'download_process_id': 'dp', + 'last_accessed': 0} + states = {'k1': s1} + body, code = get_playlist_states(states, error_label='Tidal', info_log_label='Tidal') + assert code == 200 + assert body == {"states": [{ + 'playlist_id': 'k1', 'phase': 'discovered', 'status': 'done', + 'discovery_progress': 100, 'spotify_matches': 3, 'spotify_total': 5, + 'discovery_results': [{'a': 1}], 'converted_spotify_playlist_id': 'cv', + 'download_process_id': 'dp', 'last_accessed': s1['last_accessed'], + }]} + assert s1['last_accessed'] != 0 # bumped + + +def test_playlist_states_empty(): + from core.discovery.endpoints import get_playlist_states + body, code = get_playlist_states({}, error_label='Deezer', info_log_label='Deezer') + assert code == 200 and body == {"states": []} + + +def test_playlist_states_optional_ids_default_none(): + from core.discovery.endpoints import get_playlist_states + state = {'phase': 'fresh', 'status': 'fresh', 'discovery_progress': 0, + 'spotify_matches': 0, 'spotify_total': 0, 'discovery_results': []} + body, _ = get_playlist_states({'k': state}, error_label='iTunes Link') + assert body['states'][0]['converted_spotify_playlist_id'] is None + assert body['states'][0]['download_process_id'] is None + + +def test_playlist_states_missing_required_field_raises_500(): + from core.discovery.endpoints import get_playlist_states + # state missing 'phase' -> strict access raises -> 500 + body, code = get_playlist_states({'k': {'status': 'x'}}, error_label='Qobuz') + assert code == 500 and "error" in body + + +# --------------------------------------------------------------------------- +# start_sync +# --------------------------------------------------------------------------- + +def _start_kwargs(infra, *, convert_fn=None, name='PL', image='img', + activity_label='Tidal', error_label='Tidal', + not_found_message='Tidal playlist not found', + not_ready_message='Tidal playlist not ready for sync', + sync_id_prefix='tidal'): + submitted = [] + + def submit(sync_playlist_id, playlist_name, tracks, image_url): + rec = (sync_playlist_id, playlist_name, tracks, image_url) + submitted.append(rec) + return f"future:{sync_playlist_id}" + + calls, add = _activity_recorder() + kw = dict( + sync_id_prefix=sync_id_prefix, not_found_message=not_found_message, + not_ready_message=not_ready_message, + convert_fn=convert_fn or (lambda r: [{'id': 't'}]), + playlist_name_getter=lambda s: name, playlist_image_getter=lambda s: image, + activity_label=activity_label, error_label=error_label, + sync_lock=infra['sync_lock'], sync_states=infra['sync_states'], + active_sync_workers=infra['active_sync_workers'], + submit_sync_task=submit, add_activity_item=add, + ) + return kw, submitted, calls + + +def test_start_sync_not_found(): + from core.discovery.endpoints import start_sync + infra = _cancel_infra() + kw, _, _ = _start_kwargs(infra) + body, code = start_sync({}, 'missing', **kw) + assert code == 404 and body == {"error": "Tidal playlist not found"} + + +def test_start_sync_not_ready_phase(): + from core.discovery.endpoints import start_sync + infra = _cancel_infra() + kw, _, _ = _start_kwargs(infra) + states = {'pl': {'phase': 'discovering'}} + body, code = start_sync(states, 'pl', **kw) + assert code == 400 and body == {"error": "Tidal playlist not ready for sync"} + + +def test_start_sync_no_matches(): + from core.discovery.endpoints import start_sync + infra = _cancel_infra() + kw, _, _ = _start_kwargs(infra, convert_fn=lambda r: []) + states = {'pl': {'phase': 'discovered', 'discovery_results': []}} + body, code = start_sync(states, 'pl', **kw) + assert code == 400 and body == {"error": "No Spotify matches found for sync"} + + +def test_start_sync_happy_path(): + from core.discovery.endpoints import start_sync + infra = _cancel_infra() + kw, submitted, calls = _start_kwargs( + infra, convert_fn=lambda r: [{'id': 'a'}, {'id': 'b'}], + name='My Mix', image='cover.jpg', + activity_label='Spotify Link', error_label='Spotify Public', + sync_id_prefix='spotify_public') + states = {'h1': {'phase': 'discovered', 'discovery_results': [1, 2]}} + body, code = start_sync(states, 'h1', **kw) + + assert code == 200 + assert body == {"success": True, "sync_playlist_id": "spotify_public_h1"} + # state mutated + assert states['h1']['phase'] == 'syncing' + assert states['h1']['sync_playlist_id'] == 'spotify_public_h1' + assert states['h1']['sync_progress'] == {} + # sync infra seeded + worker registered + assert infra['sync_states']['spotify_public_h1'] == {"status": "starting", "progress": {}} + assert infra['active_sync_workers']['spotify_public_h1'] == 'future:spotify_public_h1' + # submit got name/tracks/image + assert submitted == [('spotify_public_h1', 'My Mix', [{'id': 'a'}, {'id': 'b'}], 'cover.jpg')] + # activity uses activity_label (not error_label) + track count + assert calls == [("", "Spotify Link Sync Started", "'My Mix' - 2 tracks", "Now")] + + +def test_start_sync_allows_resync_phases(): + from core.discovery.endpoints import start_sync + for phase in ('sync_complete', 'download_complete'): + infra = _cancel_infra() + kw, _, _ = _start_kwargs(infra) + states = {'pl': {'phase': phase, 'discovery_results': [1]}} + body, code = start_sync(states, 'pl', **kw) + assert code == 200, phase + + +def test_start_sync_exception_returns_500(): + from core.discovery.endpoints import start_sync + infra = _cancel_infra() + def boom(r): + raise RuntimeError("convert blew up") + kw, _, _ = _start_kwargs(infra, convert_fn=boom) + states = {'pl': {'phase': 'discovered', 'discovery_results': [1]}} + body, code = start_sync(states, 'pl', **kw) + assert code == 500 and "error" in body + + +# --------------------------------------------------------------------------- +# first_artist extractors +# --------------------------------------------------------------------------- + +def test_first_artist_str_or_obj(): + from core.discovery.endpoints import first_artist_str_or_obj as g + assert g({'artists': ['A', 'B']}) == 'A' + assert g({'artists': [{'name': 'Obj'}]}) == 'Obj' + assert g({'artists': []}) == '' + assert g({}) == '' + + +def test_first_artist_plain(): + from core.discovery.endpoints import first_artist_plain as g + assert g({'artists': ['A', 'B']}) == 'A' + assert g({'artists': []}) == '' + assert g({}) == '' + + +# --------------------------------------------------------------------------- +# update_discovery_match +# --------------------------------------------------------------------------- + +class _FakeCacheDB: + def __init__(self): + self.saved = [] + + def save_discovery_cache_match(self, *args): + self.saved.append(args) + + +def _update_kwargs(*, json_data, cache_db=None, getter=None): + from core.discovery.endpoints import first_artist_plain + db = cache_db or _FakeCacheDB() + kw = dict( + source_log_label='tidal', error_label='Tidal', + original_track_key='tidal_track', + original_artist_getter=getter or first_artist_plain, + join_artist_names=lambda arts: ", ".join(arts), + extract_artist_name=lambda a: str(a), + build_fix_modal_spotify_data=lambda st: {'built': st['id']}, + get_discovery_cache_key=lambda name, artist: (name.lower(), artist.lower()), + get_database=lambda: db, + get_active_discovery_source=lambda: 'spotify', + ) + return (lambda: json_data), kw, db + + +def test_update_match_missing_fields(): + from core.discovery.endpoints import update_discovery_match + gj, kw, _ = _update_kwargs(json_data={'identifier': 'p'}) # missing track_index/spotify_track + body, code = update_discovery_match({}, gj, **kw) + assert code == 400 and body == {'error': 'Missing required fields'} + + +def test_update_match_state_not_found(): + from core.discovery.endpoints import update_discovery_match + gj, kw, _ = _update_kwargs(json_data={ + 'identifier': 'p', 'track_index': 0, 'spotify_track': {'id': 'x'}}) + body, code = update_discovery_match({}, gj, **kw) + assert code == 404 and body == {'error': 'Discovery state not found'} + + +def test_update_match_invalid_index(): + from core.discovery.endpoints import update_discovery_match + gj, kw, _ = _update_kwargs(json_data={ + 'identifier': 'p', 'track_index': 5, + 'spotify_track': {'id': 'x', 'name': 'n', 'artists': [], 'album': 'a'}}) + states = {'p': {'discovery_results': []}} + body, code = update_discovery_match(states, gj, **kw) + assert code == 400 and body == {'error': 'Invalid track index'} + + +def test_update_match_happy_path_full(): + from core.discovery.endpoints import update_discovery_match + sp = {'id': 'sp9', 'name': 'New Song', 'artists': ['Art1', 'Art2'], + 'album': 'Alb', 'duration_ms': 185000, 'image_url': 'cov.jpg'} + gj, kw, db = _update_kwargs(json_data={ + 'identifier': 'p', 'track_index': 0, 'spotify_track': sp}) + result = {'status': 'not_found', 'tidal_track': {'name': 'Orig', 'artists': ['OrigArt']}} + states = {'p': {'discovery_results': [result], 'spotify_matches': 2}} + + body, code = update_discovery_match(states, gj, **kw) + + assert code == 200 and body['success'] is True + assert result['status'] == 'Found' + assert result['status_class'] == 'found' + assert result['spotify_track'] == 'New Song' + assert result['spotify_artist'] == 'Art1, Art2' + assert result['spotify_id'] == 'sp9' + assert result['duration'] == '3:05' + assert result['spotify_data'] == {'built': 'sp9'} + assert result['wing_it_fallback'] is False + assert result['manual_match'] is True + assert states['p']['spotify_matches'] == 3 # incremented (was not found) + # cache saved with normalized key + matched_data carrying image + assert len(db.saved) == 1 + key0, key1, source, score, matched, oname, oartist = db.saved[0] + assert (key0, key1) == ('orig', 'origart') + assert source == 'spotify' and score == 1.0 + assert matched['album'] == {'name': 'Alb', 'image_url': 'cov.jpg', 'images': [{'url': 'cov.jpg'}]} + assert oname == 'Orig' and oartist == 'OrigArt' + + +def test_update_match_no_increment_when_already_found(): + from core.discovery.endpoints import update_discovery_match + sp = {'id': 'x', 'name': 'n', 'artists': ['A'], 'album': 'a', 'duration_ms': 0} + gj, kw, _ = _update_kwargs(json_data={'identifier': 'p', 'track_index': 0, 'spotify_track': sp}) + result = {'status': 'Found', 'tidal_track': {}} + states = {'p': {'discovery_results': [result], 'spotify_matches': 5}} + body, code = update_discovery_match(states, gj, **kw) + assert code == 200 + assert states['p']['spotify_matches'] == 5 # unchanged + assert result['duration'] == '0:00' + + +def test_update_match_cache_error_is_swallowed(): + from core.discovery.endpoints import update_discovery_match + class _BoomDB: + def save_discovery_cache_match(self, *a): + raise RuntimeError("db down") + sp = {'id': 'x', 'name': 'n', 'artists': ['A'], 'album': 'a', 'duration_ms': 0} + gj, kw, _ = _update_kwargs(json_data={'identifier': 'p', 'track_index': 0, 'spotify_track': sp}, + cache_db=_BoomDB()) + states = {'p': {'discovery_results': [{'status': 'x', 'tidal_track': {}}], 'spotify_matches': 0}} + body, code = update_discovery_match(states, gj, **kw) + assert code == 200 and body['success'] is True # cache failure doesn't fail the request + + +def test_update_match_get_json_raises_returns_500(): + from core.discovery.endpoints import update_discovery_match + def boom(): + raise ValueError("bad json") + _, kw, _ = _update_kwargs(json_data={}) + body, code = update_discovery_match({}, boom, **kw) + assert code == 500 and 'error' in body + + +# --------------------------------------------------------------------------- +# update_playlist_phase +# --------------------------------------------------------------------------- + +_PHASES = ['fresh', 'discovering', 'discovered', 'syncing', 'sync_complete', 'downloading', 'download_complete'] + + +def test_update_phase_not_found(): + from core.discovery.endpoints import update_playlist_phase + body, code = update_playlist_phase({}, 'k', lambda: {'phase': 'fresh'}, + not_found_message='Tidal playlist not found', + error_label='Tidal', valid_phases=_PHASES, + apply_extra_fields=False) + assert code == 404 and body == {"error": "Tidal playlist not found"} + + +def test_update_phase_missing_phase(): + from core.discovery.endpoints import update_playlist_phase + states = {'k': {'phase': 'discovered'}} + body, code = update_playlist_phase(states, 'k', lambda: {}, + not_found_message='nf', error_label='Tidal', + valid_phases=_PHASES, apply_extra_fields=False) + assert code == 400 and body == {"error": "Phase not provided"} + + +def test_update_phase_invalid(): + from core.discovery.endpoints import update_playlist_phase + states = {'k': {'phase': 'discovered'}} + body, code = update_playlist_phase(states, 'k', lambda: {'phase': 'bogus'}, + not_found_message='nf', error_label='Tidal', + valid_phases=_PHASES, apply_extra_fields=False) + assert code == 400 and 'Invalid phase' in body['error'] + + +def test_update_phase_happy_no_extra_fields(): + from core.discovery.endpoints import update_playlist_phase + state = {'phase': 'download_complete', 'last_accessed': 0} + body, code = update_playlist_phase({'k': state}, 'k', + lambda: {'phase': 'discovered', + 'download_process_id': 'dp'}, + not_found_message='nf', error_label='Tidal', + valid_phases=_PHASES, apply_extra_fields=False) + assert code == 200 + assert body == {"success": True, "message": "Phase updated to discovered", + "old_phase": "download_complete", "new_phase": "discovered"} + assert state['phase'] == 'discovered' + assert state['last_accessed'] != 0 + # apply_extra_fields=False -> download_process_id NOT applied (1:1 for Tidal/YouTube) + assert 'download_process_id' not in state + + +def test_update_phase_applies_extra_fields_when_enabled(): + from core.discovery.endpoints import update_playlist_phase + state = {'phase': 'discovered'} + body, code = update_playlist_phase({'k': state}, 'k', + lambda: {'phase': 'downloading', + 'download_process_id': 'dp7', + 'converted_spotify_playlist_id': 'cv7'}, + not_found_message='nf', error_label='Deezer', + valid_phases=_PHASES, apply_extra_fields=True) + assert code == 200 + assert state['download_process_id'] == 'dp7' + assert state['converted_spotify_playlist_id'] == 'cv7' + + +def test_update_phase_youtube_parsed_allowed(): + from core.discovery.endpoints import update_playlist_phase + yt_phases = ['fresh', 'parsed', 'discovering', 'discovered', 'syncing', + 'sync_complete', 'downloading', 'download_complete'] + state = {'phase': 'discovered'} + body, code = update_playlist_phase({'k': state}, 'k', lambda: {'phase': 'parsed'}, + not_found_message='nf', error_label='YouTube', + valid_phases=yt_phases, apply_extra_fields=False) + assert code == 200 and state['phase'] == 'parsed' + + +def test_update_phase_exception_500(): + from core.discovery.endpoints import update_playlist_phase + def boom(): + raise ValueError('bad') + states = {'k': {'phase': 'discovered'}} + body, code = update_playlist_phase(states, 'k', boom, not_found_message='nf', + error_label='Tidal', valid_phases=_PHASES, + apply_extra_fields=False) + assert code == 500 and 'error' in body + + +# --------------------------------------------------------------------------- +# save_bubble_snapshot +# --------------------------------------------------------------------------- + +class _SnapDB: + def __init__(self): + self.saved = [] + + def save_bubble_snapshot(self, kind, items, profile_id=None): + self.saved.append((kind, items, profile_id)) + + +def _snap_kwargs(db, *, payload_key='bubbles', no_data_error='No bubble data provided', + snapshot_kind='artist_bubbles', success_noun='artist bubbles', + log_subject='artist bubble snapshot', log_noun='artists'): + return dict( + payload_key=payload_key, no_data_error=no_data_error, snapshot_kind=snapshot_kind, + success_noun=success_noun, log_subject=log_subject, log_noun=log_noun, + get_database=lambda: db, get_current_profile_id=lambda: 7, + ) + + +def test_snapshot_missing_payload_key(): + from core.discovery.endpoints import save_bubble_snapshot + db = _SnapDB() + body, code = save_bubble_snapshot(lambda: {'other': 1}, **_snap_kwargs(db)) + assert code == 400 and body == {'success': False, 'error': 'No bubble data provided'} + assert db.saved == [] + + +def test_snapshot_none_body(): + from core.discovery.endpoints import save_bubble_snapshot + db = _SnapDB() + body, code = save_bubble_snapshot(lambda: None, + **_snap_kwargs(db, payload_key='downloads', + no_data_error='No download data provided')) + assert code == 400 and body['error'] == 'No download data provided' + + +def test_snapshot_happy_path(): + from core.discovery.endpoints import save_bubble_snapshot + db = _SnapDB() + items = [{'a': 1}, {'b': 2}, {'c': 3}] + body, code = save_bubble_snapshot(lambda: {'bubbles': items}, + **_snap_kwargs(db, snapshot_kind='search_bubbles', + success_noun='search bubbles')) + assert code == 200 + assert body['success'] is True + assert body['message'] == 'Snapshot saved with 3 search bubbles' + assert isinstance(body['timestamp'], str) and body['timestamp'] + # persisted with kind + profile id + assert db.saved == [('search_bubbles', items, 7)] + + +def test_snapshot_discover_downloads_key(): + from core.discovery.endpoints import save_bubble_snapshot + db = _SnapDB() + body, code = save_bubble_snapshot(lambda: {'downloads': [1, 2]}, + **_snap_kwargs(db, payload_key='downloads', + no_data_error='No download data provided', + snapshot_kind='discover_downloads', + success_noun='downloads', + log_subject='discover download snapshot', + log_noun='downloads')) + assert code == 200 + assert body['message'] == 'Snapshot saved with 2 downloads' + assert db.saved[0][0] == 'discover_downloads' + + +def test_snapshot_exception_returns_500(): + from core.discovery.endpoints import save_bubble_snapshot + class _BoomDB: + def save_bubble_snapshot(self, *a, **k): + raise RuntimeError('db down') + body, code = save_bubble_snapshot(lambda: {'bubbles': [1]}, **_snap_kwargs(_BoomDB())) + assert code == 500 and body == {'success': False, 'error': 'db down'} diff --git a/web_server.py b/web_server.py index 51c4e844..17c4322e 100644 --- a/web_server.py +++ b/web_server.py @@ -20560,178 +20560,19 @@ def start_tidal_discovery(playlist_id): @app.route('/api/tidal/discovery/status/', methods=['GET']) def get_tidal_discovery_status(playlist_id): """Get real-time discovery status for a Tidal playlist""" - try: - if playlist_id not in tidal_discovery_states: - return jsonify({"error": "Tidal discovery not found"}), 404 - - state = tidal_discovery_states[playlist_id] - state['last_accessed'] = time.time() # Update access time - - response = { - 'phase': state['phase'], - 'status': state['status'], - 'progress': state['discovery_progress'], - 'spotify_matches': state['spotify_matches'], - 'spotify_total': state['spotify_total'], - 'results': state['discovery_results'], - 'complete': state['phase'] == 'discovered' - } - - return jsonify(response) - - except Exception as e: - logger.error(f"Error getting Tidal discovery status: {e}") - return jsonify({"error": str(e)}), 500 + return _get_source_discovery_status(tidal_discovery_states, playlist_id, "Tidal discovery not found", "Tidal") @app.route('/api/tidal/discovery/update_match', methods=['POST']) def update_tidal_discovery_match(): """Update a Tidal discovery result with manually selected Spotify track""" - try: - data = request.get_json() - identifier = data.get('identifier') # playlist_id - track_index = data.get('track_index') - spotify_track = data.get('spotify_track') - - if not identifier or track_index is None or not spotify_track: - return jsonify({'error': 'Missing required fields'}), 400 - - # Get the state - state = tidal_discovery_states.get(identifier) - - if not state: - return jsonify({'error': 'Discovery state not found'}), 404 - - if track_index >= len(state['discovery_results']): - return jsonify({'error': 'Invalid track index'}), 400 - - # Update the result - result = state['discovery_results'][track_index] - old_status = result.get('status') - - # Update with user-selected track - result['status'] = 'Found' - result['status_class'] = 'found' - result['spotify_track'] = spotify_track['name'] - result['spotify_artist'] = _join_artist_names(spotify_track['artists']) if isinstance(spotify_track['artists'], list) else _extract_artist_name(spotify_track['artists']) - result['spotify_album'] = spotify_track['album'] - result['spotify_id'] = spotify_track['id'] - - # Format duration (Tidal doesn't show duration in table, but store it anyway) - duration_ms = spotify_track.get('duration_ms', 0) - if duration_ms: - minutes = duration_ms // 60000 - seconds = (duration_ms % 60000) // 1000 - result['duration'] = f"{minutes}:{seconds:02d}" - else: - result['duration'] = '0:00' - - # IMPORTANT: Also set spotify_data for sync/download compatibility. - # Manual match from the fix modal — build a rich spotify_data (album - # as dict with image info) matching the normal discovery shape, and - # explicitly clear any prior wing-it flag since the user picked a - # real metadata match. - result['spotify_data'] = _build_fix_modal_spotify_data(spotify_track) - result['wing_it_fallback'] = False - - result['manual_match'] = True # Flag for tracking - - # Update match count if status changed from not found/error - if old_status != 'found' and old_status != 'Found': - state['spotify_matches'] = state.get('spotify_matches', 0) + 1 - - logger.info(f"Manual match updated: tidal - {identifier} - track {track_index}") - logger.info(f" → {result['spotify_artist']} - {result['spotify_track']}") - - # Save manual fix to discovery cache so it appears in discovery pool - try: - original_track = result.get('tidal_track', {}) - original_name = original_track.get('name', spotify_track['name']) - original_artist = '' - original_artists = original_track.get('artists', []) - if original_artists: - original_artist = original_artists[0] if isinstance(original_artists[0], str) else original_artists[0].get('name', '') - - cache_key = _get_discovery_cache_key(original_name, original_artist) - # Normalize artists to plain strings for cache consistency - artists_list = spotify_track['artists'] - if isinstance(artists_list, list): - artists_list = [a if isinstance(a, str) else a.get('name', '') for a in artists_list] - # Preserve cover image info so the download pipeline can find - # artwork when this cached match is used later. The fix modal - # sends image_url at the top level; search results often return - # album as a bare string, which previously dropped the artwork. - image_url = spotify_track.get('image_url') or '' - album_raw = spotify_track.get('album', '') - if isinstance(album_raw, dict): - album_obj = dict(album_raw) - if image_url and not album_obj.get('image_url'): - album_obj['image_url'] = image_url - if image_url and not album_obj.get('images'): - album_obj['images'] = [{'url': image_url}] - else: - album_obj = {'name': album_raw or ''} - if image_url: - album_obj['image_url'] = image_url - album_obj['images'] = [{'url': image_url}] - - matched_data = { - 'id': spotify_track['id'], - 'name': spotify_track['name'], - 'artists': artists_list, - 'album': album_obj, - 'duration_ms': spotify_track.get('duration_ms', 0), - 'image_url': image_url, - 'source': 'spotify', - } - cache_db = get_database() - cache_db.save_discovery_cache_match( - cache_key[0], cache_key[1], _get_active_discovery_source(), 1.0, matched_data, - original_name, original_artist - ) - logger.info(f"Manual fix saved to discovery cache: {original_name} by {original_artist}") - except Exception as cache_err: - logger.error(f"Error saving manual fix to discovery cache: {cache_err}") - - return jsonify({'success': True, 'result': result}) - - except Exception as e: - logger.error(f"Error updating Tidal discovery match: {e}") - return jsonify({'error': str(e)}), 500 + return _update_source_discovery_match(tidal_discovery_states, "tidal", "Tidal", "tidal_track", _first_artist_str_or_obj) @app.route('/api/tidal/playlists/states', methods=['GET']) def get_tidal_playlist_states(): """Get all stored Tidal playlist discovery states for frontend hydration (similar to YouTube playlists)""" - try: - states = [] - current_time = time.time() - - for playlist_id, state in tidal_discovery_states.items(): - # Update access time when requested - state['last_accessed'] = current_time - - # Return essential data for card state recreation - state_info = { - 'playlist_id': playlist_id, - 'phase': state['phase'], - 'status': state['status'], - 'discovery_progress': state['discovery_progress'], - 'spotify_matches': state['spotify_matches'], - 'spotify_total': state['spotify_total'], - 'discovery_results': state['discovery_results'], - 'converted_spotify_playlist_id': state.get('converted_spotify_playlist_id'), - 'download_process_id': state.get('download_process_id'), - 'last_accessed': state['last_accessed'] - } - states.append(state_info) - - logger.info(f"Returning {len(states)} stored Tidal playlist states for hydration") - return jsonify({"states": states}) - - except Exception as e: - logger.error(f"Error getting Tidal playlist states: {e}") - return jsonify({"error": str(e)}), 500 + return _get_source_playlist_states(tidal_discovery_states, "Tidal", "Tidal") @app.route('/api/tidal/state/', methods=['GET']) def get_tidal_playlist_state(playlist_id): @@ -20769,87 +20610,17 @@ def get_tidal_playlist_state(playlist_id): @app.route('/api/tidal/reset/', methods=['POST']) def reset_tidal_playlist(playlist_id): """Reset Tidal playlist to fresh phase (clear discovery/sync data)""" - try: - if playlist_id not in tidal_discovery_states: - return jsonify({"error": "Tidal playlist not found"}), 404 - - state = tidal_discovery_states[playlist_id] - - # Stop any active discovery - if 'discovery_future' in state and state['discovery_future']: - state['discovery_future'].cancel() - - # Reset state to fresh (preserve original playlist data) - state['phase'] = 'fresh' - state['status'] = 'fresh' - state['discovery_results'] = [] - state['discovery_progress'] = 0 - state['spotify_matches'] = 0 - state['sync_playlist_id'] = None - state['converted_spotify_playlist_id'] = None - state['download_process_id'] = None - state['sync_progress'] = {} - state['discovery_future'] = None - state['last_accessed'] = time.time() - - logger.info(f"Reset Tidal playlist to fresh: {playlist_id}") - return jsonify({"success": True, "message": "Playlist reset to fresh phase"}) - - except Exception as e: - logger.error(f"Error resetting Tidal playlist: {e}") - return jsonify({"error": str(e)}), 500 + return _reset_source_playlist(tidal_discovery_states, playlist_id, "Tidal", "Tidal playlist not found") @app.route('/api/tidal/delete/', methods=['POST']) def delete_tidal_playlist(playlist_id): """Delete Tidal playlist state completely""" - try: - if playlist_id not in tidal_discovery_states: - return jsonify({"error": "Tidal playlist not found"}), 404 - - state = tidal_discovery_states[playlist_id] - - # Stop any active discovery - if 'discovery_future' in state and state['discovery_future']: - state['discovery_future'].cancel() - - # Remove from state dictionary - del tidal_discovery_states[playlist_id] - - logger.info(f"Deleted Tidal playlist state: {playlist_id}") - return jsonify({"success": True, "message": "Playlist deleted"}) - - except Exception as e: - logger.error(f"Error deleting Tidal playlist: {e}") - return jsonify({"error": str(e)}), 500 + return _delete_source_playlist(tidal_discovery_states, playlist_id, "Tidal", "Tidal playlist not found") @app.route('/api/tidal/update_phase/', methods=['POST']) def update_tidal_playlist_phase(playlist_id): """Update Tidal playlist phase (used when modal closes to reset from download_complete to discovered)""" - try: - if playlist_id not in tidal_discovery_states: - return jsonify({"error": "Tidal playlist not found"}), 404 - - data = request.get_json() - if not data or 'phase' not in data: - return jsonify({"error": "Phase not provided"}), 400 - - new_phase = data['phase'] - valid_phases = ['fresh', 'discovering', 'discovered', 'syncing', 'sync_complete', 'downloading', 'download_complete'] - - if new_phase not in valid_phases: - return jsonify({"error": f"Invalid phase. Must be one of: {', '.join(valid_phases)}"}), 400 - - state = tidal_discovery_states[playlist_id] - old_phase = state.get('phase', 'unknown') - state['phase'] = new_phase - state['last_accessed'] = time.time() - - logger.info(f"Updated Tidal playlist {playlist_id} phase: {old_phase} → {new_phase}") - return jsonify({"success": True, "message": f"Phase updated to {new_phase}", "old_phase": old_phase, "new_phase": new_phase}) - - except Exception as e: - logger.error(f"Error updating Tidal playlist phase: {e}") - return jsonify({"error": str(e)}), 500 + return _update_source_playlist_phase(tidal_discovery_states, playlist_id, "Tidal playlist not found", "Tidal", _PHASE_LIST, False) _playlist_discovery_cancelled = set() # Set of automation_ids that have been cancelled @@ -21073,6 +20844,159 @@ from core.discovery.scoring import ( # Tidal discovery worker logic lives in core/discovery/tidal.py. from core.discovery import tidal as _discovery_tidal +# Source-agnostic discovery route helpers (lifted from the per-source copies). +from core.discovery.endpoints import ( + convert_results_to_spotify_tracks, + cancel_sync as _cancel_sync_core, + delete_playlist_state as _delete_playlist_state_core, + get_sync_status as _get_sync_status_core, + get_discovery_status as _get_discovery_status_core, + reset_playlist as _reset_playlist_core, + get_playlist_states as _get_playlist_states_core, + start_sync as _start_sync_core, + update_discovery_match as _update_discovery_match_core, + update_playlist_phase as _update_playlist_phase_core, + save_bubble_snapshot as _save_bubble_snapshot_core, + playlist_name_attr_or_unknown as _pl_name_attr_or_unknown, + playlist_name_strict as _pl_name_strict, + playlist_name_safe as _pl_name_safe, + playlist_name_obj as _pl_name_obj, + playlist_image_obj as _pl_image_obj, + playlist_image_dict as _pl_image_dict, + first_artist_str_or_obj as _first_artist_str_or_obj, + first_artist_plain as _first_artist_plain, +) + + +def _cancel_source_sync(states, key, label, not_found_message): + """Thin glue: wire web_server's sync infra into the lifted cancel_sync + helper and jsonify the result. Used by the per-source cancel routes.""" + body, code = _cancel_sync_core( + states, key, label=label, not_found_message=not_found_message, + sync_lock=sync_lock, sync_states=sync_states, + active_sync_workers=active_sync_workers, + ) + return jsonify(body), code + + +def _delete_source_playlist(states, key, label, not_found_message): + """Thin glue for the per-source delete routes that share the identical + delete body (Tidal/Deezer/Qobuz/Spotify-Public).""" + body, code = _delete_playlist_state_core( + states, key, label=label, not_found_message=not_found_message, + ) + return jsonify(body), code + + +def _get_source_sync_status(states, key, not_found_message, error_label, + activity_subject, name_getter): + """Thin glue for the per-source get_*_sync_status routes — wires the sync + infra + add_activity_item into the lifted helper and jsonifies.""" + body, code = _get_sync_status_core( + states, key, not_found_message=not_found_message, error_label=error_label, + activity_subject=activity_subject, playlist_name_getter=name_getter, + sync_lock=sync_lock, sync_states=sync_states, add_activity_item=add_activity_item, + ) + return jsonify(body), code + + +def _get_source_discovery_status(states, key, not_found_message, error_label): + """Thin glue for the per-source get_*_discovery_status routes.""" + body, code = _get_discovery_status_core( + states, key, not_found_message=not_found_message, error_label=error_label, + ) + return jsonify(body), code + + +def _reset_source_playlist(states, key, label, not_found_message): + """Thin glue for the per-source reset routes that share the identical + reset body (Tidal/Deezer/Qobuz/Spotify-Public).""" + body, code = _reset_playlist_core( + states, key, label=label, not_found_message=not_found_message, + ) + return jsonify(body), code + + +def _get_source_playlist_states(states, error_label, info_log_label=None): + """Thin glue for the per-source get_*_playlist_states bulk-hydration routes + (Tidal/Deezer/Qobuz/Spotify-Public/iTunes-Link).""" + body, code = _get_playlist_states_core( + states, error_label=error_label, info_log_label=info_log_label, + ) + return jsonify(body), code + + +def _submit_sync_task(sync_playlist_id, playlist_name, spotify_tracks, playlist_image_url): + """Submit a sync to the shared executor (closes over sync_executor / + _run_sync_task / get_current_profile_id so the lifted start_sync helper + stays free of those globals).""" + return sync_executor.submit( + _run_sync_task, sync_playlist_id, playlist_name, spotify_tracks, + None, get_current_profile_id(), playlist_image_url, + ) + + +def _start_source_sync(states, key, *, sync_id_prefix, not_found_message, + not_ready_message, convert_fn, name_getter, image_getter, + activity_label, error_label): + """Thin glue for the per-source start_*_sync routes (Tidal/Deezer/Qobuz/ + Spotify-Public/YouTube) — wires sync infra into the lifted start_sync.""" + body, code = _start_sync_core( + states, key, sync_id_prefix=sync_id_prefix, + not_found_message=not_found_message, not_ready_message=not_ready_message, + convert_fn=convert_fn, playlist_name_getter=name_getter, + playlist_image_getter=image_getter, activity_label=activity_label, + error_label=error_label, sync_lock=sync_lock, sync_states=sync_states, + active_sync_workers=active_sync_workers, submit_sync_task=_submit_sync_task, + add_activity_item=add_activity_item, + ) + return jsonify(body), code + + +def _update_source_discovery_match(states, source_log_label, error_label, + original_track_key, artist_getter): + """Thin glue for the per-source update_*_discovery_match (fix-modal) routes + (Tidal/Deezer/Qobuz/Spotify-Public) — injects the web_server helpers.""" + body, code = _update_discovery_match_core( + states, lambda: request.get_json(), + source_log_label=source_log_label, error_label=error_label, + original_track_key=original_track_key, original_artist_getter=artist_getter, + join_artist_names=_join_artist_names, extract_artist_name=_extract_artist_name, + build_fix_modal_spotify_data=_build_fix_modal_spotify_data, + get_discovery_cache_key=_get_discovery_cache_key, get_database=get_database, + get_active_discovery_source=_get_active_discovery_source, + ) + return jsonify(body), code + + +# Valid phase lists for update_*_playlist_phase (YouTube additionally allows 'parsed'). +_PHASE_LIST = ['fresh', 'discovering', 'discovered', 'syncing', 'sync_complete', 'downloading', 'download_complete'] +_PHASE_LIST_YT = ['fresh', 'parsed', 'discovering', 'discovered', 'syncing', 'sync_complete', 'downloading', 'download_complete'] + + +def _update_source_playlist_phase(states, key, not_found_message, error_label, + valid_phases, apply_extra_fields): + """Thin glue for the per-source update_*_playlist_phase routes + (Tidal/Deezer/Qobuz/Spotify-Public/YouTube).""" + body, code = _update_playlist_phase_core( + states, key, lambda: request.get_json(), + not_found_message=not_found_message, error_label=error_label, + valid_phases=valid_phases, apply_extra_fields=apply_extra_fields, + ) + return jsonify(body), code + + +def _save_source_bubble_snapshot(payload_key, no_data_error, snapshot_kind, + success_noun, log_subject, log_noun): + """Thin glue for the snapshot routes (discover_downloads / artist_bubbles / + search_bubbles / beatport_bubbles).""" + body, code = _save_bubble_snapshot_core( + lambda: request.json, payload_key=payload_key, no_data_error=no_data_error, + snapshot_kind=snapshot_kind, success_noun=success_noun, log_subject=log_subject, + log_noun=log_noun, get_database=get_database, + get_current_profile_id=get_current_profile_id, + ) + return jsonify(body), code def _build_tidal_discovery_deps(): @@ -21103,39 +21027,7 @@ def _run_tidal_discovery_worker(playlist_id): def convert_tidal_results_to_spotify_tracks(discovery_results): """Convert Tidal discovery results to Spotify tracks format for sync""" - spotify_tracks = [] - - 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'] - - # Create track object matching the expected format - 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': - # Build from individual fields (automatic discovery format) - track = { - '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 - } - spotify_tracks.append(track) - - logger.info(f"Converted {len(spotify_tracks)} Tidal matches to Spotify tracks for sync") - return spotify_tracks + return convert_results_to_spotify_tracks(discovery_results, "Tidal") # =================================================================== @@ -21145,132 +21037,23 @@ def convert_tidal_results_to_spotify_tracks(discovery_results): @app.route('/api/tidal/sync/start/', methods=['POST']) def start_tidal_sync(playlist_id): """Start sync process for a Tidal playlist using discovered Spotify tracks""" - try: - if playlist_id not in tidal_discovery_states: - return jsonify({"error": "Tidal playlist not found"}), 404 - - state = tidal_discovery_states[playlist_id] - state['last_accessed'] = time.time() # Update access time - - if state['phase'] not in ['discovered', 'sync_complete', 'download_complete']: - return jsonify({"error": "Tidal playlist not ready for sync"}), 400 - - # Convert discovery results to Spotify tracks format - spotify_tracks = convert_tidal_results_to_spotify_tracks(state['discovery_results']) - - if not spotify_tracks: - return jsonify({"error": "No Spotify matches found for sync"}), 400 - - # Create a temporary playlist ID for sync tracking - sync_playlist_id = f"tidal_{playlist_id}" - playlist_name = state['playlist'].name # Tidal playlist object has .name attribute - - # Add activity for sync start - add_activity_item("", "Tidal Sync Started", f"'{playlist_name}' - {len(spotify_tracks)} tracks", "Now") - - # Update Tidal state - state['phase'] = 'syncing' - state['sync_playlist_id'] = sync_playlist_id - state['sync_progress'] = {} - - # Start the sync using existing sync infrastructure - sync_data = { - 'playlist_id': sync_playlist_id, - 'playlist_name': playlist_name, - 'tracks': spotify_tracks - } - - with sync_lock: - sync_states[sync_playlist_id] = {"status": "starting", "progress": {}} - - # Submit sync task - playlist_image_url = getattr(state['playlist'], 'image_url', '') - future = sync_executor.submit(_run_sync_task, sync_playlist_id, sync_data['playlist_name'], spotify_tracks, None, get_current_profile_id(), playlist_image_url) - active_sync_workers[sync_playlist_id] = future - - logger.info(f"Started Tidal sync for: {playlist_name} ({len(spotify_tracks)} tracks)") - return jsonify({"success": True, "sync_playlist_id": sync_playlist_id}) - - except Exception as e: - logger.error(f"Error starting Tidal sync: {e}") - return jsonify({"error": str(e)}), 500 + return _start_source_sync( + tidal_discovery_states, playlist_id, sync_id_prefix="tidal", + not_found_message="Tidal playlist not found", + not_ready_message="Tidal playlist not ready for sync", + convert_fn=convert_tidal_results_to_spotify_tracks, + name_getter=_pl_name_obj, image_getter=_pl_image_obj, + activity_label="Tidal", error_label="Tidal") @app.route('/api/tidal/sync/status/', methods=['GET']) def get_tidal_sync_status(playlist_id): """Get sync status for a Tidal playlist""" - try: - if playlist_id not in tidal_discovery_states: - return jsonify({"error": "Tidal playlist not found"}), 404 - - state = tidal_discovery_states[playlist_id] - state['last_accessed'] = time.time() # Update access time - sync_playlist_id = state.get('sync_playlist_id') - - if not sync_playlist_id: - return jsonify({"error": "No sync in progress"}), 404 - - # Get sync status from existing sync infrastructure - with sync_lock: - sync_state = sync_states.get(sync_playlist_id, {}) - - response = { - 'phase': state['phase'], - 'sync_status': sync_state.get('status', 'unknown'), - 'progress': sync_state.get('progress', {}), - 'complete': sync_state.get('status') == 'finished', - 'error': sync_state.get('error') - } - - # Update Tidal state if sync completed - if sync_state.get('status') == 'finished': - state['phase'] = 'sync_complete' - state['sync_progress'] = sync_state.get('progress', {}) - # Add activity for sync completion - playlist = state.get('playlist') - playlist_name = playlist.name if playlist and hasattr(playlist, 'name') else 'Unknown Playlist' - add_activity_item("", "Sync Complete", f"Tidal playlist '{playlist_name}' synced successfully", "Now") - elif sync_state.get('status') == 'error': - state['phase'] = 'discovered' # Revert on error - playlist = state.get('playlist') - playlist_name = playlist.name if playlist and hasattr(playlist, 'name') else 'Unknown Playlist' - add_activity_item("", "Sync Failed", f"Tidal playlist '{playlist_name}' sync failed", "Now") - - return jsonify(response) - - except Exception as e: - logger.error(f"Error getting Tidal sync status: {e}") - return jsonify({"error": str(e)}), 500 + return _get_source_sync_status(tidal_discovery_states, playlist_id, "Tidal playlist not found", "Tidal", "Tidal playlist", _pl_name_attr_or_unknown) @app.route('/api/tidal/sync/cancel/', methods=['POST']) def cancel_tidal_sync(playlist_id): """Cancel sync for a Tidal playlist""" - try: - if playlist_id not in tidal_discovery_states: - return jsonify({"error": "Tidal playlist not found"}), 404 - - state = tidal_discovery_states[playlist_id] - state['last_accessed'] = time.time() # Update access time - sync_playlist_id = state.get('sync_playlist_id') - - if sync_playlist_id: - # Cancel the sync using existing sync infrastructure - with sync_lock: - sync_states[sync_playlist_id] = {"status": "cancelled"} - - # Clean up sync worker - if sync_playlist_id in active_sync_workers: - del active_sync_workers[sync_playlist_id] - - # Revert Tidal state - state['phase'] = 'discovered' - state['sync_playlist_id'] = None - state['sync_progress'] = {} - - return jsonify({"success": True, "message": "Tidal sync cancelled"}) - - except Exception as e: - logger.error(f"Error cancelling Tidal sync: {e}") - return jsonify({"error": str(e)}), 500 + return _cancel_source_sync(tidal_discovery_states, playlist_id, "Tidal", "Tidal playlist not found") # =================================================================== @@ -21493,172 +21276,17 @@ def start_deezer_discovery(playlist_id): @app.route('/api/deezer/discovery/status/', methods=['GET']) def get_deezer_discovery_status(playlist_id): """Get real-time discovery status for a Deezer playlist""" - try: - if playlist_id not in deezer_discovery_states: - return jsonify({"error": "Deezer discovery not found"}), 404 - - state = deezer_discovery_states[playlist_id] - state['last_accessed'] = time.time() - - response = { - 'phase': state['phase'], - 'status': state['status'], - 'progress': state['discovery_progress'], - 'spotify_matches': state['spotify_matches'], - 'spotify_total': state['spotify_total'], - 'results': state['discovery_results'], - 'complete': state['phase'] == 'discovered' - } - - return jsonify(response) - - except Exception as e: - logger.error(f"Error getting Deezer discovery status: {e}") - return jsonify({"error": str(e)}), 500 + return _get_source_discovery_status(deezer_discovery_states, playlist_id, "Deezer discovery not found", "Deezer") @app.route('/api/deezer/discovery/update_match', methods=['POST']) def update_deezer_discovery_match(): """Update a Deezer discovery result with manually selected Spotify track""" - try: - data = request.get_json() - identifier = data.get('identifier') # playlist_id - track_index = data.get('track_index') - spotify_track = data.get('spotify_track') - - if not identifier or track_index is None or not spotify_track: - return jsonify({'error': 'Missing required fields'}), 400 - - # Get the state - state = deezer_discovery_states.get(identifier) - - if not state: - return jsonify({'error': 'Discovery state not found'}), 404 - - if track_index >= len(state['discovery_results']): - return jsonify({'error': 'Invalid track index'}), 400 - - # Update the result - result = state['discovery_results'][track_index] - old_status = result.get('status') - - # Update with user-selected track - result['status'] = 'Found' - result['status_class'] = 'found' - result['spotify_track'] = spotify_track['name'] - result['spotify_artist'] = _join_artist_names(spotify_track['artists']) if isinstance(spotify_track['artists'], list) else _extract_artist_name(spotify_track['artists']) - result['spotify_album'] = spotify_track['album'] - result['spotify_id'] = spotify_track['id'] - - # Format duration - duration_ms = spotify_track.get('duration_ms', 0) - if duration_ms: - minutes = duration_ms // 60000 - seconds = (duration_ms % 60000) // 1000 - result['duration'] = f"{minutes}:{seconds:02d}" - else: - result['duration'] = '0:00' - - # IMPORTANT: Also set spotify_data for sync/download compatibility. - # Manual match from the fix modal — build a rich spotify_data (album - # as dict with image info) matching the normal discovery shape, and - # explicitly clear any prior wing-it flag since the user picked a - # real metadata match. - result['spotify_data'] = _build_fix_modal_spotify_data(spotify_track) - result['wing_it_fallback'] = False - - result['manual_match'] = True - - # Update match count if status changed from not found/error - if old_status != 'found' and old_status != 'Found': - state['spotify_matches'] = state.get('spotify_matches', 0) + 1 - - logger.info(f"Manual match updated: deezer - {identifier} - track {track_index}") - logger.info(f" → {result['spotify_artist']} - {result['spotify_track']}") - - # Save manual fix to discovery cache so it appears in discovery pool - try: - original_track = result.get('deezer_track', {}) - original_name = original_track.get('name', spotify_track['name']) - original_artists = original_track.get('artists', []) - original_artist = original_artists[0] if original_artists else '' - - cache_key = _get_discovery_cache_key(original_name, original_artist) - # Normalize artists to plain strings for cache consistency - artists_list = spotify_track['artists'] - if isinstance(artists_list, list): - artists_list = [a if isinstance(a, str) else a.get('name', '') for a in artists_list] - # Preserve cover image info so the download pipeline can find - # artwork when this cached match is used later. The fix modal - # sends image_url at the top level; search results often return - # album as a bare string, which previously dropped the artwork. - image_url = spotify_track.get('image_url') or '' - album_raw = spotify_track.get('album', '') - if isinstance(album_raw, dict): - album_obj = dict(album_raw) - if image_url and not album_obj.get('image_url'): - album_obj['image_url'] = image_url - if image_url and not album_obj.get('images'): - album_obj['images'] = [{'url': image_url}] - else: - album_obj = {'name': album_raw or ''} - if image_url: - album_obj['image_url'] = image_url - album_obj['images'] = [{'url': image_url}] - - matched_data = { - 'id': spotify_track['id'], - 'name': spotify_track['name'], - 'artists': artists_list, - 'album': album_obj, - 'duration_ms': spotify_track.get('duration_ms', 0), - 'image_url': image_url, - 'source': 'spotify', - } - cache_db = get_database() - cache_db.save_discovery_cache_match( - cache_key[0], cache_key[1], _get_active_discovery_source(), 1.0, matched_data, - original_name, original_artist - ) - logger.info(f"Manual fix saved to discovery cache: {original_name} by {original_artist}") - except Exception as cache_err: - logger.error(f"Error saving manual fix to discovery cache: {cache_err}") - - return jsonify({'success': True, 'result': result}) - - except Exception as e: - logger.error(f"Error updating Deezer discovery match: {e}") - return jsonify({'error': str(e)}), 500 + return _update_source_discovery_match(deezer_discovery_states, "deezer", "Deezer", "deezer_track", _first_artist_plain) @app.route('/api/deezer/playlists/states', methods=['GET']) def get_deezer_playlist_states(): """Get all stored Deezer playlist discovery states for frontend hydration""" - try: - states = [] - current_time = time.time() - - for playlist_id, state in deezer_discovery_states.items(): - state['last_accessed'] = current_time - - state_info = { - 'playlist_id': playlist_id, - 'phase': state['phase'], - 'status': state['status'], - 'discovery_progress': state['discovery_progress'], - 'spotify_matches': state['spotify_matches'], - 'spotify_total': state['spotify_total'], - 'discovery_results': state['discovery_results'], - 'converted_spotify_playlist_id': state.get('converted_spotify_playlist_id'), - 'download_process_id': state.get('download_process_id'), - 'last_accessed': state['last_accessed'] - } - states.append(state_info) - - logger.info(f"Returning {len(states)} stored Deezer playlist states for hydration") - return jsonify({"states": states}) - - except Exception as e: - logger.error(f"Error getting Deezer playlist states: {e}") - return jsonify({"error": str(e)}), 500 + return _get_source_playlist_states(deezer_discovery_states, "Deezer", "Deezer") @app.route('/api/deezer/state/', methods=['GET']) def get_deezer_playlist_state(playlist_id): @@ -21696,95 +21324,17 @@ def get_deezer_playlist_state(playlist_id): @app.route('/api/deezer/reset/', methods=['POST']) def reset_deezer_playlist(playlist_id): """Reset Deezer playlist to fresh phase (clear discovery/sync data)""" - try: - if playlist_id not in deezer_discovery_states: - return jsonify({"error": "Deezer playlist not found"}), 404 - - state = deezer_discovery_states[playlist_id] - - # Stop any active discovery - if 'discovery_future' in state and state['discovery_future']: - state['discovery_future'].cancel() - - # Reset state to fresh (preserve original playlist data) - state['phase'] = 'fresh' - state['status'] = 'fresh' - state['discovery_results'] = [] - state['discovery_progress'] = 0 - state['spotify_matches'] = 0 - state['sync_playlist_id'] = None - state['converted_spotify_playlist_id'] = None - state['download_process_id'] = None - state['sync_progress'] = {} - state['discovery_future'] = None - state['last_accessed'] = time.time() - - logger.info(f"Reset Deezer playlist to fresh: {playlist_id}") - return jsonify({"success": True, "message": "Playlist reset to fresh phase"}) - - except Exception as e: - logger.error(f"Error resetting Deezer playlist: {e}") - return jsonify({"error": str(e)}), 500 + return _reset_source_playlist(deezer_discovery_states, playlist_id, "Deezer", "Deezer playlist not found") @app.route('/api/deezer/delete/', methods=['POST']) def delete_deezer_playlist(playlist_id): """Delete Deezer playlist state completely""" - try: - if playlist_id not in deezer_discovery_states: - return jsonify({"error": "Deezer playlist not found"}), 404 - - state = deezer_discovery_states[playlist_id] - - # Stop any active discovery - if 'discovery_future' in state and state['discovery_future']: - state['discovery_future'].cancel() - - # Remove from state dictionary - del deezer_discovery_states[playlist_id] - - logger.info(f"Deleted Deezer playlist state: {playlist_id}") - return jsonify({"success": True, "message": "Playlist deleted"}) - - except Exception as e: - logger.error(f"Error deleting Deezer playlist: {e}") - return jsonify({"error": str(e)}), 500 + return _delete_source_playlist(deezer_discovery_states, playlist_id, "Deezer", "Deezer playlist not found") @app.route('/api/deezer/update_phase/', methods=['POST']) def update_deezer_playlist_phase(playlist_id): """Update Deezer playlist phase (used when modal closes to reset from download_complete to discovered)""" - try: - if playlist_id not in deezer_discovery_states: - return jsonify({"error": "Deezer playlist not found"}), 404 - - data = request.get_json() - if not data or 'phase' not in data: - return jsonify({"error": "Phase not provided"}), 400 - - new_phase = data['phase'] - valid_phases = ['fresh', 'discovering', 'discovered', 'syncing', 'sync_complete', 'downloading', 'download_complete'] - - if new_phase not in valid_phases: - return jsonify({"error": f"Invalid phase. Must be one of: {', '.join(valid_phases)}"}), 400 - - state = deezer_discovery_states[playlist_id] - old_phase = state.get('phase', 'unknown') - state['phase'] = new_phase - state['last_accessed'] = time.time() - - # Update download process ID if provided (for download persistence) - if 'download_process_id' in data: - state['download_process_id'] = data['download_process_id'] - - # Update converted Spotify playlist ID if provided (for download persistence) - if 'converted_spotify_playlist_id' in data: - state['converted_spotify_playlist_id'] = data['converted_spotify_playlist_id'] - - logger.info(f"Updated Deezer playlist {playlist_id} phase: {old_phase} → {new_phase}") - return jsonify({"success": True, "message": f"Phase updated to {new_phase}", "old_phase": old_phase, "new_phase": new_phase}) - - except Exception as e: - logger.error(f"Error updating Deezer playlist phase: {e}") - return jsonify({"error": str(e)}), 500 + return _update_source_playlist_phase(deezer_discovery_states, playlist_id, "Deezer playlist not found", "Deezer", _PHASE_LIST, True) # Deezer discovery worker logic lives in core/discovery/deezer.py. @@ -21817,37 +21367,7 @@ def _run_deezer_discovery_worker(playlist_id): def convert_deezer_results_to_spotify_tracks(discovery_results): """Convert Deezer discovery results to Spotify tracks format for sync""" - spotify_tracks = [] - - 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': - track = { - '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 - } - spotify_tracks.append(track) - - logger.info(f"Converted {len(spotify_tracks)} Deezer matches to Spotify tracks for sync") - return spotify_tracks + return convert_results_to_spotify_tracks(discovery_results, "Deezer") # =================================================================== @@ -21857,129 +21377,23 @@ def convert_deezer_results_to_spotify_tracks(discovery_results): @app.route('/api/deezer/sync/start/', methods=['POST']) def start_deezer_sync(playlist_id): """Start sync process for a Deezer playlist using discovered Spotify tracks""" - try: - if playlist_id not in deezer_discovery_states: - return jsonify({"error": "Deezer playlist not found"}), 404 - - state = deezer_discovery_states[playlist_id] - state['last_accessed'] = time.time() - - if state['phase'] not in ['discovered', 'sync_complete', 'download_complete']: - return jsonify({"error": "Deezer playlist not ready for sync"}), 400 - - # Convert discovery results to Spotify tracks format - spotify_tracks = convert_deezer_results_to_spotify_tracks(state['discovery_results']) - - if not spotify_tracks: - return jsonify({"error": "No Spotify matches found for sync"}), 400 - - # Create a temporary playlist ID for sync tracking - sync_playlist_id = f"deezer_{playlist_id}" - playlist_name = state['playlist']['name'] - - # Add activity for sync start - add_activity_item("", "Deezer Sync Started", f"'{playlist_name}' - {len(spotify_tracks)} tracks", "Now") - - # Update Deezer state - state['phase'] = 'syncing' - state['sync_playlist_id'] = sync_playlist_id - state['sync_progress'] = {} - - # Start the sync using existing sync infrastructure - sync_data = { - 'playlist_id': sync_playlist_id, - 'playlist_name': playlist_name, - 'tracks': spotify_tracks - } - - with sync_lock: - sync_states[sync_playlist_id] = {"status": "starting", "progress": {}} - - # Submit sync task - playlist_image_url = state['playlist'].get('image_url', '') - future = sync_executor.submit(_run_sync_task, sync_playlist_id, sync_data['playlist_name'], spotify_tracks, None, get_current_profile_id(), playlist_image_url) - active_sync_workers[sync_playlist_id] = future - - logger.info(f"Started Deezer sync for: {playlist_name} ({len(spotify_tracks)} tracks)") - return jsonify({"success": True, "sync_playlist_id": sync_playlist_id}) - - except Exception as e: - logger.error(f"Error starting Deezer sync: {e}") - return jsonify({"error": str(e)}), 500 + return _start_source_sync( + deezer_discovery_states, playlist_id, sync_id_prefix="deezer", + not_found_message="Deezer playlist not found", + not_ready_message="Deezer playlist not ready for sync", + convert_fn=convert_deezer_results_to_spotify_tracks, + name_getter=_pl_name_strict, image_getter=_pl_image_dict, + activity_label="Deezer", error_label="Deezer") @app.route('/api/deezer/sync/status/', methods=['GET']) def get_deezer_sync_status(playlist_id): """Get sync status for a Deezer playlist""" - try: - if playlist_id not in deezer_discovery_states: - return jsonify({"error": "Deezer playlist not found"}), 404 - - state = deezer_discovery_states[playlist_id] - state['last_accessed'] = time.time() - sync_playlist_id = state.get('sync_playlist_id') - - if not sync_playlist_id: - return jsonify({"error": "No sync in progress"}), 404 - - # Get sync status from existing sync infrastructure - with sync_lock: - sync_state = sync_states.get(sync_playlist_id, {}) - - response = { - 'phase': state['phase'], - 'sync_status': sync_state.get('status', 'unknown'), - 'progress': sync_state.get('progress', {}), - 'complete': sync_state.get('status') == 'finished', - 'error': sync_state.get('error') - } - - # Update Deezer state if sync completed - if sync_state.get('status') == 'finished': - state['phase'] = 'sync_complete' - state['sync_progress'] = sync_state.get('progress', {}) - playlist_name = state['playlist']['name'] - add_activity_item("", "Sync Complete", f"Deezer playlist '{playlist_name}' synced successfully", "Now") - elif sync_state.get('status') == 'error': - state['phase'] = 'discovered' # Revert on error - playlist_name = state['playlist']['name'] - add_activity_item("", "Sync Failed", f"Deezer playlist '{playlist_name}' sync failed", "Now") - - return jsonify(response) - - except Exception as e: - logger.error(f"Error getting Deezer sync status: {e}") - return jsonify({"error": str(e)}), 500 + return _get_source_sync_status(deezer_discovery_states, playlist_id, "Deezer playlist not found", "Deezer", "Deezer playlist", _pl_name_strict) @app.route('/api/deezer/sync/cancel/', methods=['POST']) def cancel_deezer_sync(playlist_id): """Cancel sync for a Deezer playlist""" - try: - if playlist_id not in deezer_discovery_states: - return jsonify({"error": "Deezer playlist not found"}), 404 - - state = deezer_discovery_states[playlist_id] - state['last_accessed'] = time.time() - sync_playlist_id = state.get('sync_playlist_id') - - if sync_playlist_id: - # Cancel the sync using existing sync infrastructure - with sync_lock: - sync_states[sync_playlist_id] = {"status": "cancelled"} - - # Clean up sync worker - if sync_playlist_id in active_sync_workers: - del active_sync_workers[sync_playlist_id] - - # Revert Deezer state - state['phase'] = 'discovered' - state['sync_playlist_id'] = None - state['sync_progress'] = {} - - return jsonify({"success": True, "message": "Deezer sync cancelled"}) - - except Exception as e: - logger.error(f"Error cancelling Deezer sync: {e}") - return jsonify({"error": str(e)}), 500 + return _cancel_source_sync(deezer_discovery_states, playlist_id, "Deezer", "Deezer playlist not found") # =================================================================== @@ -22172,159 +21586,19 @@ def start_qobuz_discovery(playlist_id): @app.route('/api/qobuz/discovery/status/', methods=['GET']) def get_qobuz_discovery_status(playlist_id): """Get real-time discovery status for a Qobuz playlist.""" - try: - if playlist_id not in qobuz_discovery_states: - return jsonify({"error": "Qobuz discovery not found"}), 404 - - state = qobuz_discovery_states[playlist_id] - state['last_accessed'] = time.time() - - response = { - 'phase': state['phase'], - 'status': state['status'], - 'progress': state['discovery_progress'], - 'spotify_matches': state['spotify_matches'], - 'spotify_total': state['spotify_total'], - 'results': state['discovery_results'], - 'complete': state['phase'] == 'discovered' - } - - return jsonify(response) - - except Exception as e: - logger.error(f"Error getting Qobuz discovery status: {e}") - return jsonify({"error": str(e)}), 500 + return _get_source_discovery_status(qobuz_discovery_states, playlist_id, "Qobuz discovery not found", "Qobuz") @app.route('/api/qobuz/discovery/update_match', methods=['POST']) def update_qobuz_discovery_match(): - """Update a Qobuz discovery result with a manually selected Spotify track.""" - try: - data = request.get_json() - identifier = data.get('identifier') # playlist_id - track_index = data.get('track_index') - spotify_track = data.get('spotify_track') - - if not identifier or track_index is None or not spotify_track: - return jsonify({'error': 'Missing required fields'}), 400 - - state = qobuz_discovery_states.get(identifier) - if not state: - return jsonify({'error': 'Discovery state not found'}), 404 - - if track_index >= len(state['discovery_results']): - return jsonify({'error': 'Invalid track index'}), 400 - - result = state['discovery_results'][track_index] - old_status = result.get('status') - - result['status'] = 'Found' - result['status_class'] = 'found' - result['spotify_track'] = spotify_track['name'] - result['spotify_artist'] = _join_artist_names(spotify_track['artists']) if isinstance(spotify_track['artists'], list) else _extract_artist_name(spotify_track['artists']) - result['spotify_album'] = spotify_track['album'] - result['spotify_id'] = spotify_track['id'] - - duration_ms = spotify_track.get('duration_ms', 0) - if duration_ms: - minutes = duration_ms // 60000 - seconds = (duration_ms % 60000) // 1000 - result['duration'] = f"{minutes}:{seconds:02d}" - else: - result['duration'] = '0:00' - - # Manual match from the fix modal — build rich spotify_data matching - # the normal discovery shape, clear wing-it flag since the user - # picked a real metadata match. - result['spotify_data'] = _build_fix_modal_spotify_data(spotify_track) - result['wing_it_fallback'] = False - result['manual_match'] = True - - if old_status != 'found' and old_status != 'Found': - state['spotify_matches'] = state.get('spotify_matches', 0) + 1 - - logger.info(f"Manual match updated: qobuz - {identifier} - track {track_index}") - logger.info(f" → {result['spotify_artist']} - {result['spotify_track']}") - - try: - original_track = result.get('qobuz_track', {}) - original_name = original_track.get('name', spotify_track['name']) - original_artists = original_track.get('artists', []) - original_artist = original_artists[0] if original_artists else '' - - cache_key = _get_discovery_cache_key(original_name, original_artist) - artists_list = spotify_track['artists'] - if isinstance(artists_list, list): - artists_list = [a if isinstance(a, str) else a.get('name', '') for a in artists_list] - image_url = spotify_track.get('image_url') or '' - album_raw = spotify_track.get('album', '') - if isinstance(album_raw, dict): - album_obj = dict(album_raw) - if image_url and not album_obj.get('image_url'): - album_obj['image_url'] = image_url - if image_url and not album_obj.get('images'): - album_obj['images'] = [{'url': image_url}] - else: - album_obj = {'name': album_raw or ''} - if image_url: - album_obj['image_url'] = image_url - album_obj['images'] = [{'url': image_url}] - - matched_data = { - 'id': spotify_track['id'], - 'name': spotify_track['name'], - 'artists': artists_list, - 'album': album_obj, - 'duration_ms': spotify_track.get('duration_ms', 0), - 'image_url': image_url, - 'source': 'spotify', - } - cache_db = get_database() - cache_db.save_discovery_cache_match( - cache_key[0], cache_key[1], _get_active_discovery_source(), 1.0, matched_data, - original_name, original_artist - ) - logger.info(f"Manual fix saved to discovery cache: {original_name} by {original_artist}") - except Exception as cache_err: - logger.error(f"Error saving manual fix to discovery cache: {cache_err}") - - return jsonify({'success': True, 'result': result}) - - except Exception as e: - logger.error(f"Error updating Qobuz discovery match: {e}") - return jsonify({'error': str(e)}), 500 + """Update a Qobuz discovery result with manually selected Spotify track""" + return _update_source_discovery_match(qobuz_discovery_states, "qobuz", "Qobuz", "qobuz_track", _first_artist_plain) @app.route('/api/qobuz/playlists/states', methods=['GET']) def get_qobuz_playlist_states(): """Get all stored Qobuz playlist discovery states for frontend hydration.""" - try: - states = [] - current_time = time.time() - - for playlist_id, state in qobuz_discovery_states.items(): - state['last_accessed'] = current_time - - state_info = { - 'playlist_id': playlist_id, - 'phase': state['phase'], - 'status': state['status'], - 'discovery_progress': state['discovery_progress'], - 'spotify_matches': state['spotify_matches'], - 'spotify_total': state['spotify_total'], - 'discovery_results': state['discovery_results'], - 'converted_spotify_playlist_id': state.get('converted_spotify_playlist_id'), - 'download_process_id': state.get('download_process_id'), - 'last_accessed': state['last_accessed'] - } - states.append(state_info) - - logger.info(f"Returning {len(states)} stored Qobuz playlist states for hydration") - return jsonify({"states": states}) - - except Exception as e: - logger.error(f"Error getting Qobuz playlist states: {e}") - return jsonify({"error": str(e)}), 500 + return _get_source_playlist_states(qobuz_discovery_states, "Qobuz", "Qobuz") @app.route('/api/qobuz/state/', methods=['GET']) @@ -22363,90 +21637,19 @@ def get_qobuz_playlist_state(playlist_id): @app.route('/api/qobuz/reset/', methods=['POST']) def reset_qobuz_playlist(playlist_id): """Reset Qobuz playlist to fresh phase (clear discovery/sync data).""" - try: - if playlist_id not in qobuz_discovery_states: - return jsonify({"error": "Qobuz playlist not found"}), 404 - - state = qobuz_discovery_states[playlist_id] - - if 'discovery_future' in state and state['discovery_future']: - state['discovery_future'].cancel() - - state['phase'] = 'fresh' - state['status'] = 'fresh' - state['discovery_results'] = [] - state['discovery_progress'] = 0 - state['spotify_matches'] = 0 - state['sync_playlist_id'] = None - state['converted_spotify_playlist_id'] = None - state['download_process_id'] = None - state['sync_progress'] = {} - state['discovery_future'] = None - state['last_accessed'] = time.time() - - logger.info(f"Reset Qobuz playlist to fresh: {playlist_id}") - return jsonify({"success": True, "message": "Playlist reset to fresh phase"}) - - except Exception as e: - logger.error(f"Error resetting Qobuz playlist: {e}") - return jsonify({"error": str(e)}), 500 + return _reset_source_playlist(qobuz_discovery_states, playlist_id, "Qobuz", "Qobuz playlist not found") @app.route('/api/qobuz/delete/', methods=['POST']) def delete_qobuz_playlist(playlist_id): """Delete Qobuz playlist state completely.""" - try: - if playlist_id not in qobuz_discovery_states: - return jsonify({"error": "Qobuz playlist not found"}), 404 - - state = qobuz_discovery_states[playlist_id] - - if 'discovery_future' in state and state['discovery_future']: - state['discovery_future'].cancel() - - del qobuz_discovery_states[playlist_id] - - logger.info(f"Deleted Qobuz playlist state: {playlist_id}") - return jsonify({"success": True, "message": "Playlist deleted"}) - - except Exception as e: - logger.error(f"Error deleting Qobuz playlist: {e}") - return jsonify({"error": str(e)}), 500 + return _delete_source_playlist(qobuz_discovery_states, playlist_id, "Qobuz", "Qobuz playlist not found") @app.route('/api/qobuz/update_phase/', methods=['POST']) def update_qobuz_playlist_phase(playlist_id): """Update Qobuz playlist phase (used when modal closes to reset from download_complete to discovered).""" - try: - if playlist_id not in qobuz_discovery_states: - return jsonify({"error": "Qobuz playlist not found"}), 404 - - data = request.get_json() - if not data or 'phase' not in data: - return jsonify({"error": "Phase not provided"}), 400 - - new_phase = data['phase'] - valid_phases = ['fresh', 'discovering', 'discovered', 'syncing', 'sync_complete', 'downloading', 'download_complete'] - - if new_phase not in valid_phases: - return jsonify({"error": f"Invalid phase. Must be one of: {', '.join(valid_phases)}"}), 400 - - state = qobuz_discovery_states[playlist_id] - old_phase = state.get('phase', 'unknown') - state['phase'] = new_phase - state['last_accessed'] = time.time() - - if 'download_process_id' in data: - state['download_process_id'] = data['download_process_id'] - if 'converted_spotify_playlist_id' in data: - state['converted_spotify_playlist_id'] = data['converted_spotify_playlist_id'] - - logger.info(f"Updated Qobuz playlist {playlist_id} phase: {old_phase} → {new_phase}") - return jsonify({"success": True, "message": f"Phase updated to {new_phase}", "old_phase": old_phase, "new_phase": new_phase}) - - except Exception as e: - logger.error(f"Error updating Qobuz playlist phase: {e}") - return jsonify({"error": str(e)}), 500 + return _update_source_playlist_phase(qobuz_discovery_states, playlist_id, "Qobuz playlist not found", "Qobuz", _PHASE_LIST, True) # Qobuz discovery worker logic lives in core/discovery/qobuz.py. @@ -22478,36 +21681,7 @@ def _run_qobuz_discovery_worker(playlist_id): def convert_qobuz_results_to_spotify_tracks(discovery_results): """Convert Qobuz discovery results to Spotify tracks format for sync.""" - spotify_tracks = [] - - for result in discovery_results: - 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': - track = { - '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 - } - spotify_tracks.append(track) - - logger.info(f"Converted {len(spotify_tracks)} Qobuz matches to Spotify tracks for sync") - return spotify_tracks + return convert_results_to_spotify_tracks(discovery_results, "Qobuz") # =================================================================== @@ -22517,118 +21691,25 @@ def convert_qobuz_results_to_spotify_tracks(discovery_results): @app.route('/api/qobuz/sync/start/', methods=['POST']) def start_qobuz_sync(playlist_id): """Start sync process for a Qobuz playlist using discovered Spotify tracks.""" - try: - if playlist_id not in qobuz_discovery_states: - return jsonify({"error": "Qobuz playlist not found"}), 404 - - state = qobuz_discovery_states[playlist_id] - state['last_accessed'] = time.time() - - if state['phase'] not in ['discovered', 'sync_complete', 'download_complete']: - return jsonify({"error": "Qobuz playlist not ready for sync"}), 400 - - spotify_tracks = convert_qobuz_results_to_spotify_tracks(state['discovery_results']) - if not spotify_tracks: - return jsonify({"error": "No Spotify matches found for sync"}), 400 - - sync_playlist_id = f"qobuz_{playlist_id}" - playlist_name = state['playlist']['name'] - - add_activity_item("", "Qobuz Sync Started", f"'{playlist_name}' - {len(spotify_tracks)} tracks", "Now") - - state['phase'] = 'syncing' - state['sync_playlist_id'] = sync_playlist_id - state['sync_progress'] = {} - - sync_data = { - 'playlist_id': sync_playlist_id, - 'playlist_name': playlist_name, - 'tracks': spotify_tracks - } - - with sync_lock: - sync_states[sync_playlist_id] = {"status": "starting", "progress": {}} - - playlist_image_url = state['playlist'].get('image_url', '') - future = sync_executor.submit(_run_sync_task, sync_playlist_id, sync_data['playlist_name'], spotify_tracks, None, get_current_profile_id(), playlist_image_url) - active_sync_workers[sync_playlist_id] = future - - logger.info(f"Started Qobuz sync for: {playlist_name} ({len(spotify_tracks)} tracks)") - return jsonify({"success": True, "sync_playlist_id": sync_playlist_id}) - - except Exception as e: - logger.error(f"Error starting Qobuz sync: {e}") - return jsonify({"error": str(e)}), 500 + return _start_source_sync( + qobuz_discovery_states, playlist_id, sync_id_prefix="qobuz", + not_found_message="Qobuz playlist not found", + not_ready_message="Qobuz playlist not ready for sync", + convert_fn=convert_qobuz_results_to_spotify_tracks, + name_getter=_pl_name_strict, image_getter=_pl_image_dict, + activity_label="Qobuz", error_label="Qobuz") @app.route('/api/qobuz/sync/status/', methods=['GET']) def get_qobuz_sync_status(playlist_id): """Get sync status for a Qobuz playlist.""" - try: - if playlist_id not in qobuz_discovery_states: - return jsonify({"error": "Qobuz playlist not found"}), 404 - - state = qobuz_discovery_states[playlist_id] - state['last_accessed'] = time.time() - sync_playlist_id = state.get('sync_playlist_id') - - if not sync_playlist_id: - return jsonify({"error": "No sync in progress"}), 404 - - with sync_lock: - sync_state = sync_states.get(sync_playlist_id, {}) - - response = { - 'phase': state['phase'], - 'sync_status': sync_state.get('status', 'unknown'), - 'progress': sync_state.get('progress', {}), - 'complete': sync_state.get('status') == 'finished', - 'error': sync_state.get('error') - } - - if sync_state.get('status') == 'finished': - state['phase'] = 'sync_complete' - state['sync_progress'] = sync_state.get('progress', {}) - playlist_name = state['playlist']['name'] - add_activity_item("", "Sync Complete", f"Qobuz playlist '{playlist_name}' synced successfully", "Now") - elif sync_state.get('status') == 'error': - state['phase'] = 'discovered' - playlist_name = state['playlist']['name'] - add_activity_item("", "Sync Failed", f"Qobuz playlist '{playlist_name}' sync failed", "Now") - - return jsonify(response) - - except Exception as e: - logger.error(f"Error getting Qobuz sync status: {e}") - return jsonify({"error": str(e)}), 500 + return _get_source_sync_status(qobuz_discovery_states, playlist_id, "Qobuz playlist not found", "Qobuz", "Qobuz playlist", _pl_name_strict) @app.route('/api/qobuz/sync/cancel/', methods=['POST']) def cancel_qobuz_sync(playlist_id): """Cancel sync for a Qobuz playlist.""" - try: - if playlist_id not in qobuz_discovery_states: - return jsonify({"error": "Qobuz playlist not found"}), 404 - - state = qobuz_discovery_states[playlist_id] - 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 jsonify({"success": True, "message": "Qobuz sync cancelled"}) - - except Exception as e: - logger.error(f"Error cancelling Qobuz sync: {e}") - return jsonify({"error": str(e)}), 500 + return _cancel_source_sync(qobuz_discovery_states, playlist_id, "Qobuz", "Qobuz playlist not found") # =================================================================== @@ -22967,32 +22048,7 @@ def _build_itunes_link_state(response_data): def _convert_link_results_to_spotify_tracks(discovery_results, label): - spotify_tracks = [] - for result in discovery_results: - 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)} {label} matches to Spotify tracks for sync") - return spotify_tracks + return convert_results_to_spotify_tracks(discovery_results, label) @app.route('/api/spotify/parse-public', methods=['POST']) def parse_spotify_public_endpoint(): @@ -23118,172 +22174,17 @@ def start_spotify_public_discovery(url_hash): @app.route('/api/spotify-public/discovery/status/', methods=['GET']) def get_spotify_public_discovery_status(url_hash): """Get real-time discovery status for a Spotify Public playlist""" - try: - if url_hash not in spotify_public_discovery_states: - return jsonify({"error": "Spotify Public discovery not found"}), 404 - - state = spotify_public_discovery_states[url_hash] - state['last_accessed'] = time.time() - - response = { - 'phase': state['phase'], - 'status': state['status'], - 'progress': state['discovery_progress'], - 'spotify_matches': state['spotify_matches'], - 'spotify_total': state['spotify_total'], - 'results': state['discovery_results'], - 'complete': state['phase'] == 'discovered' - } - - return jsonify(response) - - except Exception as e: - logger.error(f"Error getting Spotify Public discovery status: {e}") - return jsonify({"error": str(e)}), 500 + return _get_source_discovery_status(spotify_public_discovery_states, url_hash, "Spotify Public discovery not found", "Spotify Public") @app.route('/api/spotify-public/discovery/update_match', methods=['POST']) def update_spotify_public_discovery_match(): """Update a Spotify Public discovery result with manually selected Spotify track""" - try: - data = request.get_json() - identifier = data.get('identifier') # url_hash - track_index = data.get('track_index') - spotify_track = data.get('spotify_track') - - if not identifier or track_index is None or not spotify_track: - return jsonify({'error': 'Missing required fields'}), 400 - - # Get the state - state = spotify_public_discovery_states.get(identifier) - - if not state: - return jsonify({'error': 'Discovery state not found'}), 404 - - if track_index >= len(state['discovery_results']): - return jsonify({'error': 'Invalid track index'}), 400 - - # Update the result - result = state['discovery_results'][track_index] - old_status = result.get('status') - - # Update with user-selected track - result['status'] = 'Found' - result['status_class'] = 'found' - result['spotify_track'] = spotify_track['name'] - result['spotify_artist'] = _join_artist_names(spotify_track['artists']) if isinstance(spotify_track['artists'], list) else _extract_artist_name(spotify_track['artists']) - result['spotify_album'] = spotify_track['album'] - result['spotify_id'] = spotify_track['id'] - - # Format duration - duration_ms = spotify_track.get('duration_ms', 0) - if duration_ms: - minutes = duration_ms // 60000 - seconds = (duration_ms % 60000) // 1000 - result['duration'] = f"{minutes}:{seconds:02d}" - else: - result['duration'] = '0:00' - - # IMPORTANT: Also set spotify_data for sync/download compatibility. - # Manual match from the fix modal — build a rich spotify_data (album - # as dict with image info) matching the normal discovery shape, and - # explicitly clear any prior wing-it flag since the user picked a - # real metadata match. - result['spotify_data'] = _build_fix_modal_spotify_data(spotify_track) - result['wing_it_fallback'] = False - - result['manual_match'] = True - - # Update match count if status changed from not found/error - if old_status != 'found' and old_status != 'Found': - state['spotify_matches'] = state.get('spotify_matches', 0) + 1 - - logger.info(f"Manual match updated: spotify_public - {identifier} - track {track_index}") - logger.info(f" → {result['spotify_artist']} - {result['spotify_track']}") - - # Save manual fix to discovery cache so it appears in discovery pool - try: - original_track = result.get('spotify_public_track', {}) - original_name = original_track.get('name', spotify_track['name']) - original_artists = original_track.get('artists', []) - original_artist = original_artists[0] if original_artists else '' - - cache_key = _get_discovery_cache_key(original_name, original_artist) - # Normalize artists to plain strings for cache consistency - artists_list = spotify_track['artists'] - if isinstance(artists_list, list): - artists_list = [a if isinstance(a, str) else a.get('name', '') for a in artists_list] - # Preserve cover image info so the download pipeline can find - # artwork when this cached match is used later. The fix modal - # sends image_url at the top level; search results often return - # album as a bare string, which previously dropped the artwork. - image_url = spotify_track.get('image_url') or '' - album_raw = spotify_track.get('album', '') - if isinstance(album_raw, dict): - album_obj = dict(album_raw) - if image_url and not album_obj.get('image_url'): - album_obj['image_url'] = image_url - if image_url and not album_obj.get('images'): - album_obj['images'] = [{'url': image_url}] - else: - album_obj = {'name': album_raw or ''} - if image_url: - album_obj['image_url'] = image_url - album_obj['images'] = [{'url': image_url}] - - matched_data = { - 'id': spotify_track['id'], - 'name': spotify_track['name'], - 'artists': artists_list, - 'album': album_obj, - 'duration_ms': spotify_track.get('duration_ms', 0), - 'image_url': image_url, - 'source': 'spotify', - } - cache_db = get_database() - cache_db.save_discovery_cache_match( - cache_key[0], cache_key[1], _get_active_discovery_source(), 1.0, matched_data, - original_name, original_artist - ) - logger.info(f"Manual fix saved to discovery cache: {original_name} by {original_artist}") - except Exception as cache_err: - logger.error(f"Error saving manual fix to discovery cache: {cache_err}") - - return jsonify({'success': True, 'result': result}) - - except Exception as e: - logger.error(f"Error updating Spotify Public discovery match: {e}") - return jsonify({'error': str(e)}), 500 + return _update_source_discovery_match(spotify_public_discovery_states, "spotify_public", "Spotify Public", "spotify_public_track", _first_artist_plain) @app.route('/api/spotify-public/playlists/states', methods=['GET']) def get_spotify_public_playlist_states(): """Get all stored Spotify Public playlist discovery states for frontend hydration""" - try: - states = [] - current_time = time.time() - - for url_hash, state in spotify_public_discovery_states.items(): - state['last_accessed'] = current_time - - state_info = { - 'playlist_id': url_hash, - 'phase': state['phase'], - 'status': state['status'], - 'discovery_progress': state['discovery_progress'], - 'spotify_matches': state['spotify_matches'], - 'spotify_total': state['spotify_total'], - 'discovery_results': state['discovery_results'], - 'converted_spotify_playlist_id': state.get('converted_spotify_playlist_id'), - 'download_process_id': state.get('download_process_id'), - 'last_accessed': state['last_accessed'] - } - states.append(state_info) - - logger.info(f"Returning {len(states)} stored Spotify Public playlist states for hydration") - return jsonify({"states": states}) - - except Exception as e: - logger.error(f"Error getting Spotify Public playlist states: {e}") - return jsonify({"error": str(e)}), 500 + return _get_source_playlist_states(spotify_public_discovery_states, "Spotify Public", "Spotify Public") @app.route('/api/spotify-public/state/', methods=['GET']) def get_spotify_public_playlist_state(url_hash): @@ -23320,95 +22221,17 @@ def get_spotify_public_playlist_state(url_hash): @app.route('/api/spotify-public/reset/', methods=['POST']) def reset_spotify_public_playlist(url_hash): """Reset Spotify Public playlist to fresh phase (clear discovery/sync data)""" - try: - if url_hash not in spotify_public_discovery_states: - return jsonify({"error": "Spotify Public playlist not found"}), 404 - - state = spotify_public_discovery_states[url_hash] - - # Stop any active discovery - if 'discovery_future' in state and state['discovery_future']: - state['discovery_future'].cancel() - - # Reset state to fresh (preserve original playlist data) - state['phase'] = 'fresh' - state['status'] = 'fresh' - state['discovery_results'] = [] - state['discovery_progress'] = 0 - state['spotify_matches'] = 0 - state['sync_playlist_id'] = None - state['converted_spotify_playlist_id'] = None - state['download_process_id'] = None - state['sync_progress'] = {} - state['discovery_future'] = None - state['last_accessed'] = time.time() - - logger.info(f"Reset Spotify Public playlist to fresh: {url_hash}") - return jsonify({"success": True, "message": "Playlist reset to fresh phase"}) - - except Exception as e: - logger.error(f"Error resetting Spotify Public playlist: {e}") - return jsonify({"error": str(e)}), 500 + return _reset_source_playlist(spotify_public_discovery_states, url_hash, "Spotify Public", "Spotify Public playlist not found") @app.route('/api/spotify-public/delete/', methods=['POST']) def delete_spotify_public_playlist(url_hash): """Delete Spotify Public playlist state completely""" - try: - if url_hash not in spotify_public_discovery_states: - return jsonify({"error": "Spotify Public playlist not found"}), 404 - - state = spotify_public_discovery_states[url_hash] - - # Stop any active discovery - if 'discovery_future' in state and state['discovery_future']: - state['discovery_future'].cancel() - - # Remove from state dictionary - del spotify_public_discovery_states[url_hash] - - logger.info(f"Deleted Spotify Public playlist state: {url_hash}") - return jsonify({"success": True, "message": "Playlist deleted"}) - - except Exception as e: - logger.error(f"Error deleting Spotify Public playlist: {e}") - return jsonify({"error": str(e)}), 500 + return _delete_source_playlist(spotify_public_discovery_states, url_hash, "Spotify Public", "Spotify Public playlist not found") @app.route('/api/spotify-public/update_phase/', methods=['POST']) def update_spotify_public_playlist_phase(url_hash): """Update Spotify Public playlist phase (used when modal closes to reset from download_complete to discovered)""" - try: - if url_hash not in spotify_public_discovery_states: - return jsonify({"error": "Spotify Public playlist not found"}), 404 - - data = request.get_json() - if not data or 'phase' not in data: - return jsonify({"error": "Phase not provided"}), 400 - - new_phase = data['phase'] - valid_phases = ['fresh', 'discovering', 'discovered', 'syncing', 'sync_complete', 'downloading', 'download_complete'] - - if new_phase not in valid_phases: - return jsonify({"error": f"Invalid phase. Must be one of: {', '.join(valid_phases)}"}), 400 - - state = spotify_public_discovery_states[url_hash] - old_phase = state.get('phase', 'unknown') - state['phase'] = new_phase - state['last_accessed'] = time.time() - - # Update download process ID if provided (for download persistence) - if 'download_process_id' in data: - state['download_process_id'] = data['download_process_id'] - - # Update converted Spotify playlist ID if provided (for download persistence) - if 'converted_spotify_playlist_id' in data: - state['converted_spotify_playlist_id'] = data['converted_spotify_playlist_id'] - - logger.info(f"Updated Spotify Public playlist {url_hash} phase: {old_phase} → {new_phase}") - return jsonify({"success": True, "message": f"Phase updated to {new_phase}", "old_phase": old_phase, "new_phase": new_phase}) - - except Exception as e: - logger.error(f"Error updating Spotify Public playlist phase: {e}") - return jsonify({"error": str(e)}), 500 + return _update_source_playlist_phase(spotify_public_discovery_states, url_hash, "Spotify Public playlist not found", "Spotify Public", _PHASE_LIST, True) # Spotify Public discovery worker logic lives in core/discovery/spotify_public.py. @@ -23442,38 +22265,7 @@ def _run_spotify_public_discovery_worker(url_hash): def convert_spotify_public_results_to_spotify_tracks(discovery_results): """Convert Spotify Public discovery results to Spotify tracks format for sync""" - spotify_tracks = [] - - 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) - } - # Preserve track_number/disc_number from discovery enrichment - 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': - track = { - '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 - } - spotify_tracks.append(track) - - logger.info(f"Converted {len(spotify_tracks)} Spotify Public matches to Spotify tracks for sync") - return spotify_tracks + return convert_results_to_spotify_tracks(discovery_results, "Spotify Public") # =================================================================== @@ -23483,129 +22275,23 @@ def convert_spotify_public_results_to_spotify_tracks(discovery_results): @app.route('/api/spotify-public/sync/start/', methods=['POST']) def start_spotify_public_sync(url_hash): """Start sync process for a Spotify Public playlist using discovered Spotify tracks""" - try: - if url_hash not in spotify_public_discovery_states: - return jsonify({"error": "Spotify Public playlist not found"}), 404 - - state = spotify_public_discovery_states[url_hash] - state['last_accessed'] = time.time() - - if state['phase'] not in ['discovered', 'sync_complete', 'download_complete']: - return jsonify({"error": "Spotify Public playlist not ready for sync"}), 400 - - # Convert discovery results to Spotify tracks format - spotify_tracks = convert_spotify_public_results_to_spotify_tracks(state['discovery_results']) - - if not spotify_tracks: - return jsonify({"error": "No Spotify matches found for sync"}), 400 - - # Create a temporary playlist ID for sync tracking - sync_playlist_id = f"spotify_public_{url_hash}" - playlist_name = state['playlist']['name'] - - # Add activity for sync start - add_activity_item("", "Spotify Link Sync Started", f"'{playlist_name}' - {len(spotify_tracks)} tracks", "Now") - - # Update Spotify Public state - state['phase'] = 'syncing' - state['sync_playlist_id'] = sync_playlist_id - state['sync_progress'] = {} - - # Start the sync using existing sync infrastructure - sync_data = { - 'playlist_id': sync_playlist_id, - 'playlist_name': playlist_name, - 'tracks': spotify_tracks - } - - with sync_lock: - sync_states[sync_playlist_id] = {"status": "starting", "progress": {}} - - # Submit sync task - playlist_image_url = state['playlist'].get('image_url', '') - future = sync_executor.submit(_run_sync_task, sync_playlist_id, sync_data['playlist_name'], spotify_tracks, None, get_current_profile_id(), playlist_image_url) - active_sync_workers[sync_playlist_id] = future - - logger.info(f"Started Spotify Public sync for: {playlist_name} ({len(spotify_tracks)} tracks)") - return jsonify({"success": True, "sync_playlist_id": sync_playlist_id}) - - except Exception as e: - logger.error(f"Error starting Spotify Public sync: {e}") - return jsonify({"error": str(e)}), 500 + return _start_source_sync( + spotify_public_discovery_states, url_hash, sync_id_prefix="spotify_public", + not_found_message="Spotify Public playlist not found", + not_ready_message="Spotify Public playlist not ready for sync", + convert_fn=convert_spotify_public_results_to_spotify_tracks, + name_getter=_pl_name_strict, image_getter=_pl_image_dict, + activity_label="Spotify Link", error_label="Spotify Public") @app.route('/api/spotify-public/sync/status/', methods=['GET']) def get_spotify_public_sync_status(url_hash): """Get sync status for a Spotify Public playlist""" - try: - if url_hash not in spotify_public_discovery_states: - return jsonify({"error": "Spotify Public playlist not found"}), 404 - - state = spotify_public_discovery_states[url_hash] - state['last_accessed'] = time.time() - sync_playlist_id = state.get('sync_playlist_id') - - if not sync_playlist_id: - return jsonify({"error": "No sync in progress"}), 404 - - # Get sync status from existing sync infrastructure - with sync_lock: - sync_state = sync_states.get(sync_playlist_id, {}) - - response = { - 'phase': state['phase'], - 'sync_status': sync_state.get('status', 'unknown'), - 'progress': sync_state.get('progress', {}), - 'complete': sync_state.get('status') == 'finished', - 'error': sync_state.get('error') - } - - # Update Spotify Public state if sync completed - if sync_state.get('status') == 'finished': - state['phase'] = 'sync_complete' - state['sync_progress'] = sync_state.get('progress', {}) - playlist_name = state['playlist']['name'] - add_activity_item("", "Sync Complete", f"Spotify Link playlist '{playlist_name}' synced successfully", "Now") - elif sync_state.get('status') == 'error': - state['phase'] = 'discovered' # Revert on error - playlist_name = state['playlist']['name'] - add_activity_item("", "Sync Failed", f"Spotify Link playlist '{playlist_name}' sync failed", "Now") - - return jsonify(response) - - except Exception as e: - logger.error(f"Error getting Spotify Public sync status: {e}") - return jsonify({"error": str(e)}), 500 + return _get_source_sync_status(spotify_public_discovery_states, url_hash, "Spotify Public playlist not found", "Spotify Public", "Spotify Link playlist", _pl_name_strict) @app.route('/api/spotify-public/sync/cancel/', methods=['POST']) def cancel_spotify_public_sync(url_hash): """Cancel sync for a Spotify Public playlist""" - try: - if url_hash not in spotify_public_discovery_states: - return jsonify({"error": "Spotify Public playlist not found"}), 404 - - state = spotify_public_discovery_states[url_hash] - state['last_accessed'] = time.time() - sync_playlist_id = state.get('sync_playlist_id') - - if sync_playlist_id: - # Cancel the sync using existing sync infrastructure - with sync_lock: - sync_states[sync_playlist_id] = {"status": "cancelled"} - - # Clean up sync worker - if sync_playlist_id in active_sync_workers: - del active_sync_workers[sync_playlist_id] - - # Revert Spotify Public state - state['phase'] = 'discovered' - state['sync_playlist_id'] = None - state['sync_progress'] = {} - - return jsonify({"success": True, "message": "Spotify Public sync cancelled"}) - - except Exception as e: - logger.error(f"Error cancelling Spotify Public sync: {e}") - return jsonify({"error": str(e)}), 500 + return _cancel_source_sync(spotify_public_discovery_states, url_hash, "Spotify Public", "Spotify Public playlist not found") # =================================================================== @@ -23739,23 +22425,7 @@ def start_itunes_link_discovery(url_hash): @app.route('/api/itunes-link/discovery/status/', methods=['GET']) def get_itunes_link_discovery_status(url_hash): - try: - if url_hash not in itunes_link_discovery_states: - return jsonify({"error": "iTunes Link discovery not found"}), 404 - state = itunes_link_discovery_states[url_hash] - state['last_accessed'] = time.time() - return jsonify({ - 'phase': state['phase'], - 'status': state['status'], - 'progress': state['discovery_progress'], - 'spotify_matches': state['spotify_matches'], - 'spotify_total': state['spotify_total'], - 'results': state['discovery_results'], - 'complete': state['phase'] == 'discovered' - }) - except Exception as e: - logger.error(f"Error getting iTunes Link discovery status: {e}") - return jsonify({"error": str(e)}), 500 + return _get_source_discovery_status(itunes_link_discovery_states, url_hash, "iTunes Link discovery not found", "iTunes Link") def _update_itunes_link_discovery_result(identifier, track_index, spotify_track): @@ -23824,27 +22494,7 @@ def update_itunes_link_discovery_match(): @app.route('/api/itunes-link/playlists/states', methods=['GET']) def get_itunes_link_playlist_states(): - try: - current_time = time.time() - states = [] - for url_hash, state in itunes_link_discovery_states.items(): - state['last_accessed'] = current_time - states.append({ - 'playlist_id': url_hash, - 'phase': state['phase'], - 'status': state['status'], - 'discovery_progress': state['discovery_progress'], - 'spotify_matches': state['spotify_matches'], - 'spotify_total': state['spotify_total'], - 'discovery_results': state['discovery_results'], - 'converted_spotify_playlist_id': state.get('converted_spotify_playlist_id'), - 'download_process_id': state.get('download_process_id'), - 'last_accessed': state['last_accessed'] - }) - return jsonify({"states": states}) - except Exception as e: - logger.error(f"Error getting iTunes Link playlist states: {e}") - return jsonify({"error": str(e)}), 500 + return _get_source_playlist_states(itunes_link_discovery_states, "iTunes Link") @app.route('/api/itunes-link/state/', methods=['GET']) @@ -24002,57 +22652,12 @@ def start_itunes_link_sync(url_hash): @app.route('/api/itunes-link/sync/status/', methods=['GET']) def get_itunes_link_sync_status(url_hash): - try: - if url_hash not in itunes_link_discovery_states: - return jsonify({"error": "iTunes Link not found"}), 404 - state = itunes_link_discovery_states[url_hash] - state['last_accessed'] = time.time() - sync_playlist_id = state.get('sync_playlist_id') - if not sync_playlist_id: - return jsonify({"error": "No sync in progress"}), 404 - - with sync_lock: - sync_state = sync_states.get(sync_playlist_id, {}) - response = { - 'phase': state['phase'], - 'sync_status': sync_state.get('status', 'unknown'), - 'progress': sync_state.get('progress', {}), - 'complete': sync_state.get('status') == 'finished', - 'error': sync_state.get('error') - } - if sync_state.get('status') == 'finished': - state['phase'] = 'sync_complete' - state['sync_progress'] = sync_state.get('progress', {}) - add_activity_item("", "Sync Complete", f"iTunes Link '{state['playlist']['name']}' synced successfully", "Now") - elif sync_state.get('status') == 'error': - state['phase'] = 'discovered' - add_activity_item("", "Sync Failed", f"iTunes Link '{state['playlist']['name']}' sync failed", "Now") - return jsonify(response) - except Exception as e: - logger.error(f"Error getting iTunes Link sync status: {e}") - return jsonify({"error": str(e)}), 500 + return _get_source_sync_status(itunes_link_discovery_states, url_hash, "iTunes Link not found", "iTunes Link", "iTunes Link", _pl_name_strict) @app.route('/api/itunes-link/sync/cancel/', methods=['POST']) def cancel_itunes_link_sync(url_hash): - try: - if url_hash not in itunes_link_discovery_states: - return jsonify({"error": "iTunes Link not found"}), 404 - state = itunes_link_discovery_states[url_hash] - 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 jsonify({"success": True, "message": "iTunes Link sync cancelled"}) - except Exception as e: - logger.error(f"Error cancelling iTunes Link sync: {e}") - return jsonify({"error": str(e)}), 500 + return _cancel_source_sync(itunes_link_discovery_states, url_hash, "iTunes Link", "iTunes Link not found") # =================================================================== @@ -24200,28 +22805,7 @@ def start_youtube_discovery(url_hash): @app.route('/api/youtube/discovery/status/', methods=['GET']) def get_youtube_discovery_status(url_hash): """Get real-time discovery status for a YouTube playlist""" - try: - if url_hash not in youtube_playlist_states: - return jsonify({"error": "YouTube playlist not found"}), 404 - - state = youtube_playlist_states[url_hash] - state['last_accessed'] = time.time() # Update access time - - response = { - 'phase': state['phase'], - 'status': state['status'], - 'progress': state['discovery_progress'], - 'spotify_matches': state['spotify_matches'], - 'spotify_total': state['spotify_total'], - 'results': state['discovery_results'], - 'complete': state['phase'] == 'discovered' - } - - return jsonify(response) - - except Exception as e: - logger.error(f"Error getting YouTube discovery status: {e}") - return jsonify({"error": str(e)}), 500 + return _get_source_discovery_status(youtube_playlist_states, url_hash, "YouTube playlist not found", "YouTube") @app.route('/api/youtube/discovery/unmatch', methods=['POST']) @@ -24603,130 +23187,23 @@ def _calculate_similarity(str1, str2): @app.route('/api/youtube/sync/start/', methods=['POST']) def start_youtube_sync(url_hash): """Start sync process for a YouTube playlist using discovered Spotify tracks""" - try: - if url_hash not in youtube_playlist_states: - return jsonify({"error": "YouTube playlist not found"}), 404 - - state = youtube_playlist_states[url_hash] - state['last_accessed'] = time.time() # Update access time - - if state['phase'] not in ['discovered', 'sync_complete', 'download_complete']: - return jsonify({"error": "YouTube playlist not ready for sync"}), 400 - - # Convert discovery results to Spotify tracks format - spotify_tracks = convert_youtube_results_to_spotify_tracks(state['discovery_results']) - - if not spotify_tracks: - return jsonify({"error": "No Spotify matches found for sync"}), 400 - - # Create a temporary playlist ID for sync tracking - sync_playlist_id = f"youtube_{url_hash}" - playlist_name = state['playlist']['name'] - - # Add activity for sync start - add_activity_item("", "YouTube Sync Started", f"'{playlist_name}' - {len(spotify_tracks)} tracks", "Now") - - # Update YouTube state - state['phase'] = 'syncing' - state['sync_playlist_id'] = sync_playlist_id - state['sync_progress'] = {} - - # Start the sync using existing sync infrastructure - sync_data = { - 'playlist_id': sync_playlist_id, - 'playlist_name': playlist_name, - 'tracks': spotify_tracks - } - - with sync_lock: - sync_states[sync_playlist_id] = {"status": "starting", "progress": {}} - - # Submit sync task - playlist_image_url = state['playlist'].get('image_url', '') - future = sync_executor.submit(_run_sync_task, sync_playlist_id, sync_data['playlist_name'], spotify_tracks, None, get_current_profile_id(), playlist_image_url) - active_sync_workers[sync_playlist_id] = future - - logger.info(f"Started YouTube sync for: {playlist_name} ({len(spotify_tracks)} tracks)") - return jsonify({"success": True, "sync_playlist_id": sync_playlist_id}) - - except Exception as e: - logger.error(f"Error starting YouTube sync: {e}") - return jsonify({"error": str(e)}), 500 + return _start_source_sync( + youtube_playlist_states, url_hash, sync_id_prefix="youtube", + not_found_message="YouTube playlist not found", + not_ready_message="YouTube playlist not ready for sync", + convert_fn=convert_youtube_results_to_spotify_tracks, + name_getter=_pl_name_strict, image_getter=_pl_image_dict, + activity_label="YouTube", error_label="YouTube") @app.route('/api/youtube/sync/status/', methods=['GET']) def get_youtube_sync_status(url_hash): """Get sync status for a YouTube playlist""" - try: - if url_hash not in youtube_playlist_states: - return jsonify({"error": "YouTube playlist not found"}), 404 - - state = youtube_playlist_states[url_hash] - state['last_accessed'] = time.time() # Update access time - sync_playlist_id = state.get('sync_playlist_id') - - if not sync_playlist_id: - return jsonify({"error": "No sync in progress"}), 404 - - # Get sync status from existing sync infrastructure - with sync_lock: - sync_state = sync_states.get(sync_playlist_id, {}) - - response = { - 'phase': state['phase'], - 'sync_status': sync_state.get('status', 'unknown'), - 'progress': sync_state.get('progress', {}), - 'complete': sync_state.get('status') == 'finished', - 'error': sync_state.get('error') - } - - # Update YouTube state if sync completed - if sync_state.get('status') == 'finished': - state['phase'] = 'sync_complete' - state['sync_progress'] = sync_state.get('progress', {}) - # Add activity for sync completion - playlist_name = state.get('playlist', {}).get('name', 'Unknown Playlist') - add_activity_item("", "Sync Complete", f"YouTube playlist '{playlist_name}' synced successfully", "Now") - elif sync_state.get('status') == 'error': - state['phase'] = 'discovered' # Revert on error - playlist_name = state.get('playlist', {}).get('name', 'Unknown Playlist') - add_activity_item("", "Sync Failed", f"YouTube playlist '{playlist_name}' sync failed", "Now") - - return jsonify(response) - - except Exception as e: - logger.error(f"Error getting YouTube sync status: {e}") - return jsonify({"error": str(e)}), 500 + return _get_source_sync_status(youtube_playlist_states, url_hash, "YouTube playlist not found", "YouTube", "YouTube playlist", _pl_name_safe) @app.route('/api/youtube/sync/cancel/', methods=['POST']) def cancel_youtube_sync(url_hash): """Cancel sync for a YouTube playlist""" - try: - if url_hash not in youtube_playlist_states: - return jsonify({"error": "YouTube playlist not found"}), 404 - - state = youtube_playlist_states[url_hash] - state['last_accessed'] = time.time() # Update access time - sync_playlist_id = state.get('sync_playlist_id') - - if sync_playlist_id: - # Cancel the sync using existing sync infrastructure - with sync_lock: - sync_states[sync_playlist_id] = {"status": "cancelled"} - - # Clean up sync worker - if sync_playlist_id in active_sync_workers: - del active_sync_workers[sync_playlist_id] - - # Revert YouTube state - state['phase'] = 'discovered' - state['sync_playlist_id'] = None - state['sync_progress'] = {} - - return jsonify({"success": True, "message": "YouTube sync cancelled"}) - - except Exception as e: - logger.error(f"Error cancelling YouTube sync: {e}") - return jsonify({"error": str(e)}), 500 + return _cancel_source_sync(youtube_playlist_states, url_hash, "YouTube", "YouTube playlist not found") # New YouTube Playlist Management Endpoints (for persistent state) @@ -24861,67 +23338,11 @@ def delete_youtube_playlist(url_hash): @app.route('/api/youtube/update_phase/', methods=['POST']) def update_youtube_playlist_phase(url_hash): """Update YouTube playlist phase (used when modal closes to reset from download_complete to discovered)""" - try: - if url_hash not in youtube_playlist_states: - return jsonify({"error": "YouTube playlist not found"}), 404 - - data = request.get_json() - if not data or 'phase' not in data: - return jsonify({"error": "Phase not provided"}), 400 - - new_phase = data['phase'] - valid_phases = ['fresh', 'parsed', 'discovering', 'discovered', 'syncing', 'sync_complete', 'downloading', 'download_complete'] - - if new_phase not in valid_phases: - return jsonify({"error": f"Invalid phase. Must be one of: {', '.join(valid_phases)}"}), 400 - - state = youtube_playlist_states[url_hash] - old_phase = state.get('phase', 'unknown') - state['phase'] = new_phase - state['last_accessed'] = time.time() - - logger.info(f"Updated YouTube playlist {url_hash} phase: {old_phase} → {new_phase}") - return jsonify({"success": True, "message": f"Phase updated to {new_phase}", "old_phase": old_phase, "new_phase": new_phase}) - - except Exception as e: - logger.error(f"Error updating YouTube playlist phase: {e}") - return jsonify({"error": str(e)}), 500 + return _update_source_playlist_phase(youtube_playlist_states, url_hash, "YouTube playlist not found", "YouTube", _PHASE_LIST_YT, False) def convert_youtube_results_to_spotify_tracks(discovery_results): """Convert YouTube discovery results to Spotify tracks format for sync""" - spotify_tracks = [] - - 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'] - - # Create track object matching the expected format - 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': - # Build from individual fields (automatic discovery format) - track = { - '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 - } - spotify_tracks.append(track) - - logger.info(f"Converted {len(spotify_tracks)} YouTube matches to Spotify tracks for sync") - return spotify_tracks + return convert_results_to_spotify_tracks(discovery_results, "YouTube") # Add these new endpoints to the end of web_server.py @@ -25112,35 +23533,7 @@ def save_discover_download_snapshot(): """ Saves a snapshot of current discover download state for persistence across page refreshes. """ - try: - from datetime import datetime - - data = request.json - if not data or 'downloads' not in data: - return jsonify({'success': False, 'error': 'No download data provided'}), 400 - - downloads = data['downloads'] - - db = get_database() - db.save_bubble_snapshot('discover_downloads', downloads, profile_id=get_current_profile_id()) - - download_count = len(downloads) - logger.info(f"Saved discover download snapshot: {download_count} downloads") - - return jsonify({ - 'success': True, - 'message': f'Snapshot saved with {download_count} downloads', - 'timestamp': datetime.now().isoformat() - }) - - except Exception as e: - logger.error(f"Error saving discover download snapshot: {e}") - import traceback - traceback.print_exc() - return jsonify({ - 'success': False, - 'error': str(e) - }), 500 + return _save_source_bubble_snapshot("downloads", "No download data provided", "discover_downloads", "downloads", "discover download snapshot", "downloads") @app.route('/api/discover_downloads/hydrate', methods=['GET']) def hydrate_discover_downloads(): @@ -25261,35 +23654,7 @@ def save_artist_bubble_snapshot(): """ Saves a snapshot of current artist bubble state for persistence across page refreshes. """ - try: - from datetime import datetime - - data = request.json - if not data or 'bubbles' not in data: - return jsonify({'success': False, 'error': 'No bubble data provided'}), 400 - - bubbles = data['bubbles'] - - db = get_database() - db.save_bubble_snapshot('artist_bubbles', bubbles, profile_id=get_current_profile_id()) - - bubble_count = len(bubbles) - logger.info(f"Saved artist bubble snapshot: {bubble_count} artists") - - return jsonify({ - 'success': True, - 'message': f'Snapshot saved with {bubble_count} artist bubbles', - 'timestamp': datetime.now().isoformat() - }) - - except Exception as e: - logger.error(f"Error saving artist bubble snapshot: {e}") - import traceback - traceback.print_exc() - return jsonify({ - 'success': False, - 'error': str(e) - }), 500 + return _save_source_bubble_snapshot("bubbles", "No bubble data provided", "artist_bubbles", "artist bubbles", "artist bubble snapshot", "artists") @app.route('/api/artist_bubbles/hydrate', methods=['GET']) def hydrate_artist_bubbles(): @@ -25433,35 +23798,7 @@ def save_search_bubble_snapshot(): """ Saves a snapshot of current search bubble state for persistence across page refreshes. """ - try: - from datetime import datetime - - data = request.json - if not data or 'bubbles' not in data: - return jsonify({'success': False, 'error': 'No bubble data provided'}), 400 - - bubbles = data['bubbles'] - - db = get_database() - db.save_bubble_snapshot('search_bubbles', bubbles, profile_id=get_current_profile_id()) - - bubble_count = len(bubbles) - logger.info(f"Saved search bubble snapshot: {bubble_count} albums/tracks") - - return jsonify({ - 'success': True, - 'message': f'Snapshot saved with {bubble_count} search bubbles', - 'timestamp': datetime.now().isoformat() - }) - - except Exception as e: - logger.error(f"Error saving search bubble snapshot: {e}") - import traceback - traceback.print_exc() - return jsonify({ - 'success': False, - 'error': str(e) - }), 500 + return _save_source_bubble_snapshot("bubbles", "No bubble data provided", "search_bubbles", "search bubbles", "search bubble snapshot", "albums/tracks") @app.route('/api/search_bubbles/hydrate', methods=['GET']) def hydrate_search_bubbles(): @@ -25596,35 +23933,7 @@ def hydrate_search_bubbles(): @app.route('/api/beatport_bubbles/snapshot', methods=['POST']) def save_beatport_bubble_snapshot(): """Saves a snapshot of current Beatport download bubble state for persistence.""" - try: - from datetime import datetime - - data = request.json - if not data or 'bubbles' not in data: - return jsonify({'success': False, 'error': 'No bubble data provided'}), 400 - - bubbles = data['bubbles'] - - db = get_database() - db.save_bubble_snapshot('beatport_bubbles', bubbles, profile_id=get_current_profile_id()) - - bubble_count = len(bubbles) - logger.info(f"Saved Beatport bubble snapshot: {bubble_count} charts") - - return jsonify({ - 'success': True, - 'message': f'Snapshot saved with {bubble_count} Beatport bubbles', - 'timestamp': datetime.now().isoformat() - }) - - except Exception as e: - logger.error(f"Error saving Beatport bubble snapshot: {e}") - import traceback - traceback.print_exc() - return jsonify({ - 'success': False, - 'error': str(e) - }), 500 + return _save_source_bubble_snapshot("bubbles", "No bubble data provided", "beatport_bubbles", "Beatport bubbles", "Beatport bubble snapshot", "charts") @app.route('/api/beatport_bubbles/hydrate', methods=['GET']) def hydrate_beatport_bubbles(): @@ -30572,29 +28881,7 @@ def start_listenbrainz_discovery(playlist_mbid): @app.route('/api/listenbrainz/discovery/status/', methods=['GET']) def get_listenbrainz_discovery_status(playlist_mbid): """Get real-time discovery status for a ListenBrainz playlist""" - try: - state_key = _lb_state_key(playlist_mbid) - if state_key not in listenbrainz_playlist_states: - return jsonify({"error": "ListenBrainz playlist not found"}), 404 - - state = listenbrainz_playlist_states[state_key] - state['last_accessed'] = time.time() - - response = { - 'phase': state['phase'], - 'status': state['status'], - 'progress': state['discovery_progress'], - 'spotify_matches': state['spotify_matches'], - 'spotify_total': state['spotify_total'], - 'results': state['discovery_results'], - 'complete': state['phase'] == 'discovered' - } - - return jsonify(response) - - except Exception as e: - logger.error(f"Error getting ListenBrainz discovery status: {e}") - return jsonify({"error": str(e)}), 500 + return _get_source_discovery_status(listenbrainz_playlist_states, _lb_state_key(playlist_mbid), "ListenBrainz playlist not found", "ListenBrainz") @app.route('/api/listenbrainz/update-phase/', methods=['POST']) def update_listenbrainz_phase(playlist_mbid): @@ -30702,39 +28989,7 @@ def update_listenbrainz_discovery_match(): def convert_listenbrainz_results_to_spotify_tracks(discovery_results): """Convert ListenBrainz discovery results to Spotify tracks format for sync""" - spotify_tracks = [] - - 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'] - - # Create track object matching the expected format - 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': - # Build from individual fields (automatic discovery format) - track = { - '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 - } - spotify_tracks.append(track) - - logger.info(f"Converted {len(spotify_tracks)} ListenBrainz matches to Spotify tracks for sync") - return spotify_tracks + return convert_results_to_spotify_tracks(discovery_results, "ListenBrainz") @app.route('/api/wing-it/sync', methods=['POST']) def wing_it_sync(): @@ -30854,79 +29109,12 @@ def start_listenbrainz_sync(playlist_mbid): @app.route('/api/listenbrainz/sync/status/', methods=['GET']) def get_listenbrainz_sync_status(playlist_mbid): """Get sync status for a ListenBrainz playlist""" - try: - state_key = _lb_state_key(playlist_mbid) - if state_key not in listenbrainz_playlist_states: - return jsonify({"error": "ListenBrainz playlist not found"}), 404 - - state = listenbrainz_playlist_states[state_key] - state['last_accessed'] = time.time() # Update access time - sync_playlist_id = state.get('sync_playlist_id') - - if not sync_playlist_id: - return jsonify({"error": "No sync in progress"}), 404 - - # Get sync status from existing sync infrastructure - with sync_lock: - sync_state = sync_states.get(sync_playlist_id, {}) - - response = { - 'phase': state['phase'], - 'sync_status': sync_state.get('status', 'unknown'), - 'progress': sync_state.get('progress', {}), - 'complete': sync_state.get('status') == 'finished', - 'error': sync_state.get('error') - } - - # Update ListenBrainz state if sync completed - if sync_state.get('status') == 'finished': - state['phase'] = 'sync_complete' - state['sync_progress'] = sync_state.get('progress', {}) - # Add activity for sync completion - playlist_name = state.get('playlist', {}).get('name', 'Unknown Playlist') - add_activity_item("", "Sync Complete", f"ListenBrainz playlist '{playlist_name}' synced successfully", "Now") - elif sync_state.get('status') == 'error': - state['phase'] = 'discovered' # Revert on error - playlist_name = state.get('playlist', {}).get('name', 'Unknown Playlist') - add_activity_item("", "Sync Failed", f"ListenBrainz playlist '{playlist_name}' sync failed", "Now") - - return jsonify(response) - - except Exception as e: - logger.error(f"Error getting ListenBrainz sync status: {e}") - return jsonify({"error": str(e)}), 500 + return _get_source_sync_status(listenbrainz_playlist_states, _lb_state_key(playlist_mbid), "ListenBrainz playlist not found", "ListenBrainz", "ListenBrainz playlist", _pl_name_safe) @app.route('/api/listenbrainz/sync/cancel/', methods=['POST']) def cancel_listenbrainz_sync(playlist_mbid): """Cancel sync for a ListenBrainz playlist""" - try: - state_key = _lb_state_key(playlist_mbid) - if state_key not in listenbrainz_playlist_states: - return jsonify({"error": "ListenBrainz playlist not found"}), 404 - - state = listenbrainz_playlist_states[state_key] - state['last_accessed'] = time.time() # Update access time - sync_playlist_id = state.get('sync_playlist_id') - - if sync_playlist_id: - # Cancel the sync using existing sync infrastructure - with sync_lock: - sync_states[sync_playlist_id] = {"status": "cancelled"} - - # Clean up sync worker - if sync_playlist_id in active_sync_workers: - del active_sync_workers[sync_playlist_id] - - # Revert ListenBrainz state - state['phase'] = 'discovered' - state['sync_playlist_id'] = None - state['sync_progress'] = {} - - return jsonify({"success": True, "message": "ListenBrainz sync cancelled"}) - - except Exception as e: - logger.error(f"Error cancelling ListenBrainz sync: {e}") - return jsonify({"error": str(e)}), 500 + return _cancel_source_sync(listenbrainz_playlist_states, _lb_state_key(playlist_mbid), "ListenBrainz", "ListenBrainz playlist not found") @app.route('/api/metadata/start', methods=['POST']) def start_metadata_update(): @@ -32687,28 +30875,7 @@ def start_beatport_discovery(url_hash): @app.route('/api/beatport/discovery/status/', methods=['GET']) def get_beatport_discovery_status(url_hash): """Get real-time discovery status for a Beatport chart""" - try: - if url_hash not in beatport_chart_states: - return jsonify({"error": "Beatport chart not found"}), 404 - - state = beatport_chart_states[url_hash] - state['last_accessed'] = time.time() - - response = { - 'phase': state['phase'], - 'status': state['status'], - 'progress': state['discovery_progress'], - 'spotify_matches': state['spotify_matches'], - 'spotify_total': state['spotify_total'], - 'results': state['discovery_results'], - 'complete': state['phase'] == 'discovered' - } - - return jsonify(response) - - except Exception as e: - logger.error(f"Error getting Beatport discovery status: {e}") - return jsonify({"error": str(e)}), 500 + return _get_source_discovery_status(beatport_chart_states, url_hash, "Beatport chart not found", "Beatport") @app.route('/api/beatport/discovery/update_match', methods=['POST'])