import { defineStore } from 'pinia' import { ref, computed } from 'vue' import { useApi } from '../composables/useApi.js' import { useAsyncAction } from '../composables/useAsyncAction.js' export const useSourcesStore = defineStore('sources', () => { const api = useApi() // keyed cache: null => all sources, number => artist_id filtered const byArtist = ref(new Map()) const { loading, error, run } = useAsyncAction() const scheduleStatus = ref(null) const allSources = computed(() => byArtist.value.get(null) ?? []) async function loadAll() { await run(async () => { const body = await api.get('/api/sources') byArtist.value.set(null, body) }) } async function loadForArtist(artistId) { await run(async () => { const body = await api.get('/api/sources', { params: { artist_id: artistId } }) byArtist.value.set(artistId, body) }) } function _invalidate(artistId) { byArtist.value.delete(null) if (artistId != null) byArtist.value.delete(artistId) } // Patch a single updated source into every cached array IN PLACE, rather than // invalidating + refetching the whole list. The full refetch was what locked // the Subscriptions UI and collapsed the expanded row after every inline // action (check / backfill / recover / toggle) — operator-flagged 2026-06-07. // Map.set on a Vue-reactive ref(Map) triggers re-render; we hand each cache a // NEW array so the table diffs just the one changed row. function _patchSource(updated) { if (!updated || updated.id == null) return for (const [key, arr] of byArtist.value) { const i = arr.findIndex((s) => s.id === updated.id) if (i === -1) continue const next = arr.slice() next[i] = { ...arr[i], ...updated } byArtist.value.set(key, next) } } function _dropSource(id) { for (const [key, arr] of byArtist.value) { if (arr.some((s) => s.id === id)) { byArtist.value.set(key, arr.filter((s) => s.id !== id)) } } } async function create(payload) { const body = await api.post('/api/sources', { body: payload }) _invalidate(payload.artist_id) return body } async function update(id, patch, artistIdHint = null) { const body = await api.patch(`/api/sources/${id}`, { body: patch }) _patchSource(body) return body } async function remove(id, artistIdHint = null) { await api.delete(`/api/sources/${id}`) _dropSource(id) } async function findOrCreateArtist(name) { const body = await api.post('/api/artists', { body: { name } }) return { artist: { id: body.id, name: body.name, slug: body.slug }, created: body.created } } async function autocompleteArtist(query, limit = 20) { if (!query || !query.trim()) return [] return await api.get('/api/artists/autocomplete', { params: { q: query, limit } }) } // #130: move a source (+ its content) to another artist. Files don't move // (slug is immutable); the backend re-attributes source/posts/images and // deletes the old artist if it's left empty. async function reassign(id, targetArtistId) { return await api.post(`/api/sources/${id}/reassign`, { body: { target_artist_id: targetArtistId }, }) } async function loadScheduleStatus() { scheduleStatus.value = await api.get('/api/sources/schedule-status') return scheduleStatus.value } // FC-3c: trigger a download for one source. Returns {download_event_id,status}. const checkingIds = ref(new Set()) // force=true bypasses the platform-rate-limit cooldown gate. Single- // source RETRY clicks pass it (operator-explicit override, useful for // rapid auth-fix testing); bulk RETRY ALL / MaintenanceMenu retries // leave it off so the cooldown does its preventive job. async function checkNow(id, { force = false } = {}) { checkingIds.value = new Set(checkingIds.value).add(id) try { const url = force ? `/api/sources/${id}/check?force=true` : `/api/sources/${id}/check` return await api.post(url) } finally { const next = new Set(checkingIds.value) next.delete(id) checkingIds.value = next } } // Plan #693: start/stop a run-until-done backfill. 'start' walks the full // post history in time-boxed chunks until it reaches the bottom (then the // source shows backfill_state 'complete'); 'stop' cancels back to tick mode. async function startBackfill(id, artistIdHint = null) { const body = await api.post(`/api/sources/${id}/backfill`, { body: { action: 'start' } }) _patchSource(body) return body } async function stopBackfill(id, artistIdHint = null) { const body = await api.post(`/api/sources/${id}/backfill`, { body: { action: 'stop' } }) _patchSource(body) return body } // Plan #697: arm a recovery walk — a backfill that bypasses the Patreon // seen-ledger to re-fetch dropped-and-deleted near-dups and re-evaluate them // under the current pHash threshold. Stop with stopBackfill (shared lifecycle). async function recoverSource(id, artistIdHint = null) { const body = await api.post(`/api/sources/${id}/backfill`, { body: { action: 'recover' } }) _patchSource(body) return body } // #830: arm a recapture walk (Patreon-only) — re-grab every post's body + // external links and localize on-disk inline images, WITHOUT re-downloading // media. Shares the backfill lifecycle/badge; stop via stopBackfill. async function recaptureSource(id, artistIdHint = null) { const body = await api.post(`/api/sources/${id}/backfill`, { body: { action: 'recapture' } }) _patchSource(body) return body } function sourcesByArtistGrouped() { // returns [{artist: {id,name,slug}, sources: [...]}, ...] const arr = byArtist.value.get(null) ?? [] const groups = new Map() for (const s of arr) { const key = s.artist_id if (!groups.has(key)) { groups.set(key, { artist: { id: s.artist_id, name: s.artist_name, slug: s.artist_slug }, sources: [], }) } groups.get(key).sources.push(s) } return Array.from(groups.values()).sort( (a, b) => a.artist.name.localeCompare(b.artist.name) ) } return { byArtist, loading, error, scheduleStatus, allSources, checkingIds, loadAll, loadForArtist, create, update, remove, checkNow, startBackfill, stopBackfill, recoverSource, recaptureSource, findOrCreateArtist, autocompleteArtist, reassign, loadScheduleStatus, sourcesByArtistGrouped, } })