${esc(s.name)}
${esc(s.kind)} · ${t(s.enabled?'enabled':'disabled')}
diff --git a/.env.example b/.env.example index 5c837b2..f84668e 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/docs/PHOTO_SOURCES.md b/docs/PHOTO_SOURCES.md index b13323f..dd275c1 100644 --- a/docs/PHOTO_SOURCES.md +++ b/docs/PHOTO_SOURCES.md @@ -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. diff --git a/docs/openapi.json b/docs/openapi.json index 86b16ec..e74fee6 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -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": { diff --git a/pigway_frame_flow/api/app.py b/pigway_frame_flow/api/app.py index 198970d..ff19cd8 100644 --- a/pigway_frame_flow/api/app.py +++ b/pigway_frame_flow/api/app.py @@ -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], diff --git a/pigway_frame_flow/providers/photo_sources.py b/pigway_frame_flow/providers/photo_sources.py index 497b18a..5e6648b 100644 --- a/pigway_frame_flow/providers/photo_sources.py +++ b/pigway_frame_flow/providers/photo_sources.py @@ -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, diff --git a/pigway_frame_flow/providers/synology_photos.py b/pigway_frame_flow/providers/synology_photos.py new file mode 100644 index 0000000..4ccdd8a --- /dev/null +++ b/pigway_frame_flow/providers/synology_photos.py @@ -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 diff --git a/pigway_frame_flow/web/features.js b/pigway_frame_flow/web/features.js index f5d64a2..c84b112 100644 --- a/pigway_frame_flow/web/features.js +++ b/pigway_frame_flow/web/features.js @@ -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=`
${t('photosConfigure')}
`;return;} + if(!status.connected){ + target.innerHTML=`${t('photosSessionHint')}
`; + $('#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=``; + 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=>``).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=`${t('sourceHint')}
${esc(s.kind)} · ${t(s.enabled?'enabled':'disabled')}
${t('noSources')}
`}${t('sourceHint')}
${esc(s.kind)} · ${t(s.enabled?'enabled':'disabled')}${s.people_filter?' · '+t('peopleFilter'):''}
${t('noSources')}
`}