Discovery lift (6/N): get_*_playlist_states -> shared helper

Sixth cluster: the bulk-hydration get_<source>_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.
This commit is contained in:
BoulderBadgeDad 2026-05-28 17:31:33 -07:00
parent 44b032b6c0
commit 7b6615b65a
3 changed files with 104 additions and 131 deletions

View file

@ -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_<source>_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

View file

@ -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

View file

@ -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/<playlist_id>', 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/<playlist_id>', 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/<playlist_id>', 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/<url_hash>', 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/<url_hash>', methods=['GET'])