CI / lint (push) Successful in 2s
Build images / sign-extension (push) Successful in 3s
CI / extension-version (push) Successful in 2s
Build images / build-agent (push) Successful in 5s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 32s
Build images / build-web (push) Successful in 1m18s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 2m11s
Build images / promote (push) Skipped
CI / integration (push) Successful in 3m4s
Reconciliation in Subscriptions, asymmetric on purpose. Subscriptions FC does not follow get a per-row add; sources the roster cannot account for are REPORT ONLY (operator decision) and link to the list on the same page. No one-click disable, so no disabled-reason column and no migration. The membership<->source join lands as a SHARED resolver in membership_roster, not inline here: E4 now uses it as its negative check, so the two features cannot give different answers to 'is this membership already tracked?'. Keys on the exact cached campaign id FIRST and the URL handle only as fallback, because the id is written only after a source has been walked once. Read via any <platform>_campaign_id override rather than naming Patreon's, per rule 169. The report-only bucket is gated on roster freshness and carries a per-row basis, so 'your membership says former patron', 'we know this id and it is absent', and 'we only have a handle' stay three different sentences. has_paid_access None never reads as lapsed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
900 lines
32 KiB
Vue
900 lines
32 KiB
Vue
<template>
|
|
<div>
|
|
<SchedulerStatusBar :status="store.scheduleStatus" class="fc-subs__sched" />
|
|
|
|
<!-- Daily-use ordering (2026-07-02): what needs me → what came in →
|
|
the full source list. Both cards render nothing when there's
|
|
nothing to say. -->
|
|
<NeedsAttentionCard />
|
|
<RecentArrivalsCard />
|
|
<!-- #387 C4: what you pay for vs. what FC follows. Renders nothing when
|
|
the two agree. -->
|
|
<MembershipReconcileCard />
|
|
|
|
<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" prepend-icon="mdi-magnify-scan" @click="bulkBackfill">
|
|
Backfill
|
|
</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"
|
|
v-model:sort-by="sortBy"
|
|
: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 }">
|
|
<div class="fc-subs__name">{{ item.artist.name }}</div>
|
|
<!-- #2: single-source subscriptions show their URL inline (no expand). -->
|
|
<a
|
|
v-if="item.singleSource"
|
|
:href="item.singleSource.url" target="_blank" rel="noopener"
|
|
class="fc-subs__sub-url" @click.stop
|
|
>{{ item.singleSource.url }}</a>
|
|
</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 }">
|
|
<!-- #2: single source → its own actions inline (Check / Backfill / ⋮),
|
|
plus Edit, so the row is actionable without expanding. -->
|
|
<template v-if="item.singleSource">
|
|
<SourceActions
|
|
:source="item.singleSource"
|
|
:checking="store.checkingIds.has(item.singleSource.id)"
|
|
@check="onCheck" @backfill="onBackfill"
|
|
@recover="onRecover" @recapture="onRecapture" @remove="removeSource"
|
|
/>
|
|
<v-btn icon size="small" variant="text" @click.stop="openEditSource(item.singleSource)">
|
|
<v-icon>mdi-pencil</v-icon>
|
|
<v-tooltip activator="parent" location="top">Edit source</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 another source</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>
|
|
<!-- Multi-source → artist-level actions; expand for per-source rows. -->
|
|
<template v-else>
|
|
<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>
|
|
|
|
<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"
|
|
@recapture="onRecapture"
|
|
/>
|
|
<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"
|
|
@recapture="onRecapture"
|
|
/>
|
|
<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" />
|
|
</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 NeedsAttentionCard from './NeedsAttentionCard.vue'
|
|
import RecentArrivalsCard from './RecentArrivalsCard.vue'
|
|
import MembershipReconcileCard from './MembershipReconcileCard.vue'
|
|
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 SourceActions from './SourceActions.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' },
|
|
// #387 A3: no-access is deliberately its own filter and NOT folded into
|
|
// "Has errors" — nothing failed, and it is the only status here whose fix is
|
|
// the operator's subscription rather than anything FC can retry. Without a
|
|
// filter a gated source is invisible in a long list, since it correctly
|
|
// stays out of the failing rollup.
|
|
{ title: 'No access', value: 'no_access' },
|
|
{ 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('')
|
|
// URL-addressable (#387 B3) so the front-door status ribbon can link straight
|
|
// to "the sources this number is about" — a count that lands you on an
|
|
// unfiltered list makes the reader do the filtering the ribbon just did.
|
|
// Mirrors how artistFilter already reads from route.query below.
|
|
const statusFilter = computed({
|
|
get: () => route.query.status || 'all',
|
|
set: (v) => {
|
|
const q = { ...route.query }
|
|
if (!v || v === 'all') delete q.status
|
|
else q.status = v
|
|
router.replace({ query: q })
|
|
},
|
|
})
|
|
const needsAttention = ref(false)
|
|
const expanded = ref([])
|
|
const selected = ref([])
|
|
// #3: default to worst-health first, name as the tiebreak (the table header
|
|
// stays clickable to re-sort by any column).
|
|
const sortBy = ref([
|
|
{ key: 'health', order: 'desc' },
|
|
{ key: 'name', order: 'asc' },
|
|
])
|
|
|
|
// 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 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: true, 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 ?? '',
|
|
// #3: numeric health rank so the Health column is sortable worst-first.
|
|
health: healthRank(worstSource, failureThreshold.value),
|
|
// #2: when a subscription has exactly one source, surface it on the
|
|
// artist row directly (URL + actions) so the common case needs no expand.
|
|
singleSource: g.sources.length === 1 ? g.sources[0] : null,
|
|
}
|
|
})
|
|
})
|
|
|
|
// 0 never-checked · 1 ok · 2 warning · 3 failing — higher = worse, so a
|
|
// descending sort floats the broken subscriptions to the top.
|
|
function healthRank(source, threshold) {
|
|
if (!source) return 0
|
|
if (!source.last_checked_at) return 0
|
|
const f = source.consecutive_failures || 0
|
|
if (f === 0) return 1
|
|
if (f < threshold) return 2
|
|
return 3
|
|
}
|
|
|
|
// 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 === 'no_access') return g.sources.some((s) => s.error_type === 'tier_limited')
|
|
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',
|
|
})
|
|
}
|
|
}
|
|
|
|
// #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. Reuses the backfill lifecycle/badge; stop via the same Stop control.
|
|
async function onRecapture(source) {
|
|
try {
|
|
await store.recaptureSource(source.id, source.artist_id)
|
|
toast({ text: `Recapture started for ${source.artist_name}`, type: 'success' })
|
|
// recaptureSource patches the source in place — no full reload.
|
|
} catch (e) {
|
|
toast({
|
|
text: `Recapture start failed: ${e?.body?.detail || e?.message || e}`,
|
|
type: 'error',
|
|
})
|
|
}
|
|
}
|
|
|
|
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 = []
|
|
}
|
|
|
|
// #5: arm a run-until-done backfill on every enabled source in the selected
|
|
// subscriptions (skips ones already backfilling). Patches each row in place.
|
|
async function bulkBackfill() {
|
|
const sel = resolveSelectedGroups()
|
|
let armed = 0
|
|
for (const g of sel) {
|
|
for (const s of g.sources) {
|
|
if (!s.enabled || s.backfill_state === 'running') continue
|
|
try {
|
|
await store.startBackfill(s.id, s.artist_id)
|
|
armed += 1
|
|
} catch { /* keep going */ }
|
|
}
|
|
}
|
|
toast({
|
|
text: armed ? `Backfill started on ${armed} source${armed === 1 ? '' : 's'}`
|
|
: 'Nothing to backfill (already running or disabled)',
|
|
type: armed ? 'success' : 'info',
|
|
})
|
|
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__sub-url {
|
|
display: block;
|
|
max-width: 460px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
font-size: 0.76rem;
|
|
color: rgb(var(--v-theme-on-surface-variant));
|
|
text-decoration: none;
|
|
}
|
|
.fc-subs__sub-url:hover { color: rgb(var(--v-theme-accent)); }
|
|
.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>
|