"""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. An archived asset is served from its medium when that medium is mounted, and from its *protected* preview when it is not (US06-03). Protected previews are evidence, not cache: the quota never evicts them, because the original they describe may be unreachable when duplicate review needs it. 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 from photo_pipeline.services import availability # 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" # The size kept as durable comparison evidence for archived assets (concept §9). PROTECTED_SIZE = 1280 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, *, protected: bool = False, source: Path | None = None ) -> Path: """Render (or reuse) a preview. ``source`` overrides where the bytes are read from — the archiver passes its verified archive copy, which the database does not yet point at while the transfer is still in flight.""" 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}") archived = asset.availability_state in availability.ARCHIVED 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) if protected and not row.protected: self._protect(cache_key) return Path(row.path) # An archived original is read from its medium; when that medium is not # mounted the retained preview above is the only evidence there is. source = source or availability.readable_path(session, asset) if source is None: raise ThumbnailUnavailable(f"asset {asset_id} has no readable file") source = str(source) if source == asset.current_path: self._validate_path(source) # archive roots lie outside the library # 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 # Archived assets keep their preview permanently: it is the comparison # evidence that survives the original leaving active storage. self._record_ready(cache_key, asset_id, size, rendered, protected=protected or archived) self._enforce_quota(keep=rendered["path"]) return Path(rendered["path"]) def ensure_protected(self, asset_id: str, *, source: Path | None = None) -> dict: """Produce (or confirm) the durable comparison preview for an asset. Returns evidence rather than raising, because the caller — archive preflight and the transfer itself — decides what an unrenderable original means. ``unsupported`` is a recorded property of the file, not a failure of the policy: its hashes and metadata remain the comparison evidence. """ try: path = self.generate(asset_id, PROTECTED_SIZE, protected=True, source=source) except tuple(_PERSISTED_ERRORS) as error: return {"state": "unsupported", "error_code": error.code, "path": None} except ThumbnailError as error: return {"state": "unavailable", "error_code": error.code, "path": None} return {"state": "ready", "error_code": None, "path": str(path)} def evidence(self, asset_id: str) -> dict: """What durable preview this asset has right now, without rendering.""" with self._session_factory() as session: rows = list( session.execute( select(Thumbnail).where(Thumbnail.asset_id == asset_id) ).scalars() ) for row in rows: if row.state == "ready" and row.path and Path(row.path).exists(): return { "state": "ready", "protected": bool(row.protected), "size": row.size, "error_code": None, } for row in rows: if row.state == "error": return { "state": "unsupported", "protected": False, "size": row.size, "error_code": row.error_code, } return {"state": "missing", "protected": False, "size": None, "error_code": None} def _protect(self, cache_key: str) -> None: with self._session_factory() as session: row = session.get(Thumbnail, cache_key) if row is not None: row.protected = True session.commit() # ── 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, *, protected: bool = False ) -> 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"], protected=protected, ) ) 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 # Protected previews are evidence, not cache: an archived original cannot be # re-rendered once its medium is away, so eviction never touches them. protected = self._protected_paths() files = [f for f in files if str(f) not in protected] 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 _protected_paths(self) -> set[str]: with self._session_factory() as session: return { row.path for row in session.execute( select(Thumbnail).where(Thumbnail.protected.is_(True)) ).scalars() if row.path } 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