Add mount-free Synology Photos API sources with bounded image cache

This commit is contained in:
渡口浪人
2026-10-16 22:31:13 +08:00
parent 2275974cde
commit 66ed1e2c9b
21 changed files with 1081 additions and 22 deletions
+86 -6
View File
@@ -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