77e9859da3
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>
105 lines
3.5 KiB
JavaScript
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,
|
|
}
|
|
})
|