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

This commit is contained in:
渡口浪人
2026-10-16 22:31:13 +08:00
parent 2275974cde
commit 66ed1e2c9b
21 changed files with 1081 additions and 22 deletions
+12
View File
@@ -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
+51
View File
@@ -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"
+65
View File
@@ -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 规则;本次没有删除。
实际版本、镜像标识、通过 / 失败 / 未执行项见本任务验证记录。
+5 -3
View File
@@ -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",
+2 -2
View File
@@ -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)
+2 -2
View File
@@ -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"
+86 -6
View File
@@ -27,7 +27,9 @@ from pigway_frame_flow.storage.database import encode
class PhotoSourceInput(StrictModel):
name: str = Field(min_length=1, max_length=120)
kind: Literal["local", "webdav", "google-drive", "alipan", "baidu"]
kind: Literal[
"local", "synology-photos", "webdav", "google-drive", "alipan", "baidu"
]
location: str = Field(min_length=1, max_length=2048)
recursive: bool = True
enabled: bool = True
@@ -77,6 +79,7 @@ class PhotoSources:
def __init__(self, db, settings, assets):
self.db, self.settings, self.assets = db, settings, assets
self.cache = {}
self.api_items = {}
self.synology = SynologyPhotos(settings)
# Register provider secrets before request IDs or errors can be reflected.
for row in self.list():
@@ -109,19 +112,19 @@ class PhotoSources:
def save(self, source_id, source):
if source.minimum_brightness > source.maximum_brightness:
raise FlowError(E.INVALID_REQUEST, "Brightness range is reversed")
if source.kind != "local" and (
if source.kind not in ("local", "synology-photos") and (
source.organized_only or source.folder_caption or source.synology_previews
):
raise FlowError(
E.UNSUPPORTED_CAPABILITY,
"Folder captions and Synology previews require a local photo source",
)
if source.orientation and source.kind != "local":
if source.orientation and source.kind not in ("local", "synology-photos"):
raise FlowError(
E.UNSUPPORTED_CAPABILITY,
"Orientation filtering requires a local photo source",
)
if source.people_filter and source.kind != "local":
if source.people_filter and source.kind not in ("local", "synology-photos"):
raise FlowError(
E.INVALID_REQUEST,
"Person filtering requires a local Synology photo source",
@@ -140,6 +143,21 @@ class PhotoSources:
raise FlowError(
E.INVALID_REQUEST, "Local sources do not use credentials"
)
elif source.kind == "synology-photos":
location = source.location.strip("/")
if location == ".":
location = ""
if "\\" in location or any(p in (".", "..") for p in location.split("/")):
raise FlowError(
E.INVALID_REQUEST,
"Photos API folder must be a relative folder path or root",
)
if source.credential_env or source.synology_previews:
raise FlowError(
E.INVALID_REQUEST,
"Photos API sources use the shared Photos session and XL thumbnail download",
)
source = source.model_copy(update={"location": location or "."})
elif source.kind == "webdav":
source = source.model_copy(
update={
@@ -169,6 +187,7 @@ class PhotoSources:
("photo_source:" + source_id, encode(source.model_dump())),
)
self.cache.pop(source_id, None)
self.api_items.pop(source_id, None)
return dict(source.model_dump(), id=source_id)
def delete(self, source_id):
@@ -186,6 +205,7 @@ class PhotoSources:
"DELETE FROM app_config WHERE key=?", ("photo_source:" + source_id,)
)
self.cache.pop(source_id, None)
self.api_items.pop(source_id, None)
def local_directory(self, location):
root = self.settings.photo_root.resolve()
@@ -266,6 +286,9 @@ class PhotoSources:
source = self.get(source_id)
if not source.enabled:
raise FlowError(E.CONFLICT, "Photo source is disabled", 409)
session_identity = (
self.synology.sid() if source.kind == "synology-photos" else None
)
wanted = (
orientation(*target_size)
if source.orientation == "auto"
@@ -277,6 +300,7 @@ class PhotoSources:
and not refresh
and time.monotonic() - cached[0] < 60
and cached[2] == wanted
and cached[3] == session_identity
):
return cached[1]
allowed = (
@@ -286,7 +310,7 @@ class PhotoSources:
refresh=refresh,
space=source.people_space,
)
if source.people_filter
if source.people_filter and source.kind == "local"
else None
)
items = []
@@ -406,6 +430,58 @@ class PhotoSources:
break
if len(items) >= source.catalog_limit:
break
elif source.kind == "synology-photos":
self.synology.sid()
rows = self.synology.items(
source.person_ids if source.people_filter else [],
source.person_match,
space=source.people_space,
limit=100000,
)
location = "" if source.location == "." else source.location.strip("/")
self.api_items[source_id] = {}
for row in rows:
folder = row.get("additional", {}).get("folder")
if isinstance(folder, dict):
folder = folder.get("name")
if not isinstance(folder, str) or not isinstance(
row.get("filename"), str
):
raise FlowError(
E.CLOUD_UNAVAILABLE,
"Photos API response lacks folder or filename",
502,
)
relative = folder.strip("/") + "/" + row["filename"]
if location and not relative.startswith(location + "/"):
continue
if not source.recursive and folder.strip("/") != location:
continue
caption = path_caption(relative)
if source.organized_only and caption is None:
continue
size = row.get("additional", {}).get("resolution", {})
width, height = size.get("width", 0), size.get("height", 0)
rotation = row.get("additional", {}).get("orientation", 1)
if rotation in (5, 6, 7, 8):
width, height = height, width
direction = orientation(width, height)
if wanted and (not width or not height or direction != wanted):
continue
items.append(
{
"id": str(row["id"]),
"name": row["filename"],
"provider_preview": True,
"caption": caption if source.folder_caption else None,
"width": width,
"height": height,
"orientation": direction,
}
)
self.api_items[source_id][str(row["id"])] = row
if len(items) >= source.catalog_limit:
break
elif source.kind in ("google-drive", "alipan", "baidu"):
from pigway_frame_flow.providers.cloud_drives import CloudDrives
@@ -468,7 +544,7 @@ class PhotoSources:
"INSERT OR REPLACE INTO photo_dimensions(path,mtime,bytes,width,height) VALUES(?,?,?,?,?)",
size_updates,
)
self.cache[source_id] = (time.monotonic(), items, wanted)
self.cache[source_id] = (time.monotonic(), items, wanted, session_identity)
return items
def read(self, source_id, item_id=None, *, target_size=(1200, 1600)):
@@ -526,6 +602,10 @@ class PhotoSources:
os.close(parent_fd)
except OSError:
raise FlowError(E.NOT_FOUND, "Photo is unavailable", 404) from None
elif source.kind == "synology-photos":
data = self.synology.download(
self.api_items[source_id][item["id"]], space=source.people_space
)
elif source.kind in ("google-drive", "alipan", "baidu"):
from pigway_frame_flow.providers.cloud_drives import CloudDrives
@@ -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)
+166 -2
View File
@@ -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()
+6
View File
@@ -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
+1 -1
View File
@@ -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=`<div id="source-local-quality"><h3>${tr('照片筛选','Photo filtering')}</h3><p class="hint">${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.')}</p><div class="field"><label for="source-people-space">${tr('人物空间','People space')}</label><select id="source-people-space"><option value="shared">${tr('共享空间','Shared space')}</option><option value="personal">${tr('个人空间','Personal space')}</option></select></div><div class="form-grid"><div class="field"><label for="source-sharpness">${tr('最低清晰度(0 不筛选)','Minimum sharpness (0 disables)')}</label><input id="source-sharpness" type="number" min="0" max="10000" value="${source?.minimum_sharpness||0}"></div><div class="field"><label for="source-bright-min">${tr('最低亮度','Minimum brightness')}</label><input id="source-bright-min" type="number" min="0" max="255" value="${source?.minimum_brightness||0}"></div><div class="field"><label for="source-bright-max">${tr('最高亮度','Maximum brightness')}</label><input id="source-bright-max" type="number" min="0" max="255" value="${source?.maximum_brightness??255}"></div></div><label class="check-row"><input id="source-dedup" type="checkbox" ${source?.avoid_duplicates?'checked':''}>${tr('避开最近 200 张的相似照片','Avoid photos similar to the last 200 selections')}</label></div><div class="field"><label for="source-oauth">${tr('网盘 OAuth 凭据 JSON(可选;仅原生网盘)','Drive OAuth credentials JSON (optional; native drives only)')}</label><input id="source-oauth" type="password" autocomplete="off" placeholder='{"access_token":"…","drive_id":"…"}'></div>`;$('#photo-source-form .modal-actions').before(box);$('#source-people-space').value=source?.people_space||'shared';
const box=document.createElement('section');box.innerHTML=`<div id="source-local-quality"><h3>${tr('照片筛选','Photo filtering')}</h3><p class="hint">${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.')}</p><div class="field"><label for="source-people-space">${tr('人物空间','People space')}</label><select id="source-people-space"><option value="shared">${tr('共享空间','Shared space')}</option><option value="personal">${tr('个人空间','Personal space')}</option></select></div><div class="form-grid"><div class="field"><label for="source-sharpness">${tr('最低清晰度(0 不筛选)','Minimum sharpness (0 disables)')}</label><input id="source-sharpness" type="number" min="0" max="10000" value="${source?.minimum_sharpness||0}"></div><div class="field"><label for="source-bright-min">${tr('最低亮度','Minimum brightness')}</label><input id="source-bright-min" type="number" min="0" max="255" value="${source?.minimum_brightness||0}"></div><div class="field"><label for="source-bright-max">${tr('最高亮度','Maximum brightness')}</label><input id="source-bright-max" type="number" min="0" max="255" value="${source?.maximum_brightness??255}"></div></div><label class="check-row"><input id="source-dedup" type="checkbox" ${source?.avoid_duplicates?'checked':''}>${tr('避开最近 200 张的相似照片','Avoid photos similar to the last 200 selections')}</label></div><div class="field"><label for="source-oauth">${tr('网盘 OAuth 凭据 JSON(可选;仅原生网盘)','Drive OAuth credentials JSON (optional; native drives only)')}</label><input id="source-oauth" type="password" autocomplete="off" placeholder='{"access_token":"…","drive_id":"…"}'></div>`;$('#photo-source-form .modal-actions').before(box);$('#source-people-space').value=source?.people_space||'shared';
}
async function aiImagePanel(){
+4 -4
View File
@@ -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=`<div class="section-heading"><div><h3>${t('photoSources')}</h3><p class="hint">${t('sourceHint')}</p></div>${buttons('add-photo-source','addSource')}</div><section id="photos-connection" class="photos-connection" aria-label="Synology Photos"></section><div class="source-list">${sources.length?sources.map(s=>`<article class="source-card"><h4>${esc(s.name)}</h4><p class="hint source-identity">${t('sourceIdLabel')}: ${esc(s.id)} · ${t('sourceLocation')}: ${esc(s.location)}</p><p class="hint">${s.people_filter?t('sourceSharedPhotos'):s.kind==='local'?t('sourceLocalOnly'):t('sourceOwnCredentials')}</p><p class="hint">${esc(s.kind)} · ${t(s.enabled?'enabled':'disabled')}${s.people_filter?' · '+t('peopleFilter'):''}${s.orientation?' · '+t({auto:'orientationAuto',landscape:'orientationLandscape',portrait:'orientationPortrait'}[s.orientation]):''}</p><div class="actions"><button data-source-browse="${esc(s.id)}">${t('browse')}</button><button data-source-random="${esc(s.id)}" ${s.enabled?'':'disabled'}>${t('randomPreview')}</button><button data-source-job="${esc(s.id)}">${t('sourceJob')}</button><button data-source-edit="${esc(s.id)}">${t('edit')}</button><button class="danger" data-source-delete="${esc(s.id)}">${t('remove')}</button></div></article>`).join(''):`<p class="hint">${t('noSources')}</p>`}</div><div id="source-browser"></div>`;
section.innerHTML=`<div class="section-heading"><div><h3>${t('photoSources')}</h3><p class="hint">${t('sourceHint')}</p></div>${buttons('add-photo-source','addSource')}</div><section id="photos-connection" class="photos-connection" aria-label="Synology Photos"></section><div class="source-list">${sources.length?sources.map(s=>`<article class="source-card"><h4>${esc(s.name)}</h4><p class="hint source-identity">${t('sourceIdLabel')}: ${esc(s.id)} · ${t('sourceLocation')}: ${esc(s.location)}</p><p class="hint">${s.kind==='synology-photos'?t('sourcePhotosAPI'):s.people_filter?t('sourceSharedPhotos'):s.kind==='local'?t('sourceLocalOnly'):t('sourceOwnCredentials')}</p><p class="hint">${esc(s.kind)} · ${t(s.enabled?'enabled':'disabled')}${s.people_filter?' · '+t('peopleFilter'):''}${s.orientation?' · '+t({auto:'orientationAuto',landscape:'orientationLandscape',portrait:'orientationPortrait'}[s.orientation]):''}</p><div class="actions"><button data-source-browse="${esc(s.id)}">${t('browse')}</button><button data-source-random="${esc(s.id)}" ${s.enabled?'':'disabled'}>${t('randomPreview')}</button><button data-source-job="${esc(s.id)}">${t('sourceJob')}</button><button data-source-edit="${esc(s.id)}">${t('edit')}</button><button class="danger" data-source-delete="${esc(s.id)}">${t('remove')}</button></div></article>`).join(''):`<p class="hint">${t('noSources')}</p>`}</div><div id="source-browser"></div>`;
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(`<h2>${t(source?'edit':'addSource')}</h2><p class="notice">${t('sourceIndependentHelp')}</p><form id="photo-source-form"><div class="field"><label for="source-id">ID</label><input id="source-id" pattern="[a-z0-9-]{1,60}" required value="${esc(source?.id||'')}" ${source?'readonly':''}></div><div class="field"><label for="source-name">${t('name')}</label><input id="source-name" required maxlength="120" value="${esc(source?.name||'')}"></div><div class="field"><label for="source-kind">${t('sourceKind')}</label><select id="source-kind"><option value="local">${t('nasDirectory')}</option><option value="webdav">WebDAV</option><option value="google-drive">Google Drive</option><option value="alipan">阿里云盘 / AliPan</option><option value="baidu">百度网盘 / Baidu</option></select></div><div class="field"><label for="source-location">${t('sourceLocation')}</label><input id="source-location" required maxlength="2048" value="${esc(source?.location||'.')}"><p class="hint">${t('sourceLocationHint')}</p></div><div class="field"><label for="source-credential">${t('sourceCredential')}</label><input id="source-credential" pattern="FRAME_FLOW_SOURCE_[A-Z0-9_]+" value="${esc(source?.credential_env||'')}" placeholder="FRAME_FLOW_SOURCE_CLOUD"><p class="hint">${t('sourceCredentialHint')}</p></div><div class="field"><label for="source-orientation">${t('photoOrientation')}</label><select id="source-orientation">${source&&!source.orientation?`<option value="">${t('orientationLegacy')}</option>`:''}<option value="auto">${t('orientationAuto')}</option><option value="landscape">${t('orientationLandscape')}</option><option value="portrait">${t('orientationPortrait')}</option></select><p class="hint">${t('orientationHint')}</p></div><div class="field"><label class="check-row"><input id="source-organized" type="checkbox" ${source?.organized_only?'checked':''}>${t('organizedPhotos')}</label><label class="check-row"><input id="source-caption" type="checkbox" ${source?.folder_caption?'checked':''}>${t('folderCaption')}</label><label class="check-row"><input id="source-synology-previews" type="checkbox" ${source?.synology_previews?'checked':''}>${t('synologyPreviews')}</label><p class="hint">${t('folderCaptionHint')}</p></div><div class="field"><label class="check-row"><input id="source-people-filter" type="checkbox" ${source?.people_filter?'checked':''}>${t('peopleFilter')}</label><p class="hint">${t('peopleHint')}</p><button id="source-people-load" type="button">${t('choosePeople')}</button><div id="source-people-panel"><p class="hint">${t('peopleSelected')}${(source?.person_ids||[]).length}</p></div><label for="source-person-match">${t('personMatch')}</label><select id="source-person-match"><option value="any">${t('personAny')}</option><option value="all">${t('personAll')}</option></select></div><div class="field"><label class="check-row"><input id="source-recursive" type="checkbox" ${source?.recursive===false?'':'checked'}>${t('sourceRecursive')}</label><label class="check-row"><input id="source-enabled" type="checkbox" ${source?.enabled===false?'':'checked'}>${t('enabled')}</label></div><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('save')}</button></div></form>`);
$('#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(`<h2>${t(source?'edit':'addSource')}</h2><p class="notice">${t('sourceIndependentHelp')}</p><form id="photo-source-form"><div class="field"><label for="source-id">ID</label><input id="source-id" pattern="[a-z0-9-]{1,60}" required value="${esc(source?.id||'')}" ${source?'readonly':''}></div><div class="field"><label for="source-name">${t('name')}</label><input id="source-name" required maxlength="120" value="${esc(source?.name||'')}"></div><div class="field"><label for="source-kind">${t('sourceKind')}</label><select id="source-kind"><option value="local">${t('nasDirectory')}</option><option value="synology-photos">${t('synologyAPISource')}</option><option value="webdav">WebDAV</option><option value="google-drive">Google Drive</option><option value="alipan">阿里云盘 / AliPan</option><option value="baidu">百度网盘 / Baidu</option></select></div><div class="field"><label for="source-location">${t('sourceLocation')}</label><input id="source-location" required maxlength="2048" value="${esc(source?.location||'.')}"><p class="hint">${t('sourceLocationHint')}</p></div><div class="field"><label for="source-credential">${t('sourceCredential')}</label><input id="source-credential" pattern="FRAME_FLOW_SOURCE_[A-Z0-9_]+" value="${esc(source?.credential_env||'')}" placeholder="FRAME_FLOW_SOURCE_CLOUD"><p class="hint">${t('sourceCredentialHint')}</p></div><div class="field"><label for="source-orientation">${t('photoOrientation')}</label><select id="source-orientation">${source&&!source.orientation?`<option value="">${t('orientationLegacy')}</option>`:''}<option value="auto">${t('orientationAuto')}</option><option value="landscape">${t('orientationLandscape')}</option><option value="portrait">${t('orientationPortrait')}</option></select><p class="hint">${t('orientationHint')}</p></div><div class="field"><label class="check-row"><input id="source-organized" type="checkbox" ${source?.organized_only?'checked':''}>${t('organizedPhotos')}</label><label class="check-row"><input id="source-caption" type="checkbox" ${source?.folder_caption?'checked':''}>${t('folderCaption')}</label><label class="check-row"><input id="source-synology-previews" type="checkbox" ${source?.synology_previews?'checked':''}>${t('synologyPreviews')}</label><p class="hint">${t('folderCaptionHint')}</p></div><div class="field"><label class="check-row"><input id="source-people-filter" type="checkbox" ${source?.people_filter?'checked':''}>${t('peopleFilter')}</label><p class="hint">${t('peopleHint')}</p><button id="source-people-load" type="button">${t('choosePeople')}</button><div id="source-people-panel"><p class="hint">${t('peopleSelected')}${(source?.person_ids||[]).length}</p></div><label for="source-person-match">${t('personMatch')}</label><select id="source-person-match"><option value="any">${t('personAny')}</option><option value="all">${t('personAll')}</option></select></div><div class="field"><label class="check-row"><input id="source-recursive" type="checkbox" ${source?.recursive===false?'':'checked'}>${t('sourceRecursive')}</label><label class="check-row"><input id="source-enabled" type="checkbox" ${source?.enabled===false?'':'checked'}>${t('enabled')}</label></div><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('save')}</button></div></form>`);
$('#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;
+5
View File
@@ -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.'});
+1 -1
View File
@@ -1,3 +1,3 @@
<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="color-scheme" content="light dark"><title>Pigway Frame Flow</title><link rel="stylesheet" href="/ui/style.css"></head>
<body><aside class="sidebar"><a class="brand" href="/"><span class="brand-icon">F<span>↗</span></span><span>Frame Flow<small>Pigway · Smart Frame Automation</small></span></a><nav id="nav"></nav><div class="sidebar-bottom"><small>Version 0.1.8</small></div></aside><div class="workspace"><header><div><span class="eyebrow">PIGWAY FRAME FLOW</span><h1 id="page-title"></h1></div><div class="toolbar"><select id="device" aria-label="Device"></select><button id="lang" class="icon-button" aria-label="Change language">EN</button><button id="theme" class="icon-button" aria-label="Change theme">◐</button></div></header><main id="main"></main><footer id="footer"></footer></div><div id="toast" role="status" aria-live="polite"></div><dialog id="modal"><div id="modal-content"></div></dialog><script src="/ui/i18n.js"></script><script src="/ui/features.js"></script><script src="/ui/extensions.js"></script><script src="/ui/app.js"></script></body></html>
<body><aside class="sidebar"><a class="brand" href="/"><span class="brand-icon">F<span>↗</span></span><span>Frame Flow<small>Pigway · Smart Frame Automation</small></span></a><nav id="nav"></nav><div class="sidebar-bottom"><small>Version 0.1.9</small></div></aside><div class="workspace"><header><div><span class="eyebrow">PIGWAY FRAME FLOW</span><h1 id="page-title"></h1></div><div class="toolbar"><select id="device" aria-label="Device"></select><button id="lang" class="icon-button" aria-label="Change language">EN</button><button id="theme" class="icon-button" aria-label="Change theme">◐</button></div></header><main id="main"></main><footer id="footer"></footer></div><div id="toast" role="status" aria-live="polite"></div><dialog id="modal"><div id="modal-content"></div></dialog><script src="/ui/i18n.js"></script><script src="/ui/features.js"></script><script src="/ui/extensions.js"></script><script src="/ui/app.js"></script></body></html>
+1 -1
View File
@@ -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]
+83
View File
@@ -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()
+4
View File
@@ -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)});
+45
View File
@@ -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()
+112
View File
@@ -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()
+2
View File
@@ -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",
+344
View File
@@ -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"
)