b7d07324ee
Lock/reload: every inline source action (check / backfill / recover / toggle / remove) ended by refetching the WHOLE subscription list (store.loadAll / refresh), which blocked the UI and re-rendered the table — collapsing the expanded row, which read as "locks then resets." The action APIs already return the updated source, so the store now patches that one row in place (_patchSource / _dropSource); the post-action loadAll/refresh calls are gone. Toggling enabled, starting/stopping a backfill, recovering, and removing are now instant and leave the expansion intact. Button regroup (operator-flagged: tiny, mis-clickable, not grouped by function): - New shared SourceActions.vue used by desktop SourceRow + mobile SourceCard. - Frequent actions stay as size="small" buttons: Check, Backfill/Stop. - Low-frequency / destructive actions move into a labelled overflow (⋮) menu — Preview backfill, Recover dropped near-duplicates, Remove source — so they can't be fat-fingered, and the labels spell out recover-vs-backfill. - Edit moves next to the source URL (its identity), out of the action cluster where it sat beside Remove. - Single-source Remove now confirms (it had no guard before). Tests: store patches/drops in place without dropping the cache. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
779 lines
26 KiB
Vue
779 lines
26 KiB
Vue
<template>
|
|
<div>
|
|
<SchedulerStatusBar :status="store.scheduleStatus" class="fc-subs__sched" />
|
|
|
|
<div class="fc-subs__bar">
|
|
<v-btn color="accent" prepend-icon="mdi-plus" @click="openAddSource(null)">
|
|
Add subscription
|
|
</v-btn>
|
|
<v-btn variant="outlined" prepend-icon="mdi-account-plus" @click="showArtistDialog = true">
|
|
New artist
|
|
</v-btn>
|
|
<v-chip
|
|
:color="needsAttention ? 'error' : undefined"
|
|
:variant="needsAttention ? 'flat' : 'outlined'"
|
|
prepend-icon="mdi-alert-circle-outline"
|
|
@click="needsAttention = !needsAttention"
|
|
>
|
|
Needs attention
|
|
<v-chip v-if="attentionCount" size="x-small" class="ms-2" variant="tonal">
|
|
{{ attentionCount }}
|
|
</v-chip>
|
|
</v-chip>
|
|
<v-spacer />
|
|
<v-select
|
|
v-model="statusFilter"
|
|
:items="STATUS_OPTIONS"
|
|
:disabled="needsAttention"
|
|
density="compact" variant="outlined" hide-details
|
|
class="fc-subs__status"
|
|
/>
|
|
<v-text-field
|
|
v-model="search"
|
|
density="compact" variant="outlined" hide-details clearable
|
|
prepend-inner-icon="mdi-magnify"
|
|
placeholder="Search subscriptions"
|
|
class="fc-subs__search"
|
|
/>
|
|
</div>
|
|
|
|
<v-slide-y-transition>
|
|
<v-card
|
|
v-if="selected.length"
|
|
variant="tonal" color="info"
|
|
class="fc-subs__bulk mb-3"
|
|
>
|
|
<v-card-text class="d-flex align-center pa-3 ga-3">
|
|
<span class="text-body-2">
|
|
{{ selected.length }} selected
|
|
</span>
|
|
<v-spacer />
|
|
<v-btn size="small" variant="text" prepend-icon="mdi-toggle-switch" @click="bulkSetEnabled(true)">
|
|
Enable all
|
|
</v-btn>
|
|
<v-btn size="small" variant="text" prepend-icon="mdi-toggle-switch-off-outline" @click="bulkSetEnabled(false)">
|
|
Disable all
|
|
</v-btn>
|
|
<v-btn size="small" variant="text" color="error" prepend-icon="mdi-delete" @click="bulkDelete">
|
|
Delete
|
|
</v-btn>
|
|
<v-btn size="small" variant="text" @click="selected = []">Clear</v-btn>
|
|
</v-card-text>
|
|
</v-card>
|
|
</v-slide-y-transition>
|
|
|
|
<v-alert v-if="store.error" type="error" variant="tonal" closable class="mb-3">
|
|
{{ String(store.error) }}
|
|
</v-alert>
|
|
|
|
<div v-if="store.loading && groups.length === 0" class="fc-subs__loading">
|
|
<v-progress-circular indeterminate color="accent" size="36" />
|
|
</div>
|
|
|
|
<div v-else-if="filteredGroups.length === 0" class="fc-subs__empty">
|
|
<p v-if="groups.length === 0">No subscriptions yet. Add your first artist.</p>
|
|
<p v-else>No subscriptions match the current filter.</p>
|
|
</div>
|
|
|
|
<v-card v-else-if="!isMobile" class="fc-subs__card" variant="outlined">
|
|
<v-data-table
|
|
:headers="headers"
|
|
:items="filteredGroups"
|
|
item-value="key"
|
|
v-model="selected"
|
|
v-model:expanded="expanded"
|
|
:items-per-page="50"
|
|
:items-per-page-options="ITEMS_PER_PAGE_OPTIONS"
|
|
density="comfortable"
|
|
hover show-select show-expand
|
|
@click:row="onRowClick"
|
|
>
|
|
<template #item.name="{ item }">
|
|
<span class="fc-subs__name">{{ item.artist.name }}</span>
|
|
</template>
|
|
|
|
<template #item.platforms="{ item }">
|
|
<div class="fc-subs__chips">
|
|
<PlatformChip
|
|
v-for="p in item.platforms" :key="p"
|
|
:platform="p" size="x-small"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<template #item.sources_count="{ item }">
|
|
<v-chip size="x-small" variant="tonal" label>
|
|
{{ item.sources.length }}
|
|
</v-chip>
|
|
</template>
|
|
|
|
<template #item.health="{ item }">
|
|
<SourceHealthDot
|
|
v-if="item.worstSource"
|
|
:source="item.worstSource"
|
|
:warning-threshold="failureThreshold"
|
|
/>
|
|
<span v-else class="fc-subs__zero">—</span>
|
|
</template>
|
|
|
|
<template #item.last_activity="{ item }">
|
|
<span class="fc-subs__when">{{ formatRelative(item.lastActivity) }}</span>
|
|
</template>
|
|
|
|
<template #item.actions="{ item }">
|
|
<v-btn
|
|
icon size="small" variant="text"
|
|
:loading="anyChecking(item.sources)"
|
|
@click.stop="checkAll(item)"
|
|
>
|
|
<v-icon>mdi-refresh</v-icon>
|
|
<v-tooltip activator="parent" location="top">Check all sources</v-tooltip>
|
|
</v-btn>
|
|
<v-btn icon size="small" variant="text" @click.stop="openAddSource(item.artist)">
|
|
<v-icon>mdi-plus</v-icon>
|
|
<v-tooltip activator="parent" location="top">Add source</v-tooltip>
|
|
</v-btn>
|
|
<v-btn
|
|
icon size="small" variant="text"
|
|
:to="`/posts?artist_id=${item.artist.id}`" @click.stop
|
|
>
|
|
<v-icon>mdi-rss</v-icon>
|
|
<v-tooltip activator="parent" location="top">View posts</v-tooltip>
|
|
</v-btn>
|
|
<v-btn
|
|
icon size="small" variant="text"
|
|
:to="`/artist/${item.artist.slug}`" @click.stop
|
|
>
|
|
<v-icon>mdi-account</v-icon>
|
|
<v-tooltip activator="parent" location="top">Open artist page</v-tooltip>
|
|
</v-btn>
|
|
</template>
|
|
|
|
<template #expanded-row="{ columns, item }">
|
|
<tr class="fc-subs__sources-row">
|
|
<td :colspan="columns.length" class="fc-subs__sources-cell">
|
|
<v-table density="compact" class="fc-subs__sources-table">
|
|
<thead>
|
|
<tr>
|
|
<th></th>
|
|
<th>Platform</th>
|
|
<th>URL</th>
|
|
<th>Enabled</th>
|
|
<th>Last check</th>
|
|
<th>Next check</th>
|
|
<th>Errors</th>
|
|
<th class="text-right">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<SourceRow
|
|
v-for="s in item.sources" :key="s.id" :source="s"
|
|
:checking="store.checkingIds.has(s.id)"
|
|
:warning-threshold="failureThreshold"
|
|
@edit="openEditSource"
|
|
@remove="removeSource"
|
|
@toggle="toggleSourceEnabled"
|
|
@check="onCheck"
|
|
@backfill="onBackfill"
|
|
@recover="onRecover"
|
|
@preview="onPreview"
|
|
/>
|
|
<tr v-if="item.sources.length === 0">
|
|
<td colspan="8" class="fc-subs__sources-empty">
|
|
No sources yet. Click + to add one.
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</v-table>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</v-data-table>
|
|
</v-card>
|
|
|
|
<!-- Mobile: compact cards (several fit per screen), expanding to STACKED
|
|
source cards so the wide source columns never force lateral scroll. -->
|
|
<div v-else class="fc-subs__mlist">
|
|
<div
|
|
v-for="item in filteredGroups" :key="item.key"
|
|
class="fc-subs__mcard"
|
|
>
|
|
<div class="fc-subs__mhead" @click="toggleExpand(item)">
|
|
<v-checkbox-btn
|
|
:model-value="isSelected(item)" density="compact" hide-details
|
|
@click.stop @update:model-value="toggleSelect(item)"
|
|
/>
|
|
<span class="fc-subs__name">{{ item.artist.name }}</span>
|
|
<v-spacer />
|
|
<SourceHealthDot
|
|
v-if="item.worstSource"
|
|
:source="item.worstSource" :warning-threshold="failureThreshold"
|
|
/>
|
|
<v-icon size="small">
|
|
{{ isExpanded(item) ? 'mdi-chevron-up' : 'mdi-chevron-down' }}
|
|
</v-icon>
|
|
</div>
|
|
<div class="fc-subs__mmeta">
|
|
<PlatformChip
|
|
v-for="p in item.platforms" :key="p" :platform="p" size="x-small"
|
|
/>
|
|
<span class="fc-subs__mmeta-text">
|
|
{{ item.sources.length }} src · {{ formatRelative(item.lastActivity) }}
|
|
</span>
|
|
</div>
|
|
|
|
<div v-if="isExpanded(item)" class="fc-subs__mbody">
|
|
<div class="fc-subs__mactions">
|
|
<v-btn
|
|
size="small" variant="text" :loading="anyChecking(item.sources)"
|
|
@click="checkAll(item)"
|
|
>
|
|
<v-icon>mdi-refresh</v-icon>
|
|
<v-tooltip activator="parent" location="top">Check all</v-tooltip>
|
|
</v-btn>
|
|
<v-btn size="small" variant="text" @click="openAddSource(item.artist)">
|
|
<v-icon>mdi-plus</v-icon>
|
|
<v-tooltip activator="parent" location="top">Add source</v-tooltip>
|
|
</v-btn>
|
|
<v-btn size="small" variant="text" :to="`/posts?artist_id=${item.artist.id}`">
|
|
<v-icon>mdi-rss</v-icon>
|
|
<v-tooltip activator="parent" location="top">View posts</v-tooltip>
|
|
</v-btn>
|
|
<v-btn size="small" variant="text" :to="`/artist/${item.artist.slug}`">
|
|
<v-icon>mdi-account</v-icon>
|
|
<v-tooltip activator="parent" location="top">Artist page</v-tooltip>
|
|
</v-btn>
|
|
</div>
|
|
<SourceCard
|
|
v-for="s in item.sources" :key="s.id" :source="s"
|
|
:checking="store.checkingIds.has(s.id)"
|
|
:warning-threshold="failureThreshold"
|
|
@edit="openEditSource"
|
|
@remove="removeSource"
|
|
@toggle="toggleSourceEnabled"
|
|
@check="onCheck"
|
|
@backfill="onBackfill"
|
|
@recover="onRecover"
|
|
@preview="onPreview"
|
|
/>
|
|
<div v-if="item.sources.length === 0" class="fc-subs__sources-empty">
|
|
No sources yet. Tap + to add one.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<SourceFormDialog
|
|
v-model="showSourceDialog"
|
|
:source="editingSource"
|
|
:initial-artist="editingArtist"
|
|
@saved="onSourceSaved"
|
|
/>
|
|
<ArtistCreateDialog v-model="showArtistDialog" @created="onArtistCreated" />
|
|
<PreviewDialog
|
|
v-model="showPreviewDialog"
|
|
:source="previewTarget"
|
|
@backfill="onPreviewBackfill"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { toast } from '../../utils/toast.js'
|
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
|
import { useDisplay } from 'vuetify'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import { useSourcesStore } from '../../stores/sources.js'
|
|
import { usePlatformsStore } from '../../stores/platforms.js'
|
|
import { useImportStore } from '../../stores/import.js'
|
|
import SourceRow from './SourceRow.vue'
|
|
import SourceCard from './SourceCard.vue'
|
|
import SourceHealthDot from './SourceHealthDot.vue'
|
|
import SourceFormDialog from './SourceFormDialog.vue'
|
|
import ArtistCreateDialog from './ArtistCreateDialog.vue'
|
|
import PreviewDialog from './PreviewDialog.vue'
|
|
import PlatformChip from './PlatformChip.vue'
|
|
import SchedulerStatusBar from './SchedulerStatusBar.vue'
|
|
import { formatRelative } from '../../utils/date.js'
|
|
|
|
const ITEMS_PER_PAGE_OPTIONS = [
|
|
{ value: 25, title: '25' },
|
|
{ value: 50, title: '50' },
|
|
{ value: 100, title: '100' },
|
|
{ value: -1, title: 'All' },
|
|
]
|
|
const STATUS_OPTIONS = [
|
|
{ title: 'All status', value: 'all' },
|
|
{ title: 'Enabled', value: 'enabled' },
|
|
{ title: 'Disabled', value: 'disabled' },
|
|
{ title: 'Has errors', value: 'errors' },
|
|
{ title: 'Stale', value: 'stale' },
|
|
]
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const display = useDisplay()
|
|
// Match the 600px breakpoint the rest of the hub uses; below it the table is
|
|
// replaced by the custom compact-card list.
|
|
const isMobile = computed(() => display.width.value < 600)
|
|
const store = useSourcesStore()
|
|
const platformsStore = usePlatformsStore()
|
|
const importStore = useImportStore()
|
|
|
|
const search = ref('')
|
|
const statusFilter = ref('all')
|
|
const needsAttention = ref(false)
|
|
const expanded = ref([])
|
|
const selected = ref([])
|
|
|
|
// Mobile card list drives the same `selected`/`expanded` key arrays the
|
|
// desktop v-data-table binds, so selection + bulk actions work identically.
|
|
function _toggleKey(arr, key) {
|
|
const i = arr.value.indexOf(key)
|
|
if (i === -1) arr.value = [...arr.value, key]
|
|
else arr.value = arr.value.filter((k) => k !== key)
|
|
}
|
|
function isSelected(item) { return selected.value.includes(item.key) }
|
|
function toggleSelect(item) { _toggleKey(selected, item.key) }
|
|
function isExpanded(item) { return expanded.value.includes(item.key) }
|
|
function toggleExpand(item) { _toggleKey(expanded, item.key) }
|
|
|
|
const showSourceDialog = ref(false)
|
|
const editingSource = ref(null)
|
|
const editingArtist = ref(null)
|
|
const showArtistDialog = ref(false)
|
|
const showPreviewDialog = ref(false)
|
|
const previewTarget = ref(null)
|
|
|
|
const artistFilter = computed(() => {
|
|
const raw = route.query.artist_id
|
|
return raw == null ? null : Number(raw)
|
|
})
|
|
|
|
const failureThreshold = computed(() =>
|
|
importStore.settings?.download_failure_warning_threshold ?? 5,
|
|
)
|
|
|
|
async function refresh() {
|
|
await store.loadAll()
|
|
await platformsStore.loadAll()
|
|
store.loadScheduleStatus()
|
|
if (!importStore.settings) await importStore.loadSettings()
|
|
}
|
|
|
|
// Re-poll scheduler status every 30s so the "last ran" reading + health
|
|
// dot stay honest (the Beat tick fires every 60s).
|
|
let schedId = null
|
|
onMounted(() => {
|
|
refresh()
|
|
if (artistFilter.value != null) {
|
|
expanded.value = [`artist-${artistFilter.value}`]
|
|
}
|
|
schedId = setInterval(() => {
|
|
if (!document.hidden) store.loadScheduleStatus()
|
|
}, 30000)
|
|
})
|
|
onUnmounted(() => {
|
|
if (schedId) { clearInterval(schedId); schedId = null }
|
|
})
|
|
watch(() => route.query.artist_id, refresh)
|
|
|
|
const headers = [
|
|
{ title: 'Subscription', key: 'name', sortable: true, align: 'start' },
|
|
{ title: 'Platforms', key: 'platforms', sortable: false, align: 'start', width: 240 },
|
|
{ title: 'Sources', key: 'sources_count', sortable: true, align: 'start', width: 90 },
|
|
{ title: 'Health', key: 'health', sortable: false, align: 'start', width: 80 },
|
|
{ title: 'Last activity',key: 'last_activity', sortable: true, align: 'start', width: 140 },
|
|
{ title: 'Actions', key: 'actions', sortable: false, align: 'end', width: 200 },
|
|
]
|
|
|
|
const groups = computed(() => {
|
|
const all = store.sourcesByArtistGrouped()
|
|
return all.map((g) => {
|
|
const worstSource = pickWorstSource(g.sources, failureThreshold.value)
|
|
const lastActivity = pickLastActivity(g.sources)
|
|
const platforms = [...new Set(g.sources.map((s) => s.platform).filter(Boolean))]
|
|
return {
|
|
key: `artist-${g.artist.id}`,
|
|
artist: g.artist,
|
|
sources: g.sources,
|
|
sources_count: g.sources.length,
|
|
platforms,
|
|
worstSource,
|
|
lastActivity,
|
|
name: g.artist.name,
|
|
last_activity: lastActivity ?? '',
|
|
}
|
|
})
|
|
})
|
|
|
|
// A group "needs attention" if any of its sources has accumulated
|
|
// failures OR has never been checked — the ones worth acting on.
|
|
function groupNeedsAttention(g) {
|
|
return groupMatchesStatus(g, 'errors') || groupMatchesStatus(g, 'stale')
|
|
}
|
|
const attentionCount = computed(
|
|
() => groups.value.filter(groupNeedsAttention).length,
|
|
)
|
|
|
|
const filteredGroups = computed(() => {
|
|
let arr = groups.value
|
|
if (artistFilter.value != null) {
|
|
arr = arr.filter((g) => g.artist.id === artistFilter.value)
|
|
}
|
|
if (needsAttention.value) {
|
|
arr = arr.filter(groupNeedsAttention)
|
|
} else if (statusFilter.value !== 'all') {
|
|
arr = arr.filter((g) => groupMatchesStatus(g, statusFilter.value))
|
|
}
|
|
const q = search.value?.trim().toLowerCase()
|
|
if (q) {
|
|
arr = arr.filter(
|
|
(g) =>
|
|
g.artist.name.toLowerCase().includes(q) ||
|
|
g.sources.some(
|
|
(s) =>
|
|
(s.url || '').toLowerCase().includes(q) ||
|
|
(s.platform || '').toLowerCase().includes(q),
|
|
),
|
|
)
|
|
}
|
|
return arr
|
|
})
|
|
|
|
function groupMatchesStatus(g, status) {
|
|
if (status === 'enabled') return g.sources.some((s) => s.enabled)
|
|
if (status === 'disabled') return g.sources.every((s) => !s.enabled)
|
|
if (status === 'errors') return g.sources.some((s) => (s.consecutive_failures || 0) > 0)
|
|
if (status === 'stale') return g.sources.some((s) => !s.last_checked_at)
|
|
return true
|
|
}
|
|
|
|
function pickLastActivity(sources) {
|
|
let max = null
|
|
for (const s of sources) {
|
|
if (s.last_checked_at && (!max || s.last_checked_at > max)) max = s.last_checked_at
|
|
}
|
|
return max
|
|
}
|
|
|
|
function pickWorstSource(sources, threshold) {
|
|
if (!sources || sources.length === 0) return null
|
|
function level(s) {
|
|
if (!s.last_checked_at) return 0
|
|
const f = s.consecutive_failures || 0
|
|
if (f === 0) return 1
|
|
if (f < threshold) return 2
|
|
return 3
|
|
}
|
|
return sources.reduce((worst, s) => (level(s) > level(worst) ? s : worst), sources[0])
|
|
}
|
|
|
|
function onRowClick(_evt, { item }) {
|
|
const key = item.key
|
|
const idx = expanded.value.indexOf(key)
|
|
if (idx === -1) expanded.value = [...expanded.value, key]
|
|
else expanded.value = expanded.value.filter((k) => k !== key)
|
|
}
|
|
|
|
function openAddSource(artist) {
|
|
editingSource.value = null
|
|
editingArtist.value = artist
|
|
showSourceDialog.value = true
|
|
}
|
|
|
|
function openEditSource(source) {
|
|
editingSource.value = source
|
|
editingArtist.value = {
|
|
id: source.artist_id, name: source.artist_name, slug: source.artist_slug,
|
|
}
|
|
showSourceDialog.value = true
|
|
}
|
|
|
|
async function removeSource(source) {
|
|
// Confirm — Remove moved into the overflow menu, but a destructive action
|
|
// still deserves a guard (single-source removal had none before).
|
|
if (!globalThis.window?.confirm(
|
|
`Remove this ${source.platform} source?\n${source.url}\n\nThe artist and already-downloaded images stay.`,
|
|
)) return
|
|
try {
|
|
// store._dropSource patches the cache in place — no full reload, so the
|
|
// table + expansion don't reset (operator-flagged lock/reload, 2026-06-07).
|
|
await store.remove(source.id, source.artist_id)
|
|
} catch (e) {
|
|
toast({ text: `Remove failed: ${e?.body?.detail || e?.message || e}`, type: 'error' })
|
|
}
|
|
}
|
|
|
|
async function toggleSourceEnabled({ source, enabled }) {
|
|
try {
|
|
await store.update(source.id, { enabled }, source.artist_id)
|
|
} catch (e) {
|
|
toast({ text: `Update failed: ${e?.body?.detail || e?.message || e}`, type: 'error' })
|
|
}
|
|
}
|
|
|
|
async function onSourceSaved() {
|
|
showSourceDialog.value = false
|
|
await refresh()
|
|
}
|
|
|
|
function onArtistCreated(artist) {
|
|
showArtistDialog.value = false
|
|
openAddSource(artist)
|
|
}
|
|
|
|
async function onCheck(source) {
|
|
try {
|
|
const body = await store.checkNow(source.id)
|
|
// Audit 2026-06-02: /api/sources/<id>/check returns 202 with
|
|
// `{status:'deferred', cooldown_until}` when the platform is in
|
|
// cooldown — the previous handler treated this as success and
|
|
// toasted "event #undefined", masking that nothing was enqueued.
|
|
if (body?.status === 'deferred') {
|
|
toast({
|
|
text: 'Check deferred — platform in cooldown',
|
|
type: 'info',
|
|
})
|
|
return
|
|
}
|
|
toast({
|
|
text: `Check enqueued (event #${body.download_event_id})`,
|
|
type: 'success',
|
|
})
|
|
} catch (e) {
|
|
if (e?.body?.download_event_id) {
|
|
toast({
|
|
text: 'Already running — see Downloads',
|
|
type: 'info',
|
|
})
|
|
router.push({ path: '/subscriptions', query: { tab: 'downloads', source_id: source.id } })
|
|
} else {
|
|
toast({
|
|
text: `Check failed: ${e?.detail || e?.message || e}`,
|
|
type: 'error',
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// Plan #693: toggle a run-until-done backfill. Starting walks the full post
|
|
// history in time-boxed chunks across ticks until it reaches the bottom (the
|
|
// row badge tracks progress / completion); stopping cancels back to tick mode.
|
|
async function onBackfill(source) {
|
|
const running = source.backfill_state === 'running'
|
|
try {
|
|
if (running) {
|
|
await store.stopBackfill(source.id, source.artist_id)
|
|
toast({ text: `Backfill stopped for ${source.artist_name}`, type: 'success' })
|
|
} else {
|
|
await store.startBackfill(source.id, source.artist_id)
|
|
toast({ text: `Backfill started for ${source.artist_name}`, type: 'success' })
|
|
}
|
|
// startBackfill/stopBackfill patch the source in place — no full reload.
|
|
} catch (e) {
|
|
toast({
|
|
text: `Backfill ${running ? 'stop' : 'start'} failed: ${e?.body?.detail || e?.message || e}`,
|
|
type: 'error',
|
|
})
|
|
}
|
|
}
|
|
|
|
// Plan #697: arm a recovery walk (Patreon-only) — re-fetches dropped-and-deleted
|
|
// near-dups and re-evaluates them under the current pHash threshold. Reuses the
|
|
// backfill lifecycle/badge; stop via the same Stop control (onBackfill).
|
|
async function onRecover(source) {
|
|
try {
|
|
await store.recoverSource(source.id, source.artist_id)
|
|
toast({ text: `Recovery started for ${source.artist_name}`, type: 'success' })
|
|
// recoverSource patches the source in place — no full reload.
|
|
} catch (e) {
|
|
toast({
|
|
text: `Recovery start failed: ${e?.body?.detail || e?.message || e}`,
|
|
type: 'error',
|
|
})
|
|
}
|
|
}
|
|
|
|
// Plan #708 B4: open the dry-run preview dialog (it self-fetches on open).
|
|
function onPreview(source) {
|
|
previewTarget.value = source
|
|
showPreviewDialog.value = true
|
|
}
|
|
|
|
// "Start backfill" from inside the preview dialog → arm it + close.
|
|
async function onPreviewBackfill(source) {
|
|
showPreviewDialog.value = false
|
|
await onBackfill(source)
|
|
}
|
|
|
|
async function checkAll(group) {
|
|
let ok = 0
|
|
let conflict = 0
|
|
let deferred = 0
|
|
for (const s of group.sources) {
|
|
if (!s.enabled) continue
|
|
try {
|
|
const body = await store.checkNow(s.id)
|
|
// Audit 2026-06-02: deferred (202 + cooldown_until) used to be
|
|
// counted as queued, inflating the success tally and hiding
|
|
// that the cooldown actually held the work back.
|
|
if (body?.status === 'deferred') deferred += 1
|
|
else ok += 1
|
|
} catch (e) {
|
|
if (e?.body?.download_event_id) conflict += 1
|
|
}
|
|
}
|
|
const parts = []
|
|
if (ok) parts.push(`${ok} queued`)
|
|
if (deferred) parts.push(`${deferred} deferred (cooldown)`)
|
|
if (conflict) parts.push(`${conflict} already running`)
|
|
toast({
|
|
text: parts.join(', ') || 'Nothing to check (no enabled sources)',
|
|
type: 'info',
|
|
})
|
|
}
|
|
|
|
function anyChecking(sources) {
|
|
return sources.some((s) => store.checkingIds.has(s.id))
|
|
}
|
|
|
|
function resolveSelectedGroups() {
|
|
return groups.value.filter((g) => selected.value.includes(g.key))
|
|
}
|
|
|
|
async function bulkSetEnabled(enabled) {
|
|
const groups = resolveSelectedGroups()
|
|
let changed = 0
|
|
for (const g of groups) {
|
|
for (const s of g.sources) {
|
|
if (s.enabled === enabled) continue
|
|
try {
|
|
await store.update(s.id, { enabled }, s.artist_id)
|
|
changed += 1
|
|
} catch { /* keep going */ }
|
|
}
|
|
}
|
|
await refresh()
|
|
toast({
|
|
text: `${changed} source${changed === 1 ? '' : 's'} ${enabled ? 'enabled' : 'disabled'}`,
|
|
type: 'success',
|
|
})
|
|
selected.value = []
|
|
}
|
|
|
|
async function bulkDelete() {
|
|
const groups = resolveSelectedGroups()
|
|
const total = groups.reduce((n, g) => n + g.sources.length, 0)
|
|
if (!globalThis.window?.confirm(
|
|
`Delete ${total} source${total === 1 ? '' : 's'} across ${groups.length} subscription${groups.length === 1 ? '' : 's'}? Artist rows remain.`,
|
|
)) return
|
|
let deleted = 0
|
|
for (const g of groups) {
|
|
for (const s of g.sources) {
|
|
try {
|
|
await store.remove(s.id, s.artist_id)
|
|
deleted += 1
|
|
} catch { /* keep going */ }
|
|
}
|
|
}
|
|
await refresh()
|
|
toast({
|
|
text: `${deleted} source${deleted === 1 ? '' : 's'} deleted`,
|
|
type: 'success',
|
|
})
|
|
selected.value = []
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.fc-subs__sched { margin-bottom: 10px; }
|
|
.fc-subs__bar {
|
|
display: flex; gap: 0.75rem; align-items: center;
|
|
flex-wrap: wrap;
|
|
/* Sticky to the top of the hub's scroll window so the filter/search
|
|
controls stay reachable while scrolling a long subscription list.
|
|
Opaque bg so table rows don't bleed through. Operator-flagged
|
|
2026-05-28. */
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 3;
|
|
background: rgb(var(--v-theme-background));
|
|
padding: 8px 0 1rem;
|
|
}
|
|
.fc-subs__bar .v-chip { cursor: pointer; }
|
|
.fc-subs__status { max-width: 180px; }
|
|
.fc-subs__search { max-width: 320px; flex: 1 1 200px; }
|
|
/* Phones: status + search each take a full-width row; drop the spacer that
|
|
would otherwise eat a row pushing them around. */
|
|
@media (max-width: 600px) {
|
|
.fc-subs__status, .fc-subs__search { max-width: none; flex-basis: 100%; }
|
|
.fc-subs__bar :deep(.v-spacer) { display: none; }
|
|
/* The table renders as stacked cards (mobile-breakpoint); reclaim the
|
|
desktop indent on the expanded sources detail (it keeps its own
|
|
horizontal scroll for the wide source columns). */
|
|
.fc-subs__sources-cell { padding-left: 0.5rem !important; }
|
|
}
|
|
.fc-subs__loading, .fc-subs__empty {
|
|
display: flex; justify-content: center; padding: 2rem;
|
|
color: rgb(var(--v-theme-on-surface-variant));
|
|
}
|
|
.fc-subs__card {
|
|
background: rgb(var(--v-theme-surface));
|
|
}
|
|
|
|
/* Mobile compact-card list (replaces the data-table <600px). */
|
|
.fc-subs__mlist { display: flex; flex-direction: column; gap: 8px; }
|
|
.fc-subs__mcard {
|
|
border: 1px solid rgb(var(--v-theme-surface-light));
|
|
border-radius: 8px;
|
|
background: rgb(var(--v-theme-surface));
|
|
padding: 8px 10px;
|
|
}
|
|
.fc-subs__mhead { display: flex; align-items: center; gap: 6px; cursor: pointer; }
|
|
.fc-subs__mmeta {
|
|
display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
|
|
margin-top: 4px;
|
|
}
|
|
.fc-subs__mmeta-text {
|
|
font-size: 0.78rem; color: rgb(var(--v-theme-on-surface-variant));
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
.fc-subs__mbody {
|
|
margin-top: 8px; padding-top: 8px;
|
|
border-top: 1px solid rgb(var(--v-theme-surface-light));
|
|
display: flex; flex-direction: column; gap: 6px;
|
|
}
|
|
.fc-subs__mactions { display: flex; gap: 2px; }
|
|
.fc-subs__name { font-weight: 600; }
|
|
.fc-subs__chips {
|
|
display: flex; flex-wrap: wrap; gap: 4px;
|
|
}
|
|
.fc-subs__when {
|
|
color: rgb(var(--v-theme-on-surface-variant));
|
|
white-space: nowrap;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
.fc-subs__zero {
|
|
color: rgb(var(--v-theme-on-surface-variant));
|
|
opacity: 0.6;
|
|
}
|
|
.fc-subs__sources-row td {
|
|
padding: 0 !important;
|
|
background: rgb(var(--v-theme-surface-light));
|
|
}
|
|
.fc-subs__sources-cell {
|
|
padding-left: 2rem !important;
|
|
border-top: 1px solid rgb(var(--v-theme-on-surface-variant) / 0.15);
|
|
}
|
|
.fc-subs__sources-table {
|
|
background: transparent !important;
|
|
}
|
|
.fc-subs__sources-empty {
|
|
color: rgb(var(--v-theme-on-surface-variant));
|
|
text-align: center;
|
|
padding: 1rem;
|
|
}
|
|
.fc-subs__bulk { border-radius: 8px; }
|
|
</style>
|