9cbdb70e13
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
170 lines
6.0 KiB
Python
170 lines
6.0 KiB
Python
"""generate_thumbnail task: PIL/ffmpeg thumbnail generation on the thumbnail queue.
|
||
|
||
Lives separately from import_file because thumbnails can be regenerated en
|
||
masse (FC-2c adds the 'regenerate all' admin action) and they're CPU-bound
|
||
so they deserve their own queue lane.
|
||
"""
|
||
|
||
from pathlib import Path
|
||
|
||
from sqlalchemy.exc import DBAPIError, OperationalError
|
||
|
||
from ..celery_app import celery
|
||
from ..models import ImageRecord
|
||
from ..services.importer import is_video
|
||
from ..services.thumbnailer import Thumbnailer
|
||
from ._sync_engine import sync_session_factory as _sync_session_factory
|
||
|
||
IMAGES_ROOT = Path("/images")
|
||
|
||
THUMB_MAGIC_JPEG = b"\xff\xd8\xff"
|
||
THUMB_MAGIC_PNG = b"\x89PNG\r\n\x1a\n"
|
||
|
||
# Minimum file size for a thumbnail to count as valid. Anything smaller
|
||
# is almost certainly truncated/corrupt — a legitimate 400×400 JPEG@85
|
||
# bottoms out around 2KB even on a solid-color image; 400×400 PNG starts
|
||
# around 1KB. 256 bytes is well below any real thumbnail and well above
|
||
# header-only corrupt files (~8-12 bytes). Operator-flagged 2026-06-01:
|
||
# header-only corrupt files were silently passing the magic-byte check
|
||
# and backfill counted them as "ok" — so broken-image tiles in the UI
|
||
# never got regenerated even after running backfill.
|
||
MIN_THUMB_BYTES = 256
|
||
|
||
|
||
def _thumb_is_valid(path: Path) -> bool:
|
||
"""Return True iff `path` exists, starts with a JPEG or PNG magic
|
||
header, AND is at least MIN_THUMB_BYTES on disk.
|
||
|
||
The on-disk thumbnail format is set by services/thumbnailer.py — JPEG
|
||
for opaque sources, PNG for alpha sources. Anything else (missing
|
||
file, OSError, truncated below the size floor, wrong magic) is
|
||
invalid and gets re-enqueued.
|
||
"""
|
||
try:
|
||
size = path.stat().st_size
|
||
except OSError:
|
||
return False
|
||
if size < MIN_THUMB_BYTES:
|
||
return False
|
||
try:
|
||
with path.open("rb") as f:
|
||
head = f.read(12)
|
||
except OSError:
|
||
return False
|
||
if len(head) < 8:
|
||
return False
|
||
if head[:3] == THUMB_MAGIC_JPEG:
|
||
return True
|
||
if head[:8] == THUMB_MAGIC_PNG:
|
||
return True
|
||
return False
|
||
|
||
|
||
def _run_backfill_scan() -> dict:
|
||
"""Synchronous scan logic shared by the Celery task and the API
|
||
endpoint. Returns {enqueued, ok, regenerated, scanned}.
|
||
|
||
Operator-flagged 2026-06-01: the original task was fire-and-forget,
|
||
so the admin UI couldn't show what backfill actually found —
|
||
operator saw \"Enqueued.\" with no counts and assumed nothing was
|
||
happening. Now the API runs this synchronously and returns the
|
||
real numbers; the periodic Celery task wraps it too."""
|
||
from sqlalchemy import select, update
|
||
|
||
SessionLocal = _sync_session_factory()
|
||
enqueued = 0
|
||
ok = 0
|
||
regenerated = 0
|
||
scanned = 0
|
||
last_id = 0
|
||
with SessionLocal() as session:
|
||
while True:
|
||
rows = session.execute(
|
||
select(ImageRecord.id, ImageRecord.thumbnail_path)
|
||
.where(ImageRecord.id > last_id)
|
||
.order_by(ImageRecord.id.asc())
|
||
.limit(500)
|
||
).all()
|
||
if not rows:
|
||
break
|
||
scanned += len(rows)
|
||
for image_id, thumb_path in rows:
|
||
if thumb_path is None:
|
||
generate_thumbnail.delay(image_id)
|
||
enqueued += 1
|
||
elif _thumb_is_valid(Path(thumb_path)):
|
||
ok += 1
|
||
else:
|
||
session.execute(
|
||
update(ImageRecord)
|
||
.where(ImageRecord.id == image_id)
|
||
.values(thumbnail_path=None)
|
||
)
|
||
generate_thumbnail.delay(image_id)
|
||
enqueued += 1
|
||
regenerated += 1
|
||
session.commit()
|
||
last_id = rows[-1][0]
|
||
return {
|
||
"scanned": scanned,
|
||
"enqueued": enqueued,
|
||
"ok": ok,
|
||
"regenerated": regenerated,
|
||
}
|
||
|
||
|
||
@celery.task(
|
||
name="backend.app.tasks.thumbnail.generate_thumbnail",
|
||
bind=True,
|
||
autoretry_for=(OperationalError, DBAPIError, OSError),
|
||
retry_backoff=5,
|
||
retry_backoff_max=60,
|
||
retry_jitter=True,
|
||
max_retries=3,
|
||
soft_time_limit=120,
|
||
time_limit=180,
|
||
)
|
||
def generate_thumbnail(self, image_id: int) -> dict:
|
||
SessionLocal = _sync_session_factory()
|
||
with SessionLocal() as session:
|
||
record = session.get(ImageRecord, image_id)
|
||
if record is None:
|
||
return {"status": "missing", "image_id": image_id}
|
||
|
||
thumbnailer = Thumbnailer(images_root=IMAGES_ROOT)
|
||
source = Path(record.path)
|
||
try:
|
||
if is_video(source):
|
||
result = thumbnailer.generate_video_thumbnail(source, record.sha256)
|
||
else:
|
||
result = thumbnailer.generate_image_thumbnail(source, record.sha256)
|
||
except Exception as exc: # pragma: no cover — thumbnail failure is non-fatal
|
||
return {"status": "failed", "image_id": image_id, "error": f"{type(exc).__name__}: {exc}"}
|
||
|
||
record.thumbnail_path = str(result.path)
|
||
session.add(record)
|
||
session.commit()
|
||
return {"status": "ok", "image_id": image_id, "path": str(result.path)}
|
||
|
||
|
||
@celery.task(
|
||
name="backend.app.tasks.thumbnail.backfill_thumbnails",
|
||
bind=True,
|
||
)
|
||
def backfill_thumbnails(self) -> dict:
|
||
"""Scan ImageRecord and enqueue generate_thumbnail for rows whose
|
||
thumbnail is missing, gone from disk, or has wrong magic bytes.
|
||
|
||
Keyset paginates by id ASC, page size 500. NULLs out thumbnail_path
|
||
for rows that point at a missing or corrupt file before enqueueing —
|
||
keeps the DB self-consistent on partial runs and makes re-runs safe.
|
||
|
||
Returns {scanned, enqueued, ok, regenerated} where:
|
||
- scanned = total rows examined
|
||
- enqueued = total generate_thumbnail.delay() calls
|
||
- ok = rows whose existing thumbnail file is valid (skipped)
|
||
- regenerated = subset of enqueued that had a non-NULL
|
||
thumbnail_path cleared (i.e. missing + corrupt)
|
||
"""
|
||
return _run_backfill_scan()
|