diff --git a/deploy/Dockerfile.runtime-update b/deploy/Dockerfile.runtime-update
new file mode 100644
index 0000000..8fcc218
--- /dev/null
+++ b/deploy/Dockerfile.runtime-update
@@ -0,0 +1,12 @@
+# Reuse a reviewed image only when the locked Python dependencies are identical.
+# For a fresh installation use the repository's main Dockerfile instead.
+ARG BASE_IMAGE
+FROM ${BASE_IMAGE}
+COPY requirements.lock /app/requirements.expected.lock
+RUN python -c "from pathlib import Path; assert Path('/app/requirements.lock').read_bytes() == Path('/app/requirements.expected.lock').read_bytes(), 'Dependency lock changed; use the full Dockerfile'"
+COPY pigway_frame_flow /app/pigway_frame_flow
+COPY vendor /app/vendor
+ARG BUILD_COMMIT
+ARG BUILD_TIME
+ENV BUILD_COMMIT=$BUILD_COMMIT BUILD_TIME=$BUILD_TIME
+LABEL org.opencontainers.image.title="Pigway Frame Flow" org.opencontainers.image.version="0.1.9" org.opencontainers.image.revision=$BUILD_COMMIT
diff --git a/deploy/compose.ubuntu-photos-api.yml b/deploy/compose.ubuntu-photos-api.yml
new file mode 100644
index 0000000..2676622
--- /dev/null
+++ b/deploy/compose.ubuntu-photos-api.yml
@@ -0,0 +1,51 @@
+# Candidate only. Never start alongside a live-writing NAS instance.
+name: pigway-frame-flow
+services:
+ pigway-frame-flow:
+ image: ${FRAME_FLOW_IMAGE:?Set the verified immutable image reference}
+ container_name: pigway-frame-flow
+ platform: linux/amd64
+ restart: unless-stopped
+ init: true
+ user: "10333:10333"
+ ports:
+ - "${FRAME_FLOW_BIND:-192.0.2.10}:81:18787"
+ env_file: ${FRAME_FLOW_ENV_FILE:-/srv/pigway/apps/pigway-frame-flow/.env}
+ environment:
+ TZ: Asia/Shanghai
+ DATA_DIR: /data
+ CONFIG_DIR: /config
+ FRAME_FLOW_STORAGE_NAME: Ubuntu Server
+ SYNOLOGY_PHOTOS_CACHE_BYTES: ${SYNOLOGY_PHOTOS_CACHE_BYTES:-268435456}
+ # Migration starts read-only. Activation is a separate authorized action.
+ KOKONNA_WRITE_ENABLED: "false"
+ PYTHONDONTWRITEBYTECODE: "1"
+ TMPDIR: /data/tmp
+ volumes:
+ - type: bind
+ source: /srv/pigway/apps/pigway-frame-flow/data
+ target: /data
+ bind:
+ create_host_path: false
+ - type: bind
+ source: /srv/pigway/apps/pigway-frame-flow/config
+ target: /config
+ bind:
+ create_host_path: false
+ read_only: true
+ cap_drop: [ALL]
+ security_opt: [no-new-privileges:true]
+ pids_limit: 100
+ mem_limit: 768m
+ healthcheck:
+ test: [CMD, python, -c, "import urllib.request; urllib.request.urlopen('http://127.0.0.1:18787/ready',timeout=3)"]
+ interval: 30s
+ timeout: 5s
+ start_period: 20s
+ retries: 3
+ # Docker data-root and container logs must reside on the T7 too.
+ logging:
+ driver: json-file
+ options:
+ max-size: 10m
+ max-file: "3"
diff --git a/docs/SYNOLOGY_PHOTOS_API.md b/docs/SYNOLOGY_PHOTOS_API.md
new file mode 100644
index 0000000..b72a641
--- /dev/null
+++ b/docs/SYNOLOGY_PHOTOS_API.md
@@ -0,0 +1,65 @@
+# Synology Photos API 来源与 Ubuntu 迁移候选
+
+本功能从 NAS 的 Photos API 按需读取照片,不需要 NFS、SMB 或 `/photos` 文件挂载。NAS 原照片不复制整库到 Server。
+
+## 已支持与边界
+
+- 新来源类型 `synology-photos`,与 `local` 挂载目录明确分开。
+- 共享空间 `SYNO.FotoTeam`、当前登录账号的个人空间 `SYNO.Foto`;不是任意 NAS 用户的个人空间。
+- 人物列表分批读取,照片按 ID 做 any 并集 / all 交集;无匹配时明确为空,不回退全库。
+- 支持目录相对路径、递归开关、日期文件夹 / 标题、方向及现有质量筛选。`.` 表示所选空间根目录;路径以 Photos 返回的目录名为准,不是 Ubuntu 绝对路径。
+- 图片使用经过实测的 `Thumbnail.get` v2、`size=xl`。原始文件名不代表下载的是原图。对 NAS 当前原图下载接口的探测被拒绝,原图下载**未宣称支持**。
+- 照片枚举 `Browse.Item.list` v7,人物 `Browse.Person.list` v3;每批 500 条,最多 100000 条,超过明确失败,界面目录上限仍由 `catalog_limit` 控制。不支持 NAS 未暴露或账号无权访问的相册。
+- 旧 `local` 来源保留挂载读取方式,不自动变成 API 来源。新建 / 编辑来源时可明确选择 API 类型。
+
+## 安全与缓存
+
+保持 HTTPS SHA-256 证书指纹校验,在同一 TLS socket 验证证书后才发送 SID。仅调用固定 API 与只读方法,不使用通用 RPC 转发、不跟随重定向。密码不持久化,会话文件保持 0600。会话过期返回明确重连提示;不保存密码以自动重登,也不对 NAS 原会话发 logout。权限不足与会话失效分开处理。
+
+缓存为 `/data/cache/synology-photos`,默认总图片字节上限 256 MiB,可用 `SYNOLOGY_PHOTOS_CACHE_BYTES` 调整(环境配置最小 1 MiB),单张不超过应用 20 MiB 限制。只下载选中的图片。文件锁合并跨线程、跨进程下载,预留容量后写 0600 临时文件,完成长度核对及 Pillow 验证 / 解码后原子提交;超时、中断及无效数据清理临时文件,下次持锁读取清理进程异常退出遗留的 `.part`。按最近使用淘汰。缓存身份包含端点、会话摘要、空间、照片 ID、索引版本及缩略图缓存键,避免账号 / 空间混用。
+
+容量上限针对图片和下载临时文件的逻辑字节,不含 SQLite、锁文件与文件系统元数据;并发串行缓存锁会等待正在进行的单张下载。网络 socket 超时 30 秒,流读取检查 90 秒期限,已阻塞的单次 socket 读取最多再等待其超时。
+
+## Ubuntu 候选配置
+
+正式代码在 `/home/developer/Documents/Projects/pigway-frame-flow` 的 `codex/synology-photos-api` 分支开发,不能以迁移临时解包目录作为开发仓库。
+
+候选文件:`deploy/compose.ubuntu-photos-api.yml`。外部 `192.0.2.10:81`,内部 `18787`,UID/GID `10333:10333`,数据和配置固定为:
+
+- `/srv/pigway/apps/pigway-frame-flow/data:/data`
+- `/srv/pigway/apps/pigway-frame-flow/config:/config`
+
+无 `/photos` 绑定。`create_host_path: false` 防止在 T7 未挂载时悄悄创建目录;但仍需在启动前检查 `findmnt -T /srv/pigway` 的实际源设备。
+
+`.env` 需从已确认的配置安全迁移,保留 Photos URL、证书 pin 与已有 Frame Flow / 相框鉴权;不要把密钥写入仓库。模板默认 `KOKONNA_WRITE_ENABLED=false`。创建 `data/tmp`,属主保持 10333:10333,`TMPDIR=/data/tmp`。Docker data-root 和 containerd root 也必须在 T7,不能仅检查 bind mount。
+
+```sh
+# 仅检查候选配置;不会启动应用。
+FRAME_FLOW_IMAGE='<已验证的镜像标签>' docker compose -f deploy/compose.ubuntu-photos-api.yml config --quiet
+```
+
+完整构建使用根目录 Dockerfile。若使用 `deploy/Dockerfile.runtime-update`,必须指定已审核的基础镜像,构建步骤会检查依赖锁与基础镜像相同;不相同时必须完整构建。本次依赖未新增,已比对现有 0.1.8 基础镜像的锁文件。
+
+## 旧来源转换与恢复
+
+**停止目标实例、复制数据、转换真实配置及切换部署均需另行授权,本次没有执行。**
+
+1. 确认 T7 挂载,备份 NAS 原有完整 `/data`、`/config`、旧镜像及 Compose。SQLite 必须停服务后复制,或使用 SQLite backup API;不要只复制有活动 WAL 的主文件。
+2. 将备份恢复到上述 Server 目录,保持私密文件权限,创建 `data/tmp`。保留旧 NAS 备份直至切换验收。
+3. 对每个需要摆脱挂载的来源显式 dry run:
+
+ ```sh
+ python scripts/migrate-photos-api.py --database /data/frameflow.db --source family
+ # 已获授权、目标服务已停止时才执行:
+ python scripts/migrate-photos-api.py --database /data/frameflow.db --source family --apply --service-stopped
+ ```
+
+ 工具保持来源 ID、任务引用、人物空间、any/all、目录、方向及筛选设置,只修改来源类型并关闭旧本地 `@eaDir` 预览开关;自动在同一数据目录生成 0600 SQLite 备份。转换不会验证该账号在 Photos 中是否可访问相同目录,必须再做真实只读预览。不要转换确实依赖挂载且 Photos 未收录的目录。
+4. 已获得单独授权后才停旧实例、以只读候选配置启动 Server,并逐个来源验收。没有授权前不执行 `compose up` 或开启真实写入。
+5. 回退时停止 Server,恢复已备份的数据库和配置(停服务时处理对应 WAL/SHM),恢复旧来源类型与 NAS 原部署,保证只有一个实例有写权限。原 NAS 照片不受缓存或来源转换影响。
+
+## NFS 授权判断
+
+API 来源的共享 / 个人空间取图已经可以完全不依赖 NFS,因此这条新取图链路不需要为 `192.0.2.10` 保留 DSM `photo` NFS 授权。但目前旧来源和待迁移配置没有全部切换验收,不能据此宣称迁移完成或旧本地来源已可脱离挂载。确认没有其他已批准用途后,可以单独授权删除该特定 NFS 规则;本次没有删除。
+
+实际版本、镜像标识、通过 / 失败 / 未执行项见本任务验证记录。
diff --git a/docs/openapi.json b/docs/openapi.json
index f63e123..29a22f5 100644
--- a/docs/openapi.json
+++ b/docs/openapi.json
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "Pigway Frame Flow API",
- "version": "0.1.8"
+ "version": "0.1.9"
},
"paths": {
"/api/v1/devices/{device_id}/widgets/selection": {
@@ -15216,7 +15216,7 @@
"version": {
"type": "string",
"title": "Version",
- "default": "0.1.8"
+ "default": "0.1.9"
}
},
"type": "object",
@@ -16332,6 +16332,7 @@
"type": "string",
"enum": [
"local",
+ "synology-photos",
"webdav",
"google-drive",
"alipan",
@@ -16484,6 +16485,7 @@
"type": "string",
"enum": [
"local",
+ "synology-photos",
"webdav",
"google-drive",
"alipan",
@@ -17412,7 +17414,7 @@
"version": {
"type": "string",
"title": "Version",
- "default": "0.1.8"
+ "default": "0.1.9"
},
"api_version": {
"type": "string",
diff --git a/pigway_frame_flow/api/app.py b/pigway_frame_flow/api/app.py
index b60e138..83f0bed 100644
--- a/pigway_frame_flow/api/app.py
+++ b/pigway_frame_flow/api/app.py
@@ -126,7 +126,7 @@ def create_app(settings=None, start_worker=True):
app = FastAPI(
title="Pigway Frame Flow API",
docs_url=None,
- version="0.1.8",
+ version="0.1.9",
lifespan=lifespan,
responses=error_responses,
)
@@ -1031,7 +1031,7 @@ def create_app(settings=None, start_worker=True):
supports_upload=True,
),
],
- version="0.1.8",
+ version="0.1.9",
mock=settings.mock,
write_enabled=settings.write_enabled,
api_key_configured=bool(settings.api_key)
diff --git a/pigway_frame_flow/models.py b/pigway_frame_flow/models.py
index b3815d1..e2a8404 100644
--- a/pigway_frame_flow/models.py
+++ b/pigway_frame_flow/models.py
@@ -363,7 +363,7 @@ class SettingsView(BaseModel):
class Health(BaseModel):
status: str
- version: str = "0.1.8"
+ version: str = "0.1.9"
class Readiness(BaseModel):
@@ -393,7 +393,7 @@ class RestoreRequest(StrictModel):
class Version(BaseModel):
product: str = "Pigway Frame Flow"
- version: str = "0.1.8"
+ version: str = "0.1.9"
api_version: str = "v1"
commit: str = "development"
build_time: str = "unknown"
diff --git a/pigway_frame_flow/providers/photo_sources.py b/pigway_frame_flow/providers/photo_sources.py
index 0f2bc89..b397a97 100644
--- a/pigway_frame_flow/providers/photo_sources.py
+++ b/pigway_frame_flow/providers/photo_sources.py
@@ -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
diff --git a/pigway_frame_flow/providers/photos_cache.py b/pigway_frame_flow/providers/photos_cache.py
new file mode 100644
index 0000000..4236ce8
--- /dev/null
+++ b/pigway_frame_flow/providers/photos_cache.py
@@ -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)
diff --git a/pigway_frame_flow/providers/synology_photos.py b/pigway_frame_flow/providers/synology_photos.py
index de64df0..c687be0 100644
--- a/pigway_frame_flow/providers/synology_photos.py
+++ b/pigway_frame_flow/providers/synology_photos.py
@@ -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()
diff --git a/pigway_frame_flow/settings.py b/pigway_frame_flow/settings.py
index ae0899c..e137518 100644
--- a/pigway_frame_flow/settings.py
+++ b/pigway_frame_flow/settings.py
@@ -56,6 +56,12 @@ class Settings:
photo_root: Path = field(
default_factory=lambda: Path(os.getenv("PHOTO_ROOT", "/photos"))
)
+ photos_cache_bytes: int = field(
+ default_factory=lambda: max(
+ 1024 * 1024,
+ int(os.getenv("SYNOLOGY_PHOTOS_CACHE_BYTES", str(256 * 1024 * 1024))),
+ )
+ )
max_bytes: int = 20 * 1024 * 1024
idempotency_hours: int = 168
sync_timeout: int = 600
diff --git a/pigway_frame_flow/web/extensions.js b/pigway_frame_flow/web/extensions.js
index 481c0d1..3776e24 100644
--- a/pigway_frame_flow/web/extensions.js
+++ b/pigway_frame_flow/web/extensions.js
@@ -49,7 +49,7 @@ async function officialWidgetControls(info,data){
const send=cover_id=>confirmDialog(tr('此操作会改变官方订阅和画框内容,是否继续?','This changes the official subscription and frame content. Continue?'),async()=>{const args=cover_id?Object.fromEntries([...document.querySelectorAll('[data-widget-arg]')].filter(x=>x.value).map(x=>[x.dataset.widgetArg,x.value])):{};const r=await api('/api/v1/devices/'+state.device+'/widgets/selection',{method:'POST',body:{cover_id,args},headers:{'Idempotency-Key':'widget-'+clientUUID()}});await waitOperation(r.operation_id);await navigate('templates');});if(!disabled){$('#official-widget-use').onclick=busy(()=>send(Number($('#official-widget-choice').value)));$('#official-widget-cancel').onclick=busy(()=>send(null));}
}
function addPhotoQualityFields(source){
- const box=document.createElement('section');box.innerHTML=`
${tr('照片筛选','Photo filtering')}
${tr('按小图清晰度、亮度和相似度筛选,不改动或删除 NAS 原照片;无法保证识别闭眼。建议先用低阈值。个人空间还需要将相应目录只读挂载到 /photos。','Filters thumbnail sharpness, brightness and similarity without modifying or deleting originals. Closed-eye detection is not guaranteed. Start with low thresholds. Personal space also requires mounting its folder read-only at /photos.')}
${tr('按小图清晰度、亮度和相似度筛选,不改动或删除 NAS 原照片;无法保证识别闭眼。建议先用低阈值。本地目录来源仍需挂载 /photos;Synology Photos API 来源通过网络读取,无需文件挂载。','Filters thumbnail sharpness, brightness and similarity without modifying or deleting originals. Closed-eye detection is not guaranteed. Start with low thresholds. Local-folder sources still require /photos; Synology Photos API sources read over the network without file mounts.')}
`;$('#photo-source-form .modal-actions').before(box);$('#source-people-space').value=source?.people_space||'shared';
}
async function aiImagePanel(){
diff --git a/pigway_frame_flow/web/features.js b/pigway_frame_flow/web/features.js
index 207c1c4..699db78 100644
--- a/pigway_frame_flow/web/features.js
+++ b/pigway_frame_flow/web/features.js
@@ -140,7 +140,7 @@ function photosLoginDialog(){
async function photoSourcesPanel(){
const section=document.createElement('section');section.className='card photo-sources-panel';$('#main').append(section);
const sources=await api('/api/v1/photo-sources');if(!section.isConnected)return;
- section.innerHTML=`
`);
+ $('#source-orientation').value=source?(source.orientation||''):'auto';const selectedPeople=new Set(source?.person_ids||[]);$('#source-person-match').value=source?.person_match||'any';$('#source-people-load').onclick=busy(async()=>{await photosPeoplePicker(selectedPeople);});$('#source-kind').value=source?.kind||'local';const update=()=>{const dav=!['local','synology-photos'].includes($('#source-kind').value);$('#source-orientation').disabled=dav;$('#source-credential').disabled=!dav;$('#source-credential').closest('.field').hidden=$('#source-kind').value!=='webdav';const oauth=$('#source-oauth');if(oauth)oauth.closest('.field').hidden=!['google-drive','alipan','baidu'].includes($('#source-kind').value);const quality=$('#source-local-quality');if(quality)quality.hidden=dav;$('#source-recursive').disabled=$('#source-kind').value==='webdav';$('#source-synology-previews').disabled=$('#source-kind').value!=='local';$('#source-synology-previews').closest('.check-row').hidden=$('#source-kind').value==='synology-photos';$('#source-organized').disabled=dav;$('#source-caption').disabled=dav;$('#source-people-filter').disabled=dav;$('#source-people-load').disabled=dav;};addPhotoQualityFields(source);update();$('#source-kind').onchange=update;$('#source-people-space').onchange=()=>{selectedPeople.clear();$('#source-people-panel').innerHTML='';};
+ $('#photo-source-form').onsubmit=busy(async e=>{e.preventDefault();const oauth=$('#source-oauth').value;const sourceId=$('#source-id').value;const native=['google-drive','alipan','baidu'].includes($('#source-kind').value);if(oauth&&native)JSON.parse(oauth);const dav=!['local','synology-photos'].includes($('#source-kind').value);await api('/api/v1/photo-sources/'+$('#source-id').value,{method:'PUT',body:{name:$('#source-name').value,kind:$('#source-kind').value,location:$('#source-location').value,recursive:$('#source-kind').value!=='webdav'&&$('#source-recursive').checked,synology_previews:$('#source-kind').value==='local'&&$('#source-synology-previews').checked,catalog_limit:$('#source-organized').checked?100000:(source?.catalog_limit||10000),organized_only:!dav&&$('#source-organized').checked,folder_caption:!dav&&$('#source-caption').checked,orientation:dav?null:($('#source-orientation').value||null),people_filter:!dav&&$('#source-people-filter').checked,person_ids:[...selectedPeople],person_match:$('#source-person-match').value,people_space:$('#source-people-space').value,minimum_sharpness:Number($('#source-sharpness').value),minimum_brightness:Number($('#source-bright-min').value),maximum_brightness:Number($('#source-bright-max').value),avoid_duplicates:$('#source-dedup').checked,enabled:$('#source-enabled').checked,credential_env:dav?($('#source-credential').value||null):null}});if(oauth&&native){$('#source-oauth').value='';await api('/api/v1/photo-sources/'+sourceId+'/credentials',{method:'PUT',body:{key:oauth}});}$('#modal').close();await navigate('photo-sources');});
}
async function browseSource(id,offset=0,refresh=false){
const result=await api('/api/v1/photo-sources/'+id+'/images?device_id='+encodeURIComponent(state.device)+'&offset='+offset+'&limit=50&refresh='+refresh);const box=$('#source-browser');if(!box)return;
diff --git a/pigway_frame_flow/web/i18n.js b/pigway_frame_flow/web/i18n.js
index f56207a..3484e47 100644
--- a/pigway_frame_flow/web/i18n.js
+++ b/pigway_frame_flow/web/i18n.js
@@ -139,3 +139,8 @@ Object.assign(MESSAGES.en,{currentImageId:'Current image ID',modelSource:'Model
Object.assign(MESSAGES.zh,{noSources:'尚未配置照片数据源,请先添加。',noSourcePhotos:'当前目录或筛选条件下没有可用照片。',sourceLocation:'目录路径 / 地址 / ID',temporaryOptionsHint:'勾选到期恢复时,显示时长和优先级用于临时显示;未勾选时持续显示,直到其他操作切换图片。'});
Object.assign(MESSAGES.en,{noSources:'No photo sources configured. Add a source first.',noSourcePhotos:'No photos match this folder and its filters.',sourceLocation:'Folder path / URL / ID',temporaryOptionsHint:'With restore enabled, duration and priority apply to the temporary display. Otherwise, the image stays until another action changes it.'});
+
+Object.assign(MESSAGES.zh,{synologyAPISource:'Synology Photos API(无需挂载)',sourcePhotosAPI:'关联:共享 Photos 登录;按需下载 XL 预览图,无需 /photos 挂载',sourceLocationHint:'本地目录:挂载根目录下的相对路径。Photos API:所选空间的目录路径,. 表示整个空间;按需取 XL 预览图,不下载原图。WebDAV:HTTPS 目录地址。Google Drive / 阿里云盘:目录 ID;百度网盘:目录路径。',photosConnectionHelp:'当前共享一个 NAS Photos 登录。Photos API 来源通过此会话获取照片和人物,不需要挂载;本地目录来源仍从 /photos 读取。更换账号影响所有 Photos API 来源和人物筛选。',sourceRecursive:'包含子目录(本地 / Photos API)'});
+Object.assign(MESSAGES.en,{synologyAPISource:'Synology Photos API (no mount)',sourcePhotosAPI:'Connection: shared Photos session; downloads XL previews on demand, without /photos',sourceLocationHint:'Local: relative folder under the mounted root. Photos API: folder path in the selected space; . means the whole space. Downloads XL previews, not originals. WebDAV: HTTPS folder URL. Google Drive / AliPan: folder ID. Baidu: folder path.',photosConnectionHelp:'One NAS Photos session is shared. Photos API sources use it for photos and people without a mount; local-folder sources still read /photos. Changing accounts affects all Photos API sources and people filtering.',sourceRecursive:'Include subfolders (local / Photos API)'});
+Object.assign(MESSAGES.zh,{photosReconnectHelp:'尚未连接或会话已失效。Photos API 来源及依赖人物筛选的任务需要重新登录后才能读取照片。',photosSharedDisconnectWarning:'这将断开所有 Photos API 来源和人物筛选共用的登录,相关任务将无法读取照片。目录配置和独立网盘连接不会删除。确定断开吗?',photosConfigure:'管理员需配置 SYNOLOGY_PHOTOS_URL 和 SYNOLOGY_PHOTOS_CERT_SHA256。Photos API 来源无需 /photos 挂载;本地目录来源仍需挂载。'});
+Object.assign(MESSAGES.en,{photosReconnectHelp:'Not connected or session expired. Photos API sources and people-filtered tasks need a new sign-in before reading photos.',photosSharedDisconnectWarning:'Disconnect the shared session used by all Photos API sources and people filters? Their tasks cannot read photos until reconnected. Folder settings and separate drive connections are retained.',photosConfigure:'Configure SYNOLOGY_PHOTOS_URL and SYNOLOGY_PHOTOS_CERT_SHA256. Photos API sources need no /photos mount; local-folder sources still require it.'});
diff --git a/pigway_frame_flow/web/index.html b/pigway_frame_flow/web/index.html
index e6ac7b1..9d6ed9b 100644
--- a/pigway_frame_flow/web/index.html
+++ b/pigway_frame_flow/web/index.html
@@ -1,3 +1,3 @@
Pigway Frame Flow
-