"""Image thumbnail generation and disk cache (roadmap #108-C). Thumbnails are generated on demand with Pillow and cached under ``/.obsigate-cache/thumbs/.webp``. The cache key embeds the source path, mtime (ns) and size, so an edited image naturally invalidates its stale thumbnail without any explicit cleanup. """ from __future__ import annotations import hashlib import os from pathlib import Path DEFAULT_THUMB_SIZE = 256 # Extensions Pillow cannot decode without extra native libraries: served as-is. _UNDECODABLE = {".svg"} def thumbs_cache_dir() -> Path: """Return (and create) the thumbnail cache directory.""" base = Path(os.environ.get("OBSIGATE_DATA_DIR", "data")) / ".obsigate-cache" / "thumbs" base.mkdir(parents=True, exist_ok=True) return base def thumb_cache_path(file_path: Path, size: int) -> Path: """Compute the deterministic cache path for *file_path* at *size*.""" try: st = file_path.stat() stamp = f"{st.st_mtime_ns}:{st.st_size}" except OSError: stamp = "0:0" # Clé de cache miniature (pas un usage sécurité). key = hashlib.sha1(f"{file_path}:{stamp}:{size}".encode()).hexdigest() # nosec B324 return thumbs_cache_dir() / f"{key}.webp" def is_decodable(file_path: Path) -> bool: """True when Pillow can be expected to decode *file_path*.""" return file_path.suffix.lower() not in _UNDECODABLE def generate_thumbnail(file_path: Path, size: int = DEFAULT_THUMB_SIZE) -> Path | None: """Generate (or reuse) a WebP thumbnail and return its path. Returns ``None`` when the file cannot be decoded (e.g. SVG) or Pillow is unavailable, so the caller can fall back to serving the original. """ cache_path = thumb_cache_path(file_path, size) if cache_path.exists(): return cache_path try: from PIL import Image, ImageOps except Exception: # pragma: no cover - Pillow is an optional runtime dep return None try: with Image.open(file_path) as opened: # Animated formats: keep only the first frame. if getattr(opened, "is_animated", False): opened.seek(0) img = ImageOps.exif_transpose(opened) or opened if img.mode not in ("RGB", "RGBA"): img = img.convert("RGBA") img.thumbnail((size, size)) tmp = cache_path.with_suffix(".tmp") img.save(tmp, "WEBP", quality=80) os.replace(tmp, cache_path) return cache_path except Exception: return None