Add read-only Synology Photos people filtering
This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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: '});
|
||||
|
||||
@@ -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)});
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user