feat: Settings can re-download the Discord images the None naming broke (3999)
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
CI / extension-version (push) Successful in 2s
Build images / build-agent (push) Successful in 6s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 33s
Build images / build-web (push) Successful in 1m9s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 2m3s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m35s

The operator chose a clean re-download over relinking in place. The ~1,600 broken files can't be relinked reliably: their message ids are gone, and their sidecars collided.

Settings → Maintenance → "Repair Discord downloads" previews, then applies:
- Deletes every image whose path is `…/discord/None/<8 digits>_None_…`. Both the folder and the name are required, because that pair is only what the bug produced. It reuses cleanup_service.delete_images for the record and file deletes.
- Sweeps the leftover collided sidecars from those folders and removes the emptied folders.
- Only then clears gallery-dl's archive rows `discord%`, excluding `discordasset_%`. Upstream keys message attachments as `discord{message_id}_{num}`. Since the broken files lost their message ids, per-source forgetting is impossible. Every pre-fix Discord download is broken, and files fetched after the fix still exist on disk, so gallery-dl's `skip` won't re-fetch them.
- Arms a fresh backfill on every Discord source.

The apply defaults to preview at both the route and the task, runs on maintenance_long, and is never on a beat. The card uses the confirm-dialog pattern of AttachmentReclaimCard.

Supporting refactors, with no behaviour change:
- gallery_dl.archive_path() is the single definition of the archive location.
- source_service.arm_backfill() is the mutation start_backfill already did, now shared with the sync repair.

Tests (tests/test_discord_repair.py):
- The archive clear leaves other platforms and Discord assets alone, and counting mutates nothing.
- Case-twin artist folders are both found.
- The folder sweep works.
- An integration run shows only the broken image goes. A correctly named Discord file and a `None` folder under Patreon survive, and only Discord sources are re-armed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SHQB1YukL3VyvMK8rcbmV9
This commit is contained in:
2026-09-13 20:52:32 -04:00
co-authored by Claude Opus 5
parent dc840feec7
commit 9b82a95b7e
8 changed files with 534 additions and 8 deletions
@@ -0,0 +1,118 @@
<template>
<!-- #3999: Discord downloads made before the naming fix landed in a `None`
folder with no post and a download-time date. This re-downloads them
cleanly. Preview first; the apply deletes files. -->
<MaintenanceTile
icon="mdi-download-off-outline"
title="Repair Discord downloads"
blurb="Re-download Discord images that were saved with no post and the wrong date."
destructive
:open="applying || previewing"
>
<p class="text-body-2 mb-3">
Before the naming fix, every Discord download was saved into a folder
called <code>None</code>. Those images never got a post, so they show the
time Curator downloaded them rather than when they were posted.
<strong>Apply</strong> deletes those images, clears Discord from the
download history, and starts a fresh backfill of every Discord source so
they come back with their posts and dates. Tags you added by hand to those
images are lost. Nothing outside a Discord <code>None</code> folder is
touched.
</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-download-off-outline</v-icon> Apply
</v-btn>
</div>
<v-alert
v-if="summary" :type="summaryType" variant="tonal" class="mt-4"
density="comfortable"
>
<span v-if="applied">
Deleted {{ summary.images_deleted }} image(s) and cleared
{{ summary.archive_entries }} Discord download record(s). Backfills
started on {{ summary.backfills_started }} Discord source(s).
</span>
<span v-else-if="hasWork">
{{ summary.images }} broken image(s) ({{ humanBytes(summary.bytes) }})
across {{ summary.directories }} folder(s), and
{{ summary.archive_entries }} Discord download record(s) to clear.
{{ summary.sources }} Discord source(s) will backfill again.
</span>
<span v-else>Nothing to repair no broken Discord downloads found.</span>
<div v-if="applied && summary.files_failed" class="mt-1 text-caption">
{{ summary.files_failed }} file(s) could not be removed see the worker log.
</div>
<div v-if="applied && summary.remaining" class="mt-1 text-caption">
{{ summary.remaining }} broken image(s) are still present. Run it again.
</div>
</v-alert>
<QueueStatusBar queue="maintenance_long" queue-label="Maintenance" />
<v-dialog v-model="confirmOpen" max-width="440">
<v-card>
<v-card-title>Repair Discord downloads?</v-card-title>
<v-card-text class="text-body-2">
This permanently deletes <strong>{{ summary?.images ?? 0 }}</strong>
Discord image(s) ({{ humanBytes(summary?.bytes) }}) and re-downloads
them from Discord with their posts and dates. Any tags you added to
those images by hand will not come back.
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="confirmOpen = false">Cancel</v-btn>
<v-btn color="error" @click="apply">Repair</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)
const { previewing, applying, summary, applied, preview, apply: applyTask } = useMaintenanceTask({
endpoint: '/api/admin/maintenance/repair-discord-downloads',
storageKey: 'fc.maint.repairDiscordDownloads',
appliedToast: 'Discord downloads cleared — backfills started',
})
// The archive count matters too: files could already be gone while gallery-dl
// still believes it has them, which would stop the backfill re-fetching.
const hasWork = computed(
() => !!summary.value && (summary.value.images > 0 || summary.value.archive_entries > 0),
)
const canApply = computed(() => hasWork.value && !applied.value)
const summaryType = computed(() => {
if (applied.value) return 'success'
return hasWork.value ? 'info' : 'success'
})
// The confirm dialog gates the destructive apply; close it, then run.
function apply () {
confirmOpen.value = false
applyTask()
}
</script>
@@ -15,6 +15,7 @@
<ImportFiltersForm />
<TranslationCard />
<DiscordGroupingCard />
<DiscordRepairCard />
<PostAssociationsCard />
<MembershipRosterCard />
<MembershipSuggestionsCard />
@@ -85,6 +86,7 @@ import VideoEmbeddingCard from './VideoEmbeddingCard.vue'
import CropProposersCard from './CropProposersCard.vue'
import HeadsCard from './HeadsCard.vue'
import DiscordGroupingCard from './DiscordGroupingCard.vue'
import DiscordRepairCard from './DiscordRepairCard.vue'
import MembershipRosterCard from './MembershipRosterCard.vue'
import MembershipSuggestionsCard from './MembershipSuggestionsCard.vue'
import PostAssociationsCard from './PostAssociationsCard.vue'