diff --git a/backend/app/api/downloads.py b/backend/app/api/downloads.py index ee648ef..c3ae536 100644 --- a/backend/app/api/downloads.py +++ b/backend/app/api/downloads.py @@ -187,3 +187,20 @@ async def get_download(event_id: int): return jsonify({"error": "not_found"}), 404 event, source, artist = row return jsonify(_detail_record(event, source, artist)) + + +@downloads_bp.route("/recover-stalled", methods=["POST"]) +async def recover_stalled(): + """Trigger the recover_stalled_download_events sweep on demand. + + The same sweep runs every 5 min via Beat (see celery_app.beat_schedule); + this endpoint exists so the operator can force-clear stuck pending/ + running download_events from the Subscriptions → Downloads maintenance + menu without waiting for the next scheduled tick. + """ + # Local import: avoids registering maintenance tasks during blueprint + # import (Celery task discovery races with the API import otherwise). + from ..tasks.maintenance import recover_stalled_download_events + + recover_stalled_download_events.delay() + return jsonify({"queued": True}), 202 diff --git a/frontend/src/components/subscriptions/DownloadsTab.vue b/frontend/src/components/subscriptions/DownloadsTab.vue index a1f5a15..0307c63 100644 --- a/frontend/src/components/subscriptions/DownloadsTab.vue +++ b/frontend/src/components/subscriptions/DownloadsTab.vue @@ -22,7 +22,10 @@ mdi-refresh Refresh - +
@@ -207,6 +210,24 @@ async function onRetryAll(sources) { toast({ text: parts.join(', ') || 'Nothing to retry', type: 'info' }) } +// Manual trigger for recover_stalled_download_events. The sweep runs on +// Beat every 5 min; this lets the operator force-clear stuck pending/ +// running events on demand. Fire-and-forget: the API returns 202 once the +// task is dispatched, and we refresh after a few seconds so swept rows +// show up in the failing rollup. +async function onRecoverStalled() { + try { + await store.recoverStalled() + toast({ + text: 'Recovery sweep queued — refreshing in a few seconds', + type: 'success', + }) + setTimeout(refresh, 4000) + } catch (e) { + toast({ text: `Sweep failed: ${e?.detail || e?.message || e}`, type: 'error' }) + } +} + // Live auto-refresh: while any download is queued or running, poll the // stats + first page every 4s so the operator can watch events succeed/ // fail in real time without hitting Refresh. Polling stops automatically diff --git a/frontend/src/components/subscriptions/MaintenanceMenu.vue b/frontend/src/components/subscriptions/MaintenanceMenu.vue index b89d045..ffae2c9 100644 --- a/frontend/src/components/subscriptions/MaintenanceMenu.vue +++ b/frontend/src/components/subscriptions/MaintenanceMenu.vue @@ -9,14 +9,14 @@ diff --git a/frontend/src/stores/downloads.js b/frontend/src/stores/downloads.js index 940d124..ee274c6 100644 --- a/frontend/src/stores/downloads.js +++ b/frontend/src/stores/downloads.js @@ -88,10 +88,17 @@ export const useDownloadsStore = defineStore('downloads', () => { return activeEvents.value } + // POSTs to the download-recovery sweep endpoint (fire-and-forget — the + // Beat schedule also runs it every 5 min). The caller should refresh + // failing/stats a few seconds after this resolves to see swept rows. + async function recoverStalled() { + return api.post('/api/downloads/recover-stalled') + } + return { events, cursor, hasMore, filter, selected, loading, error, stats, activity, failing, activeEvents, loadFirst, loadMore, loadOne, applyFilter, closeDetail, loadStats, - loadActivity, loadFailing, loadActive, + loadActivity, loadFailing, loadActive, recoverStalled, } }) diff --git a/tests/test_api_downloads.py b/tests/test_api_downloads.py index b20f53b..e61e927 100644 --- a/tests/test_api_downloads.py +++ b/tests/test_api_downloads.py @@ -134,3 +134,23 @@ async def test_activity_returns_fixed_bucket_array(client, seed): async def test_activity_rejects_bogus_hours(client): resp = await client.get("/api/downloads/activity?hours=bogus") assert resp.status_code == 400 + + +@pytest.mark.asyncio +async def test_recover_stalled_dispatches_task(client, monkeypatch): + """POST /api/downloads/recover-stalled returns 202 and dispatches the + recover_stalled_download_events task. The task body itself is exercised + in test_maintenance.py — this just covers the API → Celery hand-off.""" + from backend.app.tasks import maintenance + + called: list[tuple] = [] + monkeypatch.setattr( + maintenance.recover_stalled_download_events, "delay", + lambda *a, **kw: called.append((a, kw)), + ) + + resp = await client.post("/api/downloads/recover-stalled") + assert resp.status_code == 202 + body = await resp.get_json() + assert body == {"queued": True} + assert len(called) == 1