feat(subs-hub): collapse /credentials + /downloads into /subscriptions hub with three GS-style subtabs
Replaces the three top-level routes with a single `/subscriptions` parent
owning the whole download-pipeline domain. Internal tab state via `?tab=`
query param, mirroring ArtistView's pattern. TopNav auto-drops the two
removed entries (route-driven via meta.title). Bookmark-safe redirects
from `/credentials` and `/downloads` route into the appropriate subtab.
**Subtab 1 — Subscriptions (default).** Carries over the existing
artist-grouped expandable table; adds (a) status filter dropdown, (b)
bulk-select column with Enable/Disable/Delete-all actions, (c) GS-style
color-coded `PlatformChip` per distinct platform in the collapsed row.
Reuses SourceRow, SourceHealthDot, SourceFormDialog, ArtistCreateDialog.
**Subtab 2 — Downloads.** Full GS dashboard. Five colored stat chips up
top (Queued/Running/Completed/Failed/Skipped, sourced from new
`GET /api/downloads/stats?window_hours=`). Popover-style filter UI
(Status/Source/FromDate/ToDate) with active-filter pills below.
Maintenance menu wraps existing /api/import/retry-failed and
/api/import/clear-stuck endpoints; Export-failed-logs item disabled with
a "v2" tooltip. Per-row Retry preserved via existing DownloadEventRow.
**Subtab 3 — Settings.** Four sections: ExtensionKeyBar (top), GS-style
per-platform CredentialCard grid (md=6 v-row/v-col, dashed border if
unset / accent border if set, expandable how-to panel), Downloader card
(rate limit, validate_files), Schedule defaults card (default interval,
event retention, failure warning threshold). The Downloader and Schedule
sections were extracted out of components/settings/ImportFiltersForm.vue
— SettingsView's Import tab now owns only image-import filters.
**Backend:** new `GET /api/downloads/stats` returns
{pending, running, ok, error, skipped} count grouped by status over the
configurable window. Status keys stay raw from the ENUM; UI does the
display-label mapping. Two integration tests pin the response shape +
window_hours validation.
**Util:** `frontend/src/utils/platformColor.js` — single source of truth
for the six platforms' color + icon + label, mirroring GS's palette
(patreon=red mdi-patreon, subscribestar=amber mdi-star,
hentaifoundry=purple mdi-palette, discord=indigo mdi-discord,
pixiv=blue mdi-alpha-p-box, deviantart=green mdi-deviantart). Unknown
platform falls back to grey + mdi-web.
Deferred (explicit non-goals): subscription import/export, "Trigger Due
Now" scheduler-tick button (needs new backend endpoint), Export Failed
Logs CSV dump.
This commit is contained in:
@@ -51,68 +51,6 @@
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-divider class="my-4" />
|
||||
<div class="text-subtitle-2 mb-2">Downloader (FC-3c)</div>
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-text-field
|
||||
v-model.number="local.download_rate_limit_seconds"
|
||||
label="Rate limit (seconds between requests)"
|
||||
type="number" step="0.5" min="0"
|
||||
density="compact" hide-details @blur="save"
|
||||
/>
|
||||
<div class="fc-help">gallery-dl extractor.sleep. Higher = slower but safer.</div>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-switch
|
||||
v-model="local.download_validate_files"
|
||||
label="Validate downloaded files (magic-byte check)"
|
||||
density="compact" hide-details color="accent" @change="save"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-divider class="my-4" />
|
||||
<div class="text-subtitle-2 mb-2">Download scheduling (FC-3d)</div>
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-text-field
|
||||
v-model.number="local.download_schedule_default_seconds"
|
||||
label="Default check interval (seconds)"
|
||||
type="number" :min="60" :max="86400"
|
||||
density="compact" hide-details @blur="save"
|
||||
/>
|
||||
<div class="fc-help">
|
||||
Used when a source has no per-source or per-artist override.
|
||||
Default 28800 (8 hours).
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-text-field
|
||||
v-model.number="local.download_event_retention_days"
|
||||
label="Event retention (days)"
|
||||
type="number" :min="1" :max="3650"
|
||||
density="compact" hide-details @blur="save"
|
||||
/>
|
||||
<div class="fc-help">
|
||||
Completed download events older than this are deleted nightly.
|
||||
Default 90.
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-text-field
|
||||
v-model.number="local.download_failure_warning_threshold"
|
||||
label="Failure warning threshold"
|
||||
type="number" :min="1" :max="100"
|
||||
density="compact" hide-details @blur="save"
|
||||
/>
|
||||
<div class="fc-help">
|
||||
Source row badge turns red after this many consecutive
|
||||
failures. Sources are never auto-disabled. Default 5.
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-alert v-if="store.settingsError" type="error" variant="tonal" class="mt-2" closable>
|
||||
{{ store.settingsError }}
|
||||
</v-alert>
|
||||
@@ -128,16 +66,14 @@ import { reactive, watch } from 'vue'
|
||||
import { useImportStore } from '../../stores/import.js'
|
||||
|
||||
const store = useImportStore()
|
||||
// Downloader + schedule-defaults fields moved to
|
||||
// /subscriptions?tab=settings (operator decision 2026-05-27). This form
|
||||
// now only owns image-import filters.
|
||||
const local = reactive({
|
||||
min_width: 0, min_height: 0,
|
||||
skip_transparent: false, transparency_threshold: 0.9,
|
||||
skip_single_color: false, single_color_threshold: 0.95,
|
||||
phash_threshold: 10,
|
||||
download_rate_limit_seconds: 3.0,
|
||||
download_validate_files: true,
|
||||
download_schedule_default_seconds: 28800,
|
||||
download_event_retention_days: 90,
|
||||
download_failure_warning_threshold: 5,
|
||||
})
|
||||
|
||||
watch(() => store.settings, (s) => { if (s) Object.assign(local, s) }, { immediate: true })
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
<template>
|
||||
<v-card
|
||||
:variant="hasCredential ? 'outlined' : 'flat'"
|
||||
:class="['fc-cred-card', hasCredential && 'fc-cred-card--set']"
|
||||
>
|
||||
<v-card-title class="d-flex align-center pa-3 ga-2">
|
||||
<PlatformChip :platform="platform.key" size="small" />
|
||||
<span class="text-body-1">{{ platform.name }}</span>
|
||||
<v-spacer />
|
||||
<v-chip :color="statusColor" size="x-small" variant="tonal">
|
||||
{{ statusLabel }}
|
||||
</v-chip>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="pa-3 pt-0">
|
||||
<template v-if="hasCredential">
|
||||
<div class="fc-cred-card__row">
|
||||
<v-icon size="small" color="success">mdi-check-circle</v-icon>
|
||||
<span>Stored · captured {{ fmtDate(credential.captured_at) }}</span>
|
||||
</div>
|
||||
<div v-if="credential.expires_at" class="fc-cred-card__row">
|
||||
<v-icon size="small" :color="expiringSoon ? 'warning' : 'on-surface-variant'">
|
||||
mdi-clock-outline
|
||||
</v-icon>
|
||||
<span>Expires {{ fmtDate(credential.expires_at) }}</span>
|
||||
</div>
|
||||
<div v-if="credential.last_verified_at" class="fc-cred-card__row">
|
||||
<v-icon size="small" color="on-surface-variant">mdi-shield-check</v-icon>
|
||||
<span>Last verified {{ fmtDate(credential.last_verified_at) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="fc-cred-card__empty">
|
||||
<v-icon size="40" color="on-surface-variant" class="fc-cred-card__empty-icon">
|
||||
mdi-key-remove
|
||||
</v-icon>
|
||||
<p class="text-caption text-medium-emphasis">
|
||||
No credential stored. Use the extension or paste a {{ platform.auth_type }} below.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<v-expansion-panels variant="accordion" class="mt-2 fc-cred-card__how">
|
||||
<v-expansion-panel>
|
||||
<v-expansion-panel-title class="text-caption">
|
||||
How to get {{ platform.auth_type }}
|
||||
</v-expansion-panel-title>
|
||||
<v-expansion-panel-text class="text-caption">
|
||||
<slot name="howto">
|
||||
<p>{{ howToFallback }}</p>
|
||||
</slot>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions class="px-3 pb-3 pt-0">
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
v-if="hasCredential"
|
||||
size="small" variant="text" color="error"
|
||||
@click="$emit('remove', platform)"
|
||||
>
|
||||
Remove
|
||||
</v-btn>
|
||||
<v-btn
|
||||
size="small"
|
||||
:variant="hasCredential ? 'outlined' : 'flat'"
|
||||
:color="hasCredential ? undefined : 'accent'"
|
||||
@click="$emit('replace', platform)"
|
||||
>
|
||||
{{ hasCredential ? 'Update' : 'Add credentials' }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import PlatformChip from './PlatformChip.vue'
|
||||
|
||||
const props = defineProps({
|
||||
platform: { type: Object, required: true },
|
||||
credential: { type: Object, default: null },
|
||||
})
|
||||
defineEmits(['replace', 'remove'])
|
||||
|
||||
const hasCredential = computed(() => !!props.credential)
|
||||
|
||||
// Within 7 days = expiring soon. The backend doesn't set hard rotation
|
||||
// policy yet; this just nudges the operator with a warning chip.
|
||||
const expiringSoon = computed(() => {
|
||||
const exp = props.credential?.expires_at
|
||||
if (!exp) return false
|
||||
const diff = (new Date(exp).getTime() - Date.now()) / 86400_000
|
||||
return diff > 0 && diff < 7
|
||||
})
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
if (!hasCredential.value) return 'Not configured'
|
||||
if (expiringSoon.value) return 'Expiring soon'
|
||||
return 'Active'
|
||||
})
|
||||
const statusColor = computed(() => {
|
||||
if (!hasCredential.value) return 'grey'
|
||||
if (expiringSoon.value) return 'warning'
|
||||
return 'success'
|
||||
})
|
||||
|
||||
const howToFallback = computed(() => {
|
||||
if (props.platform.auth_type === 'cookies') {
|
||||
return 'Use the FabledCurator browser extension on the platform page, or export cookies.txt and paste here.'
|
||||
}
|
||||
return 'Paste the access token from your account settings.'
|
||||
})
|
||||
|
||||
function fmtDate(iso) {
|
||||
if (!iso) return '—'
|
||||
return iso.slice(0, 10)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-cred-card {
|
||||
border: 1px dashed rgb(var(--v-theme-on-surface-variant) / 0.3);
|
||||
background: rgb(var(--v-theme-surface));
|
||||
height: 100%;
|
||||
}
|
||||
.fc-cred-card--set {
|
||||
border-style: solid;
|
||||
border-color: rgb(var(--v-theme-accent) / 0.5);
|
||||
}
|
||||
.fc-cred-card__row {
|
||||
display: flex; gap: 6px; align-items: center;
|
||||
font-size: 0.9rem;
|
||||
margin-top: 4px;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
}
|
||||
.fc-cred-card__empty {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 8px;
|
||||
padding: 12px 0;
|
||||
text-align: center;
|
||||
}
|
||||
.fc-cred-card__empty-icon { opacity: 0.5; }
|
||||
.fc-cred-card__how :deep(.v-expansion-panel) {
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<div class="fc-dl-stats">
|
||||
<v-chip
|
||||
v-for="s in STAT_DEFS" :key="s.key"
|
||||
:color="s.color"
|
||||
variant="tonal"
|
||||
:prepend-icon="s.icon"
|
||||
size="default"
|
||||
>
|
||||
{{ s.label }}
|
||||
<strong class="ms-1">{{ stats[s.key] ?? 0 }}</strong>
|
||||
</v-chip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
stats: { type: Object, required: true },
|
||||
})
|
||||
|
||||
// status keys come straight from the backend ENUM
|
||||
// (pending|running|ok|error|skipped); display order + icons are UI-only.
|
||||
const STAT_DEFS = [
|
||||
{ key: 'pending', label: 'Queued', color: 'grey', icon: 'mdi-clock-outline' },
|
||||
{ key: 'running', label: 'Running', color: 'info', icon: 'mdi-progress-clock' },
|
||||
{ key: 'ok', label: 'Completed', color: 'success', icon: 'mdi-check-circle' },
|
||||
{ key: 'error', label: 'Failed', color: 'error', icon: 'mdi-alert-circle' },
|
||||
{ key: 'skipped', label: 'Skipped', color: 'warning', icon: 'mdi-skip-next' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-dl-stats {
|
||||
display: flex; gap: 8px; flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<div class="fc-dlf">
|
||||
<v-menu :close-on-content-click="false" v-model="open">
|
||||
<template #activator="{ props }">
|
||||
<v-btn v-bind="props" variant="outlined" prepend-icon="mdi-filter-variant">
|
||||
Filter
|
||||
<v-chip v-if="activeCount" size="x-small" color="accent" class="ms-2">
|
||||
{{ activeCount }}
|
||||
</v-chip>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-card min-width="320" class="pa-3">
|
||||
<v-select
|
||||
v-model="local.status"
|
||||
:items="STATUS_OPTIONS"
|
||||
label="Status"
|
||||
density="compact" variant="outlined" hide-details clearable
|
||||
class="mb-2"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model.number="local.source_id"
|
||||
label="Source ID"
|
||||
density="compact" variant="outlined" hide-details clearable
|
||||
type="number" min="1"
|
||||
class="mb-2"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="local.from_date"
|
||||
label="From"
|
||||
density="compact" variant="outlined" hide-details clearable
|
||||
type="date"
|
||||
class="mb-2"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="local.to_date"
|
||||
label="To"
|
||||
density="compact" variant="outlined" hide-details clearable
|
||||
type="date"
|
||||
class="mb-3"
|
||||
/>
|
||||
<div class="d-flex">
|
||||
<v-btn variant="text" size="small" @click="reset">Reset</v-btn>
|
||||
<v-spacer />
|
||||
<v-btn color="accent" size="small" @click="apply">Apply</v-btn>
|
||||
</div>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
|
||||
<div v-if="activeCount" class="fc-dlf__pills mt-2">
|
||||
<v-chip
|
||||
v-for="p in activePills" :key="p.key"
|
||||
size="small" closable variant="tonal"
|
||||
@click:close="clearOne(p.key)"
|
||||
>
|
||||
{{ p.label }}
|
||||
</v-chip>
|
||||
<v-btn variant="text" size="x-small" @click="reset">Clear all</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Object, required: true },
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ title: 'Queued', value: 'pending' },
|
||||
{ title: 'Running', value: 'running' },
|
||||
{ title: 'Completed', value: 'ok' },
|
||||
{ title: 'Failed', value: 'error' },
|
||||
{ title: 'Skipped', value: 'skipped' },
|
||||
]
|
||||
const STATUS_LABEL = Object.fromEntries(STATUS_OPTIONS.map((o) => [o.value, o.title]))
|
||||
|
||||
const open = ref(false)
|
||||
const local = reactive({
|
||||
status: props.modelValue.status ?? null,
|
||||
source_id: props.modelValue.source_id ?? null,
|
||||
from_date: props.modelValue.from_date ?? null,
|
||||
to_date: props.modelValue.to_date ?? null,
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (v) => Object.assign(local, v), { deep: true })
|
||||
|
||||
const activePills = computed(() => {
|
||||
const out = []
|
||||
if (local.status) out.push({ key: 'status', label: `Status: ${STATUS_LABEL[local.status] || local.status}` })
|
||||
if (local.source_id) out.push({ key: 'source_id', label: `Source #${local.source_id}` })
|
||||
if (local.from_date) out.push({ key: 'from_date', label: `From ${local.from_date}` })
|
||||
if (local.to_date) out.push({ key: 'to_date', label: `To ${local.to_date}` })
|
||||
return out
|
||||
})
|
||||
const activeCount = computed(() => activePills.value.length)
|
||||
|
||||
function apply() {
|
||||
emit('update:modelValue', { ...local })
|
||||
open.value = false
|
||||
}
|
||||
function reset() {
|
||||
local.status = null
|
||||
local.source_id = null
|
||||
local.from_date = null
|
||||
local.to_date = null
|
||||
emit('update:modelValue', { ...local })
|
||||
}
|
||||
function clearOne(key) {
|
||||
local[key] = null
|
||||
emit('update:modelValue', { ...local })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-dlf__pills {
|
||||
display: flex; flex-wrap: wrap; gap: 6px; align-items: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="fc-dl__top">
|
||||
<DownloadStatChips :stats="store.stats" />
|
||||
<v-spacer />
|
||||
<v-btn variant="text" icon @click="refresh">
|
||||
<v-icon>mdi-refresh</v-icon>
|
||||
<v-tooltip activator="parent" location="top">Refresh</v-tooltip>
|
||||
</v-btn>
|
||||
<MaintenanceMenu @refresh="refresh" />
|
||||
</div>
|
||||
|
||||
<DownloadsFilterPopover v-model="filterModel" class="fc-dl__filter" />
|
||||
|
||||
<v-alert v-if="store.error" type="error" variant="tonal" closable class="my-4">
|
||||
{{ String(store.error) }}
|
||||
</v-alert>
|
||||
|
||||
<div v-if="store.loading && store.events.length === 0" class="fc-dl__loading">
|
||||
<v-progress-circular indeterminate color="accent" size="36" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="store.events.length === 0" class="fc-dl__empty">
|
||||
<p>No download events match the current filter.</p>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<DownloadEventRow
|
||||
v-for="e in filteredEvents" :key="e.id" :event="e"
|
||||
@open="openDetail"
|
||||
/>
|
||||
<div class="fc-dl__sentinel">
|
||||
<v-btn v-if="store.hasMore" variant="text" @click="store.loadMore()" :loading="store.loading">
|
||||
Load more
|
||||
</v-btn>
|
||||
<span v-else class="text-caption" style="opacity: 0.5">No more events.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DownloadDetailModal
|
||||
:event="store.selected"
|
||||
@close="store.closeDetail()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { useDownloadsStore } from '../../stores/downloads.js'
|
||||
import DownloadEventRow from '../downloads/DownloadEventRow.vue'
|
||||
import DownloadDetailModal from '../downloads/DownloadDetailModal.vue'
|
||||
import DownloadStatChips from './DownloadStatChips.vue'
|
||||
import MaintenanceMenu from './MaintenanceMenu.vue'
|
||||
import DownloadsFilterPopover from './DownloadsFilterPopover.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const store = useDownloadsStore()
|
||||
const filterModel = ref({ ...store.filter })
|
||||
|
||||
async function refresh() {
|
||||
await Promise.all([
|
||||
store.loadFirst(),
|
||||
store.loadStats(24),
|
||||
])
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (route.query.source_id) {
|
||||
filterModel.value = { ...filterModel.value, source_id: Number(route.query.source_id) }
|
||||
}
|
||||
refresh()
|
||||
})
|
||||
|
||||
// Client-side date filter on the loaded page (avoids a backend round-trip
|
||||
// for the date pickers; the existing /api/downloads endpoint can grow
|
||||
// these as proper query params later if a UX need shows up).
|
||||
const filteredEvents = computed(() => {
|
||||
let arr = store.events
|
||||
const from = filterModel.value.from_date
|
||||
const to = filterModel.value.to_date
|
||||
if (from) {
|
||||
const fromTs = new Date(from).getTime()
|
||||
arr = arr.filter((e) => new Date(e.started_at).getTime() >= fromTs)
|
||||
}
|
||||
if (to) {
|
||||
const toTs = new Date(to).getTime() + 24 * 3600 * 1000 - 1
|
||||
arr = arr.filter((e) => new Date(e.started_at).getTime() <= toTs)
|
||||
}
|
||||
return arr
|
||||
})
|
||||
|
||||
watch(filterModel, async (m) => {
|
||||
await store.applyFilter({
|
||||
status: m.status,
|
||||
source_id: m.source_id || null,
|
||||
from_date: m.from_date,
|
||||
to_date: m.to_date,
|
||||
})
|
||||
await store.loadStats(24)
|
||||
}, { deep: true })
|
||||
|
||||
async function openDetail(id) {
|
||||
await store.loadOne(id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-dl__top {
|
||||
display: flex; gap: 8px; align-items: center;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.fc-dl__filter { margin-bottom: 12px; }
|
||||
.fc-dl__loading, .fc-dl__empty {
|
||||
display: flex; justify-content: center; padding: 3rem 0;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
.fc-dl__sentinel {
|
||||
display: flex; justify-content: center; padding: 1rem 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<v-menu>
|
||||
<template #activator="{ props }">
|
||||
<v-btn v-bind="props" variant="outlined" prepend-icon="mdi-wrench" append-icon="mdi-chevron-down">
|
||||
Maintenance
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list density="compact">
|
||||
<v-list-item
|
||||
prepend-icon="mdi-refresh"
|
||||
title="Retry failed"
|
||||
subtitle="Re-enqueue every failed import task"
|
||||
@click="onRetry"
|
||||
/>
|
||||
<v-list-item
|
||||
prepend-icon="mdi-broom"
|
||||
title="Clear stuck"
|
||||
subtitle="Mark long-running import tasks failed and finalize their batch"
|
||||
@click="onClear"
|
||||
/>
|
||||
<v-list-item
|
||||
:disabled="true"
|
||||
prepend-icon="mdi-download-box"
|
||||
title="Export failed logs"
|
||||
subtitle="CSV dump — v2"
|
||||
>
|
||||
<v-tooltip activator="parent" location="start">Deferred to a future release</v-tooltip>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useImportStore } from '../../stores/import.js'
|
||||
|
||||
const emit = defineEmits(['refresh'])
|
||||
const importStore = useImportStore()
|
||||
|
||||
async function onRetry() {
|
||||
try {
|
||||
await importStore.retryFailed()
|
||||
globalThis.window?.__fcToast?.({ text: 'Retry queued', type: 'success' })
|
||||
emit('refresh')
|
||||
} catch (e) {
|
||||
globalThis.window?.__fcToast?.({ text: `Retry failed: ${e?.detail || e?.message || e}`, type: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
async function onClear() {
|
||||
try {
|
||||
const body = await importStore.clearStuck()
|
||||
const n = body?.cleared ?? 0
|
||||
globalThis.window?.__fcToast?.({
|
||||
text: n ? `Cleared ${n} stuck task${n === 1 ? '' : 's'}` : 'Nothing stuck',
|
||||
type: 'success',
|
||||
})
|
||||
emit('refresh')
|
||||
} catch (e) {
|
||||
globalThis.window?.__fcToast?.({ text: `Clear failed: ${e?.detail || e?.message || e}`, type: 'error' })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<v-chip
|
||||
:color="color"
|
||||
:size="size"
|
||||
:variant="variant"
|
||||
:prepend-icon="icon"
|
||||
>
|
||||
{{ label }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { platformColor, platformIcon, platformLabel } from '../../utils/platformColor.js'
|
||||
|
||||
const props = defineProps({
|
||||
platform: { type: String, required: true },
|
||||
size: { type: String, default: 'small' },
|
||||
variant: { type: String, default: 'tonal' },
|
||||
})
|
||||
|
||||
const color = computed(() => platformColor(props.platform))
|
||||
const icon = computed(() => platformIcon(props.platform))
|
||||
const label = computed(() => platformLabel(props.platform))
|
||||
</script>
|
||||
@@ -0,0 +1,196 @@
|
||||
<template>
|
||||
<div>
|
||||
<ExtensionKeyBar class="mb-4" />
|
||||
|
||||
<v-alert v-if="credentialsStore.error" type="error" variant="tonal" closable class="mb-4">
|
||||
{{ String(credentialsStore.error) }}
|
||||
</v-alert>
|
||||
|
||||
<h3 class="text-h6 mb-3">Platform credentials</h3>
|
||||
<v-row>
|
||||
<v-col
|
||||
v-for="p in platformsStore.list"
|
||||
:key="p.key"
|
||||
cols="12" md="6"
|
||||
>
|
||||
<CredentialCard
|
||||
:platform="p"
|
||||
:credential="credentialsStore.byPlatform.get(p.key) || null"
|
||||
@replace="openUpload"
|
||||
@remove="confirmRemove"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<h3 class="text-h6 mb-3 mt-6">Downloader</h3>
|
||||
<v-card variant="outlined">
|
||||
<v-card-text v-if="importStore.settings">
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-text-field
|
||||
v-model.number="dl.download_rate_limit_seconds"
|
||||
label="Rate limit (seconds between requests)"
|
||||
type="number" step="0.5" min="0"
|
||||
density="compact" hide-details
|
||||
@blur="saveDownloader"
|
||||
/>
|
||||
<div class="fc-help">gallery-dl extractor.sleep. Higher = slower but safer.</div>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-switch
|
||||
v-model="dl.download_validate_files"
|
||||
label="Validate downloaded files (magic-byte check)"
|
||||
density="compact" hide-details color="accent"
|
||||
@change="saveDownloader"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
<v-card-text v-else>
|
||||
<v-skeleton-loader type="paragraph" />
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<h3 class="text-h6 mb-3 mt-6">Schedule defaults</h3>
|
||||
<v-card variant="outlined">
|
||||
<v-card-text v-if="importStore.settings">
|
||||
<v-row>
|
||||
<v-col cols="12" sm="4">
|
||||
<v-text-field
|
||||
v-model.number="dl.download_schedule_default_seconds"
|
||||
label="Default check interval (seconds)"
|
||||
type="number" :min="60" :max="86400"
|
||||
density="compact" hide-details
|
||||
@blur="saveDownloader"
|
||||
/>
|
||||
<div class="fc-help">
|
||||
Used when a source has no per-source or per-artist override.
|
||||
Default 28800 (8 hours).
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="4">
|
||||
<v-text-field
|
||||
v-model.number="dl.download_event_retention_days"
|
||||
label="Event retention (days)"
|
||||
type="number" :min="1" :max="3650"
|
||||
density="compact" hide-details
|
||||
@blur="saveDownloader"
|
||||
/>
|
||||
<div class="fc-help">
|
||||
Completed download events older than this are deleted nightly.
|
||||
Default 90.
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="4">
|
||||
<v-text-field
|
||||
v-model.number="dl.download_failure_warning_threshold"
|
||||
label="Failure warning threshold"
|
||||
type="number" :min="1" :max="100"
|
||||
density="compact" hide-details
|
||||
@blur="saveDownloader"
|
||||
/>
|
||||
<div class="fc-help">
|
||||
Source row badge turns red after this many consecutive
|
||||
failures. Sources are never auto-disabled. Default 5.
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-alert v-if="importStore.settingsError" type="error" variant="tonal" class="mt-2" closable>
|
||||
{{ importStore.settingsError }}
|
||||
</v-alert>
|
||||
</v-card-text>
|
||||
<v-card-text v-else>
|
||||
<v-skeleton-loader type="paragraph" />
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<CredentialUploadDialog
|
||||
v-model="showUpload"
|
||||
:platform="uploadPlatform"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
|
||||
<v-dialog v-model="removeConfirm.open" max-width="420">
|
||||
<v-card>
|
||||
<v-card-title>Delete {{ removeConfirm.platform?.name }} credential?</v-card-title>
|
||||
<v-card-text>
|
||||
The encrypted credential will be removed permanently. You'll need to
|
||||
re-upload to use this platform again.
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="removeConfirm.open = false">Cancel</v-btn>
|
||||
<v-btn color="error" variant="flat" @click="doRemove">Delete</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref, watch } from 'vue'
|
||||
import { usePlatformsStore } from '../../stores/platforms.js'
|
||||
import { useCredentialsStore } from '../../stores/credentials.js'
|
||||
import { useImportStore } from '../../stores/import.js'
|
||||
import ExtensionKeyBar from '../credentials/ExtensionKeyBar.vue'
|
||||
import CredentialUploadDialog from '../credentials/CredentialUploadDialog.vue'
|
||||
import CredentialCard from './CredentialCard.vue'
|
||||
|
||||
const platformsStore = usePlatformsStore()
|
||||
const credentialsStore = useCredentialsStore()
|
||||
const importStore = useImportStore()
|
||||
|
||||
const showUpload = ref(false)
|
||||
const uploadPlatform = ref(null)
|
||||
const removeConfirm = reactive({ open: false, platform: null })
|
||||
|
||||
const dl = reactive({
|
||||
download_rate_limit_seconds: 3.0,
|
||||
download_validate_files: true,
|
||||
download_schedule_default_seconds: 28800,
|
||||
download_event_retention_days: 90,
|
||||
download_failure_warning_threshold: 5,
|
||||
})
|
||||
|
||||
watch(() => importStore.settings, (s) => { if (s) Object.assign(dl, s) }, { immediate: true })
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
platformsStore.loadAll(),
|
||||
credentialsStore.loadAll(),
|
||||
importStore.loadSettings(),
|
||||
])
|
||||
})
|
||||
|
||||
function openUpload(platform) {
|
||||
uploadPlatform.value = platform
|
||||
showUpload.value = true
|
||||
}
|
||||
|
||||
async function onSaved() {
|
||||
await credentialsStore.loadAll()
|
||||
}
|
||||
|
||||
function confirmRemove(platform) {
|
||||
removeConfirm.platform = platform
|
||||
removeConfirm.open = true
|
||||
}
|
||||
|
||||
async function doRemove() {
|
||||
await credentialsStore.remove(removeConfirm.platform.key)
|
||||
removeConfirm.open = false
|
||||
await credentialsStore.loadAll()
|
||||
}
|
||||
|
||||
async function saveDownloader() {
|
||||
await importStore.patchSettings({ ...dl })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-help {
|
||||
font-size: 12px;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
margin-top: 2px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,521 @@
|
||||
<template>
|
||||
<div>
|
||||
<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-spacer />
|
||||
<v-select
|
||||
v-model="statusFilter"
|
||||
:items="STATUS_OPTIONS"
|
||||
density="compact" variant="outlined" hide-details
|
||||
style="max-width: 180px"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="search"
|
||||
density="compact" variant="outlined" hide-details clearable
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
placeholder="Search subscriptions"
|
||||
style="max-width: 320px"
|
||||
/>
|
||||
</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 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"
|
||||
/>
|
||||
<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>
|
||||
|
||||
<SourceFormDialog
|
||||
v-model="showSourceDialog"
|
||||
:source="editingSource"
|
||||
:initial-artist="editingArtist"
|
||||
@saved="onSourceSaved"
|
||||
/>
|
||||
<ArtistCreateDialog v-model="showArtistDialog" @created="onArtistCreated" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
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 SourceHealthDot from './SourceHealthDot.vue'
|
||||
import SourceFormDialog from './SourceFormDialog.vue'
|
||||
import ArtistCreateDialog from './ArtistCreateDialog.vue'
|
||||
import PlatformChip from './PlatformChip.vue'
|
||||
|
||||
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 store = useSourcesStore()
|
||||
const platformsStore = usePlatformsStore()
|
||||
const importStore = useImportStore()
|
||||
|
||||
const search = ref('')
|
||||
const statusFilter = ref('all')
|
||||
const expanded = ref([])
|
||||
const selected = ref([])
|
||||
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()
|
||||
if (!importStore.settings) await importStore.loadSettings()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refresh()
|
||||
if (artistFilter.value != null) {
|
||||
expanded.value = [`artist-${artistFilter.value}`]
|
||||
}
|
||||
})
|
||||
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 ?? '',
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const filteredGroups = computed(() => {
|
||||
let arr = groups.value
|
||||
if (artistFilter.value != null) {
|
||||
arr = arr.filter((g) => g.artist.id === artistFilter.value)
|
||||
}
|
||||
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 formatRelative(iso) {
|
||||
if (!iso) return 'Never'
|
||||
const then = new Date(iso).getTime()
|
||||
const diff = (Date.now() - then) / 1000
|
||||
if (diff < 60) return `${Math.floor(diff)}s ago`
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
|
||||
return `${Math.floor(diff / 86400)}d ago`
|
||||
}
|
||||
|
||||
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) {
|
||||
await store.remove(source.id, source.artist_id)
|
||||
await refresh()
|
||||
}
|
||||
|
||||
async function toggleSourceEnabled({ source, enabled }) {
|
||||
await store.update(source.id, { enabled }, source.artist_id)
|
||||
await refresh()
|
||||
}
|
||||
|
||||
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)
|
||||
globalThis.window?.__fcToast?.({
|
||||
text: `Check enqueued (event #${body.download_event_id})`,
|
||||
type: 'success',
|
||||
})
|
||||
} catch (e) {
|
||||
if (e?.body?.download_event_id) {
|
||||
globalThis.window?.__fcToast?.({
|
||||
text: 'Already running — see Downloads',
|
||||
type: 'info',
|
||||
})
|
||||
router.push({ path: '/subscriptions', query: { tab: 'downloads', source_id: source.id } })
|
||||
} else {
|
||||
globalThis.window?.__fcToast?.({
|
||||
text: `Check failed: ${e?.detail || e?.message || e}`,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function checkAll(group) {
|
||||
let ok = 0
|
||||
let conflict = 0
|
||||
for (const s of group.sources) {
|
||||
if (!s.enabled) continue
|
||||
try {
|
||||
await store.checkNow(s.id)
|
||||
ok += 1
|
||||
} catch (e) {
|
||||
if (e?.body?.download_event_id) conflict += 1
|
||||
}
|
||||
}
|
||||
const parts = []
|
||||
if (ok) parts.push(`${ok} queued`)
|
||||
if (conflict) parts.push(`${conflict} already running`)
|
||||
globalThis.window?.__fcToast?.({
|
||||
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()
|
||||
globalThis.window?.__fcToast?.({
|
||||
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()
|
||||
globalThis.window?.__fcToast?.({
|
||||
text: `${deleted} source${deleted === 1 ? '' : 's'} deleted`,
|
||||
type: 'success',
|
||||
})
|
||||
selected.value = []
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-subs__bar {
|
||||
display: flex; gap: 0.75rem; align-items: center;
|
||||
padding-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.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));
|
||||
}
|
||||
.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>
|
||||
@@ -7,8 +7,6 @@ import ArtistView from './views/ArtistView.vue'
|
||||
import SeriesManageView from './views/SeriesManageView.vue'
|
||||
import SeriesReaderView from './views/SeriesReaderView.vue'
|
||||
import SubscriptionsView from './views/SubscriptionsView.vue'
|
||||
import CredentialsView from './views/CredentialsView.vue'
|
||||
import DownloadsView from './views/DownloadsView.vue'
|
||||
import PostsView from './views/PostsView.vue'
|
||||
import ArtistsView from './views/ArtistsView.vue'
|
||||
|
||||
@@ -34,10 +32,16 @@ const routes = [
|
||||
{ path: '/settings', name: 'settings', component: SettingsView, meta: { title: 'Settings' } },
|
||||
|
||||
// FC-3: subscription backbone
|
||||
// /credentials and /downloads were folded into /subscriptions as subtabs
|
||||
// 2026-05-27 (?tab=settings and ?tab=downloads). The hub view owns the
|
||||
// whole download pipeline domain.
|
||||
{ path: '/posts', name: 'posts', component: PostsView, meta: { title: 'Posts' } },
|
||||
{ path: '/subscriptions', name: 'subscriptions', component: SubscriptionsView, meta: { title: 'Subscriptions' } },
|
||||
{ path: '/credentials', name: 'credentials', component: CredentialsView, meta: { title: 'Credentials' } },
|
||||
{ path: '/downloads', name: 'downloads', component: DownloadsView, meta: { title: 'Downloads' } }
|
||||
|
||||
// Bookmark/back-button safety net for the routes that got folded in
|
||||
// (no meta.title — stay out of TopNav).
|
||||
{ path: '/credentials', redirect: '/subscriptions?tab=settings' },
|
||||
{ path: '/downloads', redirect: '/subscriptions?tab=downloads' }
|
||||
]
|
||||
|
||||
// Browser uses HTML5 history; non-browser (Vitest/SSR) falls back to memory
|
||||
|
||||
@@ -8,10 +8,14 @@ export const useDownloadsStore = defineStore('downloads', () => {
|
||||
const events = ref([])
|
||||
const cursor = ref(null)
|
||||
const hasMore = ref(true)
|
||||
const filter = ref({ status: null, source_id: null, artist_id: null })
|
||||
const filter = ref({
|
||||
status: null, source_id: null, artist_id: null,
|
||||
from_date: null, to_date: null,
|
||||
})
|
||||
const selected = ref(null)
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
const stats = ref({ pending: 0, running: 0, ok: 0, error: 0, skipped: 0 })
|
||||
|
||||
function _params(extra = {}) {
|
||||
const out = { limit: 50, ...extra }
|
||||
@@ -65,8 +69,13 @@ export const useDownloadsStore = defineStore('downloads', () => {
|
||||
selected.value = null
|
||||
}
|
||||
|
||||
async function loadStats(windowHours = 24) {
|
||||
stats.value = await api.get('/api/downloads/stats', { params: { window_hours: windowHours } })
|
||||
return stats.value
|
||||
}
|
||||
|
||||
return {
|
||||
events, cursor, hasMore, filter, selected, loading, error,
|
||||
loadFirst, loadMore, loadOne, applyFilter, closeDetail,
|
||||
events, cursor, hasMore, filter, selected, loading, error, stats,
|
||||
loadFirst, loadMore, loadOne, applyFilter, closeDetail, loadStats,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// Single source of truth for platform → color + icon mapping. Used by
|
||||
// PlatformChip and any other GS-style platform-tagged surface. The six
|
||||
// platforms FC supports map 1:1 to the GS palette; unknown platforms fall
|
||||
// back to grey + mdi-web. Operator-confirmed scope 2026-05-27.
|
||||
|
||||
const ICONS = {
|
||||
patreon: 'mdi-patreon',
|
||||
subscribestar: 'mdi-star',
|
||||
hentaifoundry: 'mdi-palette',
|
||||
discord: 'mdi-discord',
|
||||
pixiv: 'mdi-alpha-p-box',
|
||||
deviantart: 'mdi-deviantart',
|
||||
}
|
||||
|
||||
const COLORS = {
|
||||
patreon: 'red',
|
||||
subscribestar: 'amber',
|
||||
hentaifoundry: 'purple',
|
||||
discord: 'indigo',
|
||||
pixiv: 'blue',
|
||||
deviantart: 'green',
|
||||
}
|
||||
|
||||
const LABELS = {
|
||||
patreon: 'Patreon',
|
||||
subscribestar: 'SubscribeStar',
|
||||
hentaifoundry: 'HentaiFoundry',
|
||||
discord: 'Discord',
|
||||
pixiv: 'Pixiv',
|
||||
deviantart: 'DeviantArt',
|
||||
}
|
||||
|
||||
export function platformIcon(platform) {
|
||||
return ICONS[platform] || 'mdi-web'
|
||||
}
|
||||
|
||||
export function platformColor(platform) {
|
||||
return COLORS[platform] || 'grey'
|
||||
}
|
||||
|
||||
export function platformLabel(platform) {
|
||||
return LABELS[platform] || platform
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
<template>
|
||||
<v-container fluid class="pt-2 pb-6">
|
||||
<ExtensionKeyBar />
|
||||
|
||||
<v-alert v-if="credentialsStore.error" type="error" variant="tonal" closable class="mb-4">
|
||||
{{ String(credentialsStore.error) }}
|
||||
</v-alert>
|
||||
|
||||
<PlatformCredentialRow
|
||||
v-for="p in platformsStore.list"
|
||||
:key="p.key"
|
||||
:platform="p"
|
||||
:credential="credentialsStore.byPlatform.get(p.key) || null"
|
||||
@replace="openUpload"
|
||||
@remove="confirmRemove"
|
||||
/>
|
||||
|
||||
<CredentialUploadDialog
|
||||
v-model="showUpload"
|
||||
:platform="uploadPlatform"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
|
||||
<v-dialog v-model="removeConfirm.open" max-width="420">
|
||||
<v-card>
|
||||
<v-card-title>Delete {{ removeConfirm.platform?.name }} credential?</v-card-title>
|
||||
<v-card-text>
|
||||
The encrypted credential will be removed permanently. You'll need to
|
||||
re-upload to use this platform again.
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="removeConfirm.open = false">Cancel</v-btn>
|
||||
<v-btn color="error" variant="flat" @click="doRemove">Delete</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { usePlatformsStore } from '../stores/platforms.js'
|
||||
import { useCredentialsStore } from '../stores/credentials.js'
|
||||
import ExtensionKeyBar from '../components/credentials/ExtensionKeyBar.vue'
|
||||
import PlatformCredentialRow from '../components/credentials/PlatformCredentialRow.vue'
|
||||
import CredentialUploadDialog from '../components/credentials/CredentialUploadDialog.vue'
|
||||
|
||||
const platformsStore = usePlatformsStore()
|
||||
const credentialsStore = useCredentialsStore()
|
||||
|
||||
const showUpload = ref(false)
|
||||
const uploadPlatform = ref(null)
|
||||
const removeConfirm = reactive({ open: false, platform: null })
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([platformsStore.loadAll(), credentialsStore.loadAll()])
|
||||
})
|
||||
|
||||
function openUpload(platform) {
|
||||
uploadPlatform.value = platform
|
||||
showUpload.value = true
|
||||
}
|
||||
|
||||
async function onSaved() {
|
||||
await credentialsStore.loadAll()
|
||||
}
|
||||
|
||||
function confirmRemove(platform) {
|
||||
removeConfirm.platform = platform
|
||||
removeConfirm.open = true
|
||||
}
|
||||
|
||||
async function doRemove() {
|
||||
await credentialsStore.remove(removeConfirm.platform.key)
|
||||
removeConfirm.open = false
|
||||
await credentialsStore.loadAll()
|
||||
}
|
||||
</script>
|
||||
@@ -1,70 +0,0 @@
|
||||
<template>
|
||||
<v-container fluid class="pt-2 pb-6">
|
||||
<FilterPills v-model="pill" />
|
||||
|
||||
<v-alert v-if="store.error" type="error" variant="tonal" closable class="my-4">
|
||||
{{ String(store.error) }}
|
||||
</v-alert>
|
||||
|
||||
<div v-if="store.loading && store.events.length === 0" class="fc-dl__loading">
|
||||
<v-progress-circular indeterminate color="accent" size="36" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="store.events.length === 0" class="fc-dl__empty">
|
||||
<p>No download events yet. Trigger a check from /subscriptions.</p>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<DownloadEventRow
|
||||
v-for="e in store.events" :key="e.id" :event="e"
|
||||
@open="openDetail"
|
||||
/>
|
||||
<div class="fc-dl__sentinel">
|
||||
<v-btn v-if="store.hasMore" variant="text" @click="store.loadMore()" :loading="store.loading">
|
||||
Load more
|
||||
</v-btn>
|
||||
<span v-else class="text-caption" style="opacity: 0.5">No more events.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DownloadDetailModal
|
||||
:event="store.selected"
|
||||
@close="store.closeDetail()"
|
||||
/>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { useDownloadsStore } from '../stores/downloads.js'
|
||||
import FilterPills from '../components/downloads/FilterPills.vue'
|
||||
import DownloadEventRow from '../components/downloads/DownloadEventRow.vue'
|
||||
import DownloadDetailModal from '../components/downloads/DownloadDetailModal.vue'
|
||||
|
||||
const store = useDownloadsStore()
|
||||
const pill = ref('all')
|
||||
|
||||
onMounted(() => store.loadFirst())
|
||||
|
||||
watch(pill, async (v) => {
|
||||
// 'quarantined' has no API filter yet — defer until the API grows
|
||||
// (a metadata.run_stats.quarantined_count > 0 filter, FC-3d territory).
|
||||
// For now route it the same as 'all' but keep the chip for discoverability.
|
||||
const statusMap = { all: null, running: 'running', error: 'error', quarantined: null }
|
||||
await store.applyFilter({ status: statusMap[v] })
|
||||
})
|
||||
|
||||
async function openDetail(id) {
|
||||
await store.loadOne(id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-dl__loading, .fc-dl__empty {
|
||||
display: flex; justify-content: center; padding: 3rem 0;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
.fc-dl__sentinel {
|
||||
display: flex; justify-content: center; padding: 1rem 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,416 +1,71 @@
|
||||
<template>
|
||||
<v-container fluid class="pt-2 pb-6">
|
||||
<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-spacer />
|
||||
<v-text-field
|
||||
v-model="search"
|
||||
density="compact" variant="outlined" hide-details clearable
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
placeholder="Search subscriptions"
|
||||
style="max-width: 320px"
|
||||
/>
|
||||
</div>
|
||||
<v-tabs
|
||||
v-model="tab"
|
||||
align-tabs="start"
|
||||
color="accent"
|
||||
density="compact"
|
||||
class="fc-subs-tabs"
|
||||
>
|
||||
<v-tab value="subscriptions">
|
||||
<v-icon start>mdi-account-multiple-check</v-icon>
|
||||
Subscriptions
|
||||
</v-tab>
|
||||
<v-tab value="downloads">
|
||||
<v-icon start>mdi-cloud-download</v-icon>
|
||||
Downloads
|
||||
</v-tab>
|
||||
<v-tab value="settings">
|
||||
<v-icon start>mdi-cog</v-icon>
|
||||
Settings
|
||||
</v-tab>
|
||||
</v-tabs>
|
||||
|
||||
<v-alert v-if="store.error" type="error" variant="tonal" closable class="mt-4">
|
||||
{{ 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 "{{ search }}".</p>
|
||||
</div>
|
||||
|
||||
<v-card v-else class="fc-subs__card" variant="outlined">
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="filteredGroups"
|
||||
item-value="key"
|
||||
v-model:expanded="expanded"
|
||||
:items-per-page="50"
|
||||
:items-per-page-options="ITEMS_PER_PAGE_OPTIONS"
|
||||
density="comfortable"
|
||||
hover
|
||||
show-expand
|
||||
@click:row="onRowClick"
|
||||
>
|
||||
<template #item.name="{ item }">
|
||||
<span class="fc-subs__name">{{ item.artist.name }}</span>
|
||||
</template>
|
||||
|
||||
<template #item.sources_count="{ item }">
|
||||
<v-chip size="x-small" variant="tonal" label>
|
||||
{{ item.sources.length }} source{{ item.sources.length === 1 ? '' : 's' }}
|
||||
</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"
|
||||
/>
|
||||
<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>
|
||||
|
||||
<SourceFormDialog
|
||||
v-model="showSourceDialog"
|
||||
:source="editingSource"
|
||||
:initial-artist="editingArtist"
|
||||
@saved="onSourceSaved"
|
||||
/>
|
||||
<ArtistCreateDialog v-model="showArtistDialog" @created="onArtistCreated" />
|
||||
<v-window v-model="tab" class="mt-4">
|
||||
<v-window-item value="subscriptions">
|
||||
<SubscriptionsTab />
|
||||
</v-window-item>
|
||||
<v-window-item value="downloads">
|
||||
<DownloadsTab />
|
||||
</v-window-item>
|
||||
<v-window-item value="settings">
|
||||
<SettingsTab />
|
||||
</v-window-item>
|
||||
</v-window>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
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 '../components/subscriptions/SourceRow.vue'
|
||||
import SourceHealthDot from '../components/subscriptions/SourceHealthDot.vue'
|
||||
import SourceFormDialog from '../components/subscriptions/SourceFormDialog.vue'
|
||||
import ArtistCreateDialog from '../components/subscriptions/ArtistCreateDialog.vue'
|
||||
|
||||
const ITEMS_PER_PAGE_OPTIONS = [
|
||||
{ value: 25, title: '25' },
|
||||
{ value: 50, title: '50' },
|
||||
{ value: 100, title: '100' },
|
||||
{ value: -1, title: 'All' },
|
||||
]
|
||||
import SubscriptionsTab from '../components/subscriptions/SubscriptionsTab.vue'
|
||||
import DownloadsTab from '../components/subscriptions/DownloadsTab.vue'
|
||||
import SettingsTab from '../components/subscriptions/SettingsTab.vue'
|
||||
|
||||
const VALID_TABS = ['subscriptions', 'downloads', 'settings']
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useSourcesStore()
|
||||
const platformsStore = usePlatformsStore()
|
||||
const importStore = useImportStore()
|
||||
|
||||
const search = ref('')
|
||||
const expanded = ref([])
|
||||
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
|
||||
const tab = ref(
|
||||
VALID_TABS.includes(route.query.tab) ? route.query.tab : 'subscriptions',
|
||||
)
|
||||
|
||||
async function refresh() {
|
||||
await store.loadAll()
|
||||
await platformsStore.loadAll()
|
||||
if (!importStore.settings) await importStore.loadSettings()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refresh()
|
||||
if (artistFilter.value != null) {
|
||||
// Pre-expand the row that the deep-link refers to.
|
||||
expanded.value = [`artist-${artistFilter.value}`]
|
||||
}
|
||||
})
|
||||
watch(() => route.query.artist_id, refresh)
|
||||
|
||||
const headers = [
|
||||
{ title: 'Subscription', key: 'name', sortable: true, align: 'start' },
|
||||
{ title: 'Sources', key: 'sources_count', sortable: true, align: 'start', width: 110 },
|
||||
{ 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)
|
||||
return {
|
||||
key: `artist-${g.artist.id}`,
|
||||
artist: g.artist,
|
||||
sources: g.sources,
|
||||
sources_count: g.sources.length,
|
||||
worstSource,
|
||||
lastActivity,
|
||||
name: g.artist.name, // for sortable column
|
||||
last_activity: lastActivity ?? '', // for sortable column
|
||||
}
|
||||
})
|
||||
watch(tab, (t) => {
|
||||
if (route.query.tab === t) return
|
||||
router.replace({ query: { ...route.query, tab: t } })
|
||||
})
|
||||
|
||||
const filteredGroups = computed(() => {
|
||||
let arr = groups.value
|
||||
if (artistFilter.value != null) {
|
||||
arr = arr.filter(g => g.artist.id === artistFilter.value)
|
||||
watch(() => route.query.tab, (q) => {
|
||||
if (q && VALID_TABS.includes(q) && tab.value !== q) {
|
||||
tab.value = q
|
||||
}
|
||||
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 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) {
|
||||
// Health order (worst → best): critical, warning, healthy, unchecked.
|
||||
// Picks the source with the worst level so the row's dot reflects the
|
||||
// worst-case state. Within a level, the first is fine.
|
||||
if (!sources || sources.length === 0) return null
|
||||
function level(s) {
|
||||
if (!s.last_checked_at) return 0 // unchecked
|
||||
const f = s.consecutive_failures || 0
|
||||
if (f === 0) return 1 // healthy
|
||||
if (f < threshold) return 2 // warning
|
||||
return 3 // critical
|
||||
}
|
||||
return sources.reduce((worst, s) =>
|
||||
level(s) > level(worst) ? s : worst,
|
||||
sources[0],
|
||||
)
|
||||
}
|
||||
|
||||
function formatRelative(iso) {
|
||||
if (!iso) return 'Never'
|
||||
const then = new Date(iso).getTime()
|
||||
const diff = (Date.now() - then) / 1000
|
||||
if (diff < 60) return `${Math.floor(diff)}s ago`
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
|
||||
return `${Math.floor(diff / 86400)}d ago`
|
||||
}
|
||||
|
||||
function onRowClick(_evt, { item, internalItem }) {
|
||||
// Toggle expansion on row click (in addition to the chevron).
|
||||
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) {
|
||||
await store.remove(source.id, source.artist_id)
|
||||
await refresh()
|
||||
}
|
||||
|
||||
async function toggleSourceEnabled({ source, enabled }) {
|
||||
await store.update(source.id, { enabled }, source.artist_id)
|
||||
await refresh()
|
||||
}
|
||||
|
||||
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)
|
||||
globalThis.window?.__fcToast?.({
|
||||
text: `Check enqueued (event #${body.download_event_id})`,
|
||||
type: 'success',
|
||||
})
|
||||
} catch (e) {
|
||||
if (e?.body?.download_event_id) {
|
||||
globalThis.window?.__fcToast?.({
|
||||
text: 'Already running — see Downloads',
|
||||
type: 'info',
|
||||
})
|
||||
router.push({ path: '/downloads', query: { source_id: source.id } })
|
||||
} else {
|
||||
globalThis.window?.__fcToast?.({
|
||||
text: `Check failed: ${e?.detail || e?.message || e}`,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function checkAll(group) {
|
||||
let ok = 0
|
||||
let conflict = 0
|
||||
for (const s of group.sources) {
|
||||
if (!s.enabled) continue
|
||||
try {
|
||||
await store.checkNow(s.id)
|
||||
ok += 1
|
||||
} catch (e) {
|
||||
if (e?.body?.download_event_id) conflict += 1
|
||||
}
|
||||
}
|
||||
const parts = []
|
||||
if (ok) parts.push(`${ok} queued`)
|
||||
if (conflict) parts.push(`${conflict} already running`)
|
||||
globalThis.window?.__fcToast?.({
|
||||
text: parts.join(', ') || 'Nothing to check (no enabled sources)',
|
||||
type: 'info',
|
||||
})
|
||||
}
|
||||
|
||||
function anyChecking(sources) {
|
||||
return sources.some(s => store.checkingIds.has(s.id))
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-subs__bar {
|
||||
display: flex; gap: 0.75rem; align-items: center;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
.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));
|
||||
}
|
||||
.fc-subs__name {
|
||||
font-weight: 600;
|
||||
}
|
||||
.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-tabs {
|
||||
border-bottom: 1px solid rgb(var(--v-theme-on-surface-variant) / 0.18);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user