Add read-only Synology Photos people filtering

This commit is contained in:
Pigway
2026-10-05 08:43:07 +08:00
parent 9e14adcb1f
commit f3d9a1ae97
12 changed files with 1405 additions and 5 deletions
+5
View File
@@ -23,3 +23,8 @@ BUILD_TIME=unknown
# Optional existing group with photo read permission; does not change NAS ACLs.
# NAS_PHOTOS_GID=101
# Optional Synology Photos shared-space people filter. Configure a pinned NAS endpoint.
# Login in the photo-source editor; only the session is saved in /config (mode 0600).
SYNOLOGY_PHOTOS_URL=
SYNOLOGY_PHOTOS_CERT_SHA256=
+16
View File
@@ -127,3 +127,19 @@ no thumbnails are generated on the NAS, and the ordinary size limit still applie
to the selected preview. The catalog marks these entries `provider_preview: true`.
If the preview is missing, the oversized original remains excluded. The folder
caption still comes from the original curated directory.
## Synology Photos 人物筛选 / People filtering
可选的只读集成使用 Synology Photos **共享空间**的人物识别结果,不运行新的识别模型,也不修改人物分组或照片。个人空间暂不支持。
1. 将共享图库(如 `/volume1/photo`)挂载为 `/photos`,保持只读。
2. 在 `.env` 配置 `SYNOLOGY_PHOTOS_URL=https://NAS_IP:9115` 和 `SYNOLOGY_PHOTOS_CERT_SHA256`(服务实际 TLS 证书的 SHA-256 指纹,从可信 NAS 配置核实)。重建容器后生效。指纹变化时必须重新核实;每次连接在发送登录信息前校验同一个 TLS socket 的证书。
3. 图库 → 编辑照片数据源 → 连接 / 选择人物。管理员输入 NAS 用户名、密码;只保存 session 到 `/config/synology-photos-session.json`(0600),不保存密码。会话过期后需要重新连接;断开连接删除本地 session。页面没有 password/token 回显。
4. 选择人物,启用筛选,选择“任意”或“同时包含所有”。默认只列出已命名人物,可搜索 ID、展开未命名分组。至少选择一个人物。
5. 与当前本地目录、日期文件夹规则取交集,再随机选图。原文件夹标题、日期、地点仍保留。不匹配返回空图库;预览 / 定时任务失败,不回退到全图库。
接口:`GET/POST/DELETE /api/v1/integrations/synology-photos`(管理员);`GET /api/v1/integrations/synology-photos/people`(管理员)。Source 新增 `people_filter`、`person_ids`、`person_match: any|all`。普通图片读取权限只能使用已配置数据源,不能登录或查询人物目录。
只允许 Auth login/logout、共享空间 Person.list、Item.list、Folder.get。人物照片按 500 条分页,成员路径缓存 15 分钟,源目录缓存 60 秒;显式刷新会重新查询。无后台高频轮询,不下载全图库原图。查询失败和登录失效明确报错。大型人物相册首次查询可能需要数十秒。
This optional integration reads the existing shared-space People catalog. It does not modify Photos or perform face inference. Login stores only a protected session, never the NAS password. Certificate pinning happens before credentials are sent. Select one or more people and match any/all, intersect with the existing curated folder rules, then randomize. Empty matches and expired sessions fail closed. Person membership caches for 15 minutes; explicit refresh re-queries. This improves relevance, not blur/closed-eye detection. Recognition can miss faces. Personal-space filtering and automatic photo-quality scoring are not implemented.
+683
View File
@@ -3061,6 +3061,572 @@
}
}
},
"/api/v1/integrations/synology-photos": {
"get": {
"tags": [
"Photo sources"
],
"summary": "Photos Connection",
"operationId": "photos_connection_api_v1_integrations_synology_photos_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PhotosConnection"
}
}
}
},
"400": {
"description": "Bad Request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"403": {
"description": "Forbidden",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"404": {
"description": "Not Found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"409": {
"description": "Conflict",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"413": {
"description": "Content Too Large",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"422": {
"description": "Unprocessable Content",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"429": {
"description": "Too Many Requests",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"500": {
"description": "Internal Server Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"502": {
"description": "Bad Gateway",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"503": {
"description": "Service Unavailable",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
},
"security": [
{
"HTTPBearer": []
},
{
"AccountSession": []
}
]
},
"post": {
"tags": [
"Photo sources"
],
"summary": "Connect Photos",
"operationId": "connect_photos_api_v1_integrations_synology_photos_post",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PhotosLogin"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PhotosConnection"
}
}
}
},
"400": {
"description": "Bad Request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"403": {
"description": "Forbidden",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"404": {
"description": "Not Found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"409": {
"description": "Conflict",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"413": {
"description": "Content Too Large",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"422": {
"description": "Unprocessable Content",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"429": {
"description": "Too Many Requests",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"500": {
"description": "Internal Server Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"502": {
"description": "Bad Gateway",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"503": {
"description": "Service Unavailable",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
},
"security": [
{
"HTTPBearer": []
},
{
"AccountSession": []
}
]
},
"delete": {
"tags": [
"Photo sources"
],
"summary": "Disconnect Photos",
"operationId": "disconnect_photos_api_v1_integrations_synology_photos_delete",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PhotosConnection"
}
}
}
},
"400": {
"description": "Bad Request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"403": {
"description": "Forbidden",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"404": {
"description": "Not Found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"409": {
"description": "Conflict",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"413": {
"description": "Content Too Large",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"422": {
"description": "Unprocessable Content",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"429": {
"description": "Too Many Requests",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"500": {
"description": "Internal Server Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"502": {
"description": "Bad Gateway",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"503": {
"description": "Service Unavailable",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
},
"security": [
{
"HTTPBearer": []
},
{
"AccountSession": []
}
]
}
},
"/api/v1/integrations/synology-photos/people": {
"get": {
"tags": [
"Photo sources"
],
"summary": "Photos People",
"operationId": "photos_people_api_v1_integrations_synology_photos_people_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/PhotosPerson"
},
"type": "array",
"title": "Response Photos People Api V1 Integrations Synology Photos People Get"
}
}
}
},
"400": {
"description": "Bad Request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"403": {
"description": "Forbidden",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"404": {
"description": "Not Found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"409": {
"description": "Conflict",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"413": {
"description": "Content Too Large",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"422": {
"description": "Unprocessable Content",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"429": {
"description": "Too Many Requests",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"500": {
"description": "Internal Server Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"502": {
"description": "Bad Gateway",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"503": {
"description": "Service Unavailable",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
},
"security": [
{
"HTTPBearer": []
},
{
"AccountSession": []
}
]
}
},
"/api/v1/photo-sources": {
"get": {
"tags": [
@@ -10436,6 +11002,28 @@
"title": "Folder Caption",
"default": false
},
"people_filter": {
"type": "boolean",
"title": "People Filter",
"default": false
},
"person_ids": {
"items": {
"type": "integer"
},
"type": "array",
"maxItems": 50,
"title": "Person Ids"
},
"person_match": {
"type": "string",
"enum": [
"any",
"all"
],
"title": "Person Match",
"default": "any"
},
"credential_env": {
"anyOf": [
{
@@ -10512,6 +11100,28 @@
"title": "Folder Caption",
"default": false
},
"people_filter": {
"type": "boolean",
"title": "People Filter",
"default": false
},
"person_ids": {
"items": {
"type": "integer"
},
"type": "array",
"maxItems": 50,
"title": "Person Ids"
},
"person_match": {
"type": "string",
"enum": [
"any",
"all"
],
"title": "Person Match",
"default": "any"
},
"credential_env": {
"anyOf": [
{
@@ -10539,6 +11149,79 @@
],
"title": "PhotoSourceView"
},
"PhotosConnection": {
"properties": {
"configured": {
"type": "boolean",
"title": "Configured"
},
"connected": {
"type": "boolean",
"title": "Connected"
}
},
"additionalProperties": false,
"type": "object",
"required": [
"configured",
"connected"
],
"title": "PhotosConnection"
},
"PhotosLogin": {
"properties": {
"username": {
"type": "string",
"maxLength": 120,
"minLength": 1,
"title": "Username"
},
"password": {
"type": "string",
"maxLength": 256,
"minLength": 1,
"format": "password",
"title": "Password",
"writeOnly": true
}
},
"additionalProperties": false,
"type": "object",
"required": [
"username",
"password"
],
"title": "PhotosLogin"
},
"PhotosPerson": {
"properties": {
"id": {
"type": "integer",
"title": "Id"
},
"name": {
"type": "string",
"title": "Name"
},
"count": {
"type": "integer",
"title": "Count",
"default": 0
},
"named": {
"type": "boolean",
"title": "Named",
"default": false
}
},
"additionalProperties": false,
"type": "object",
"required": [
"id",
"name"
],
"title": "PhotosPerson"
},
"Playlist": {
"properties": {
"device_id": {
+45
View File
@@ -63,6 +63,11 @@ from pigway_frame_flow.providers.photo_sources import (
PhotoSourceInput,
PhotoSourceView,
)
from pigway_frame_flow.providers.synology_photos import (
PhotosConnection,
PhotosLogin,
PhotosPerson,
)
from pigway_frame_flow.providers.weather import ConfigurableWeatherProvider
from pigway_frame_flow.renderers.images import Assets, convert
from pigway_frame_flow.renderers.templates import TemplateRenderer
@@ -543,6 +548,46 @@ def create_app(settings=None, start_worker=True):
def asset_file(asset_id: str):
return FileResponse(assets.path(asset_id), media_type="image/jpeg")
@app.get(
"/api/v1/integrations/synology-photos",
response_model=PhotosConnection,
tags=["Photo sources"],
dependencies=[Depends(require("admin"))],
)
def photos_connection():
return service.photo_sources.synology.status()
@app.post(
"/api/v1/integrations/synology-photos",
response_model=PhotosConnection,
tags=["Photo sources"],
dependencies=[Depends(require("admin"))],
)
def connect_photos(payload: PhotosLogin):
result = service.photo_sources.synology.connect(payload)
service.photo_sources.cache.clear()
return result
@app.delete(
"/api/v1/integrations/synology-photos",
response_model=PhotosConnection,
tags=["Photo sources"],
dependencies=[Depends(require("admin"))],
)
def disconnect_photos():
result = service.photo_sources.synology.disconnect()
service.photo_sources.cache.clear()
return result
@app.get(
"/api/v1/integrations/synology-photos/people",
response_model=list[PhotosPerson],
tags=["Photo sources"],
dependencies=[Depends(require("admin"))],
)
def photos_people():
return service.photo_sources.synology.people()
@app.get(
"/api/v1/photo-sources",
response_model=list[PhotoSourceView],
@@ -18,6 +18,7 @@ from pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.models import StrictModel
from pigway_frame_flow.providers.photo_names import path_caption
from pigway_frame_flow.providers.synology_photos import SynologyPhotos
from pigway_frame_flow.settings import secret
from pigway_frame_flow.storage.database import encode
@@ -32,6 +33,9 @@ class PhotoSourceInput(StrictModel):
synology_previews: bool = False
organized_only: bool = False
folder_caption: bool = False
people_filter: bool = False
person_ids: list[int] = Field(default_factory=list, max_length=50)
person_match: Literal["any", "all"] = "any"
credential_env: str | None = Field(
default=None, pattern=r"^FRAME_FLOW_SOURCE_[A-Z0-9_]+$"
)
@@ -62,6 +66,7 @@ class PhotoSources:
def __init__(self, db, settings, assets):
self.db, self.settings, self.assets = db, settings, assets
self.cache = {}
self.synology = SynologyPhotos(settings)
# Register provider secrets before request IDs or errors can be reflected.
for row in self.list():
if row["kind"] == "webdav" and row.get("credential_env"):
@@ -91,6 +96,19 @@ class PhotoSources:
return PhotoSourceInput.model_validate_json(row["payload"])
def save(self, source_id, source):
if source.people_filter and source.kind != "local":
raise FlowError(
E.INVALID_REQUEST,
"Person filtering requires a local Synology photo source",
)
if any(i <= 0 for i in source.person_ids) or len(set(source.person_ids)) != len(
source.person_ids
):
raise FlowError(
E.INVALID_REQUEST, "Person IDs must be unique positive integers"
)
if source.people_filter and not source.person_ids:
raise FlowError(E.INVALID_REQUEST, "Select at least one person")
if source.kind == "local":
self.local_directory(source.location)
if source.credential_env:
@@ -226,6 +244,11 @@ class PhotoSources:
cached = self.cache.get(source_id)
if cached and not refresh and time.monotonic() - cached[0] < 60:
return cached[1]
allowed = (
self.synology.paths(source.person_ids, source.person_match, refresh=refresh)
if source.people_filter
else None
)
items = []
if source.kind == "local":
directory = self.local_directory(source.location)
@@ -267,6 +290,8 @@ class PhotoSources:
if large and not preview:
continue
relative = path.relative_to(root).as_posix()
if allowed is not None and relative not in allowed:
continue
items.append(
{
"id": relative,
@@ -0,0 +1,263 @@
"""Optional, read-only Synology Photos person catalog with pinned TLS."""
import hashlib
import http.client
import json
import os
import ssl
import threading
import time
from urllib.parse import urlencode, urlsplit
from pydantic import Field, SecretStr
from pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.models import StrictModel
class PhotosLogin(StrictModel):
username: str = Field(min_length=1, max_length=120)
password: SecretStr = Field(min_length=1, max_length=256)
class PhotosConnection(StrictModel):
configured: bool
connected: bool
class PhotosPerson(StrictModel):
id: int
name: str
count: int = 0
named: bool = False
class SynologyPhotos:
def __init__(self, settings):
self.settings = settings
self.path = settings.config_dir / "synology-photos-session.json"
self.lock = threading.RLock()
self.cache = {}
if self.path.is_file():
try:
self.sid()
except FlowError:
pass
def endpoint(self):
url = urlsplit(os.getenv("SYNOLOGY_PHOTOS_URL", ""))
pin = os.getenv("SYNOLOGY_PHOTOS_CERT_SHA256", "").lower().replace(":", "")
if (
url.scheme != "https"
or not url.hostname
or url.username
or url.password
or url.query
or url.fragment
or url.path not in ("", "/")
or len(pin) != 64
or any(c not in "0123456789abcdef" for c in pin)
):
raise FlowError(
E.CONFIG_REQUIRED,
"Configure Synology Photos URL and certificate fingerprint",
503,
)
return url, pin
def status(self):
try:
self.endpoint()
configured = True
except FlowError:
configured = False
return {
"configured": configured,
"connected": configured and self.path.is_file(),
}
def request(self, api, method, version, **params):
# No arbitrary RPC forwarding or mutation methods, even for administrators.
if (api, method) not in {
("SYNO.API.Auth", "login"),
("SYNO.API.Auth", "logout"),
("SYNO.FotoTeam.Browse.Person", "list"),
("SYNO.FotoTeam.Browse.Item", "list"),
("SYNO.FotoTeam.Browse.Folder", "get"),
}:
raise FlowError(E.FORBIDDEN, "Unsupported Photos query", 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)
# Check the certificate on this exact socket BEFORE transmitting a secret.
data = urlencode(
{"api": api, "method": method, "version": version, **params}
)
connection.request(
"POST",
"/webapi/entry.cgi",
data,
{"Content-Type": "application/x-www-form-urlencoded"},
)
response = connection.getresponse()
raw = response.read(4 * 1024 * 1024 + 1)
if response.status != 200 or len(raw) > 4 * 1024 * 1024:
raise ValueError()
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":
self.disconnect()
raise FlowError(
E.CONFIG_REQUIRED, "Reconnect Synology Photos account", 409
)
raise FlowError(
E.CLOUD_UNAVAILABLE, "Synology Photos query was rejected", 502
)
return result.get("data", {})
except (OSError, ValueError, http.client.HTTPException):
raise FlowError(
E.CLOUD_UNAVAILABLE, "Synology Photos is unavailable", 502
) from None
finally:
connection.close()
def sid(self):
try:
sid = json.loads(self.path.read_text())["sid"]
if not isinstance(sid, str) or not sid:
raise ValueError()
except (OSError, ValueError, KeyError):
raise FlowError(
E.CONFIG_REQUIRED, "Connect Synology Photos account first", 409
) from None
if sid not in self.settings.additional_secrets:
self.settings.additional_secrets.append(sid)
return sid
def connect(self, payload):
password = payload.password.get_secret_value()
self.settings.additional_secrets.extend([payload.username, password])
with self.lock:
sid = self.request(
"SYNO.API.Auth",
"login",
7,
account=payload.username,
passwd=password,
session="SynologyPhotos",
format="sid",
)["sid"]
self.settings.additional_secrets.append(sid)
self.path.parent.mkdir(parents=True, exist_ok=True)
temporary = self.path.with_suffix(".tmp")
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w") as stream:
json.dump({"sid": sid}, stream)
os.chmod(temporary, 0o600)
temporary.replace(self.path)
self.cache.clear()
return self.status()
def disconnect(self):
with self.lock:
self.path.unlink(missing_ok=True)
self.cache.clear()
return self.status()
def pages(self, api, version, **params):
rows = []
for offset in range(0, 100000, 500):
page = self.request(
api,
"list",
version,
offset=offset,
limit=500,
_sid=self.sid(),
**params,
).get("list", [])
rows.extend(page)
if len(page) < 500:
return rows
raise FlowError(E.CONFLICT, "Photos catalog exceeds query limit", 409)
def people(self):
with self.lock:
rows = self.pages("SYNO.FotoTeam.Browse.Person", 3)
return [
{
"id": r["id"],
"name": r.get("name") or str(r["id"]),
"count": r.get("item_count", 0),
"named": bool(r.get("name")),
}
for r in rows
]
def paths(self, person_ids, mode, refresh=False):
key = (tuple(sorted(person_ids)), mode)
with self.lock:
cached = self.cache.get(key)
if cached and not refresh and time.monotonic() - cached[0] < 900:
return cached[1]
known = {p["id"] for p in self.people()}
ids = person_ids or sorted(known)
if not set(ids) <= known:
raise FlowError(E.INVALID_REQUEST, "Selected person no longer exists")
sets = []
folders = {}
for person in ids:
paths = set()
for row in self.pages(
"SYNO.FotoTeam.Browse.Item",
7,
person_id=person,
additional=json.dumps(["folder"]),
):
folder = row.get("additional", {}).get("folder", {})
if isinstance(folder, str):
name = folder
elif isinstance(folder, dict) and folder.get("name"):
name = folder["name"]
else:
folder_id = row.get("folder_id")
if folder_id is None:
raise FlowError(
E.CLOUD_UNAVAILABLE,
"Photos response has no folder identity",
502,
)
if folder_id not in folders:
folders[folder_id] = self.request(
"SYNO.FotoTeam.Browse.Folder",
"get",
2,
id=folder_id,
_sid=self.sid(),
)["folder"]["name"]
name = folders[folder_id]
if "filename" not in row:
raise FlowError(
E.CLOUD_UNAVAILABLE, "Photos response has no filename", 502
)
paths.add(name.strip("/") + "/" + row["filename"])
sets.append(paths)
selected = (
(set.intersection(*sets) if mode == "all" else set.union(*sets))
if sets
else set()
)
self.cache[key] = (time.monotonic(), selected)
return selected
+19 -5
View File
@@ -105,10 +105,24 @@ async function officialConnectionPanel(info){
try{render(await api(path));}catch{panel.textContent=zh?'官方账号连接由管理员管理':'Official account connection is managed by an administrator';}
}
async function photosPeoplePicker(selected){
const target=$('#source-people-panel'),path='/api/v1/integrations/synology-photos';
const status=await api(path);
if(!status.configured){target.innerHTML=`<p class="hint">${t('photosConfigure')}</p>`;return;}
if(!status.connected){
target.innerHTML=`<div class="field"><label for="photos-username">${t('username')}</label><input id="photos-username" autocomplete="username"></div><div class="field"><label for="photos-password">${t('password')}</label><input id="photos-password" type="password" autocomplete="current-password"></div><p class="hint">${t('photosSessionHint')}</p><button type="button" id="photos-connect">${t('photosConnect')}</button>`;
$('#photos-connect').onclick=busy(async()=>{const password=$('#photos-password').value;$('#photos-password').value='';await api(path,{method:'POST',body:{username:$('#photos-username').value,password}});await photosPeoplePicker(selected);});return;
}
const people=await api(path+'/people');
target.innerHTML=`<div class="field"><label for="people-search">${t('peopleSearch')}</label><input id="people-search" type="search"></div><label class="check-row"><input id="people-unnamed" type="checkbox">${t('peopleUnnamed')}</label><div id="people-list" style="max-height:260px;overflow:auto"></div><p id="people-selected" class="hint"></p><button type="button" id="photos-disconnect">${t('photosDisconnect')}</button>`;
const render=()=>{const query=$('#people-search').value.toLowerCase();const rows=people.filter(p=>(p.named||selected.has(p.id)||$('#people-unnamed').checked)&&(p.name.toLowerCase().includes(query)||String(p.id).includes(query)));$('#people-list').innerHTML=rows.map(p=>`<label class="check-row"><input type="checkbox" data-person="${p.id}" ${selected.has(p.id)?'checked':''}>${esc(p.name)} · ${p.count}</label>`).join('');$('#people-selected').textContent=t('peopleSelected')+selected.size;$('#people-list').querySelectorAll('[data-person]').forEach(box=>box.onchange=()=>{const id=Number(box.dataset.person);box.checked?selected.add(id):selected.delete(id);$('#people-selected').textContent=t('peopleSelected')+selected.size;});};
$('#people-search').oninput=render;$('#people-unnamed').onchange=render;$('#photos-disconnect').onclick=busy(async()=>{await api(path,{method:'DELETE'});await photosPeoplePicker(selected);});render();
}
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><div class="source-list">${sources.length?sources.map(s=>`<article class="source-card"><h4>${esc(s.name)}</h4><p class="hint">${esc(s.kind)} · ${t(s.enabled?'enabled':'disabled')}</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><div class="source-list">${sources.length?sources.map(s=>`<article class="source-card"><h4>${esc(s.name)}</h4><p class="hint">${esc(s.kind)} · ${t(s.enabled?'enabled':'disabled')}${s.people_filter?' · '+t('peopleFilter'):''}</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>`;
bind('add-photo-source',()=>photoSourceForm());
section.querySelectorAll('[data-source-edit]').forEach(b=>b.onclick=()=>photoSourceForm(sources.find(s=>s.id===b.dataset.sourceEdit)));
section.querySelectorAll('[data-source-delete]').forEach(b=>b.onclick=()=>confirmDialog(t('removeConfirm'),async()=>{await api('/api/v1/photo-sources/'+b.dataset.sourceDelete,{method:'DELETE'});await navigate('gallery');}));
@@ -116,10 +130,10 @@ async function photoSourcesPanel(){
section.querySelectorAll('[data-source-browse]').forEach(b=>b.onclick=busy(()=>browseSource(b.dataset.sourceBrowse)));
section.querySelectorAll('[data-source-job]').forEach(b=>b.onclick=()=>sourceJobForm(b.dataset.sourceJob));
}
function photoSourceForm(source=null){
showModal(`<h2>${t(source?'edit':'addSource')}</h2><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></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 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-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-kind').value=source?.kind||'local';const update=()=>{const dav=$('#source-kind').value==='webdav';$('#source-credential').disabled=!dav;$('#source-recursive').disabled=dav;$('#source-synology-previews').disabled=dav;};update();$('#source-kind').onchange=update;
$('#photo-source-form').onsubmit=busy(async e=>{e.preventDefault();const dav=$('#source-kind').value==='webdav';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:!dav&&$('#source-recursive').checked,synology_previews:!dav&&$('#source-synology-previews').checked,catalog_limit:$('#source-organized').checked?100000:(source?.catalog_limit||10000),organized_only:$('#source-organized').checked,folder_caption:$('#source-caption').checked,enabled:$('#source-enabled').checked,credential_env:dav?($('#source-credential').value||null):null}});$('#modal').close();await navigate('gallery');});
async function photoSourceForm(source=null){
showModal(`<h2>${t(source?'edit':'addSource')}</h2><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></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 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>`);
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==='webdav';$('#source-credential').disabled=!dav;$('#source-recursive').disabled=dav;$('#source-synology-previews').disabled=dav;$('#source-people-filter').disabled=dav;$('#source-people-load').disabled=dav;};update();$('#source-kind').onchange=update;
$('#photo-source-form').onsubmit=busy(async e=>{e.preventDefault();const dav=$('#source-kind').value==='webdav';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:!dav&&$('#source-recursive').checked,synology_previews:!dav&&$('#source-synology-previews').checked,catalog_limit:$('#source-organized').checked?100000:(source?.catalog_limit||10000),organized_only:$('#source-organized').checked,folder_caption:$('#source-caption').checked,people_filter:!dav&&$('#source-people-filter').checked,person_ids:[...selectedPeople],person_match:$('#source-person-match').value,enabled:$('#source-enabled').checked,credential_env:dav?($('#source-credential').value||null):null}});$('#modal').close();await navigate('gallery');});
}
async function browseSource(id,offset=0,refresh=false){
const result=await api('/api/v1/photo-sources/'+id+'/images?offset='+offset+'&limit=50&refresh='+refresh);const box=$('#source-browser');if(!box)return;
+3
View File
@@ -24,3 +24,6 @@ Object.assign(MESSAGES.zh,{organizedPhotos:'仅日期开头文件夹中的照片
Object.assign(MESSAGES.en,{organizedPhotos:'Only photos in date-prefixed folders',folderCaption:'Show folder caption below the photo',folderCaptionHint:'Line 1: title without date/place. Line 2: date. Line 3: place after @, omitted when absent. Subfolders inherit their nearest dated folder.'});
Object.assign(MESSAGES.zh,{synologyPreviews:'超大图片使用 Synology Photos 的现有预览图'});Object.assign(MESSAGES.en,{synologyPreviews:'Use existing Synology Photos previews for oversized images'});
Object.assign(MESSAGES.zh,{peopleFilter:'按 Synology Photos 人物筛选',peopleHint:'只在当前目录和所选人物的交集中选图;不会退回全图库。需要共享空间人物相册授权。',choosePeople:'连接 / 选择人物',personMatch:'人物匹配方式',personAny:'包含任意所选人物',personAll:'同时包含所有所选人物',photosConfigure:'管理员需配置 SYNOLOGY_PHOTOS_URL 和 SYNOLOGY_PHOTOS_CERT_SHA256,并将共享 photo 目录挂载为照片根目录。',photosSessionHint:'仅保存受保护的登录会话,不保存 NAS 密码。会话失效后需要重新连接。',photosConnect:'连接 Synology Photos',photosDisconnect:'断开连接',peopleSearch:'搜索人物名称或 ID',peopleUnnamed:'显示未命名人物分组',peopleSelected:'已选择:'});
Object.assign(MESSAGES.en,{peopleFilter:'Filter by Synology Photos people',peopleHint:'Choose only from the intersection of this folder and selected people. Requires shared-space People access; never falls back to the full catalog.',choosePeople:'Connect / choose people',personMatch:'Person matching',personAny:'Any selected person',personAll:'All selected people together',photosConfigure:'Admin must configure SYNOLOGY_PHOTOS_URL and SYNOLOGY_PHOTOS_CERT_SHA256, and mount the shared photo folder as the photo root.',photosSessionHint:'Only a protected login session is stored, never your NAS password. Reconnect when the session expires.',photosConnect:'Connect Synology Photos',photosDisconnect:'Disconnect',peopleSearch:'Search person name or ID',peopleUnnamed:'Show unnamed person groups',peopleSelected:'Selected: '});
+18
View File
@@ -0,0 +1,18 @@
/* Mock person metadata only. No NAS or device writes. */
const assert=require('assert'),{chromium}=require('playwright');
(async()=>{
const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});
const page=await browser.newPage();let checks=0;const id='ui-people-'+Date.now();
const errors=[];page.on('pageerror',e=>errors.push(e.message));
await page.route('**/api/v1/integrations/synology-photos',route=>route.fulfill({json:{configured:true,connected:true}}));
await page.route('**/api/v1/integrations/synology-photos/people',route=>route.fulfill({json:[{id:1,name:'Family A',count:20,named:true},{id:2,name:'Family B',count:15,named:true},{id:3,name:'3',count:2,named:false}]}));
try{
await page.goto('http://127.0.0.1:18787');await page.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await page.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await page.locator('#login-form button').click();await page.locator('.hero').waitFor();assert(await page.evaluate(()=>state.settings.mock));
for(const lang of ['zh','en'])for(const width of [1440,390])for(const theme of ['dark','light']){
await page.setViewportSize({width,height:1000});await page.evaluate(({lang,theme})=>{state.lang=lang;document.documentElement.dataset.theme=theme;shell();},{lang,theme});await page.locator('[data-page="gallery"]').click();await page.locator('#add-photo-source').click();await page.locator('#source-id').fill(id);await page.locator('#source-name').fill('People fixture');await page.locator('#source-people-load').click();await page.locator('[data-person="1"]').waitFor();assert(await page.locator('[data-person]').count()===2);await page.locator('#people-unnamed').check();assert(await page.locator('[data-person]').count()===3);await page.locator('#people-search').fill('Family B');assert(await page.locator('[data-person]').count()===1);await page.locator('[data-person="2"]').check();await page.locator('#source-people-filter').check();await page.locator('#source-person-match').selectOption('all');assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));
if(checks===0){await page.locator('#photo-source-form button[type="submit"]').click();await page.locator(`[data-source-edit="${id}"]`).waitFor();const saved=await page.evaluate(async id=>(await api('/api/v1/photo-sources')).find(s=>s.id===id),id);assert(saved.people_filter&&saved.person_match==='all'&&saved.person_ids.join(',')==='2');await page.locator(`[data-source-edit="${id}"]`).click();assert(await page.locator('#source-people-filter').isChecked());assert(await page.locator('#source-person-match').inputValue()==='all');}
await page.locator('#modal-cancel').click();checks++;
}
assert.deepStrictEqual(errors,[]);console.log('People picker: '+checks+' profiles passed; search, unnamed toggle, selection, persistence, no overflow or JS errors');
}finally{await page.evaluate(async id=>{if((await api('/api/v1/photo-sources')).some(s=>s.id===id))await api('/api/v1/photo-sources/'+id,{method:'DELETE'});},id);await browser.close();}
})().catch(e=>{console.error(e);process.exit(1)});
+60
View File
@@ -317,6 +317,66 @@
"503"
]
},
"/api/v1/integrations/synology-photos": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
],
"post": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
],
"delete": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/integrations/synology-photos/people": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/photo-sources": {
"get": [
"200",
+117
View File
@@ -852,6 +852,28 @@
"title": "Folder Caption",
"default": false
},
"people_filter": {
"type": "boolean",
"title": "People Filter",
"default": false
},
"person_ids": {
"items": {
"type": "integer"
},
"type": "array",
"maxItems": 50,
"title": "Person Ids"
},
"person_match": {
"type": "string",
"enum": [
"any",
"all"
],
"title": "Person Match",
"default": "any"
},
"credential_env": {
"anyOf": [
{
@@ -928,6 +950,28 @@
"title": "Folder Caption",
"default": false
},
"people_filter": {
"type": "boolean",
"title": "People Filter",
"default": false
},
"person_ids": {
"items": {
"type": "integer"
},
"type": "array",
"maxItems": 50,
"title": "Person Ids"
},
"person_match": {
"type": "string",
"enum": [
"any",
"all"
],
"title": "Person Match",
"default": "any"
},
"credential_env": {
"anyOf": [
{
@@ -1029,5 +1073,78 @@
"catalog_limit"
],
"title": "PhotoCatalog"
},
"PhotosLogin": {
"properties": {
"username": {
"type": "string",
"maxLength": 120,
"minLength": 1,
"title": "Username"
},
"password": {
"type": "string",
"maxLength": 256,
"minLength": 1,
"format": "password",
"title": "Password",
"writeOnly": true
}
},
"additionalProperties": false,
"type": "object",
"required": [
"username",
"password"
],
"title": "PhotosLogin"
},
"PhotosConnection": {
"properties": {
"configured": {
"type": "boolean",
"title": "Configured"
},
"connected": {
"type": "boolean",
"title": "Connected"
}
},
"additionalProperties": false,
"type": "object",
"required": [
"configured",
"connected"
],
"title": "PhotosConnection"
},
"PhotosPerson": {
"properties": {
"id": {
"type": "integer",
"title": "Id"
},
"name": {
"type": "string",
"title": "Name"
},
"count": {
"type": "integer",
"title": "Count",
"default": 0
},
"named": {
"type": "boolean",
"title": "Named",
"default": false
}
},
"additionalProperties": false,
"type": "object",
"required": [
"id",
"name"
],
"title": "PhotosPerson"
}
}
+151
View File
@@ -0,0 +1,151 @@
import json
import stat
import pytest
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.providers.photo_sources import PhotoSourceInput
from pigway_frame_flow.providers.synology_photos import PhotosLogin, SynologyPhotos
def test_connection_stores_session_only(settings, monkeypatch):
p = SynologyPhotos(settings)
monkeypatch.setenv("SYNOLOGY_PHOTOS_URL", "https://nas.test:9115")
monkeypatch.setenv("SYNOLOGY_PHOTOS_CERT_SHA256", "a" * 64)
monkeypatch.setattr(p, "request", lambda *args, **kwargs: {"sid": "test-session"})
assert p.connect(PhotosLogin(username="way", password="test-password"))["connected"]
assert json.loads(p.path.read_text()) == {"sid": "test-session"}
assert stat.S_IMODE(p.path.stat().st_mode) == 0o600
assert "test-password" not in p.path.read_text()
assert p.sid() == "test-session"
assert (
settings.sanitize({"message": "test-password test-session"})["message"]
== "[REDACTED] [REDACTED]"
)
assert not p.disconnect()["connected"]
def test_certificate_checked_before_password(settings, monkeypatch):
monkeypatch.setenv("SYNOLOGY_PHOTOS_URL", "https://nas.test:9115")
monkeypatch.setenv("SYNOLOGY_PHOTOS_CERT_SHA256", "a" * 64)
sent = []
class Socket:
def getpeercert(self, **kwargs):
return b"wrong-cert"
class Connection:
sock = Socket()
def __init__(self, *args, **kwargs):
pass
def connect(self):
pass
def request(self, *args):
sent.append(args)
def close(self):
pass
monkeypatch.setattr("http.client.HTTPSConnection", Connection)
with pytest.raises(FlowError) as e:
SynologyPhotos(settings).connect(
PhotosLogin(username="way", password="test-password")
)
assert e.value.status == 403 and sent == []
def test_mutating_rpc_blocked(settings):
with pytest.raises(FlowError) as e:
SynologyPhotos(settings).request("SYNO.FotoTeam.Browse.Item", "delete", 7)
assert e.value.status == 403
@pytest.mark.parametrize(
"mode,expected",
[
("any", {"20240101 Day/a.jpg", "20240101 Day/b.jpg"}),
("all", {"20240101 Day/b.jpg"}),
],
)
def test_person_paths_intersection_and_cache(settings, monkeypatch, mode, expected):
p = SynologyPhotos(settings)
monkeypatch.setattr(p, "people", lambda: [{"id": 1}, {"id": 2}])
calls = []
def pages(*args, **kwargs):
calls.append(kwargs)
names = ["a.jpg", "b.jpg"] if kwargs["person_id"] == 1 else ["b.jpg"]
return [
{"filename": n, "additional": {"folder": "/20240101 Day"}} for n in names
]
monkeypatch.setattr(p, "pages", pages)
assert p.paths([1, 2], mode) == expected
assert p.paths([2, 1], mode) == expected
assert len(calls) == 2
with pytest.raises(FlowError):
p.paths([999], mode)
def test_missing_folder_fails_closed(settings, monkeypatch):
p = SynologyPhotos(settings)
monkeypatch.setattr(p, "people", lambda: [{"id": 1}])
monkeypatch.setattr(p, "pages", lambda *a, **k: [{"filename": "a.jpg"}])
with pytest.raises(FlowError):
p.paths([1], "any")
def test_source_people_filter_and_no_fallback(service, settings, monkeypatch):
from PIL import Image
settings.photo_root = settings.data_dir.parent / "photos"
for name in ["20240101 Day/a.jpg", "20240101 Day/b.jpg", "backup/c.jpg"]:
path = settings.photo_root / name
path.parent.mkdir(parents=True, exist_ok=True)
Image.new("RGB", (10, 10)).save(path)
source = PhotoSourceInput(
name="People",
kind="local",
location=".",
organized_only=True,
folder_caption=True,
people_filter=True,
person_ids=[1],
)
p = service.photo_sources
p.save("people", source)
monkeypatch.setattr(
p.synology, "paths", lambda *a, **k: {"20240101 Day/b.jpg", "backup/c.jpg"}
)
rows = p.catalog("people")
assert len(rows) == 1 and rows[0]["id"] == "20240101 Day/b.jpg"
assert rows[0]["caption"]["date"] == "2024-01-01"
monkeypatch.setattr(p.synology, "paths", lambda *a, **k: set())
assert p.catalog("people", refresh=True) == []
with pytest.raises(FlowError):
p.read("people")
with pytest.raises(FlowError):
p.save("empty", source.model_copy(update={"person_ids": []}))
with pytest.raises(FlowError):
p.save("dav", source.model_copy(update={"kind": "webdav"}))
def test_people_api_auth_and_secret_response(client, service, monkeypatch):
p = service.photo_sources.synology
monkeypatch.setattr(
p, "connect", lambda body: {"configured": True, "connected": True}
)
r = client.post(
"/api/v1/integrations/synology-photos",
json={"username": "way", "password": "test-password"},
)
assert r.status_code == 200 and "password" not in r.text
r = client.post(
"/api/v1/integrations/synology-photos", json={"username": "way", "password": ""}
)
assert r.status_code == 422 and "input" not in r.text
client.headers.pop("Authorization")
assert client.get("/api/v1/integrations/synology-photos/people").status_code == 401