Discovery lift (2/N): cancel_*_sync + delete_*_playlist -> shared helpers

Second cluster. Two more sets of byte-identical per-source bodies:

cancel_<source>_sync (Tidal, Deezer, Qobuz, Spotify-Public, iTunes-Link,
YouTube, ListenBrainz) -> core.discovery.endpoints.cancel_sync(states, key,
*, label, not_found_message, sync_lock, sync_states, active_sync_workers).
Returns (payload, status_code); a thin web_server glue (_cancel_source_sync)
wires the sync-infra globals + jsonify. Caller passes the resolved key
(ListenBrainz transforms via _lb_state_key) and the exact 404 string
(iTunes-Link uses "iTunes Link not found").

delete_<source>_playlist (Tidal, Deezer, Qobuz, Spotify-Public) ->
delete_playlist_state(states, key, *, label, not_found_message), wired via
_delete_source_playlist.

Intentionally left with their own bodies (genuinely divergent, not 1:1):
- Beatport cancel (cancels a stored sync_future, no message, warning log).
- iTunes-Link / YouTube / ListenBrainz / Beatport deletes (different
  success messages, info-log wording, playlist-name extraction, /remove
  route, chart key).

Tests: +11 in tests/discovery/test_discovery_endpoints.py covering cancel
(404, active-worker cancel + state revert, worker-absent, no-sync-in-progress,
label in message, exception->500) and delete (404, future cancel + removal,
no/falsy future, exception->500 leaves state). Full discovery suite: 162 passed.

web_server.py: -216 lines.
This commit is contained in:
BoulderBadgeDad 2026-05-28 16:12:51 -07:00
parent 628395eda5
commit 2d76a7c061
3 changed files with 255 additions and 254 deletions

View file

@ -14,7 +14,8 @@ shape) is intentionally NOT routed through here and stays in its own function.
from __future__ import annotations
from typing import Any, Dict, List
import time
from typing import Any, Dict, List, Tuple
from utils.logging_config import get_logger
@ -74,3 +75,85 @@ def convert_results_to_spotify_tracks(
logger.info(f"Converted {len(spotify_tracks)} {source_label} matches to Spotify tracks for sync")
return spotify_tracks
def cancel_sync(
states: Dict[str, Any],
key: str,
*,
label: str,
not_found_message: str,
sync_lock: Any,
sync_states: Dict[str, Any],
active_sync_workers: Dict[str, Any],
) -> Tuple[Dict[str, Any], int]:
"""Cancel an in-progress sync for one discovery playlist.
1:1 lift of the byte-identical ``cancel_<source>_sync`` bodies (Tidal,
Deezer, Qobuz, Spotify-Public, iTunes-Link, YouTube, ListenBrainz). The
caller passes the already-resolved state key (ListenBrainz transforms it
via ``_lb_state_key`` first), the source ``label``, the exact 404 message
(iTunes-Link uses "iTunes Link not found", not "... playlist not found"),
and the shared sync infrastructure (so this stays free of web_server
globals / Flask).
Returns ``(payload_dict, status_code)``; the caller wraps in ``jsonify``.
Beatport is NOT routed here it cancels a stored ``sync_future`` and
returns a different payload.
"""
try:
if key not in states:
return {"error": not_found_message}, 404
state = states[key]
state['last_accessed'] = time.time()
sync_playlist_id = state.get('sync_playlist_id')
if sync_playlist_id:
with sync_lock:
sync_states[sync_playlist_id] = {"status": "cancelled"}
if sync_playlist_id in active_sync_workers:
del active_sync_workers[sync_playlist_id]
state['phase'] = 'discovered'
state['sync_playlist_id'] = None
state['sync_progress'] = {}
return {"success": True, "message": f"{label} sync cancelled"}, 200
except Exception as e:
logger.error(f"Error cancelling {label} sync: {e}")
return {"error": str(e)}, 500
def delete_playlist_state(
states: Dict[str, Any],
key: str,
*,
label: str,
not_found_message: str,
) -> Tuple[Dict[str, Any], int]:
"""Delete a discovery playlist's state entry, cancelling any active
discovery first.
1:1 lift of the byte-identical ``delete_<source>_playlist`` bodies
(Tidal, Deezer, Qobuz, Spotify-Public). Returns ``(payload, status_code)``.
The iTunes-Link / YouTube / ListenBrainz / Beatport deletes intentionally
keep their own bodies they differ in success message, info-log wording,
name extraction, and/or key transform.
"""
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()
del states[key]
logger.info(f"Deleted {label} playlist state: {key}")
return {"success": True, "message": "Playlist deleted"}, 200
except Exception as e:
logger.error(f"Error deleting {label} playlist: {e}")
return {"error": str(e)}, 500

View file

@ -8,7 +8,30 @@ the originals handled is exercised here.
from __future__ import annotations
from core.discovery.endpoints import convert_results_to_spotify_tracks
import threading
from core.discovery.endpoints import (
convert_results_to_spotify_tracks,
cancel_sync,
delete_playlist_state,
)
class _FakeFuture:
def __init__(self):
self.cancelled = False
def cancel(self):
self.cancelled = True
def _cancel_infra():
"""Fresh sync infra (lock + the two shared dicts) for cancel_sync tests."""
return {
'sync_lock': threading.Lock(),
'sync_states': {},
'active_sync_workers': {},
}
# ---------------------------------------------------------------------------
@ -125,3 +148,114 @@ def test_spotify_data_takes_precedence_over_auto_fields():
}]
out = convert_results_to_spotify_tracks(results, 'Tidal')
assert out[0]['id'] == 'D'
# ---------------------------------------------------------------------------
# cancel_sync
# ---------------------------------------------------------------------------
def test_cancel_sync_not_found_returns_404():
body, code = cancel_sync({}, 'missing', label='Tidal',
not_found_message='Tidal playlist not found', **_cancel_infra())
assert code == 404
assert body == {"error": "Tidal playlist not found"}
def test_cancel_sync_cancels_active_worker_and_reverts_state():
infra = _cancel_infra()
infra['sync_states']['sp1'] = {"status": "running"}
infra['active_sync_workers']['sp1'] = _FakeFuture()
states = {'pl': {'phase': 'syncing', 'sync_playlist_id': 'sp1',
'sync_progress': {'done': 1}, 'last_accessed': 0}}
body, code = cancel_sync(states, 'pl', label='Tidal',
not_found_message='nf', **infra)
assert code == 200
assert body == {"success": True, "message": "Tidal sync cancelled"}
# sync marked cancelled, worker removed
assert infra['sync_states']['sp1'] == {"status": "cancelled"}
assert 'sp1' not in infra['active_sync_workers']
# state reverted
assert states['pl']['phase'] == 'discovered'
assert states['pl']['sync_playlist_id'] is None
assert states['pl']['sync_progress'] == {}
assert states['pl']['last_accessed'] != 0 # touched
def test_cancel_sync_worker_absent_from_active_map_is_safe():
infra = _cancel_infra() # active_sync_workers empty
states = {'pl': {'phase': 'syncing', 'sync_playlist_id': 'sp1'}}
body, code = cancel_sync(states, 'pl', label='Deezer', not_found_message='nf', **infra)
assert code == 200
assert infra['sync_states']['sp1'] == {"status": "cancelled"}
def test_cancel_sync_no_sync_in_progress_still_reverts():
infra = _cancel_infra()
states = {'pl': {'phase': 'discovered'}} # no sync_playlist_id
body, code = cancel_sync(states, 'pl', label='Qobuz', not_found_message='nf', **infra)
assert code == 200
assert states['pl']['sync_playlist_id'] is None
assert states['pl']['sync_progress'] == {}
assert infra['sync_states'] == {} # nothing cancelled
def test_cancel_sync_label_in_message():
infra = _cancel_infra()
states = {'pl': {}}
body, _ = cancel_sync(states, 'pl', label='iTunes Link', not_found_message='nf', **infra)
assert body["message"] == "iTunes Link sync cancelled"
def test_cancel_sync_exception_returns_500():
infra = _cancel_infra()
states = {'pl': object()} # not subscriptable -> raises inside try
body, code = cancel_sync(states, 'pl', label='Tidal', not_found_message='nf', **infra)
assert code == 500
assert "error" in body
# ---------------------------------------------------------------------------
# delete_playlist_state
# ---------------------------------------------------------------------------
def test_delete_not_found_returns_404():
body, code = delete_playlist_state({}, 'missing', label='Tidal',
not_found_message='Tidal playlist not found')
assert code == 404
assert body == {"error": "Tidal playlist not found"}
def test_delete_cancels_discovery_future_and_removes_state():
fut = _FakeFuture()
states = {'pl': {'discovery_future': fut}}
body, code = delete_playlist_state(states, 'pl', label='Tidal', not_found_message='nf')
assert code == 200
assert body == {"success": True, "message": "Playlist deleted"}
assert fut.cancelled is True
assert 'pl' not in states
def test_delete_without_discovery_future_still_removes():
states = {'pl': {'phase': 'discovered'}} # no discovery_future
body, code = delete_playlist_state(states, 'pl', label='Deezer', not_found_message='nf')
assert code == 200
assert 'pl' not in states
def test_delete_falsy_discovery_future_not_cancelled():
states = {'pl': {'discovery_future': None}}
body, code = delete_playlist_state(states, 'pl', label='Qobuz', not_found_message='nf')
assert code == 200
assert 'pl' not in states
def test_delete_exception_returns_500():
fut = object() # no .cancel() -> AttributeError inside try
states = {'pl': {'discovery_future': fut}}
body, code = delete_playlist_state(states, 'pl', label='Tidal', not_found_message='nf')
assert code == 500
assert "error" in body
# state NOT deleted because the exception fired before del
assert 'pl' in states

View file

@ -20802,25 +20802,7 @@ def reset_tidal_playlist(playlist_id):
@app.route('/api/tidal/delete/<playlist_id>', methods=['POST'])
def delete_tidal_playlist(playlist_id):
"""Delete Tidal playlist state completely"""
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()
# Remove from state dictionary
del tidal_discovery_states[playlist_id]
logger.info(f"Deleted Tidal playlist state: {playlist_id}")
return jsonify({"success": True, "message": "Playlist deleted"})
except Exception as e:
logger.error(f"Error deleting Tidal playlist: {e}")
return jsonify({"error": str(e)}), 500
return _delete_source_playlist(tidal_discovery_states, playlist_id, "Tidal", "Tidal playlist not found")
@app.route('/api/tidal/update_phase/<playlist_id>', methods=['POST'])
def update_tidal_playlist_phase(playlist_id):
@ -21074,7 +21056,31 @@ from core.discovery.scoring import (
# Tidal discovery worker logic lives in core/discovery/tidal.py.
from core.discovery import tidal as _discovery_tidal
# Source-agnostic discovery route helpers (lifted from the per-source copies).
from core.discovery.endpoints import convert_results_to_spotify_tracks
from core.discovery.endpoints import (
convert_results_to_spotify_tracks,
cancel_sync as _cancel_sync_core,
delete_playlist_state as _delete_playlist_state_core,
)
def _cancel_source_sync(states, key, label, not_found_message):
"""Thin glue: wire web_server's sync infra into the lifted cancel_sync
helper and jsonify the result. Used by the per-source cancel routes."""
body, code = _cancel_sync_core(
states, key, label=label, not_found_message=not_found_message,
sync_lock=sync_lock, sync_states=sync_states,
active_sync_workers=active_sync_workers,
)
return jsonify(body), code
def _delete_source_playlist(states, key, label, not_found_message):
"""Thin glue for the per-source delete routes that share the identical
delete body (Tidal/Deezer/Qobuz/Spotify-Public)."""
body, code = _delete_playlist_state_core(
states, key, label=label, not_found_message=not_found_message,
)
return jsonify(body), code
def _build_tidal_discovery_deps():
@ -21214,33 +21220,7 @@ def get_tidal_sync_status(playlist_id):
@app.route('/api/tidal/sync/cancel/<playlist_id>', methods=['POST'])
def cancel_tidal_sync(playlist_id):
"""Cancel sync for a Tidal playlist"""
try:
if playlist_id not in tidal_discovery_states:
return jsonify({"error": "Tidal playlist not found"}), 404
state = tidal_discovery_states[playlist_id]
state['last_accessed'] = time.time() # Update access time
sync_playlist_id = state.get('sync_playlist_id')
if sync_playlist_id:
# Cancel the sync using existing sync infrastructure
with sync_lock:
sync_states[sync_playlist_id] = {"status": "cancelled"}
# Clean up sync worker
if sync_playlist_id in active_sync_workers:
del active_sync_workers[sync_playlist_id]
# Revert Tidal state
state['phase'] = 'discovered'
state['sync_playlist_id'] = None
state['sync_progress'] = {}
return jsonify({"success": True, "message": "Tidal sync cancelled"})
except Exception as e:
logger.error(f"Error cancelling Tidal sync: {e}")
return jsonify({"error": str(e)}), 500
return _cancel_source_sync(tidal_discovery_states, playlist_id, "Tidal", "Tidal playlist not found")
# ===================================================================
@ -21699,25 +21679,7 @@ def reset_deezer_playlist(playlist_id):
@app.route('/api/deezer/delete/<playlist_id>', methods=['POST'])
def delete_deezer_playlist(playlist_id):
"""Delete Deezer playlist state completely"""
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()
# Remove from state dictionary
del deezer_discovery_states[playlist_id]
logger.info(f"Deleted Deezer playlist state: {playlist_id}")
return jsonify({"success": True, "message": "Playlist deleted"})
except Exception as e:
logger.error(f"Error deleting Deezer playlist: {e}")
return jsonify({"error": str(e)}), 500
return _delete_source_playlist(deezer_discovery_states, playlist_id, "Deezer", "Deezer playlist not found")
@app.route('/api/deezer/update_phase/<playlist_id>', methods=['POST'])
def update_deezer_playlist_phase(playlist_id):
@ -21893,33 +21855,7 @@ def get_deezer_sync_status(playlist_id):
@app.route('/api/deezer/sync/cancel/<playlist_id>', methods=['POST'])
def cancel_deezer_sync(playlist_id):
"""Cancel sync for a Deezer playlist"""
try:
if playlist_id not in deezer_discovery_states:
return jsonify({"error": "Deezer playlist not found"}), 404
state = deezer_discovery_states[playlist_id]
state['last_accessed'] = time.time()
sync_playlist_id = state.get('sync_playlist_id')
if sync_playlist_id:
# Cancel the sync using existing sync infrastructure
with sync_lock:
sync_states[sync_playlist_id] = {"status": "cancelled"}
# Clean up sync worker
if sync_playlist_id in active_sync_workers:
del active_sync_workers[sync_playlist_id]
# Revert Deezer state
state['phase'] = 'discovered'
state['sync_playlist_id'] = None
state['sync_progress'] = {}
return jsonify({"success": True, "message": "Deezer sync cancelled"})
except Exception as e:
logger.error(f"Error cancelling Deezer sync: {e}")
return jsonify({"error": str(e)}), 500
return _cancel_source_sync(deezer_discovery_states, playlist_id, "Deezer", "Deezer playlist not found")
# ===================================================================
@ -22335,23 +22271,7 @@ def reset_qobuz_playlist(playlist_id):
@app.route('/api/qobuz/delete/<playlist_id>', methods=['POST'])
def delete_qobuz_playlist(playlist_id):
"""Delete Qobuz playlist state completely."""
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()
del qobuz_discovery_states[playlist_id]
logger.info(f"Deleted Qobuz playlist state: {playlist_id}")
return jsonify({"success": True, "message": "Playlist deleted"})
except Exception as e:
logger.error(f"Error deleting Qobuz playlist: {e}")
return jsonify({"error": str(e)}), 500
return _delete_source_playlist(qobuz_discovery_states, playlist_id, "Qobuz", "Qobuz playlist not found")
@app.route('/api/qobuz/update_phase/<playlist_id>', methods=['POST'])
@ -22517,29 +22437,7 @@ def get_qobuz_sync_status(playlist_id):
@app.route('/api/qobuz/sync/cancel/<playlist_id>', methods=['POST'])
def cancel_qobuz_sync(playlist_id):
"""Cancel sync for a Qobuz playlist."""
try:
if playlist_id not in qobuz_discovery_states:
return jsonify({"error": "Qobuz playlist not found"}), 404
state = qobuz_discovery_states[playlist_id]
state['last_accessed'] = time.time()
sync_playlist_id = state.get('sync_playlist_id')
if sync_playlist_id:
with sync_lock:
sync_states[sync_playlist_id] = {"status": "cancelled"}
if sync_playlist_id in active_sync_workers:
del active_sync_workers[sync_playlist_id]
state['phase'] = 'discovered'
state['sync_playlist_id'] = None
state['sync_progress'] = {}
return jsonify({"success": True, "message": "Qobuz sync cancelled"})
except Exception as e:
logger.error(f"Error cancelling Qobuz sync: {e}")
return jsonify({"error": str(e)}), 500
return _cancel_source_sync(qobuz_discovery_states, playlist_id, "Qobuz", "Qobuz playlist not found")
# ===================================================================
@ -23239,25 +23137,7 @@ def reset_spotify_public_playlist(url_hash):
@app.route('/api/spotify-public/delete/<url_hash>', methods=['POST'])
def delete_spotify_public_playlist(url_hash):
"""Delete Spotify Public playlist state completely"""
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()
# Remove from state dictionary
del spotify_public_discovery_states[url_hash]
logger.info(f"Deleted Spotify Public playlist state: {url_hash}")
return jsonify({"success": True, "message": "Playlist deleted"})
except Exception as e:
logger.error(f"Error deleting Spotify Public playlist: {e}")
return jsonify({"error": str(e)}), 500
return _delete_source_playlist(spotify_public_discovery_states, url_hash, "Spotify Public", "Spotify Public playlist not found")
@app.route('/api/spotify-public/update_phase/<url_hash>', methods=['POST'])
def update_spotify_public_playlist_phase(url_hash):
@ -23434,33 +23314,7 @@ def get_spotify_public_sync_status(url_hash):
@app.route('/api/spotify-public/sync/cancel/<url_hash>', methods=['POST'])
def cancel_spotify_public_sync(url_hash):
"""Cancel sync for a Spotify Public playlist"""
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]
state['last_accessed'] = time.time()
sync_playlist_id = state.get('sync_playlist_id')
if sync_playlist_id:
# Cancel the sync using existing sync infrastructure
with sync_lock:
sync_states[sync_playlist_id] = {"status": "cancelled"}
# Clean up sync worker
if sync_playlist_id in active_sync_workers:
del active_sync_workers[sync_playlist_id]
# Revert Spotify Public state
state['phase'] = 'discovered'
state['sync_playlist_id'] = None
state['sync_progress'] = {}
return jsonify({"success": True, "message": "Spotify Public sync cancelled"})
except Exception as e:
logger.error(f"Error cancelling Spotify Public sync: {e}")
return jsonify({"error": str(e)}), 500
return _cancel_source_sync(spotify_public_discovery_states, url_hash, "Spotify Public", "Spotify Public playlist not found")
# ===================================================================
@ -23890,24 +23744,7 @@ def get_itunes_link_sync_status(url_hash):
@app.route('/api/itunes-link/sync/cancel/<url_hash>', methods=['POST'])
def cancel_itunes_link_sync(url_hash):
try:
if url_hash not in itunes_link_discovery_states:
return jsonify({"error": "iTunes Link not found"}), 404
state = itunes_link_discovery_states[url_hash]
state['last_accessed'] = time.time()
sync_playlist_id = state.get('sync_playlist_id')
if sync_playlist_id:
with sync_lock:
sync_states[sync_playlist_id] = {"status": "cancelled"}
if sync_playlist_id in active_sync_workers:
del active_sync_workers[sync_playlist_id]
state['phase'] = 'discovered'
state['sync_playlist_id'] = None
state['sync_progress'] = {}
return jsonify({"success": True, "message": "iTunes Link sync cancelled"})
except Exception as e:
logger.error(f"Error cancelling iTunes Link sync: {e}")
return jsonify({"error": str(e)}), 500
return _cancel_source_sync(itunes_link_discovery_states, url_hash, "iTunes Link", "iTunes Link not found")
# ===================================================================
@ -24555,33 +24392,7 @@ def get_youtube_sync_status(url_hash):
@app.route('/api/youtube/sync/cancel/<url_hash>', methods=['POST'])
def cancel_youtube_sync(url_hash):
"""Cancel sync for a YouTube playlist"""
try:
if url_hash not in youtube_playlist_states:
return jsonify({"error": "YouTube playlist not found"}), 404
state = youtube_playlist_states[url_hash]
state['last_accessed'] = time.time() # Update access time
sync_playlist_id = state.get('sync_playlist_id')
if sync_playlist_id:
# Cancel the sync using existing sync infrastructure
with sync_lock:
sync_states[sync_playlist_id] = {"status": "cancelled"}
# Clean up sync worker
if sync_playlist_id in active_sync_workers:
del active_sync_workers[sync_playlist_id]
# Revert YouTube state
state['phase'] = 'discovered'
state['sync_playlist_id'] = None
state['sync_progress'] = {}
return jsonify({"success": True, "message": "YouTube sync cancelled"})
except Exception as e:
logger.error(f"Error cancelling YouTube sync: {e}")
return jsonify({"error": str(e)}), 500
return _cancel_source_sync(youtube_playlist_states, url_hash, "YouTube", "YouTube playlist not found")
# New YouTube Playlist Management Endpoints (for persistent state)
@ -30690,34 +30501,7 @@ def get_listenbrainz_sync_status(playlist_mbid):
@app.route('/api/listenbrainz/sync/cancel/<playlist_mbid>', methods=['POST'])
def cancel_listenbrainz_sync(playlist_mbid):
"""Cancel sync for a ListenBrainz playlist"""
try:
state_key = _lb_state_key(playlist_mbid)
if state_key not in listenbrainz_playlist_states:
return jsonify({"error": "ListenBrainz playlist not found"}), 404
state = listenbrainz_playlist_states[state_key]
state['last_accessed'] = time.time() # Update access time
sync_playlist_id = state.get('sync_playlist_id')
if sync_playlist_id:
# Cancel the sync using existing sync infrastructure
with sync_lock:
sync_states[sync_playlist_id] = {"status": "cancelled"}
# Clean up sync worker
if sync_playlist_id in active_sync_workers:
del active_sync_workers[sync_playlist_id]
# Revert ListenBrainz state
state['phase'] = 'discovered'
state['sync_playlist_id'] = None
state['sync_progress'] = {}
return jsonify({"success": True, "message": "ListenBrainz sync cancelled"})
except Exception as e:
logger.error(f"Error cancelling ListenBrainz sync: {e}")
return jsonify({"error": str(e)}), 500
return _cancel_source_sync(listenbrainz_playlist_states, _lb_state_key(playlist_mbid), "ListenBrainz", "ListenBrainz playlist not found")
@app.route('/api/metadata/start', methods=['POST'])
def start_metadata_update():