From 44b032b6c019b49bc8186aa7277864735b2cb411 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Thu, 28 May 2026 17:15:13 -0700 Subject: [PATCH] Discovery lift (5/N): reset_*_playlist -> shared helper Fifth cluster: reset__playlist for the four sources with byte- identical bodies (Tidal, Deezer, Qobuz, Spotify-Public) -> core.discovery.endpoints.reset_playlist(states, key, *, label, not_found_message), wired via _reset_source_playlist. Resets phase/status to 'fresh', clears discovery/sync fields, cancels any discovery_future, and preserves the original playlist payload. Left with their own bodies (genuinely divergent): - YouTube: status -> 'parsed' (not 'fresh'), no download_process_id, logs the playlist name, "reset to fresh state". - ListenBrainz: status -> 'cached', logs playlist title, returns {"success": True, "phase": "fresh"} (different payload), _lb_state_key. - iTunes-Link: state.update(...), no info log, "iTunes Link reset to fresh phase". Tests: +4 (404, full clear + playlist preserved + future cancelled, no-future path, exception -> 500). Full discovery suite: 179 passed. web_server.py: -100 lines. --- core/discovery/endpoints.py | 46 +++++++ tests/discovery/test_discovery_endpoints.py | 60 +++++++++ web_server.py | 128 +++----------------- 3 files changed, 120 insertions(+), 114 deletions(-) diff --git a/core/discovery/endpoints.py b/core/discovery/endpoints.py index 81723df5..038dd889 100644 --- a/core/discovery/endpoints.py +++ b/core/discovery/endpoints.py @@ -288,3 +288,49 @@ def get_discovery_status( 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 diff --git a/tests/discovery/test_discovery_endpoints.py b/tests/discovery/test_discovery_endpoints.py index 846802f5..03cd31e5 100644 --- a/tests/discovery/test_discovery_endpoints.py +++ b/tests/discovery/test_discovery_endpoints.py @@ -445,3 +445,63 @@ def test_discovery_status_missing_field_raises_500(): 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 diff --git a/web_server.py b/web_server.py index 9ad29b8c..ccde67a1 100644 --- a/web_server.py +++ b/web_server.py @@ -20748,35 +20748,7 @@ 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): @@ -21041,6 +21013,7 @@ from core.discovery.endpoints import ( 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, 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, @@ -21087,6 +21060,15 @@ def _get_source_discovery_status(states, key, not_found_message, 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 _build_tidal_discovery_deps(): """Build the TidalDiscoveryDeps bundle from web_server.py globals on each call.""" return _discovery_tidal.TidalDiscoveryDeps( @@ -21588,35 +21570,7 @@ 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): @@ -22122,33 +22076,7 @@ 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']) @@ -22930,35 +22858,7 @@ 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):