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
173 lines
6.0 KiB
JavaScript
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,
|
|
}
|
|
})
|