Files
photoanalyzer/photo_pipeline/services/thumbnails.py

393 lines
16 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.
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 os
import uuid
from pathlib import Path
from PIL import Image, ImageOps
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import sessionmaker
from photo_pipeline import imaging, 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)
# Every in-flight render writes one of these beside its destination; startup
# cleanup recognises exactly this pattern and nothing else.
TEMP_SUFFIX = ".tmp"
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:
# Render the *resolved* path the check approved: revalidating and then
# reopening the original name would let a symlink swapped in between
# the two steps decide which bytes are served (US07-02).
source = str(self._validate_path(source)) # archive roots lie outside
# 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) -> Path:
"""The resolved path to read, or ``PathNotAllowed``.
The message names no path: a refusal is returned to the browser, and where
the library lives is not the caller's business (US07-02).
"""
try:
return path_policy.resolve_in_roots(self._config.library_roots, current_path)
except path_policy.PathPolicyError as error:
raise PathNotAllowed(str(error)) from None
# ── 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:
"""Decode bounded, orient, colour-manage, resize, and write atomically.
The temporary file is removed on every failure path: a decoder that dies
halfway through ``save`` would otherwise leave a stray ``.tmp`` in the cache
forever (US07-03).
"""
destination = self._cache_path(cache_key)
destination.parent.mkdir(parents=True, exist_ok=True)
tmp = destination.with_name(f".{destination.name}.{uuid.uuid4().hex}{TEMP_SUFFIX}")
try:
with imaging.open_image(source, max_pixels=self._config.thumbnail_max_pixels) as image:
imaging.draft(image, size) # JPEG decodes near the target size
oriented = ImageOps.exif_transpose(image)
mode = "RGBA" if _has_alpha(oriented) else "RGB"
converted = imaging.to_srgb(oriented, mode=mode)
converted.thumbnail((size, size), Image.LANCZOS)
out_width, out_height = converted.size
converted.save(tmp, format="WEBP", quality=82, method=4)
except imaging.ImageTooLarge as error:
tmp.unlink(missing_ok=True)
raise ImageTooLarge(str(error)) from None
except imaging.UndecodableImage as error:
tmp.unlink(missing_ok=True)
raise UnsupportedImage(str(error)) from None
except Exception:
tmp.unlink(missing_ok=True)
raise
os.replace(tmp, destination)
return {
"path": str(destination),
"width": out_width,
"height": out_height,
"format": THUMB_FORMAT,
}
def cleanup_temp_files(self) -> int:
"""Remove leftover render temporaries, and only those.
Runs at startup, where the concept is explicit: clean *recognized* stale
temporary files, never arbitrary ones. The pattern is this service's own —
a dot-prefixed name inside the managed cache directory ending in
``.tmp`` — so nothing outside the cache and nothing a user put there can
match. Returns how many were removed.
"""
if not self._cache_dir.is_dir():
return 0
removed = 0
for leftover in self._cache_dir.rglob(f".*{TEMP_SUFFIX}"):
if not leftover.is_file() or leftover.is_symlink():
continue
try:
leftover.unlink()
removed += 1
except OSError: # pragma: no cover - a racing render already won
pass
return removed
# ── 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