Discovery lift (10/N): save_*_bubble_snapshot -> shared helper

Final cluster: the four structurally-identical snapshot endpoints
(discover_downloads, artist_bubbles, search_bubbles, beatport_bubbles) ->
core.discovery.endpoints.save_bubble_snapshot(...), wired via
_save_source_bubble_snapshot. All four validate a payload key, persist via
db.save_bubble_snapshot(kind, items, profile_id=...), and return a count +
timestamp; they differ only by:
- payload_key ('downloads' for discover, 'bubbles' for the rest) + its
  no_data_error message.
- snapshot_kind, success_noun, and the info/except log subject + noun
  ("downloads"/"artists"/"albums/tracks"/"charts").

get_database / get_current_profile_id injected; get_json (request.json) invoked
inside the try, preserving the original 400/500 behavior incl. traceback dump.

Tests: +5 (missing key 400, None body 400, happy path with kind/profile/count/
timestamp, discover_downloads variant, exception -> 500). Full discovery suite:
210 passed.

web_server.py: -98 lines.
This commit is contained in:
BoulderBadgeDad 2026-05-28 18:17:36 -07:00
parent 4caf36deb1
commit d5f6a14ba1
3 changed files with 151 additions and 116 deletions

View file

@ -398,6 +398,61 @@ def get_playlist_states(
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 <noun>".
- ``log_subject`` / ``log_noun`` the info ("Saved <subject>: N <noun>")
and except ("Error saving <subject>") 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,

View file

@ -877,3 +877,81 @@ def test_update_phase_exception_500():
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'}

View file

@ -20856,6 +20856,7 @@ from core.discovery.endpoints import (
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,
@ -20985,6 +20986,19 @@ def _update_source_playlist_phase(states, key, not_found_message, error_label,
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():
"""Build the TidalDiscoveryDeps bundle from web_server.py globals on each call."""
return _discovery_tidal.TidalDiscoveryDeps(
@ -23519,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():
@ -23668,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():
@ -23840,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():
@ -24003,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():