393 lines
16 KiB
Python
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
|