PostAttachment's two FKs are both ON DELETE SET NULL, so a deleted post or artist left the row behind rather than taking it. Nothing ever pruned those rows, and nothing in the repo had ever unlinked a file under the attachment store — so both rows and bytes accumulated permanently, invisible to every existing diagnostic. Why a disk->DB reconciliation rather than a row sweep: the store is sha-addressed and idempotent, so ONE blob backs MANY rows. Deleting a row does not free its blob, and since the artist cascade (#3066) now deletes its attachment rows outright, a freed blob has no DB pointer left to find it by. Walking the store and asking "does any row still reference this sha?" catches orphans from every cause, including ones no future delete path will think to report. Preview and apply share `_orphan_attachment_conditions` (rule 93). The dry-run derives its surviving-sha set by NEGATING that same predicate, so it is honest about blobs the delete would free rather than counting them as still-referenced — the one place this was easy to get backwards, so it has its own parity test. Guards, each with a reason: - A blob is written before its row commits, so a just-stored file legitimately has no referencing row. Files under 6h are never judged — same guard and reasoning as ORPHAN_TEMP_MIN_AGE_HOURS. - `.partial` staging files belong to cleanup_orphaned_temp_files; skipped rather than raced. - The sha is parsed as the first 64 chars, not via Path.stem: store() takes the extension from the source filename, and a URL-encoded basename yields a multi-dot suffix that would make stem eat part of the sha. - A 900s walk budget reports partial=True instead of running to the task's hard limit (rule 89). - TASK_STUCK_THRESHOLD_MINUTES override at 30 (= time_limit 25 + 5). Without it a healthy 20-minute walk is phantom-flagged 'RecoverySweep' at the bare 5-min default — the #883 failure class; its invariant test is mirrored here. Defaults to the safe preview at both the task and the route, unlike the other maintenance triggers: this apply unlinks files. Operator-triggered only, never on a beat. Ships with its UI (rule 27): AttachmentReclaimCard in Cleanup → Duplicates & leftovers, built on the existing useMaintenanceTask/MaintenanceTile shapes, so a run survives navigating away. Surfaces files_failed and partial explicitly, since both change what the numbers mean. Also promotes humanBytes to utils/bytes.js — it was byte-identical in VideoDedupCard and GatedPurgeCard and this card would have been the third copy. The three divergent `formatBytes` helpers are deliberately left alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
132 lines
5.2 KiB
Vue
132 lines
5.2 KiB
Vue
<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). -->
|
||
<MaintenanceTile
|
||
icon="mdi-image-off"
|
||
title="Gated-post previews"
|
||
blurb="Delete blurred locked-preview images from tier-gated posts."
|
||
destructive
|
||
:open="applying || previewing"
|
||
>
|
||
<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_long" queue-label="Maintenance" />
|
||
|
||
<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>
|
||
</MaintenanceTile>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { computed, ref } from 'vue'
|
||
|
||
import { useMaintenanceTask } from '../../composables/useMaintenanceTask.js'
|
||
import { humanBytes } from '../../utils/bytes.js'
|
||
import MaintenanceTile from '../common/MaintenanceTile.vue'
|
||
import QueueStatusBar from './QueueStatusBar.vue'
|
||
|
||
const confirmOpen = ref(false)
|
||
|
||
// The re-walk hits every enabled Patreon feed and can run long — the lifecycle +
|
||
// resurface-after-navigation live in the shared composable. 300 polls × 2s ≈ 10m.
|
||
const { previewing, applying, summary, applied, preview, apply: applyTask } = useMaintenanceTask({
|
||
endpoint: '/api/admin/maintenance/purge-gated-previews',
|
||
storageKey: 'fc.maint.gatedPurge',
|
||
appliedToast: 'Gated-post previews removed',
|
||
maxPolls: 300,
|
||
})
|
||
|
||
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'
|
||
})
|
||
|
||
// The confirm dialog gates the destructive apply; close it, then run.
|
||
function apply () {
|
||
confirmOpen.value = false
|
||
applyTask()
|
||
}
|
||
</script>
|