Files
FabledCurator/frontend/src/components/settings/GatedPurgeCard.vue
T
bvandeusenandClaude Opus 5 2e0f8f8c61
CI / lint (push) Successful in 2s
CI / extension-version (push) Successful in 3s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m47s
feat(cleanup): reclaim orphaned attachments — rows and store blobs (#3068)
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>
2026-08-26 22:37:24 -04:00

132 lines
5.2 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>