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.')}

`;$('#photo-source-form .modal-actions').before(box);$('#source-people-space').value=source?.people_space||'shared'; + const box=document.createElement('section');box.innerHTML=`

${tr('照片筛选','Photo filtering')}

${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=`

${t('photoSources')}

${t('sourceHint')}

${buttons('add-photo-source','addSource')}
${sources.length?sources.map(s=>`

${esc(s.name)}

${t('sourceIdLabel')}: ${esc(s.id)} · ${t('sourceLocation')}: ${esc(s.location)}

${s.people_filter?t('sourceSharedPhotos'):s.kind==='local'?t('sourceLocalOnly'):t('sourceOwnCredentials')}

${esc(s.kind)} · ${t(s.enabled?'enabled':'disabled')}${s.people_filter?' · '+t('peopleFilter'):''}${s.orientation?' · '+t({auto:'orientationAuto',landscape:'orientationLandscape',portrait:'orientationPortrait'}[s.orientation]):''}

`).join(''):`

${t('noSources')}

`}
`; + section.innerHTML=`

${t('photoSources')}

${t('sourceHint')}

${buttons('add-photo-source','addSource')}
${sources.length?sources.map(s=>`

${esc(s.name)}

${t('sourceIdLabel')}: ${esc(s.id)} · ${t('sourceLocation')}: ${esc(s.location)}

${s.kind==='synology-photos'?t('sourcePhotosAPI'):s.people_filter?t('sourceSharedPhotos'):s.kind==='local'?t('sourceLocalOnly'):t('sourceOwnCredentials')}

${esc(s.kind)} · ${t(s.enabled?'enabled':'disabled')}${s.people_filter?' · '+t('peopleFilter'):''}${s.orientation?' · '+t({auto:'orientationAuto',landscape:'orientationLandscape',portrait:'orientationPortrait'}[s.orientation]):''}

`).join(''):`

${t('noSources')}

`}
`; await photosConnectionPanel(); bind('add-photo-source',()=>photoSourceForm()); section.querySelectorAll('[data-source-edit]').forEach(b=>b.onclick=()=>photoSourceForm(sources.find(s=>s.id===b.dataset.sourceEdit))); @@ -150,9 +150,9 @@ async function photoSourcesPanel(){ section.querySelectorAll('[data-source-job]').forEach(b=>b.onclick=()=>sourceJobForm(b.dataset.sourceJob)); } async function photoSourceForm(source=null){ - showModal(`

${t(source?'edit':'addSource')}

${t('sourceIndependentHelp')}

${t('sourceLocationHint')}

${t('sourceCredentialHint')}

${t('orientationHint')}

${t('folderCaptionHint')}

${t('peopleHint')}

${t('peopleSelected')}${(source?.person_ids||[]).length}

`); - $('#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=$('#source-kind').value!=='local';$('#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=dav;$('#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=$('#source-kind').value!=='local';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:!dav&&$('#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');}); + showModal(`

${t(source?'edit':'addSource')}

${t('sourceIndependentHelp')}

${t('sourceLocationHint')}

${t('sourceCredentialHint')}

${t('orientationHint')}

${t('folderCaptionHint')}

${t('peopleHint')}

${t('peopleSelected')}${(source?.person_ids||[]).length}

`); + $('#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 -
PIGWAY FRAME FLOW

+
PIGWAY FRAME FLOW

diff --git a/pyproject.toml b/pyproject.toml index c92fa30..7e743ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pigway-frame-flow" -version = "0.1.8" +version = "0.1.9" requires-python = ">=3.12" dependencies = ["paho-mqtt==2.1.0", "python-dateutil==2.9.0.post0", "fastapi==0.142.2", "uvicorn==0.54.0", "pillow==12.3.0", "requests==2.34.2", "croniter==6.2.4", "python-multipart==0.0.32", "pyyaml==6.0.3"] [project.optional-dependencies] diff --git a/scripts/migrate-photos-api.py b/scripts/migrate-photos-api.py new file mode 100644 index 0000000..deb84f3 --- /dev/null +++ b/scripts/migrate-photos-api.py @@ -0,0 +1,83 @@ +"""Offline, explicit local-to-Photos-API source conversion; defaults to dry run.""" + +import argparse +import json +import sqlite3 +from datetime import UTC, datetime +from pathlib import Path + +from pigway_frame_flow.providers.photo_sources import PhotoSourceInput + + +def convert(database, source_ids, *, apply=False): + connection = sqlite3.connect(f"file:{database}?mode=rw", uri=True) + try: + updates = [] + for source_id in source_ids: + key = "photo_source:" + source_id + row = connection.execute( + "SELECT payload FROM app_config WHERE key=?", (key,) + ).fetchone() + if not row: + raise ValueError("Source not found: " + source_id) + source = PhotoSourceInput.model_validate_json(row[0]) + if source.kind != "local": + raise ValueError( + "Only explicitly selected local sources can be converted" + ) + location = source.location.strip("/") + if ( + source.location.startswith("/") + or "\\" in location + or ".." in location.split("/") + ): + raise ValueError( + "Review the source's relative Photos folder path first" + ) + payload = source.model_copy( + update={"kind": "synology-photos", "synology_previews": False} + ) + updates.append((payload.model_dump_json(), key)) + backup = None + if apply: + backup = database.with_name( + database.name + + ".before-photos-api-" + + datetime.now(UTC).strftime("%Y%m%dT%H%M%S%fZ") + ) + with sqlite3.connect(backup) as snapshot: + connection.backup(snapshot) + backup.chmod(0o600) + with connection: + connection.executemany( + "UPDATE app_config SET payload=? WHERE key=?", updates + ) + return { + "applied": apply, + "sources": source_ids, + "backup": str(backup) if backup else None, + } + finally: + connection.close() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--database", type=Path, required=True) + parser.add_argument("--source", action="append", required=True) + parser.add_argument("--apply", action="store_true") + parser.add_argument( + "--service-stopped", + action="store_true", + help="Confirm the target service is stopped before changing its database", + ) + args = parser.parse_args() + if args.apply and not args.service_stopped: + parser.error( + "Stop the target service and pass --service-stopped before applying" + ) + print(json.dumps(convert(args.database, args.source, apply=args.apply), indent=2)) + + +if __name__ == "__main__": + main() diff --git a/scripts/ui-photos-api.cjs b/scripts/ui-photos-api.cjs new file mode 100644 index 0000000..7a9d66b --- /dev/null +++ b/scripts/ui-photos-api.cjs @@ -0,0 +1,4 @@ +/* Isolated Mock UI only: explicit API type, people spaces, payload and no mount wording. */ +const assert=require('assert'),{chromium}=require('playwright'); +(async()=>{const b=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});let checks=0; +try{for(const width of [1440,390])for(const lang of ['zh','en'])for(const theme of ['light','dark']){const p=await b.newPage({viewport:{width,height:1000}}),errors=[];let saved;const spaces=[];p.on('pageerror',e=>errors.push(e.message));await p.route('**/api/v1/photo-sources',r=>r.fulfill({json:[]}));await p.route('**/api/v1/photo-sources/api-fixture',r=>{saved=r.request().postDataJSON();return r.fulfill({json:{...saved,id:'api-fixture'}})});await p.route('**/api/v1/integrations/synology-photos',r=>r.fulfill({json:{configured:true,connected:true}}));await p.route('**/api/v1/integrations/synology-photos/people?*',r=>{spaces.push(new URL(r.request().url()).searchParams.get('space'));return r.fulfill({json:[{id:7,name:'Fixture person',count:3,named:true}]})});await p.goto(process.env.FRAME_FLOW_URL||'http://127.0.0.1:6005');await p.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await p.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await p.locator('#login-form button').click();await p.locator('.hero').waitFor();assert(await p.evaluate(()=>state.settings.mock));await p.evaluate(async({lang,theme})=>{state.lang=lang;document.documentElement.dataset.theme=theme;await navigate('photo-sources')},{lang,theme});await p.locator('#add-photo-source').click();await p.locator('#source-kind').selectOption('synology-photos');await p.locator('#source-id').fill('api-fixture');await p.locator('#source-name').fill('Photos API');assert(await p.locator('#source-credential').isHidden());assert(await p.locator('#source-oauth').isHidden());assert(await p.locator('#source-synology-previews').isHidden());assert(await p.locator('#source-orientation').isEnabled());await p.locator('#source-people-filter').check();await p.locator('#source-people-load').click();await p.locator('[data-person="7"]').check();await p.locator('#source-people-space').selectOption('personal');await p.locator('#source-people-load').click();assert(!await p.locator('[data-person="7"]').isChecked());await p.locator('[data-person="7"]').check();await p.locator('#source-person-match').selectOption('all');assert(await p.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));if(process.env.FRAME_FLOW_SCREENSHOT_DIR&&lang==='zh'&&theme==='dark'){require('fs').mkdirSync(process.env.FRAME_FLOW_SCREENSHOT_DIR,{recursive:true});await p.screenshot({path:process.env.FRAME_FLOW_SCREENSHOT_DIR+'/photos-api-'+width+'.png'});}await p.locator('#photo-source-form button[type="submit"]').click();await p.waitForFunction(()=>!document.querySelector('#modal').open);assert.equal(saved.kind,'synology-photos');assert.equal(saved.people_space,'personal');assert.equal(saved.person_match,'all');assert.deepEqual(saved.person_ids,[7]);assert.equal(saved.credential_env,null);assert.equal(saved.synology_previews,false);assert.deepEqual(spaces,['shared','personal']);assert.deepEqual(errors,[]);await p.close();checks++;}console.log('Photos API UI:',checks,'profiles PASS; type/space/person any-all/config and responsive layout verified');}finally{await b.close()}})().catch(e=>{console.error(e);process.exit(1)}); diff --git a/scripts/verify-photos-api-container.py b/scripts/verify-photos-api-container.py new file mode 100644 index 0000000..ef4aa0f --- /dev/null +++ b/scripts/verify-photos-api-container.py @@ -0,0 +1,45 @@ +"""Smoke-check an already running isolated Mock container, via its loopback HTTP.""" + +import json +import os +import time +import urllib.request +from pathlib import Path + + +def main(): + assert os.getuid() == 10333 and os.getgid() == 10333 + assert not Path("/photos").exists() + for _ in range(30): + try: + with urllib.request.urlopen( + "http://127.0.0.1:18787/ready", timeout=2 + ) as response: + assert json.load(response)["status"] == "ready" + break + except OSError: + time.sleep(1) + else: + raise SystemExit("Mock readiness failed") + with urllib.request.urlopen( + "http://127.0.0.1:18787/api/v1/version", timeout=2 + ) as response: + version = json.load(response) + assert version["version"] == "0.1.9" + assert Path("/data/frameflow.db").is_file() + print( + json.dumps( + { + "ready": True, + "version": version["version"], + "commit": version["commit"], + "uid": os.getuid(), + "gid": os.getgid(), + "no_photos_mount": True, + } + ) + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/verify-photos-api.py b/scripts/verify-photos-api.py new file mode 100644 index 0000000..4f589b8 --- /dev/null +++ b/scripts/verify-photos-api.py @@ -0,0 +1,112 @@ +"""Read-only live Photos check. Never creates an app, worker, job or frame driver.""" + +import argparse +import io +import json +import os +from pathlib import Path + +from PIL import Image + +from pigway_frame_flow.errors import FlowError +from pigway_frame_flow.providers.photo_sources import PhotoSourceInput, PhotoSources +from pigway_frame_flow.settings import Settings +from pigway_frame_flow.storage.database import Database + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--bootstrap", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + if Path("/photos").exists(): + raise SystemExit("Verification requires /photos to be absent") + bootstrap = json.loads(args.bootstrap.read_text()) + os.environ["SYNOLOGY_PHOTOS_URL"] = bootstrap["url"] + os.environ["SYNOLOGY_PHOTOS_CERT_SHA256"] = bootstrap["pin"] + settings = Settings(mock=True, write_enabled=False, api_key="", api_token="") + settings.data_dir.mkdir(parents=True, exist_ok=True) + settings.config_dir.mkdir(parents=True, exist_ok=True) + session = settings.config_dir / "synology-photos-session.json" + session.write_text(json.dumps(bootstrap["session"])) + session.chmod(0o600) + db = Database(settings.data_dir / "verification.db") + photos = PhotoSources(db, settings, None) + report = {"no_photos_mount": True, "device_writes": 0, "spaces": {}} + try: + for space in ("shared", "personal"): + people = photos.synology.people(space) + candidates = sorted( + (p for p in people if p["count"] > 1), key=lambda p: p["count"] + ) + if len(candidates) < 2: + raise RuntimeError("Need two accessible populated person albums") + selected = [p["id"] for p in candidates[:2]] + one = photos.synology.items(selected[:1], space=space) + two = photos.synology.items(selected[1:], space=space) + ids_one, ids_two = {r["id"] for r in one}, {r["id"] for r in two} + any_rows = photos.synology.items(selected, "any", space=space) + all_rows = photos.synology.items(selected, "all", space=space) + assert {r["id"] for r in any_rows} == ids_one | ids_two + assert {r["id"] for r in all_rows} == ids_one & ids_two + source_id = "live-" + space + photos.save( + source_id, + PhotoSourceInput( + name="Read-only verification", + kind="synology-photos", + location=".", + people_space=space, + people_filter=True, + person_ids=selected, + ), + ) + catalog = photos.catalog(source_id) + assert catalog + data, _, _ = photos.read(source_id, catalog[0]["id"]) + with Image.open(io.BytesIO(data)) as image: + image.load() + decoded = { + "width": image.width, + "height": image.height, + "format": image.format, + } + cache = photos.synology.image_cache + before = cache.downloads + assert photos.read(source_id, catalog[0]["id"])[0] == data + assert cache.downloads == before + report["spaces"][space] = { + "people": len(people), + "any_count": len(any_rows), + "all_count": len(all_rows), + "catalog_count": len(catalog), + "bytes": len(data), + "decoded": decoded, + "cache_reused": True, + } + # A deliberately invalid session is isolated to this verification config; + # no logout request or change is made to the original NAS session. + photos.synology.path.write_text( + json.dumps({"sid": "frameflow-deliberately-invalid-session"}) + ) + try: + photos.synology.people() + except FlowError as error: + assert error.status == 409 + report["expired_session"] = "explicit_reconnect_required" + else: + raise RuntimeError("Invalid session unexpectedly accepted") + report["cache_bytes"] = sum( + p.stat().st_size + for p in (settings.data_dir / "cache/synology-photos").glob("*.image") + ) + assert report["cache_bytes"] <= settings.photos_cache_bytes + report["original_download"] = "not_supported_by_this_verified_implementation" + args.output.write_text(json.dumps(report, indent=2) + "\n") + print(json.dumps(report, indent=2)) + finally: + session.unlink(missing_ok=True) + + +if __name__ == "__main__": + main() diff --git a/tests/schema-contract.json b/tests/schema-contract.json index 42857a8..338d8ce 100644 --- a/tests/schema-contract.json +++ b/tests/schema-contract.json @@ -869,6 +869,7 @@ "type": "string", "enum": [ "local", + "synology-photos", "webdav", "google-drive", "alipan", @@ -1021,6 +1022,7 @@ "type": "string", "enum": [ "local", + "synology-photos", "webdav", "google-drive", "alipan", diff --git a/tests/test_synology_photos_api.py b/tests/test_synology_photos_api.py new file mode 100644 index 0000000..74e3b3d --- /dev/null +++ b/tests/test_synology_photos_api.py @@ -0,0 +1,344 @@ +import hashlib +import io +import json +from concurrent.futures import ThreadPoolExecutor + +import pytest +from PIL import Image + +from pigway_frame_flow.errors import FlowError +from pigway_frame_flow.providers.photo_sources import PhotoSourceInput +from pigway_frame_flow.providers.photos_cache import PhotosCache +from pigway_frame_flow.providers.synology_photos import SynologyPhotos + + +def jpeg(color="red"): + out = io.BytesIO() + Image.new("RGB", (24, 16), color).save(out, "JPEG") + return out.getvalue() + + +def session(settings, value="fixture-session"): + settings.config_dir.mkdir(parents=True, exist_ok=True) + (settings.config_dir / "synology-photos-session.json").write_text( + json.dumps({"sid": value}) + ) + + +def row(id, folder="/family", name="same.jpg"): + return { + "id": id, + "filename": name, + "type": "photo", + "filesize": 9000, + "indexed_time": 42, + "additional": { + "folder": folder, + "resolution": {"width": 24, "height": 16}, + "thumbnail": {"unit_id": id, "cache_key": "fixture-key"}, + }, + } + + +@pytest.mark.parametrize( + "space,prefix", [("shared", "SYNO.FotoTeam"), ("personal", "SYNO.Foto")] +) +@pytest.mark.parametrize("mode,expected", [("any", ["1", "2", "3"]), ("all", ["2"])]) +def test_api_identity_selection(settings, monkeypatch, space, prefix, mode, expected): + session(settings) + p = SynologyPhotos(settings) + monkeypatch.setattr(p, "people", lambda space: [{"id": 10}, {"id": 20}]) + + def pages(api, version, **params): + assert api == prefix + ".Browse.Item" + return [row(i) for i in ([1, 2] if params["person_id"] == 10 else [2, 3])] + + monkeypatch.setattr(p, "pages", pages) + assert [str(x["id"]) for x in p.items([10, 20], mode, space=space)] == expected + with pytest.raises(FlowError): + p.items([99], space=space) + + +def test_people_pagination_does_not_stop_at_500(settings, monkeypatch): + session(settings) + p = SynologyPhotos(settings) + calls = [] + + def request(api, method, version, **params): + calls.append(params["offset"]) + return { + "list": [ + {"id": i} + for i in range(params["offset"], min(1334, params["offset"] + 500)) + ] + } + + monkeypatch.setattr(p, "request", request) + assert len(p.people()) == 1334 + assert calls == [0, 500, 1000] + + +def test_source_without_mount_pagination_scope_and_session( + client, service, settings, monkeypatch +): + session(settings) + settings.photo_root = settings.data_dir / "must-not-exist" + p = service.photo_sources + monkeypatch.setattr( + p.synology, + "items", + lambda *a, **k: [row(1), row(2, "/family/child"), row(3, "/other")], + ) + monkeypatch.setattr(p.synology, "download", lambda *a, **k: jpeg()) + source = PhotoSourceInput( + name="API", + kind="synology-photos", + location="family", + people_filter=True, + person_ids=[10], + orientation="landscape", + ) + p.save("api", source) + response = client.get("/api/v1/photo-sources/api/images?offset=1&limit=1") + assert response.status_code == 200, response.text + assert response.json()["total"] == 2 + assert response.json()["items"][0]["id"] == "2" + assert "cache_key" not in response.text and "_photos" not in response.text + data, name, _ = p.read("api", "1") + Image.open(io.BytesIO(data)).load() + assert name == "same.jpg" and not settings.photo_root.exists() + with pytest.raises(FlowError): + p.read("api", "3") + p.synology.disconnect() + with pytest.raises(FlowError) as error: + p.catalog("api") + assert error.value.status == 409 + session(settings, "different-account") + monkeypatch.setattr(p.synology, "items", lambda *a, **k: []) + assert p.catalog("api") == [] + + +def test_existing_local_sources_are_not_silently_converted(service, settings): + settings.photo_root = settings.data_dir / "missing" + with pytest.raises(FlowError): + service.photo_sources.save( + "local", PhotoSourceInput(name="Legacy", kind="local", location=".") + ) + with pytest.raises(FlowError): + service.photo_sources.save( + "api", + PhotoSourceInput(name="API", kind="synology-photos", location="../secrets"), + ) + + +def test_cache_coalesces_concurrency_and_recovers_partial_files(settings): + settings.photos_cache_bytes = 2000 + settings.max_bytes = 1000 + cache = PhotosCache(settings) + + def download(handle, cap, deadline): + handle.write(jpeg()) + + with ThreadPoolExecutor(max_workers=8) as pool: + results = list(pool.map(lambda _: cache.read("one", download), range(8))) + assert all(x == jpeg() for x in results) + assert cache.downloads == 1 and cache.hits == 7 + (cache.root / "interrupted.part").write_bytes(b"partial") + cache.read("two", download) + cache.read("three", download) + assert sum(p.stat().st_size for p in cache.root.glob("*.image")) <= 2000 + assert not list(cache.root.glob("*.part")) + assert cache.read("three", download) == jpeg() + + +@pytest.mark.parametrize("failure", ["interrupt", "invalid", "oversize"]) +def test_cache_failures_never_commit(settings, failure): + settings.max_bytes = 1000 + cache = PhotosCache(settings) + + def download(handle, cap, deadline): + handle.write( + jpeg() + if failure == "interrupt" + else b"wrong" + if failure == "invalid" + else b"x" * 1001 + ) + if failure == "interrupt": + raise TimeoutError() + + with pytest.raises(FlowError): + cache.read("bad", download) + assert not list(cache.root.glob("*.part")) + assert not list(cache.root.glob("*.image")) + + +def transport( + settings, monkeypatch, *, bad_pin=False, expired=False, interrupted=False +): + monkeypatch.setenv("SYNOLOGY_PHOTOS_URL", "https://nas.test:9115") + monkeypatch.setenv( + "SYNOLOGY_PHOTOS_CERT_SHA256", + "a" * 64 if bad_pin else hashlib.sha256(b"certificate").hexdigest(), + ) + sent = [] + + class Socket: + def getpeercert(self, **kw): + return b"certificate" + + class Response: + status = 200 + + def __init__(self): + self.buffer = io.BytesIO( + b'{"success":false,"error":{"code":106}}' if expired else jpeg() + ) + + def getheader(self, name): + return ( + ("application/json" if expired else "image/jpeg") + if name == "Content-Type" + else str(len(self.buffer.getvalue())) + ) + + def read(self, size): + if interrupted: + raise TimeoutError() + return self.buffer.read(size) + + class Connection: + sock = Socket() + + def __init__(self, *a, **kw): + pass + + def connect(self): + pass + + def request(self, *args): + sent.append(args) + + def getresponse(self): + return Response() + + def close(self): + pass + + monkeypatch.setattr("http.client.HTTPSConnection", Connection) + session(settings) + return SynologyPhotos(settings), sent + + +def test_binary_transport_pin_before_secret_and_read_allowlist(settings, monkeypatch): + p, sent = transport(settings, monkeypatch, bad_pin=True) + with pytest.raises(FlowError): + p.download(row(1)) + assert not sent + with pytest.raises(FlowError): + p._download( + "SYNO.FotoTeam.Browse.Item", + "delete", + {}, + "fixture", + io.BytesIO(), + 1000, + 100, + ) + assert not sent + + +def test_download_decodes_cache_reuse_and_rendition_rejection(settings, monkeypatch): + p, sent = transport(settings, monkeypatch) + assert p.download(row(1)) == jpeg() + assert p.download(row(1)) == jpeg() + assert len(sent) == 1 + assert "SYNO.FotoTeam.Thumbnail" in sent[0][2] + assert p.download(row(1), space="personal") == jpeg() + assert len(sent) == 2 + session(settings, "different-session") + assert p.download(row(1)) == jpeg() + assert len(sent) == 3 + with pytest.raises(FlowError): + p.download(row(1), rendition="original") + + +def test_download_session_error_and_interruption(settings, monkeypatch): + p, _ = transport(settings, monkeypatch, expired=True) + with pytest.raises(FlowError) as error: + p.download(row(1)) + assert error.value.status == 409 + assert not p.path.exists() + assert not list(p.image_cache.root.glob("*.part")) + p, _ = transport(settings, monkeypatch, interrupted=True) + with pytest.raises(FlowError): + p.download(row(1)) + assert not list(p.image_cache.root.glob("*.part")) + + +def cache_process(settings, marker): + cache = PhotosCache(settings) + + def download(handle, cap, deadline): + with open(marker, "ab") as counter: + counter.write(b"download\n") + handle.write(jpeg()) + + assert cache.read("shared-process-key", download) == jpeg() + + +def test_cache_coalesces_across_processes(settings, tmp_path): + import multiprocessing + + context = multiprocessing.get_context("spawn") + marker = tmp_path / "downloads" + processes = [ + context.Process(target=cache_process, args=(settings, marker)) for _ in range(3) + ] + for process in processes: + process.start() + for process in processes: + process.join(15) + assert process.exitcode == 0 + assert marker.read_bytes() == b"download\n" + + +def test_explicit_offline_conversion_preserves_task_reference(service, settings): + import importlib.util + from pathlib import Path + + spec = importlib.util.spec_from_file_location( + "photos_migration", Path(__file__).parents[1] / "scripts/migrate-photos-api.py" + ) + migration = importlib.util.module_from_spec(spec) + spec.loader.exec_module(migration) + # The production service uses this filename; inspect the fixture connection + # rather than modifying an unrelated database. + path = Path(service.db.conn.execute("PRAGMA database_list").fetchone()[2]) + source = PhotoSourceInput( + name="Existing", + kind="local", + location="family", + people_filter=True, + person_ids=[5], + synology_previews=True, + people_space="personal", + person_match="all", + ) + service.db.execute( + "INSERT INTO app_config VALUES(?,?)", + ("photo_source:existing", source.model_dump_json()), + ) + before = service.photo_sources.get("existing") + assert not migration.convert(path, ["existing"])["applied"] + assert service.photo_sources.get("existing") == before + result = migration.convert(path, ["existing"], apply=True) + assert Path(result["backup"]).is_file() + after = service.photo_sources.get("existing") + assert after.kind == "synology-photos" and not after.synology_previews + assert ( + after.person_ids == [5] + and after.people_space == "personal" + and after.person_match == "all" + and after.location == "family" + )