Files
FabledCurator/frontend/src/stores/downloads.js
T
bvandeusen 77e9859da3 fix(downloads): rewire MaintenanceMenu to the downloads pipeline
The maintenance dropdown in Subscriptions → Downloads was wired to the
filesystem-import pipeline (POST /api/import/retry-failed +
POST /api/import/clear-stuck) — the subtitles even said so ("Re-enqueue
every failed import task"), but it was contextually misplaced. From the
Downloads view "Retry failed" queued nothing the operator could see
because the action operated on import_task rows, not download_event
rows. Import-pipeline maintenance is already reachable from Settings →
Imports (ImportTaskList.vue), so removing the import wiring loses
nothing.

Rewired:
- "Retry failed" → bulk-retries the failing-sources list, same loop as
  FailingSourcesCard's RETRY ALL (sourcesStore.checkNow per source).
  Subtitle now matches: "Re-queue every currently failing source".
- "Force recovery sweep" → triggers recover_stalled_download_events on
  demand via a new POST /api/downloads/recover-stalled endpoint. The
  sweep also runs every 5 min on Beat; this is the manual fallback so
  the operator doesn't have to wait for the next tick to clear newly
  stranded events.

MaintenanceMenu is now stateless — emits retry-failed and recover-
stalled. DownloadsTab owns the handlers (reuses the existing
onRetryAll; new onRecoverStalled with a delayed refresh so swept rows
land in the failing rollup).

Operator-flagged 2026-05-29 — "the retry failed button in the
maintenance dropdown doesn't appear to queue anything but manual
requeues works."

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 00:59:05 -04:00

105 lines
3.5 KiB
JavaScript

import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useApi } from '../composables/useApi.js'
import { useAsyncAction } from '../composables/useAsyncAction.js'
export const useDownloadsStore = defineStore('downloads', () => {
const api = useApi()
const events = ref([])
const cursor = ref(null)
const hasMore = ref(true)
const filter = ref({
status: null, source_id: null, artist_id: null,
from_date: null, to_date: null,
})
const selected = ref(null)
const { loading, error, run } = useAsyncAction()
const stats = ref({ pending: 0, running: 0, ok: 0, error: 0, skipped: 0 })
const activity = ref({ hours: 24, buckets: [] })
const failing = ref([])
// Running + queued events, fetched independent of the feed's filter so
// the "active now" panel always reflects what's happening regardless of
// how the operator has filtered the historical list below.
const activeEvents = ref([])
function _params(extra = {}) {
const out = { limit: 50, ...extra }
if (filter.value.status) out.status = filter.value.status
if (filter.value.source_id != null) out.source_id = filter.value.source_id
if (filter.value.artist_id != null) out.artist_id = filter.value.artist_id
return out
}
async function loadFirst() {
await run(async () => {
const body = await api.get('/api/downloads', { params: _params() })
events.value = body
cursor.value = body.length ? body[body.length - 1].id : null
hasMore.value = body.length === 50
})
}
async function loadMore() {
if (!hasMore.value || cursor.value == null) return
await run(async () => {
const body = await api.get('/api/downloads', { params: _params({ before: cursor.value }) })
events.value.push(...body)
cursor.value = body.length ? body[body.length - 1].id : cursor.value
hasMore.value = body.length === 50
})
}
async function loadOne(id) {
selected.value = await api.get(`/api/downloads/${id}`)
return selected.value
}
async function applyFilter(patch) {
filter.value = { ...filter.value, ...patch }
await loadFirst()
}
function closeDetail() {
selected.value = null
}
async function loadStats(windowHours = 24) {
stats.value = await api.get('/api/downloads/stats', { params: { window_hours: windowHours } })
return stats.value
}
async function loadActivity(hours = 24) {
activity.value = await api.get('/api/downloads/activity', { params: { hours } })
return activity.value
}
async function loadFailing() {
failing.value = await api.get('/api/sources', { params: { failing: true } })
return failing.value
}
async function loadActive() {
const [running, pending] = await Promise.all([
api.get('/api/downloads', { params: { status: 'running', limit: 50 } }),
api.get('/api/downloads', { params: { status: 'pending', limit: 50 } }),
])
activeEvents.value = [...running, ...pending]
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, recoverStalled,
}
})