feat(subscriptions): dry-run backfill preview — B4 preview (plan #708)
Owning the walk lets an operator gauge "is this source worth a backfill?" before
arming one. ingest_core.Ingester.preview walks the first few feed pages and
counts media NOT already in the seen/dead ledgers, downloading nothing
(read-only). download_backends.preview_source resolves the campaign id + runs it
(native-only, mirrors verify_source_credential / run_download); POST
/api/sources/{id}/preview returns {total_new, posts_scanned, has_more, sample[]}
(409 on auth/drift/unresolvable, 400 for gallery-dl platforms). PatreonClient
gains post_meta(post) for the sample's title/date.
UI: a Patreon-only Preview button (mdi-eye-outline) on SourceRow + SourceCard
opens PreviewDialog — self-fetches with loading / error / empty / result states
and a "Start backfill" shortcut. Store action previewSource.
Tests: preview counts new media without downloading + samples only posts with
new items; page_limit caps the walk + flags has_more.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<!-- Plan #708 B4: dry-run preview — what a backfill WOULD download, without
|
||||
downloading. Self-fetches on open; loading / error / empty / result. -->
|
||||
<v-dialog
|
||||
:model-value="modelValue" max-width="520"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
>
|
||||
<v-card v-if="source">
|
||||
<v-card-title class="d-flex align-center">
|
||||
<v-icon class="mr-2">mdi-eye-outline</v-icon>
|
||||
Preview · {{ source.artist_name }}
|
||||
</v-card-title>
|
||||
<v-card-subtitle class="fc-preview__url">{{ source.url }}</v-card-subtitle>
|
||||
|
||||
<v-card-text>
|
||||
<div v-if="loading" class="text-center py-8">
|
||||
<v-progress-circular indeterminate color="accent" />
|
||||
<div class="text-caption mt-3 text-medium-emphasis">Walking the feed…</div>
|
||||
</div>
|
||||
|
||||
<v-alert
|
||||
v-else-if="error" type="error" variant="tonal" density="comfortable"
|
||||
>{{ error }}</v-alert>
|
||||
|
||||
<div v-else-if="result">
|
||||
<div class="text-h5">
|
||||
{{ result.total_new }} new item{{ result.total_new === 1 ? '' : 's' }}
|
||||
</div>
|
||||
<div class="text-body-2 text-medium-emphasis mb-3">
|
||||
in the {{ result.posts_scanned }} most recent
|
||||
post{{ result.posts_scanned === 1 ? '' : 's' }}<span
|
||||
v-if="result.has_more"> · more pages not scanned</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="result.total_new === 0"
|
||||
class="text-body-2 text-medium-emphasis"
|
||||
>You’re caught up — nothing new to download.</div>
|
||||
|
||||
<v-list v-else density="compact" class="fc-preview__list" bg-color="transparent">
|
||||
<v-list-item v-for="(row, i) in result.sample" :key="i" class="px-0">
|
||||
<template #prepend>
|
||||
<v-chip
|
||||
size="x-small" color="info" variant="tonal" label class="mr-3"
|
||||
>+{{ row.new }}</v-chip>
|
||||
</template>
|
||||
<v-list-item-title class="text-body-2">{{ row.title }}</v-list-item-title>
|
||||
<v-list-item-subtitle v-if="row.date" class="text-caption">
|
||||
{{ formatRelative(row.date) }}
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="$emit('update:modelValue', false)">Close</v-btn>
|
||||
<v-btn
|
||||
v-if="result && result.total_new > 0"
|
||||
color="accent" variant="flat"
|
||||
@click="$emit('backfill', source)"
|
||||
>Start backfill</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { useSourcesStore } from '../../stores/sources.js'
|
||||
import { formatRelative } from '../../utils/date.js'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
source: { type: Object, default: null },
|
||||
})
|
||||
defineEmits(['update:modelValue', 'backfill'])
|
||||
|
||||
const store = useSourcesStore()
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const result = ref(null)
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
async (open) => {
|
||||
if (!open || !props.source) return
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
result.value = null
|
||||
try {
|
||||
result.value = await store.previewSource(props.source.id)
|
||||
} catch (e) {
|
||||
error.value = e?.body?.detail || e?.message || 'Preview failed'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-preview__url {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.fc-preview__list {
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -61,6 +61,16 @@
|
||||
: 'Backfill full history' }}
|
||||
</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="source.platform === 'patreon' && source.backfill_state !== 'running'"
|
||||
size="x-small" variant="text"
|
||||
@click.stop="$emit('preview', source)"
|
||||
>
|
||||
<v-icon>mdi-eye-outline</v-icon>
|
||||
<v-tooltip activator="parent" location="top">
|
||||
Preview — count what a backfill would download (no download)
|
||||
</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="source.platform === 'patreon' && source.backfill_state !== 'running'"
|
||||
size="x-small" variant="text"
|
||||
@@ -98,7 +108,7 @@ const props = defineProps({
|
||||
checking: { type: Boolean, default: false },
|
||||
warningThreshold: { type: Number, default: 5 },
|
||||
})
|
||||
const emit = defineEmits(['edit', 'remove', 'toggle', 'check', 'backfill', 'recover'])
|
||||
const emit = defineEmits(['edit', 'remove', 'toggle', 'check', 'backfill', 'recover', 'preview'])
|
||||
|
||||
function onToggleEnabled(value) {
|
||||
emit('toggle', { source: props.source, enabled: value })
|
||||
|
||||
@@ -69,6 +69,16 @@
|
||||
: 'Backfill — walk the full post history until complete' }}
|
||||
</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="source.platform === 'patreon' && source.backfill_state !== 'running'"
|
||||
size="x-small" variant="text"
|
||||
@click.stop="$emit('preview', source)"
|
||||
>
|
||||
<v-icon>mdi-eye-outline</v-icon>
|
||||
<v-tooltip activator="parent" location="top">
|
||||
Preview — count what a backfill would download (no download)
|
||||
</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="source.platform === 'patreon' && source.backfill_state !== 'running'"
|
||||
size="x-small" variant="text"
|
||||
@@ -106,7 +116,7 @@ const props = defineProps({
|
||||
checking: { type: Boolean, default: false },
|
||||
warningThreshold: { type: Number, default: 5 },
|
||||
})
|
||||
const emit = defineEmits(['edit', 'remove', 'toggle', 'check', 'backfill', 'recover'])
|
||||
const emit = defineEmits(['edit', 'remove', 'toggle', 'check', 'backfill', 'recover', 'preview'])
|
||||
|
||||
function onToggleEnabled(value) {
|
||||
emit('toggle', { source: props.source, enabled: value })
|
||||
|
||||
@@ -176,6 +176,7 @@
|
||||
@check="onCheck"
|
||||
@backfill="onBackfill"
|
||||
@recover="onRecover"
|
||||
@preview="onPreview"
|
||||
/>
|
||||
<tr v-if="item.sources.length === 0">
|
||||
<td colspan="8" class="fc-subs__sources-empty">
|
||||
@@ -253,6 +254,7 @@
|
||||
@check="onCheck"
|
||||
@backfill="onBackfill"
|
||||
@recover="onRecover"
|
||||
@preview="onPreview"
|
||||
/>
|
||||
<div v-if="item.sources.length === 0" class="fc-subs__sources-empty">
|
||||
No sources yet. Tap + to add one.
|
||||
@@ -268,6 +270,11 @@
|
||||
@saved="onSourceSaved"
|
||||
/>
|
||||
<ArtistCreateDialog v-model="showArtistDialog" @created="onArtistCreated" />
|
||||
<PreviewDialog
|
||||
v-model="showPreviewDialog"
|
||||
:source="previewTarget"
|
||||
@backfill="onPreviewBackfill"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -284,6 +291,7 @@ import SourceCard from './SourceCard.vue'
|
||||
import SourceHealthDot from './SourceHealthDot.vue'
|
||||
import SourceFormDialog from './SourceFormDialog.vue'
|
||||
import ArtistCreateDialog from './ArtistCreateDialog.vue'
|
||||
import PreviewDialog from './PreviewDialog.vue'
|
||||
import PlatformChip from './PlatformChip.vue'
|
||||
import SchedulerStatusBar from './SchedulerStatusBar.vue'
|
||||
import { formatRelative } from '../../utils/date.js'
|
||||
@@ -334,6 +342,8 @@ const showSourceDialog = ref(false)
|
||||
const editingSource = ref(null)
|
||||
const editingArtist = ref(null)
|
||||
const showArtistDialog = ref(false)
|
||||
const showPreviewDialog = ref(false)
|
||||
const previewTarget = ref(null)
|
||||
|
||||
const artistFilter = computed(() => {
|
||||
const raw = route.query.artist_id
|
||||
@@ -572,6 +582,18 @@ async function onRecover(source) {
|
||||
}
|
||||
}
|
||||
|
||||
// Plan #708 B4: open the dry-run preview dialog (it self-fetches on open).
|
||||
function onPreview(source) {
|
||||
previewTarget.value = source
|
||||
showPreviewDialog.value = true
|
||||
}
|
||||
|
||||
// "Start backfill" from inside the preview dialog → arm it + close.
|
||||
async function onPreviewBackfill(source) {
|
||||
showPreviewDialog.value = false
|
||||
await onBackfill(source)
|
||||
}
|
||||
|
||||
async function checkAll(group) {
|
||||
let ok = 0
|
||||
let conflict = 0
|
||||
|
||||
@@ -107,6 +107,12 @@ export const useSourcesStore = defineStore('sources', () => {
|
||||
return body
|
||||
}
|
||||
|
||||
// Plan #708 B4: dry-run preview — count what a backfill WOULD download
|
||||
// (native platforms), without downloading. Read-only, so no cache invalidate.
|
||||
async function previewSource(id) {
|
||||
return await api.post(`/api/sources/${id}/preview`)
|
||||
}
|
||||
|
||||
function sourcesByArtistGrouped() {
|
||||
// returns [{artist: {id,name,slug}, sources: [...]}, ...]
|
||||
const arr = byArtist.value.get(null) ?? []
|
||||
@@ -136,6 +142,7 @@ export const useSourcesStore = defineStore('sources', () => {
|
||||
startBackfill,
|
||||
stopBackfill,
|
||||
recoverSource,
|
||||
previewSource,
|
||||
findOrCreateArtist, autocompleteArtist,
|
||||
loadScheduleStatus,
|
||||
sourcesByArtistGrouped,
|
||||
|
||||
Reference in New Issue
Block a user