From 2d76a7c06176d6afee5e79bf548401a954b1a5eb Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Thu, 28 May 2026 16:12:51 -0700 Subject: [PATCH] Discovery lift (2/N): cancel_*_sync + delete_*_playlist -> shared helpers Second cluster. Two more sets of byte-identical per-source bodies: cancel__sync (Tidal, Deezer, Qobuz, Spotify-Public, iTunes-Link, YouTube, ListenBrainz) -> core.discovery.endpoints.cancel_sync(states, key, *, label, not_found_message, sync_lock, sync_states, active_sync_workers). Returns (payload, status_code); a thin web_server glue (_cancel_source_sync) wires the sync-infra globals + jsonify. Caller passes the resolved key (ListenBrainz transforms via _lb_state_key) and the exact 404 string (iTunes-Link uses "iTunes Link not found"). delete__playlist (Tidal, Deezer, Qobuz, Spotify-Public) -> delete_playlist_state(states, key, *, label, not_found_message), wired via _delete_source_playlist. Intentionally left with their own bodies (genuinely divergent, not 1:1): - Beatport cancel (cancels a stored sync_future, no message, warning log). - iTunes-Link / YouTube / ListenBrainz / Beatport deletes (different success messages, info-log wording, playlist-name extraction, /remove route, chart key). Tests: +11 in tests/discovery/test_discovery_endpoints.py covering cancel (404, active-worker cancel + state revert, worker-absent, no-sync-in-progress, label in message, exception->500) and delete (404, future cancel + removal, no/falsy future, exception->500 leaves state). Full discovery suite: 162 passed. web_server.py: -216 lines. --- core/discovery/endpoints.py | 85 +++++- tests/discovery/test_discovery_endpoints.py | 136 ++++++++- web_server.py | 288 +++----------------- 3 files changed, 255 insertions(+), 254 deletions(-) diff --git a/core/discovery/endpoints.py b/core/discovery/endpoints.py index b8c4e411..61d8cc0b 100644 --- a/core/discovery/endpoints.py +++ b/core/discovery/endpoints.py @@ -14,7 +14,8 @@ shape) is intentionally NOT routed through here and stays in its own function. from __future__ import annotations -from typing import Any, Dict, List +import time +from typing import Any, Dict, List, Tuple from utils.logging_config import get_logger @@ -74,3 +75,85 @@ def convert_results_to_spotify_tracks( 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 diff --git a/tests/discovery/test_discovery_endpoints.py b/tests/discovery/test_discovery_endpoints.py index 0b78c1b4..2e346f84 100644 --- a/tests/discovery/test_discovery_endpoints.py +++ b/tests/discovery/test_discovery_endpoints.py @@ -8,7 +8,30 @@ the originals handled is exercised here. from __future__ import annotations -from core.discovery.endpoints import convert_results_to_spotify_tracks +import threading + +from core.discovery.endpoints import ( + convert_results_to_spotify_tracks, + cancel_sync, + delete_playlist_state, +) + + +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': {}, + } # --------------------------------------------------------------------------- @@ -125,3 +148,114 @@ def test_spotify_data_takes_precedence_over_auto_fields(): }] 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 diff --git a/web_server.py b/web_server.py index 27742136..54d0a247 100644 --- a/web_server.py +++ b/web_server.py @@ -20802,25 +20802,7 @@ def reset_tidal_playlist(playlist_id): @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): @@ -21074,7 +21056,31 @@ 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 +from core.discovery.endpoints import ( + convert_results_to_spotify_tracks, + cancel_sync as _cancel_sync_core, + delete_playlist_state as _delete_playlist_state_core, +) + + +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 _build_tidal_discovery_deps(): @@ -21214,33 +21220,7 @@ def get_tidal_sync_status(playlist_id): @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") # =================================================================== @@ -21699,25 +21679,7 @@ def reset_deezer_playlist(playlist_id): @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): @@ -21893,33 +21855,7 @@ def get_deezer_sync_status(playlist_id): @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") # =================================================================== @@ -22335,23 +22271,7 @@ def reset_qobuz_playlist(playlist_id): @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']) @@ -22517,29 +22437,7 @@ def get_qobuz_sync_status(playlist_id): @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") # =================================================================== @@ -23239,25 +23137,7 @@ def reset_spotify_public_playlist(url_hash): @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): @@ -23434,33 +23314,7 @@ def get_spotify_public_sync_status(url_hash): @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") # =================================================================== @@ -23890,24 +23744,7 @@ def get_itunes_link_sync_status(url_hash): @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") # =================================================================== @@ -24555,33 +24392,7 @@ def get_youtube_sync_status(url_hash): @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) @@ -30690,34 +30501,7 @@ def get_listenbrainz_sync_status(playlist_mbid): @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():