Add mount-free Synology Photos API sources with bounded image cache
This commit is contained in:
@@ -27,7 +27,9 @@ from pigway_frame_flow.storage.database import encode
|
||||
|
||||
class PhotoSourceInput(StrictModel):
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
kind: Literal["local", "webdav", "google-drive", "alipan", "baidu"]
|
||||
kind: Literal[
|
||||
"local", "synology-photos", "webdav", "google-drive", "alipan", "baidu"
|
||||
]
|
||||
location: str = Field(min_length=1, max_length=2048)
|
||||
recursive: bool = True
|
||||
enabled: bool = True
|
||||
@@ -77,6 +79,7 @@ class PhotoSources:
|
||||
def __init__(self, db, settings, assets):
|
||||
self.db, self.settings, self.assets = db, settings, assets
|
||||
self.cache = {}
|
||||
self.api_items = {}
|
||||
self.synology = SynologyPhotos(settings)
|
||||
# Register provider secrets before request IDs or errors can be reflected.
|
||||
for row in self.list():
|
||||
@@ -109,19 +112,19 @@ class PhotoSources:
|
||||
def save(self, source_id, source):
|
||||
if source.minimum_brightness > source.maximum_brightness:
|
||||
raise FlowError(E.INVALID_REQUEST, "Brightness range is reversed")
|
||||
if source.kind != "local" and (
|
||||
if source.kind not in ("local", "synology-photos") and (
|
||||
source.organized_only or source.folder_caption or source.synology_previews
|
||||
):
|
||||
raise FlowError(
|
||||
E.UNSUPPORTED_CAPABILITY,
|
||||
"Folder captions and Synology previews require a local photo source",
|
||||
)
|
||||
if source.orientation and source.kind != "local":
|
||||
if source.orientation and source.kind not in ("local", "synology-photos"):
|
||||
raise FlowError(
|
||||
E.UNSUPPORTED_CAPABILITY,
|
||||
"Orientation filtering requires a local photo source",
|
||||
)
|
||||
if source.people_filter and source.kind != "local":
|
||||
if source.people_filter and source.kind not in ("local", "synology-photos"):
|
||||
raise FlowError(
|
||||
E.INVALID_REQUEST,
|
||||
"Person filtering requires a local Synology photo source",
|
||||
@@ -140,6 +143,21 @@ class PhotoSources:
|
||||
raise FlowError(
|
||||
E.INVALID_REQUEST, "Local sources do not use credentials"
|
||||
)
|
||||
elif source.kind == "synology-photos":
|
||||
location = source.location.strip("/")
|
||||
if location == ".":
|
||||
location = ""
|
||||
if "\\" in location or any(p in (".", "..") for p in location.split("/")):
|
||||
raise FlowError(
|
||||
E.INVALID_REQUEST,
|
||||
"Photos API folder must be a relative folder path or root",
|
||||
)
|
||||
if source.credential_env or source.synology_previews:
|
||||
raise FlowError(
|
||||
E.INVALID_REQUEST,
|
||||
"Photos API sources use the shared Photos session and XL thumbnail download",
|
||||
)
|
||||
source = source.model_copy(update={"location": location or "."})
|
||||
elif source.kind == "webdav":
|
||||
source = source.model_copy(
|
||||
update={
|
||||
@@ -169,6 +187,7 @@ class PhotoSources:
|
||||
("photo_source:" + source_id, encode(source.model_dump())),
|
||||
)
|
||||
self.cache.pop(source_id, None)
|
||||
self.api_items.pop(source_id, None)
|
||||
return dict(source.model_dump(), id=source_id)
|
||||
|
||||
def delete(self, source_id):
|
||||
@@ -186,6 +205,7 @@ class PhotoSources:
|
||||
"DELETE FROM app_config WHERE key=?", ("photo_source:" + source_id,)
|
||||
)
|
||||
self.cache.pop(source_id, None)
|
||||
self.api_items.pop(source_id, None)
|
||||
|
||||
def local_directory(self, location):
|
||||
root = self.settings.photo_root.resolve()
|
||||
@@ -266,6 +286,9 @@ class PhotoSources:
|
||||
source = self.get(source_id)
|
||||
if not source.enabled:
|
||||
raise FlowError(E.CONFLICT, "Photo source is disabled", 409)
|
||||
session_identity = (
|
||||
self.synology.sid() if source.kind == "synology-photos" else None
|
||||
)
|
||||
wanted = (
|
||||
orientation(*target_size)
|
||||
if source.orientation == "auto"
|
||||
@@ -277,6 +300,7 @@ class PhotoSources:
|
||||
and not refresh
|
||||
and time.monotonic() - cached[0] < 60
|
||||
and cached[2] == wanted
|
||||
and cached[3] == session_identity
|
||||
):
|
||||
return cached[1]
|
||||
allowed = (
|
||||
@@ -286,7 +310,7 @@ class PhotoSources:
|
||||
refresh=refresh,
|
||||
space=source.people_space,
|
||||
)
|
||||
if source.people_filter
|
||||
if source.people_filter and source.kind == "local"
|
||||
else None
|
||||
)
|
||||
items = []
|
||||
@@ -406,6 +430,58 @@ class PhotoSources:
|
||||
break
|
||||
if len(items) >= source.catalog_limit:
|
||||
break
|
||||
elif source.kind == "synology-photos":
|
||||
self.synology.sid()
|
||||
rows = self.synology.items(
|
||||
source.person_ids if source.people_filter else [],
|
||||
source.person_match,
|
||||
space=source.people_space,
|
||||
limit=100000,
|
||||
)
|
||||
location = "" if source.location == "." else source.location.strip("/")
|
||||
self.api_items[source_id] = {}
|
||||
for row in rows:
|
||||
folder = row.get("additional", {}).get("folder")
|
||||
if isinstance(folder, dict):
|
||||
folder = folder.get("name")
|
||||
if not isinstance(folder, str) or not isinstance(
|
||||
row.get("filename"), str
|
||||
):
|
||||
raise FlowError(
|
||||
E.CLOUD_UNAVAILABLE,
|
||||
"Photos API response lacks folder or filename",
|
||||
502,
|
||||
)
|
||||
relative = folder.strip("/") + "/" + row["filename"]
|
||||
if location and not relative.startswith(location + "/"):
|
||||
continue
|
||||
if not source.recursive and folder.strip("/") != location:
|
||||
continue
|
||||
caption = path_caption(relative)
|
||||
if source.organized_only and caption is None:
|
||||
continue
|
||||
size = row.get("additional", {}).get("resolution", {})
|
||||
width, height = size.get("width", 0), size.get("height", 0)
|
||||
rotation = row.get("additional", {}).get("orientation", 1)
|
||||
if rotation in (5, 6, 7, 8):
|
||||
width, height = height, width
|
||||
direction = orientation(width, height)
|
||||
if wanted and (not width or not height or direction != wanted):
|
||||
continue
|
||||
items.append(
|
||||
{
|
||||
"id": str(row["id"]),
|
||||
"name": row["filename"],
|
||||
"provider_preview": True,
|
||||
"caption": caption if source.folder_caption else None,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"orientation": direction,
|
||||
}
|
||||
)
|
||||
self.api_items[source_id][str(row["id"])] = row
|
||||
if len(items) >= source.catalog_limit:
|
||||
break
|
||||
elif source.kind in ("google-drive", "alipan", "baidu"):
|
||||
from pigway_frame_flow.providers.cloud_drives import CloudDrives
|
||||
|
||||
@@ -468,7 +544,7 @@ class PhotoSources:
|
||||
"INSERT OR REPLACE INTO photo_dimensions(path,mtime,bytes,width,height) VALUES(?,?,?,?,?)",
|
||||
size_updates,
|
||||
)
|
||||
self.cache[source_id] = (time.monotonic(), items, wanted)
|
||||
self.cache[source_id] = (time.monotonic(), items, wanted, session_identity)
|
||||
return items
|
||||
|
||||
def read(self, source_id, item_id=None, *, target_size=(1200, 1600)):
|
||||
@@ -526,6 +602,10 @@ class PhotoSources:
|
||||
os.close(parent_fd)
|
||||
except OSError:
|
||||
raise FlowError(E.NOT_FOUND, "Photo is unavailable", 404) from None
|
||||
elif source.kind == "synology-photos":
|
||||
data = self.synology.download(
|
||||
self.api_items[source_id][item["id"]], space=source.people_space
|
||||
)
|
||||
elif source.kind in ("google-drive", "alipan", "baidu"):
|
||||
from pigway_frame_flow.providers.cloud_drives import CloudDrives
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -119,12 +119,23 @@ class SynologyPhotos:
|
||||
result = json.loads(raw)
|
||||
if not result.get("success"):
|
||||
code = result.get("error", {}).get("code")
|
||||
if api == "SYNO.API.Auth" or code in (105, 106, 107, 119):
|
||||
if api == "SYNO.API.Auth" or code in (106, 107, 119):
|
||||
if api != "SYNO.API.Auth":
|
||||
self.disconnect()
|
||||
raise FlowError(
|
||||
E.CONFIG_REQUIRED, "Reconnect Synology Photos account", 409
|
||||
)
|
||||
if code in (102, 103, 104):
|
||||
raise FlowError(
|
||||
E.UNSUPPORTED_CAPABILITY,
|
||||
"This NAS Photos API or method is unavailable",
|
||||
)
|
||||
if code == 105:
|
||||
raise FlowError(
|
||||
E.FORBIDDEN,
|
||||
"Photos account cannot access the selected space",
|
||||
403,
|
||||
)
|
||||
raise FlowError(
|
||||
E.CLOUD_UNAVAILABLE, "Synology Photos query was rejected", 502
|
||||
)
|
||||
@@ -141,7 +152,7 @@ class SynologyPhotos:
|
||||
sid = json.loads(self.path.read_text())["sid"]
|
||||
if not isinstance(sid, str) or not sid:
|
||||
raise ValueError()
|
||||
except (OSError, ValueError, KeyError):
|
||||
except (OSError, ValueError, KeyError, TypeError):
|
||||
raise FlowError(
|
||||
E.CONFIG_REQUIRED, "Connect Synology Photos account first", 409
|
||||
) from None
|
||||
@@ -269,3 +280,156 @@ class SynologyPhotos:
|
||||
)
|
||||
self.cache[key] = (time.monotonic(), selected)
|
||||
return selected
|
||||
|
||||
def items(self, person_ids=(), mode="any", *, space="shared", limit=10000):
|
||||
"""Intersect identities, not filenames, for any/all people selection."""
|
||||
prefix = "SYNO.FotoTeam" if space == "shared" else "SYNO.Foto"
|
||||
if mode not in ("any", "all") or space not in ("shared", "personal"):
|
||||
raise FlowError(E.INVALID_REQUEST, "Invalid Photos selection")
|
||||
with self.lock:
|
||||
self.sid()
|
||||
if person_ids:
|
||||
known = {x["id"] for x in self.people(space)}
|
||||
if not set(person_ids) <= known:
|
||||
raise FlowError(
|
||||
E.INVALID_REQUEST, "Selected person no longer exists"
|
||||
)
|
||||
groups = []
|
||||
for person in person_ids or [None]:
|
||||
rows = self.pages(
|
||||
prefix + ".Browse.Item",
|
||||
7,
|
||||
additional=json.dumps(["folder", "thumbnail", "resolution"]),
|
||||
**({"person_id": person} if person is not None else {}),
|
||||
)
|
||||
groups.append(
|
||||
{str(r["id"]): r for r in rows if r.get("type") == "photo"}
|
||||
)
|
||||
selected = set(groups[0])
|
||||
for group in groups[1:]:
|
||||
selected = (
|
||||
selected & group.keys()
|
||||
if mode == "all"
|
||||
else selected | group.keys()
|
||||
)
|
||||
merged = {key: row for group in groups for key, row in group.items()}
|
||||
return [merged[key] for key in sorted(selected, key=int)][:limit]
|
||||
|
||||
def download(self, row, *, space="shared", rendition="thumbnail"):
|
||||
from pigway_frame_flow.providers.photos_cache import PhotosCache
|
||||
|
||||
sid = self.sid()
|
||||
url, pin = self.endpoint()
|
||||
prefix = "SYNO.FotoTeam" if space == "shared" else "SYNO.Foto"
|
||||
if rendition == "thumbnail":
|
||||
thumb = row.get("additional", {}).get("thumbnail", {})
|
||||
if not thumb.get("cache_key") or not thumb.get("unit_id"):
|
||||
raise FlowError(
|
||||
E.UNSUPPORTED_CAPABILITY, "Photos thumbnail is unavailable"
|
||||
)
|
||||
api, method = prefix + ".Thumbnail", "get"
|
||||
params = dict(
|
||||
id=thumb["unit_id"],
|
||||
cache_key=thumb["cache_key"],
|
||||
size="xl",
|
||||
type="unit",
|
||||
)
|
||||
else:
|
||||
raise FlowError(
|
||||
E.UNSUPPORTED_CAPABILITY,
|
||||
"Only verified XL thumbnails are supported; original download is unavailable",
|
||||
)
|
||||
if not hasattr(self, "image_cache"):
|
||||
self.image_cache = PhotosCache(self.settings)
|
||||
# Session/endpoint scoping prevents a different account from reusing a
|
||||
# previous account's cached photo. Cache keys never contain raw secrets.
|
||||
identity = hashlib.sha256((sid + str(url.geturl()) + pin).encode()).hexdigest()
|
||||
key = json.dumps(
|
||||
[
|
||||
identity,
|
||||
space,
|
||||
row["id"],
|
||||
rendition,
|
||||
row.get("indexed_time"),
|
||||
row.get("filesize"),
|
||||
row.get("additional", {}).get("thumbnail", {}).get("cache_key"),
|
||||
]
|
||||
)
|
||||
return self.image_cache.read(
|
||||
key,
|
||||
lambda handle, cap, deadline: self._download(
|
||||
api, method, params, sid, handle, cap, deadline
|
||||
),
|
||||
)
|
||||
|
||||
def _download(self, api, method, params, sid, handle, cap, deadline):
|
||||
if (api, method) not in {
|
||||
("SYNO.FotoTeam.Thumbnail", "get"),
|
||||
("SYNO.Foto.Thumbnail", "get"),
|
||||
}:
|
||||
raise FlowError(E.FORBIDDEN, "Unsupported Photos download", 403)
|
||||
url, pin = self.endpoint()
|
||||
connection = http.client.HTTPSConnection(
|
||||
url.hostname,
|
||||
url.port or 443,
|
||||
timeout=30,
|
||||
context=ssl._create_unverified_context(),
|
||||
)
|
||||
try:
|
||||
connection.connect()
|
||||
certificate = connection.sock.getpeercert(binary_form=True)
|
||||
if not certificate or hashlib.sha256(certificate).hexdigest() != pin:
|
||||
raise FlowError(E.FORBIDDEN, "Synology Photos certificate changed", 403)
|
||||
connection.request(
|
||||
"POST",
|
||||
"/webapi/entry.cgi",
|
||||
urlencode(dict(api=api, method=method, version=2, _sid=sid, **params)),
|
||||
{"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
response = connection.getresponse()
|
||||
if response.status != 200:
|
||||
raise ValueError()
|
||||
if "json" in (response.getheader("Content-Type") or "").lower():
|
||||
result = json.loads(response.read(65537))
|
||||
if result.get("error", {}).get("code") in (106, 107, 119):
|
||||
self.disconnect()
|
||||
raise FlowError(
|
||||
E.CONFIG_REQUIRED, "Reconnect Synology Photos account", 409
|
||||
)
|
||||
if result.get("error", {}).get("code") == 105:
|
||||
raise FlowError(
|
||||
E.FORBIDDEN, "Photos account cannot download this photo", 403
|
||||
)
|
||||
raise FlowError(
|
||||
E.CLOUD_UNAVAILABLE, "Photos download was rejected", 502
|
||||
)
|
||||
expected = response.getheader("Content-Length")
|
||||
if expected and int(expected) > cap:
|
||||
raise FlowError(
|
||||
E.INVALID_REQUEST,
|
||||
"Photos image exceeds download limit; select thumbnail",
|
||||
)
|
||||
total = 0
|
||||
while True:
|
||||
if time.monotonic() > deadline:
|
||||
raise TimeoutError()
|
||||
chunk = response.read(min(65536, cap - total + 1))
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > cap:
|
||||
raise FlowError(
|
||||
E.INVALID_REQUEST,
|
||||
"Photos image exceeds download limit; select thumbnail",
|
||||
)
|
||||
handle.write(chunk)
|
||||
if expected and total != int(expected):
|
||||
raise ValueError()
|
||||
except (OSError, ValueError, http.client.HTTPException):
|
||||
raise FlowError(
|
||||
E.CLOUD_UNAVAILABLE,
|
||||
"Photos download interrupted; retry explicitly",
|
||||
502,
|
||||
) from None
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
Reference in New Issue
Block a user