feat(subscriptions): dry-run backfill preview — B4 preview (plan #708)
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 24s
CI / integration (push) Successful in 3m1s
CI / frontend-build (push) Successful in 5m13s

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:
2026-06-06 12:18:08 -04:00
parent cd43439401
commit e82c2ee57b
10 changed files with 368 additions and 2 deletions
+44
View File
@@ -191,6 +191,50 @@ async def set_backfill(source_id: int):
return jsonify(record.to_dict())
@sources_bp.route("/<int:source_id>/preview", methods=["POST"])
async def preview_source_endpoint(source_id: int):
"""Plan #708 B4: dry-run — count what a backfill WOULD download for a native
platform (Patreon today), without downloading. Walks the first few feed pages
and counts media not already in the seen/dead ledgers. Returns
{total_new, posts_scanned, pages_scanned, has_more, sample[]} or 409 + reason
(unresolvable campaign id / auth / drift). 400 for gallery-dl platforms (no
cheap dry-run — their verify is a slow --simulate)."""
from pathlib import Path
from ..services.credential_service import CredentialService
from ..services.download_backends import preview_source, uses_native_ingester
from ..tasks._sync_engine import sync_session_factory
from .credentials import _get_crypto
async with get_session() as session:
rec = await SourceService(session).get(source_id)
if rec is None:
return _bad("not_found", status=404)
if not uses_native_ingester(rec.platform):
return _bad(
"unsupported",
detail="Preview is only available for native-ingester platforms.",
status=400,
)
cred = CredentialService(session, _get_crypto())
cookies_path = await cred.get_cookies_path(rec.platform)
# The walk + ledger reads are sync (run off the request loop); the process
# sync engine is the same one the download task uses.
result = await preview_source(
platform=rec.platform,
url=rec.url,
source_id=source_id,
config_overrides=rec.config_overrides or {},
cookies_path=str(cookies_path) if cookies_path else None,
images_root=Path("/images"),
sync_session_factory=sync_session_factory(),
)
if "error" in result:
return _bad("preview_failed", detail=result["error"], status=409)
return jsonify(result)
@sources_bp.route("/<int:source_id>/check", methods=["POST"])
async def check_source(source_id: int):
"""FC-3c: enqueue a download for this source.
+48
View File
@@ -144,6 +144,54 @@ async def _run_native_ingester(
return dl_result, resolved_campaign_id
async def preview_source(
*,
platform: str,
url: str,
source_id: int,
config_overrides: dict | None,
cookies_path: str | None,
images_root: Path,
sync_session_factory,
page_limit: int = 3,
) -> dict:
"""Dry-run preview for a native platform (plan #708 B4): resolve the campaign
id, then walk a few pages counting media not already seen/dead — no download.
Returns the preview dict (total_new / posts_scanned / pages_scanned /
has_more / sample), or `{"error": msg}` on a resolve / auth / drift failure.
Native-only — the caller gates on `uses_native_ingester`.
"""
import asyncio
from .patreon_client import PatreonAPIError
campaign_id, _ = await resolve_campaign_id_for_source(
url, cookies_path, config_overrides or {}
)
if not campaign_id:
return {
"error": (
"Couldn't resolve the campaign id from the source URL "
"(cookies expired, or the creator moved/renamed?)."
)
}
ingester = PatreonIngester(
images_root=images_root,
cookies_path=cookies_path,
session_factory=sync_session_factory,
)
loop = asyncio.get_running_loop()
try:
result = await loop.run_in_executor(
None,
lambda: ingester.preview(source_id, campaign_id, page_limit=page_limit),
)
except PatreonAPIError as exc:
return {"error": f"Couldn't preview: {exc}"}
return result
async def verify_source_credential(
*,
platform: str,
+61
View File
@@ -361,6 +361,67 @@ class Ingester:
error_type=None, error_message=None,
)
# -- preview (dry-run) -------------------------------------------------
def preview(
self,
source_id: int,
campaign_id: str,
*,
page_limit: int = 3,
sample_size: int = 10,
) -> dict:
"""Dry-run (plan #708 B4): walk up to `page_limit` pages and count media
NOT already in the seen/dead ledgers, WITHOUT downloading anything.
Read-only — only the seen/dead SELECTs touch the DB (short sessions). Lets
an operator gauge "is this source worth a backfill?" cheaply. Returns:
{total_new, posts_scanned, pages_scanned, has_more,
sample: [{title, date, new}, ...]} # sample = posts with new media
A client-level failure (auth/drift) propagates to the caller.
"""
total_new = 0
posts_scanned = 0
pages_scanned = 0
has_more = False
sample: list[dict] = []
unset = object()
last_page: object = unset
for post, included, page_cursor in self.client.iter_posts(
campaign_id, cursor=None
):
if page_cursor != last_page:
last_page = page_cursor
pages_scanned += 1
if pages_scanned > page_limit:
has_more = True
pages_scanned = page_limit
break
posts_scanned += 1
media = self.client.extract_media(post, included)
if not media:
continue
keys = [self._ledger_key(m) for m in media]
skip = self._seen_keys(source_id, keys) | self._dead_keys(source_id, keys)
new_count = sum(1 for m in media if self._ledger_key(m) not in skip)
total_new += new_count
if new_count > 0 and len(sample) < sample_size:
meta = self.client.post_meta(post)
sample.append(
{
"title": meta.get("title") or "(untitled)",
"date": meta.get("date"),
"new": new_count,
}
)
return {
"total_new": total_new,
"posts_scanned": posts_scanned,
"pages_scanned": pages_scanned,
"has_more": has_more,
"sample": sample,
}
# -- failure mapping (adapter overrides) -------------------------------
def _failure_result(self, exc: Exception, _result) -> DownloadResult:
+12
View File
@@ -520,6 +520,18 @@ class PatreonClient:
return _dedup_by_filehash(items)
@staticmethod
def post_meta(post: dict) -> dict:
"""Title + published date for a post — for the preview sample (plan #708
B4). Part of the client contract `ingest_core.Ingester.preview` calls."""
attrs = post.get("attributes") or {}
title = attrs.get("title")
published = attrs.get("published_at")
return {
"title": title if isinstance(title, str) else None,
"date": published if isinstance(published, str) else None,
}
# -- iteration ---------------------------------------------------------
def iter_posts(
@@ -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"
>Youre 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
+7
View File
@@ -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,
+39
View File
@@ -65,6 +65,9 @@ class _FakeClient:
def extract_media(self, post, included_index):
return post["_media"]
def post_meta(self, post):
return {"title": post.get("id"), "date": None}
class _FakeDownloader:
"""Stub PatreonDownloader. Honors the injected is_seen (tier-1); media in
@@ -382,6 +385,42 @@ async def test_backfill_budget_cut_returns_partial_with_progress(
assert result.cursor == "CUR2"
@pytest.mark.asyncio
async def test_preview_counts_new_media_without_downloading(
source_id, sync_engine, tmp_path,
):
"""plan #708 B4: preview walks + counts media not already seen/dead, downloads
nothing, and samples only posts that have new media."""
m1, m2, m3 = _media("p1", 1), _media("p2", 1), _media("p2", 2)
# Seed m1 as already-seen → only p2's two media are "new".
factory = sessionmaker(sync_engine, expire_on_commit=False)
with factory() as s:
s.add(PatreonSeenMedia(source_id=source_id, filehash=_ledger_key(m1), post_id="p1"))
s.commit()
client = _FakeClient([(None, [("p1", [m1]), ("p2", [m2, m3])])])
downloader = _FakeDownloader(tmp_path)
ing = _ingester(sync_engine, tmp_path, client, downloader)
result = ing.preview(source_id, "c1")
assert result["total_new"] == 2 # p2's m2 + m3 (m1 already seen)
assert result["posts_scanned"] == 2
assert result["has_more"] is False
assert downloader.download_calls == 0 # dry-run — nothing downloaded
# The sample lists only posts WITH new media (p2), not the all-seen p1.
assert [row["new"] for row in result["sample"]] == [2]
@pytest.mark.asyncio
async def test_preview_page_limit_caps_walk(source_id, sync_engine, tmp_path):
"""plan #708 B4: preview stops after page_limit pages and flags has_more."""
pages = [(f"CUR{i}", [(f"p{i}", [_media(f"p{i}", 1)])]) for i in range(6)]
ing = _ingester(sync_engine, tmp_path, _FakeClient(pages), _FakeDownloader(tmp_path))
result = ing.preview(source_id, "c1", page_limit=2)
assert result["pages_scanned"] == 2
assert result["posts_scanned"] == 2 # one post per page, 2 pages
assert result["has_more"] is True
@pytest.mark.asyncio
async def test_still_running_reads_backfill_state(source_id, sync_engine, tmp_path, db):
"""plan #708 B4: _still_running reflects the source's `_backfill_state` — the