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, } })