Files
FabledCurator/backend/app/tasks/thumbnail.py
T

77 lines
2.5 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"
def _thumb_is_valid(path: Path) -> bool:
"""Return True iff `path` exists and starts with a JPEG or PNG magic header.
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, wrong magic) is invalid.
"""
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
@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)}