"""Bounded, process-safe cache for individually requested Photos images.""" import fcntl import hashlib import os import tempfile import time from pathlib import Path from PIL import Image from pigway_frame_flow.errors import ErrorCode as E from pigway_frame_flow.errors import FlowError from pigway_frame_flow.renderers.images import MAX_IMAGE_PIXELS class PhotosCache: def __init__(self, settings): self.root = settings.data_dir / "cache" / "synology-photos" self.capacity = settings.photos_cache_bytes self.max_bytes = min(settings.max_bytes, self.capacity) self.hits = 0 self.downloads = 0 def _prune(self, reserve=0): entries = [] for path in self.root.iterdir(): if path.name.endswith(".part"): path.unlink(missing_ok=True) elif path.suffix == ".image" and not path.is_symlink(): entries.append(path) entries.sort(key=lambda p: p.stat().st_mtime_ns) total = sum(p.stat().st_size for p in entries) for path in entries: if total + reserve <= self.capacity: break total -= path.stat().st_size path.unlink() def read(self, key, download): self.root.mkdir(parents=True, exist_ok=True, mode=0o700) identity = hashlib.sha256(key.encode()).hexdigest() path = self.root / (identity + ".image") # One lock bounds total disk use and coalesces same-item downloads across # threads/processes. The lock file is constant, not one file per photo. with (self.root / ".lock").open("a+b") as lock: fcntl.flock(lock, fcntl.LOCK_EX) self._prune() if path.is_file() and not path.is_symlink(): data = path.read_bytes() os.utime(path, None) self.hits += 1 return data self._prune(self.max_bytes) fd, temporary = tempfile.mkstemp(suffix=".part", dir=self.root) part = Path(temporary) try: with os.fdopen(fd, "wb") as handle: download(handle, self.max_bytes, time.monotonic() + 90) handle.flush() os.fsync(handle.fileno()) if not 0 < part.stat().st_size <= self.max_bytes: raise ValueError() with Image.open(part, formats=("JPEG", "PNG", "WEBP")) as image: if image.format not in ("JPEG", "PNG", "WEBP"): raise ValueError() if image.width * image.height > MAX_IMAGE_PIXELS: raise ValueError() image.verify() # Decode before committing; truncated/compressed-bomb payloads # must never become a reusable cache hit. with Image.open(part, formats=("JPEG", "PNG", "WEBP")) as image: image.load() part.replace(path) self.downloads += 1 return path.read_bytes() except (OSError, ValueError, Image.DecompressionBombError): raise FlowError( E.CLOUD_UNAVAILABLE, "Photos download is incomplete or not a supported image", 502, ) from None finally: part.unlink(missing_ok=True)