85 lines
3.3 KiB
Python
85 lines
3.3 KiB
Python
"""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) 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) 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)
|