Files
FabledCurator/frontend/src/stores/downloads.js
T
bvandeusenandClaude Fable 5 dc7fa6eae2
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 38s
CI / integration (push) Successful in 3m30s
feat(ia): wave 3 — Subscriptions landing answers 'what needs me, what came in?'
Daily-use reorder of the Subscriptions tab: needs-attention strip first
(FailingSourcesCard moves up from below the Downloads fold — a broken
subscription was invisible unless you went looking), then a new Recent
arrivals card (real downloads only, no-change scans filtered out, artist
links), then the source list. Both cards render nothing when there's nothing
to say.

Retry logic moves into the downloads store (retrySource / retryAllFailing) so
the needs-attention card and the Downloads maintenance menu share one
implementation — single-retry forces past cooldown, bulk keeps cooldown
enforcement, same tally shape. The card's Logs button deep-links into the
Downloads tab pre-filtered (?source_id now watched, not just read on mount).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
2026-07-02 17:52:00 -04:00

173 lines
6.0 KiB
JavaScript

import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useApi } from '../composables/useApi.js'
import { useAsyncAction } from '../composables/useAsyncAction.js'
import { useInflightToken } from '../composables/useInflightToken.js'
import { useSourcesStore } from './sources.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([])
// Filter changes (applyFilter) and rapid pagination can interleave
// responses; without an inflight guard the late response from a
// prior filter overwrites the current view. Audit 2026-06-02.
const inflight = useInflightToken()
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() {
const t = inflight.claim()
await run(async () => {
const body = await api.get('/api/downloads', { params: _params() })
if (!t.isCurrent()) return
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
const t = inflight.claim()
await run(async () => {
const body = await api.get('/api/downloads', { params: _params({ before: cursor.value }) })
if (!t.isCurrent()) return
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
}
// Open the detail modal for the most recent DownloadEvent of a given
// source. Used by the failing-sources rollup's "Logs" button so the
// operator can troubleshoot without leaving the Downloads tab to find
// the row (operator-flagged 2026-06-01).
async function loadLastForSource(sourceId) {
const events = await api.get('/api/downloads', {
params: { source_id: sourceId, limit: 1 },
})
if (!events.length) {
selected.value = null
return null
}
return await loadOne(events[0].id)
}
async function applyFilter(patch) {
// Drop any in-flight loadFirst/loadMore from the previous filter
// so its late response doesn't overwrite this filter's results.
inflight.cancel()
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
}
// --- Failing-source retries (shared by the Subscriptions needs-attention
// card and the Downloads maintenance menu — one implementation for both
// surfaces). Data-only: callers own the toasts, per this store's style.
// A single deliberate retry forces past cooldown; BULK retries keep
// cooldown enforcement ON so N failing sources on one platform don't all
// retry into the very rate limit the cooldown is preventing.
async function retrySource(source) {
const sourcesStore = useSourcesStore()
try {
await sourcesStore.checkNow(source.id, { force: true })
return 'queued'
} catch (e) {
if (e?.body?.download_event_id) return 'already_running'
throw e
} finally {
await Promise.all([loadFailing(), loadStats(24)])
}
}
async function retryAllFailing(sources) {
const sourcesStore = useSourcesStore()
const tally = { ok: 0, deferred: 0, conflict: 0 }
try {
for (const s of sources) {
try {
const body = await sourcesStore.checkNow(s.id)
if (body?.status === 'deferred') tally.deferred += 1
else tally.ok += 1
} catch (e) {
if (e?.body?.download_event_id) tally.conflict += 1
}
}
} finally {
await Promise.all([loadFailing(), loadStats(24)])
}
return tally
}
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, loadLastForSource, applyFilter,
closeDetail, loadStats,
loadActivity, loadFailing, loadActive, recoverStalled,
retrySource, retryAllFailing,
}
})