From 7b6615b65adb6f2e09b7214e1df9a088c0b943dc Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Thu, 28 May 2026 17:31:33 -0700 Subject: [PATCH] Discovery lift (6/N): get_*_playlist_states -> shared helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sixth cluster: the bulk-hydration get__playlist_states endpoints for the five sources that build the identical per-entry dict + {"states": [...]} shape (Tidal, Deezer, Qobuz, Spotify-Public, iTunes-Link) -> core.discovery.endpoints.get_playlist_states(states, *, error_label, info_log_label=None), wired via _get_source_playlist_states. iTunes-Link is the only one of the five without the "Returning N stored ..." info log, so info_log_label is optional (iTunes passes None to suppress it). NOT folded in: the YouTube/ListenBrainz get_all_*_playlists endpoints. They return {"playlists": [...]} (different key) with a different field set (url / created_at / playlist, no discovery_results) and filter out mirrored_/profile-scoped entries — genuinely divergent, kept as-is. Tests: +4 (list build + last_accessed bump + exact shape, empty, optional ids default None, missing-required-field -> 500). Full discovery suite: 183 passed. web_server.py: -116 lines. --- core/discovery/endpoints.py | 45 ++++++ tests/discovery/test_discovery_endpoints.py | 44 ++++++ web_server.py | 146 ++------------------ 3 files changed, 104 insertions(+), 131 deletions(-) diff --git a/core/discovery/endpoints.py b/core/discovery/endpoints.py index 038dd889..b86f4f1e 100644 --- a/core/discovery/endpoints.py +++ b/core/discovery/endpoints.py @@ -334,3 +334,48 @@ def reset_playlist( 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 diff --git a/tests/discovery/test_discovery_endpoints.py b/tests/discovery/test_discovery_endpoints.py index 03cd31e5..0d0d5e5d 100644 --- a/tests/discovery/test_discovery_endpoints.py +++ b/tests/discovery/test_discovery_endpoints.py @@ -505,3 +505,47 @@ def test_reset_exception_returns_500(): 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 diff --git a/web_server.py b/web_server.py index ccde67a1..b3dcbe71 100644 --- a/web_server.py +++ b/web_server.py @@ -20682,35 +20682,7 @@ def update_tidal_discovery_match(): @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): @@ -21014,6 +20986,7 @@ from core.discovery.endpoints import ( 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, 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, @@ -21069,6 +21042,15 @@ def _reset_source_playlist(states, key, label, 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 _build_tidal_discovery_deps(): """Build the TidalDiscoveryDeps bundle from web_server.py globals on each call.""" return _discovery_tidal.TidalDiscoveryDeps( @@ -21506,33 +21488,7 @@ def update_deezer_discovery_match(): @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): @@ -22011,33 +21967,7 @@ def update_qobuz_discovery_match(): @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']) @@ -22795,33 +22725,7 @@ def update_spotify_public_discovery_match(): @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): @@ -23205,27 +23109,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'])