Files
pigway-frame-flow/pigway_frame_flow/providers/photos_cache.py
T

85 lines
3.4 KiB
Python
Raw Normal View History

"""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)