feat(cleanup): purge misgrabbed gated-post blurred previews (#874 follow-up)
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:
@@ -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'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user