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