Add mount-free Synology Photos API sources with bounded image cache
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user