feat(cleanup): purge misgrabbed gated-post blurred previews (#874 follow-up)
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 31s
CI / integration (push) Successful in 3m18s

A one-shot Maintenance action to remove the blurred locked-preview images
the ingester downloaded from tier-gated Patreon posts before #874.

current_user_can_view was never persisted, so the cleanup re-walks each
enabled Patreon source (read-only) to re-derive which posts are gated now
and the blurred filehashes Patreon serves for them, then matches by
CONTENT HASH against stored source_filehash. Because the hash is
content-addressed, a real file downloaded when access existed has a
different hash and can never match — regained-then-lost-access content is
provably spared (operator's hard requirement). NULL source_filehash =>
unverifiable, kept + reported.

On apply: delete matched ImageRecords + files (provenance cascades),
clear seen/dead-letter ledger rows for those hashes so the real media
re-ingests if access returns, and delete gated posts left bare. Shares
one match predicate between preview and apply (rule 93).

- cleanup_service: collect_gated_previews + purge_gated_previews
- tasks.admin: purge_gated_previews_task (async re-walk bridge, timeboxed)
- api.admin: POST /maintenance/purge-gated-previews
- GatedPurgeCard.vue in Settings > Maintenance (preview -> confirm -> apply)
- tests: collect predicate, hash-match delete/spare/unverifiable, ledger
  clear, bare-post removal, no-op

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 15:20:35 -04:00
parent 9422eadabe
commit 540151290b
6 changed files with 650 additions and 1 deletions
@@ -0,0 +1,173 @@
<template>
<!-- #874 follow-up: purge blurred locked-preview images grabbed from
tier-gated Patreon posts before the ingester fix. Re-walks each feed and
matches by content hash, so real content downloaded when access existed is
provably spared. Preview first, then apply (destructive: deletes the
matched blurred-preview files). -->
<v-card>
<v-card-title>Clean up gated-post previews</v-card-title>
<v-card-text>
<p class="text-body-2 mb-3">
Removes the blurred locked-preview images that were grabbed from
tier-gated Patreon posts before they were filtered out. It re-walks every
enabled Patreon source and matches by exact content hash, so anything you
downloaded while you <em>had</em> access is left untouched
only the blurred previews are removed. <strong>Preview</strong> first to
see the count; <strong>Apply</strong> deletes the matched files and clears
their download ledger so the real media can re-download if you regain
access. The re-walk can take a while across many sources.
</p>
<div class="d-flex align-center flex-wrap" style="gap: 12px;">
<v-btn
color="primary" variant="tonal" rounded="pill"
:loading="previewing" :disabled="applying" @click="preview"
>
<v-icon start>mdi-magnify</v-icon> Preview
</v-btn>
<v-btn
color="error" rounded="pill"
:loading="applying"
:disabled="previewing || !canApply"
@click="confirmOpen = true"
>
<v-icon start>mdi-image-off</v-icon> Apply
</v-btn>
</div>
<v-alert
v-if="summary" :type="summaryType" variant="tonal" class="mt-4"
density="comfortable"
>
<span v-if="applied">
Removed {{ summary.deleted }} blurred preview image(s) across
{{ summary.gated_posts }} gated post(s); reclaimed
{{ humanBytes(summary.reclaim_bytes) }};
removed {{ summary.posts_deleted }} now-empty post(s).
<template v-if="summary.unverifiable > 0">
{{ summary.unverifiable }} image(s) couldn't be verified (no stored
source hash) and were kept.
</template>
</span>
<span v-else-if="summary.matched > 0">
{{ summary.matched }} blurred preview image(s) across
{{ summary.gated_posts }} gated post(s)
{{ humanBytes(summary.reclaim_bytes) }} reclaimable. Click
<strong>Apply</strong> to delete them.
<template v-if="summary.unverifiable > 0">
({{ summary.unverifiable }} image(s) on gated posts have no stored
source hash, so they can't be verified and will be kept.)
</template>
</span>
<span v-else>
No blurred gated-post previews found
<template v-if="summary.gated_posts > 0">
across {{ summary.gated_posts }} gated post(s)</template>.
</span>
<template v-if="summary.partial">
<br>
<small>
Scanned {{ summary.sources_scanned }}/{{ summary.sources_total }}
sources before the time budget run again to finish the rest.
</small>
</template>
</v-alert>
<QueueStatusBar queue="maintenance" queue-label="Maintenance" />
</v-card-text>
<v-dialog v-model="confirmOpen" max-width="460">
<v-card>
<v-card-title>Delete gated-post previews?</v-card-title>
<v-card-text class="text-body-2">
This permanently deletes
<strong>{{ summary?.matched ?? 0 }}</strong> blurred preview file(s)
matched by exact content hash. Real content you downloaded with access
has a different hash and is not affected.
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="confirmOpen = false">Cancel</v-btn>
<v-btn color="error" @click="apply">Delete previews</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-card>
</template>
<script setup>
import { computed, ref } from 'vue'
import { useApi } from '../../composables/useApi.js'
import { toast } from '../../utils/toast.js'
import QueueStatusBar from './QueueStatusBar.vue'
const api = useApi()
const previewing = ref(false)
const applying = ref(false)
const confirmOpen = ref(false)
const summary = ref(null)
const applied = ref(false)
const canApply = computed(() => !!summary.value && !applied.value && summary.value.matched > 0)
const summaryType = computed(() => {
if (applied.value) return 'success'
return summary.value && summary.value.matched > 0 ? 'info' : 'success'
})
function humanBytes (n) {
const b = Number(n || 0)
if (b >= 1 << 30) return (b / (1 << 30)).toFixed(1) + ' GB'
if (b >= 1 << 20) return (b / (1 << 20)).toFixed(1) + ' MB'
if (b >= 1 << 10) return (b / (1 << 10)).toFixed(1) + ' KB'
return b + ' B'
}
// Poll the maintenance task result until ready. The re-walk hits every Patreon
// feed, so allow a generous window.
async function pollResult (taskId) {
for (let i = 0; i < 300; i++) {
await new Promise(r => setTimeout(r, 2000))
const r = await api.get(`/api/admin/maintenance/task-result/${taskId}`)
if (r.ready) {
if (!r.successful) throw new Error('the cleanup task failed — check the worker logs')
return r.result
}
}
throw new Error('timed out waiting for the cleanup task — check the task dashboard')
}
async function run (dryRun) {
const { task_id: taskId } = await api.post(
'/api/admin/maintenance/purge-gated-previews', { body: { dry_run: dryRun } },
)
return pollResult(taskId)
}
async function preview () {
previewing.value = true
applied.value = false
summary.value = null
try {
summary.value = await run(true)
} catch (e) {
toast({ text: e?.body?.detail || e?.message || 'Preview failed', type: 'error' })
} finally {
previewing.value = false
}
}
async function apply () {
confirmOpen.value = false
applying.value = true
try {
summary.value = await run(false)
applied.value = true
toast({ text: 'Gated-post previews removed', type: 'success' })
} catch (e) {
toast({ text: e?.body?.detail || e?.message || 'Apply failed', type: 'error' })
} finally {
applying.value = false
}
}
</script>
@@ -18,6 +18,7 @@
<ArchiveReextractCard class="mt-6" />
<MissingFileRepairCard class="mt-6" />
<VideoDedupCard class="mt-6" />
<GatedPurgeCard class="mt-6" />
<BackupCard class="mt-6" />
<!-- TagMaintenanceCard moved to Cleanup tab (v26.05.25.7) it
operates on the existing library which fits the Cleanup-tab
@@ -39,6 +40,7 @@ import DbMaintenanceCard from './DbMaintenanceCard.vue'
import ArchiveReextractCard from './ArchiveReextractCard.vue'
import MissingFileRepairCard from './MissingFileRepairCard.vue'
import VideoDedupCard from './VideoDedupCard.vue'
import GatedPurgeCard from './GatedPurgeCard.vue'
import BackupCard from './BackupCard.vue'
import { useSystemActivityStore } from '../../stores/systemActivity.js'