fix(thumbnails): surface backfill results + tighten validity check
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 16s
CI / frontend-build (push) Successful in 24s
CI / intimp (push) Successful in 3m48s
CI / intapi (push) Successful in 7m54s
CI / intcore (push) Failing after 8m18s

Two coupled problems, operator-flagged 2026-06-01: "missing thumbnails
but triggering backfill found nothing."

1. **Backfill UI was a black box.** `POST /api/thumbnails/backfill`
   returned just `{celery_task_id}` and the admin card said
   "Enqueued." with no counts. There was no way to tell whether the
   scan found 0 candidates, 5000 candidates, or whether the worker
   even picked up the task. "Found nothing" was indistinguishable
   from a broken queue.

   Fix: refactor the scan into a sync helper (`_run_backfill_scan`)
   shared by the Celery task and the API endpoint. The API now runs
   the scan in an executor and returns `{scanned, enqueued, ok,
   regenerated}`. The actual thumbnail generation work still goes
   to the thumbnail Celery queue per row via
   `generate_thumbnail.delay()` — the scan itself is fast
   (SELECT id+thumbnail_path + a file.stat() per row).

2. **`_thumb_is_valid` accepted header-only corrupt files.** The
   magic-byte check passed for any 8-byte file starting with a JPEG
   or PNG header, including empty/truncated/zero-pad files that
   browsers render as broken. Backfill counted these as `ok` and
   never regenerated.

   Fix: also require file size ≥ MIN_THUMB_BYTES (256). Real
   thumbnails are minimum ~2KB even on solid-color sources; header-
   only corrupt files top out around 12 bytes. 256 is well above
   the corrupt floor and well below any legitimate thumbnail.

Plus the admin card now shows the per-run counts instead of
"Enqueued.":
  Scanned 5,432 · enqueued 3 (2 regenerated) · 5,429 ok
This commit is contained in:
2026-06-01 21:57:29 -04:00
parent bd06794647
commit 9cbdb70e13
6 changed files with 148 additions and 62 deletions
@@ -10,7 +10,14 @@
<v-btn color="primary" rounded="pill" :loading="busy" @click="run">
<v-icon start>mdi-image-refresh</v-icon> Run backfill now
</v-btn>
<span v-if="done" class="ml-3 text-caption">Enqueued.</span>
<span v-if="result" class="ml-3 text-caption">
Scanned <strong>{{ result.scanned }}</strong> · enqueued
<strong>{{ result.enqueued }}</strong>
<span v-if="result.regenerated > 0">
({{ result.regenerated }} regenerated)
</span>
· {{ result.ok }} ok
</span>
<QueueStatusBar queue="thumbnail" queue-label="Thumbnail" />
</v-card-text>
</v-card>
@@ -23,10 +30,11 @@ import { useThumbnailsStore } from '../../stores/thumbnails.js'
import QueueStatusBar from './QueueStatusBar.vue'
const store = useThumbnailsStore()
const busy = ref(false)
const done = ref(false)
const result = ref(null)
async function run () {
busy.value = true
try { await store.triggerBackfill(); done.value = true }
result.value = null
try { result.value = await store.triggerBackfill() }
catch (e) { toast({ text: e.message, type: 'error' }) }
finally { busy.value = false }
}
+4 -1
View File
@@ -4,8 +4,11 @@ import { useApi } from '../composables/useApi.js'
export const useThumbnailsStore = defineStore('thumbnails', () => {
const api = useApi()
// Returns { scanned, enqueued, ok, regenerated } — the API now runs
// the scan synchronously so the operator gets immediate feedback
// instead of just a Celery task id with no visible outcome.
async function triggerBackfill () {
await api.post('/api/thumbnails/backfill')
return await api.post('/api/thumbnails/backfill')
}
return { triggerBackfill }