"""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()