diff --git a/core/discovery/endpoints.py b/core/discovery/endpoints.py index 5b2f1312..69dbb0d8 100644 --- a/core/discovery/endpoints.py +++ b/core/discovery/endpoints.py @@ -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 ". + - ``log_subject`` / ``log_noun`` — the info ("Saved : N ") + and except ("Error saving ") 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, diff --git a/tests/discovery/test_discovery_endpoints.py b/tests/discovery/test_discovery_endpoints.py index 618e18cc..2f1b0ace 100644 --- a/tests/discovery/test_discovery_endpoints.py +++ b/tests/discovery/test_discovery_endpoints.py @@ -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'} diff --git a/web_server.py b/web_server.py index a4cc81e5..17c4322e 100644 --- a/web_server.py +++ b/web_server.py @@ -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():