Files
photoanalyzer/photo_pipeline/services/thumbnails.py

275 lines
10 KiB
Python

"""ThumbnailService — safe, cached, correctly oriented previews by asset ID.
Thumbnails are requested by asset ID (never a browser-supplied path), resolved to
the current path through the database, and validated against the configured library
boundary. Generation applies EXIF orientation before resizing, preserves
transparency, and encodes WebP. The cache key is ``pixel_hash + size + version``, so
an EXIF-only edit reuses the file while a real pixel change invalidates it; writes
are atomic and the cache is bounded by an LRU quota. Failures are persisted as typed
errors so a broken original is not retried on every request.
Reuses photo_analyzer.prepare_image decode/resize/HEIC handling, adding the missing
EXIF-orientation step, WebP output, and a managed cache (donor_ledger.yaml:
pa-imaging).
"""
from __future__ import annotations
import uuid
from pathlib import Path
from PIL import Image, ImageOps, UnidentifiedImageError
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import sessionmaker
from photo_pipeline import path_policy
from photo_pipeline.config import Config
from photo_pipeline.models import Asset, Thumbnail
# Best-effort HEIC support: registered only if the optional decoder is installed.
try: # pragma: no cover - depends on an optional native dependency
import pillow_heif
pillow_heif.register_heif_opener()
except Exception: # pragma: no cover
pass
SIZES = (256, 512, 1280)
THUMB_VERSION = 1
THUMB_FORMAT = "webp"
class ThumbnailError(RuntimeError):
code = "thumbnail_error"
http_status = 500
class InvalidSize(ThumbnailError):
code = "invalid_size"
http_status = 422
class ThumbnailNotFound(ThumbnailError):
code = "not_found"
http_status = 404
class ThumbnailUnavailable(ThumbnailError):
code = "unavailable"
http_status = 409
class UnsupportedImage(ThumbnailError):
code = "unsupported_image"
http_status = 415
class ImageTooLarge(ThumbnailError):
code = "image_too_large"
http_status = 413
class PathNotAllowed(ThumbnailError):
code = "path_not_allowed"
http_status = 403
# Errors that describe a broken source and are persisted to avoid retry loops.
_PERSISTED_ERRORS = {UnsupportedImage, ImageTooLarge}
_ERROR_BY_CODE = {cls.code: cls for cls in _PERSISTED_ERRORS}
class ThumbnailService:
def __init__(self, session_factory: sessionmaker, config: Config) -> None:
self._session_factory = session_factory
self._config = config
self._cache_dir = config.thumbnail_cache_dir
def generate(self, asset_id: str, size: int) -> Path:
if size not in SIZES:
raise InvalidSize(f"size must be one of {SIZES}")
with self._session_factory() as session:
asset = session.get(Asset, asset_id)
if asset is None:
raise ThumbnailNotFound(f"unknown asset {asset_id}")
if asset.availability_state != "active" or not asset.current_path:
raise ThumbnailUnavailable(f"asset {asset_id} has no active file")
self._validate_path(asset.current_path)
cache_key = self._cache_key(asset, size)
row = session.get(Thumbnail, cache_key)
if row is not None:
if row.state == "error":
raise _ERROR_BY_CODE[row.error_code](
f"cached failure for {asset_id}: {row.error_code}"
)
if row.path and Path(row.path).exists():
_touch(row.path)
return Path(row.path)
source = asset.current_path
# Rendering happens outside the DB session (no transaction held during I/O).
try:
rendered = self._render(source, size, cache_key)
except ThumbnailError as error:
if type(error) in _PERSISTED_ERRORS:
self._record_error(cache_key, asset_id, size, error.code)
raise
self._record_ready(cache_key, asset_id, size, rendered)
self._enforce_quota(keep=rendered["path"])
return Path(rendered["path"])
# ── path safety ──────────────────────────────────────────────────────────
def _validate_path(self, current_path: str) -> None:
path = Path(current_path)
if path_policy.is_excluded(path):
raise PathNotAllowed(f"excluded path: {current_path}")
roots = self._config.library_roots
if not roots:
return
for root in roots:
try:
path_policy.resolve_within(Path(root), path)
return
except path_policy.PathPolicyError:
continue
raise PathNotAllowed(f"path outside configured roots: {current_path}")
# ── cache key + rendering ──────────────────────────────────────────────────
@staticmethod
def _cache_key(asset: Asset, size: int) -> str:
if asset.pixel_sha256:
return f"{asset.pixel_sha256}-{size}-v{THUMB_VERSION}"
# Undecodable at inventory time: key by source bytes so a fixed file retries.
return f"src:{asset.current_sha256 or asset.id}-{size}-v{THUMB_VERSION}"
def _cache_path(self, cache_key: str) -> Path:
safe = cache_key.replace(":", "_")
return self._cache_dir / safe[:2] / f"{safe}.{THUMB_FORMAT}"
def _render(self, source: str, size: int, cache_key: str) -> dict:
try:
with Image.open(source) as image:
width, height = image.size
if width * height > self._config.thumbnail_max_pixels:
raise ImageTooLarge(
f"{width}x{height} exceeds {self._config.thumbnail_max_pixels} px"
)
oriented = ImageOps.exif_transpose(image)
mode = "RGBA" if _has_alpha(oriented) else "RGB"
converted = oriented.convert(mode)
converted.thumbnail((size, size), Image.LANCZOS)
out_width, out_height = converted.size
destination = self._cache_path(cache_key)
destination.parent.mkdir(parents=True, exist_ok=True)
tmp = destination.with_name(f".{destination.name}.{uuid.uuid4().hex}.tmp")
converted.save(tmp, format="WEBP", quality=82, method=4)
except ImageTooLarge:
raise
except (UnidentifiedImageError, OSError, ValueError) as error:
raise UnsupportedImage(f"cannot decode {source}: {error}") from error
import os
os.replace(tmp, destination)
return {
"path": str(destination),
"width": out_width,
"height": out_height,
"format": THUMB_FORMAT,
}
# ── persistence ────────────────────────────────────────────────────────────
def _record_ready(self, cache_key: str, asset_id: str, size: int, rendered: dict) -> None:
with self._session_factory() as session:
session.merge(
Thumbnail(
cache_key=cache_key,
asset_id=asset_id,
size=size,
state="ready",
error_code=None,
path=rendered["path"],
width=rendered["width"],
height=rendered["height"],
format=rendered["format"],
)
)
try:
session.commit()
except IntegrityError:
session.rollback() # another worker recorded it first; the file stands
def _record_error(self, cache_key: str, asset_id: str, size: int, code: str) -> None:
with self._session_factory() as session:
session.merge(
Thumbnail(
cache_key=cache_key,
asset_id=asset_id,
size=size,
state="error",
error_code=code,
)
)
try:
session.commit()
except IntegrityError:
session.rollback()
# ── quota / eviction ───────────────────────────────────────────────────────
def _enforce_quota(self, keep: str | None = None) -> None:
quota = self._config.thumbnail_cache_quota_bytes
if not self._cache_dir.exists():
return
files = [f for f in self._cache_dir.rglob(f"*.{THUMB_FORMAT}") if f.is_file()]
total = sum(f.stat().st_size for f in files)
if total <= quota:
return
files.sort(key=lambda f: f.stat().st_mtime) # least-recently-used first
keep_path = str(Path(keep)) if keep else None
evicted: list[str] = []
for f in files:
if total <= quota:
break
if str(f) == keep_path:
continue
size = f.stat().st_size
try:
f.unlink()
total -= size
evicted.append(str(f))
except OSError:
pass
if evicted:
self._forget(evicted)
def _forget(self, paths: list[str]) -> None:
with self._session_factory() as session:
rows = session.execute(
select(Thumbnail).where(Thumbnail.path.in_(paths))
).scalars()
for row in rows:
session.delete(row)
session.commit()
def _has_alpha(image: Image.Image) -> bool:
return image.mode in ("RGBA", "LA") or (
image.mode == "P" and "transparency" in image.info
)
def _touch(path: str) -> None:
import os
import time
try:
now = time.time()
os.utime(path, (now, now))
except OSError:
pass