fix(external): path-safe unlink + per-link staging + orphan repair (#859)
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 27s
CI / frontend-build (push) Successful in 21s
CI / integration (push) Successful in 3m17s

External downloads import IN PLACE, so the post-attach dedup-skip unlink could
delete a file that IS an ImageRecord's backing file — orphaning the record and
404-ing on playback. Two sources of that:

- Two links on the same post (same film from mega + gdrive) emitted the same
  filename into one external/<post_id>/ dir; the second overwrote the first.
  Stage per-LINK now (external/<post_id>/<link_id>/) so each file keeps its path.
- The duplicate_hash/duplicate_phash branch unlinked `f` unconditionally. Make it
  path-safe: only unlink when `f` is NOT the existing record's canonical file.

Plus an operator-triggered orphan-repair maintenance task
(prune_missing_file_records_task) to clean up records already orphaned by the
bug: scans ImageRecords, deletes those whose file is gone (cascade), with an
NFS-stall guard that aborts without deleting if a large sample is mostly missing.
Wired through POST /api/admin/maintenance/prune-missing-files and a
MissingFileRepairCard in the Maintenance panel.

Tests: refetch-same-link keeps the canonical file; orphan repair deletes only
real orphans and aborts on the mostly-missing guard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 01:48:38 -04:00
parent f897e2534b
commit 949c9abcc6
7 changed files with 295 additions and 4 deletions
@@ -16,6 +16,7 @@
<AliasTable class="mt-4" />
<DbMaintenanceCard class="mt-6" />
<ArchiveReextractCard class="mt-6" />
<MissingFileRepairCard 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
@@ -35,6 +36,7 @@ import AllowlistTable from './AllowlistTable.vue'
import AliasTable from './AliasTable.vue'
import DbMaintenanceCard from './DbMaintenanceCard.vue'
import ArchiveReextractCard from './ArchiveReextractCard.vue'
import MissingFileRepairCard from './MissingFileRepairCard.vue'
import BackupCard from './BackupCard.vue'
import { useSystemActivityStore } from '../../stores/systemActivity.js'
@@ -0,0 +1,47 @@
<template>
<!-- #859: delete ImageRecords whose backing file is gone from disk (orphans
left by the external-attach unlink bug) so they stop 404-ing on playback. -->
<v-card>
<v-card-title>Repair missing-file records</v-card-title>
<v-card-text>
<p class="text-body-2 mb-3">
Scans every image/video record and removes any whose underlying file is
gone from disk orphaned records that otherwise 404 when you open the
post. Safe: it <strong>aborts without deleting</strong> if a large share of
files look missing (which means a storage/NFS hiccup, not real orphans).
Run it only when storage is healthy.
</p>
<v-btn color="primary" rounded="pill" :loading="busy" @click="run">
<v-icon start>mdi-file-remove-outline</v-icon> Repair missing-file records
</v-btn>
<span v-if="queued" class="ml-3 text-caption text-success">Queued </span>
<QueueStatusBar queue="maintenance" queue-label="Maintenance" />
</v-card-text>
</v-card>
</template>
<script setup>
import { ref } from 'vue'
import { useApi } from '../../composables/useApi.js'
import { toast } from '../../utils/toast.js'
import QueueStatusBar from './QueueStatusBar.vue'
const api = useApi()
const busy = ref(false)
const queued = ref(false)
async function run () {
busy.value = true
queued.value = false
try {
await api.post('/api/admin/maintenance/prune-missing-files')
queued.value = true
toast({ text: 'Missing-file repair queued', type: 'success' })
} catch (e) {
toast({ text: e?.body?.detail || e?.message || 'Failed to queue', type: 'error' })
} finally {
busy.value = false
}
}
</script>