From f5ab1ade77067a13edd068103f3c4cd733525224 Mon Sep 17 00:00:00 2001 From: Pigway Date: Mon, 5 Oct 2026 00:52:39 +0800 Subject: [PATCH] Add read-only NAS and WebDAV photo sources with random scheduling --- .env.example | 6 + README.md | 7 + deploy/docker-compose.photos.yml | 12 + docs/PHOTO_SOURCES.md | 85 + docs/openapi.json | 4448 ++++++++++++++++++ pigway_frame_flow/api/app.py | 59 +- pigway_frame_flow/display/service.py | 8 + pigway_frame_flow/models.py | 10 +- pigway_frame_flow/providers/photo_sources.py | 335 ++ pigway_frame_flow/renderers/images.py | 14 +- pigway_frame_flow/settings.py | 3 + pigway_frame_flow/web/features.js | 37 +- pigway_frame_flow/web/i18n.js | 3 + pigway_frame_flow/web/style.css | 2 + scripts/ui-photo-sources.cjs | 3 + tests/contract.json | 210 +- tests/schema-contract.json | 214 + tests/test_external_photo_sources.py | 206 + 18 files changed, 5578 insertions(+), 84 deletions(-) create mode 100644 deploy/docker-compose.photos.yml create mode 100644 docs/PHOTO_SOURCES.md create mode 100644 pigway_frame_flow/providers/photo_sources.py create mode 100644 scripts/ui-photo-sources.cjs create mode 100644 tests/test_external_photo_sources.py diff --git a/.env.example b/.env.example index 22151f7..793ee5b 100644 --- a/.env.example +++ b/.env.example @@ -14,3 +14,9 @@ CORS_ALLOWED_ORIGINS= IMAGE_URL_ALLOWED_HOSTS= BUILD_COMMIT=development BUILD_TIME=unknown + +# Optional read-only NAS photo mount; use deploy/docker-compose.photos.yml. +# NAS_PHOTOS_DIR=/volume2/photo +# PHOTO_ROOT=/photos +# Optional WebDAV credential reference; prefer a secret file under /config. +# FRAME_FLOW_SOURCE_CLOUD_FILE=/config/webdav-cloud.secret diff --git a/README.md b/README.md index a298a49..b197f4a 100644 --- a/README.md +++ b/README.md @@ -230,3 +230,10 @@ Login, device discovery, local association and disconnect are available through `/api/v1/devices/{device_id}/official/connection`. Passwords are never persisted; server-side sessions are stored as restricted secret files. No official widget mutation or other extra RPC write is enabled by connecting. + +### Photo sources / 照片数据源 + +Gallery supports read-only NAS folders and HTTPS WebDAV sources, random photo +preview, and device-bound scheduled random photos. Source definitions are persisted +in SQLite. See [setup and limits](docs/PHOTO_SOURCES.md). +NAS 本地目录需要显式只读挂载;网盘先支持通用 WebDAV,也可使用同步到 NAS 的目录。 diff --git a/deploy/docker-compose.photos.yml b/deploy/docker-compose.photos.yml new file mode 100644 index 0000000..a062efd --- /dev/null +++ b/deploy/docker-compose.photos.yml @@ -0,0 +1,12 @@ +# Opt-in read-only photo mount. Set NAS_PHOTOS_DIR to the actual NAS folder. +services: + pigway-frame-flow: + environment: + PHOTO_ROOT: /photos + volumes: + - type: bind + source: ${NAS_PHOTOS_DIR:?Set NAS_PHOTOS_DIR to your existing photo directory} + target: /photos + read_only: true + bind: + create_host_path: false diff --git a/docs/PHOTO_SOURCES.md b/docs/PHOTO_SOURCES.md new file mode 100644 index 0000000..0470161 --- /dev/null +++ b/docs/PHOTO_SOURCES.md @@ -0,0 +1,85 @@ +# Photo sources + +Photo sources are independent read-only providers. They never call a frame driver. +SQLite persists source definitions and Scheduler jobs; passwords are not stored there. +The shared DisplayService handles fitting, upload, idempotency, overrides and restore. + +## NAS local folder + +Add the actual existing photo directory to `.env`: + +```dotenv +NAS_PHOTOS_DIR=/volume2/photo +``` + +The path above is an example, not a detected directory. Enable the optional mount: + +```sh +docker compose -f docker-compose.yml -f deploy/docker-compose.photos.yml config -q +docker compose -f docker-compose.yml -f deploy/docker-compose.photos.yml up -d +``` + +The folder is mounted **read-only** at `/photos`. The existing container user must +already have read/traverse permission. Frame Flow does not change NAS permissions. +Missing host folders fail explicitly rather than being created automatically. + +Open **Gallery → Photo sources → Add source**, choose **NAS local folder**, +enter a relative folder (`.` for root or `family` for a subfolder). +Supported formats: JPEG, PNG, WebP. Optional recursion excludes hidden directories, +Synology `@eaDir` thumbnail trees, and symlinks. Original files remain unchanged. + +## WebDAV + +Open Settings and allow the server hostname under Image URL hosts. +Add a WebDAV source using a plain HTTPS directory URL on port 443. +The implementation uses read-only `PROPFIND Depth: 1` and `GET`, following +[RFC 4918](https://www.rfc-editor.org/rfc/rfc4918#section-9.1). +This version lists images immediately inside one folder; recursive WebDAV traversal +is not supported. Basic authentication is supported; OAuth, SSO and Digest are not. +Redirects, private-address endpoints and URL-embedded credentials are rejected. + +For authentication, reference an environment variable such as +`FRAME_FLOW_SOURCE_CLOUD`; set its value to a JSON object containing `username` +and `password`, or set `FRAME_FLOW_SOURCE_CLOUD_FILE` to a mounted Secret file. +Compose's env_file supplies the variable; a Secret file belongs in the existing +`/config` mount with restricted permissions and container read access. Never commit +credentials. The UI/API stores only the variable name. + +## Use + +- **Browse photos** returns paginated metadata (50 items/page), without fetching originals. +- **Preview random photo** imports one selected photo into local Frame Flow storage; + the frame remains unchanged. Display uses that exact previewed asset. +- **Schedule random photos** creates a device-bound Cron job, paused by default. + Each execution picks a random photo; ordinary random selection can repeat. + Editing the job supports other schedule types, fitting/profile via advanced JSON, + and the existing temporary override/restore options. +- Sources can be edited, disabled or removed. Remove referencing jobs before deletion. + +Metadata is cached for 60 seconds, with explicit refresh. Each catalog is limited +to 10,000 supported images (not a complete inventory for larger folders). +Maximum selected image size is 20 MiB; WebDAV XML responses are limited to 2 MiB. +Preview imports use the existing local asset store; monitor disk space over time. +Unreadable/invalid images fail the operation without retrying frame writes. + +## API + +- `GET /api/v1/photo-sources` +- `PUT /api/v1/photo-sources/{id}` (admin) +- `DELETE /api/v1/photo-sources/{id}` (admin) +- `GET /api/v1/photo-sources/{id}/images?offset=0&limit=50&refresh=false` + +Display or Scheduler content: + +```json +{"type":"photo_source","source_id":"family","fit":"contain","profile":"color"} +``` + +Optional `item_id` selects an exact catalog item. Omit it for random selection. +The device upload endpoint accepts the same content with `display_now=false` +(default) to import/preview locally. `POST /api/v1/display` performs the usual +protected asynchronous device display. Schemas are in `/openapi.json` and `/docs`. + +Native Aliyun Drive/Baidu Drive/Google Drive adapters are not implemented. +A compatible WebDAV gateway or an independently managed NAS sync folder can be used. +Frame Flow does not install a gateway or receive vendor API credentials automatically. diff --git a/docs/openapi.json b/docs/openapi.json index f8d14ce..75736a2 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -5,6 +5,551 @@ "version": "0.1.0" }, "paths": { + "/api/v1/auth/login": { + "post": { + "tags": [ + "Accounts" + ], + "summary": "Login", + "operationId": "login_api_v1_auth_login_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Login" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountView" + } + } + } + }, + "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" + } + } + } + } + } + } + }, + "/api/v1/auth/me": { + "get": { + "tags": [ + "Accounts" + ], + "summary": "Me", + "operationId": "me_api_v1_auth_me_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountView" + } + } + } + }, + "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": [ + { + "AccountSession": [] + } + ] + } + }, + "/api/v1/auth/logout": { + "post": { + "tags": [ + "Accounts" + ], + "summary": "Logout", + "operationId": "logout_api_v1_auth_logout_post", + "responses": { + "204": { + "description": "Successful Response" + }, + "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": [ + { + "AccountSession": [] + } + ] + } + }, + "/api/v1/auth/password": { + "put": { + "tags": [ + "Accounts" + ], + "summary": "Change Password", + "operationId": "change_password_api_v1_auth_password_put", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasswordChange" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Successful Response" + }, + "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": [ + { + "AccountSession": [] + } + ] + } + }, "/health": { "get": { "tags": [ @@ -534,6 +1079,9 @@ "security": [ { "HTTPBearer": [] + }, + { + "AccountSession": [] } ] } @@ -548,6 +1096,9 @@ "security": [ { "HTTPBearer": [] + }, + { + "AccountSession": [] } ], "parameters": [ @@ -705,6 +1256,9 @@ "security": [ { "HTTPBearer": [] + }, + { + "AccountSession": [] } ], "parameters": [ @@ -856,6 +1410,9 @@ "security": [ { "HTTPBearer": [] + }, + { + "AccountSession": [] } ], "parameters": [ @@ -1024,6 +1581,9 @@ "security": [ { "HTTPBearer": [] + }, + { + "AccountSession": [] } ], "parameters": [ @@ -1188,6 +1748,9 @@ "security": [ { "HTTPBearer": [] + }, + { + "AccountSession": [] } ], "parameters": [ @@ -1352,6 +1915,9 @@ "security": [ { "HTTPBearer": [] + }, + { + "AccountSession": [] } ], "parameters": [ @@ -1525,6 +2091,9 @@ "security": [ { "HTTPBearer": [] + }, + { + "AccountSession": [] } ], "parameters": [ @@ -1689,6 +2258,9 @@ "security": [ { "HTTPBearer": [] + }, + { + "AccountSession": [] } ], "parameters": [ @@ -1739,6 +2311,9 @@ }, { "$ref": "#/components/schemas/URLContent" + }, + { + "$ref": "#/components/schemas/PhotoSourceContent" } ], "title": "Payload" @@ -2012,6 +2587,9 @@ "security": [ { "HTTPBearer": [] + }, + { + "AccountSession": [] } ] } @@ -2026,6 +2604,9 @@ "security": [ { "HTTPBearer": [] + }, + { + "AccountSession": [] } ], "parameters": [ @@ -2040,6 +2621,21 @@ "default": 100, "title": "Limit" } + }, + { + "name": "source", + "in": "query", + "required": false, + "schema": { + "enum": [ + "all", + "uploaded", + "generated" + ], + "type": "string", + "default": "all", + "title": "Source" + } } ], "responses": { @@ -2180,6 +2776,9 @@ "security": [ { "HTTPBearer": [] + }, + { + "AccountSession": [] } ], "parameters": [ @@ -2318,6 +2917,9 @@ "security": [ { "HTTPBearer": [] + }, + { + "AccountSession": [] } ], "parameters": [ @@ -2459,6 +3061,633 @@ } } }, + "/api/v1/photo-sources": { + "get": { + "tags": [ + "Photo sources" + ], + "summary": "Photo Sources", + "operationId": "photo_sources_api_v1_photo_sources_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/PhotoSourceView" + }, + "type": "array", + "title": "Response Photo Sources Api V1 Photo Sources 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/{source_id}": { + "put": { + "tags": [ + "Photo sources" + ], + "summary": "Save Photo Source", + "operationId": "save_photo_source_api_v1_photo_sources__source_id__put", + "security": [ + { + "HTTPBearer": [] + }, + { + "AccountSession": [] + } + ], + "parameters": [ + { + "name": "source_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Source Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PhotoSourceInput" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PhotoSourceView" + } + } + } + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Gateway" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + } + }, + "delete": { + "tags": [ + "Photo sources" + ], + "summary": "Delete Photo Source", + "operationId": "delete_photo_source_api_v1_photo_sources__source_id__delete", + "security": [ + { + "HTTPBearer": [] + }, + { + "AccountSession": [] + } + ], + "parameters": [ + { + "name": "source_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Source Id" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Gateway" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + } + } + }, + "/api/v1/photo-sources/{source_id}/images": { + "get": { + "tags": [ + "Photo sources" + ], + "summary": "Source Images", + "operationId": "source_images_api_v1_photo_sources__source_id__images_get", + "security": [ + { + "HTTPBearer": [] + }, + { + "AccountSession": [] + } + ], + "parameters": [ + { + "name": "source_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Source Id" + } + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 0, + "default": 0, + "title": "Offset" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 200, + "minimum": 1, + "default": 50, + "title": "Limit" + } + }, + { + "name": "refresh", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false, + "title": "Refresh" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PhotoCatalog" + } + } + } + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Gateway" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + } + } + }, "/api/v1/devices/{device_id}/images/{image_id}": { "delete": { "tags": [ @@ -2469,6 +3698,9 @@ "security": [ { "HTTPBearer": [] + }, + { + "AccountSession": [] } ], "parameters": [ @@ -2651,6 +3883,9 @@ "security": [ { "HTTPBearer": [] + }, + { + "AccountSession": [] } ], "parameters": [ @@ -2814,6 +4049,9 @@ "security": [ { "HTTPBearer": [] + }, + { + "AccountSession": [] } ], "parameters": [ @@ -2968,6 +4206,9 @@ "security": [ { "HTTPBearer": [] + }, + { + "AccountSession": [] } ], "parameters": [ @@ -3115,6 +4356,9 @@ "security": [ { "HTTPBearer": [] + }, + { + "AccountSession": [] } ], "parameters": [ @@ -3398,6 +4642,9 @@ "security": [ { "HTTPBearer": [] + }, + { + "AccountSession": [] } ] } @@ -3412,6 +4659,9 @@ "security": [ { "HTTPBearer": [] + }, + { + "AccountSession": [] } ], "parameters": [ @@ -3559,6 +4809,9 @@ "security": [ { "HTTPBearer": [] + }, + { + "AccountSession": [] } ], "parameters": [ @@ -3704,6 +4957,147 @@ "description": "Service Unavailable" } } + }, + "delete": { + "tags": [ + "Templates" + ], + "summary": "Reset Template Config", + "operationId": "reset_template_config_api_v1_templates__template_id__config_delete", + "security": [ + { + "HTTPBearer": [] + }, + { + "AccountSession": [] + } + ], + "parameters": [ + { + "name": "template_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Template Id" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Gateway" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + } } }, "/api/v1/jobs": { @@ -3842,6 +5236,9 @@ "security": [ { "HTTPBearer": [] + }, + { + "AccountSession": [] } ] }, @@ -3986,6 +5383,9 @@ "security": [ { "HTTPBearer": [] + }, + { + "AccountSession": [] } ] } @@ -4000,6 +5400,9 @@ "security": [ { "HTTPBearer": [] + }, + { + "AccountSession": [] } ], "parameters": [ @@ -4155,6 +5558,9 @@ "security": [ { "HTTPBearer": [] + }, + { + "AccountSession": [] } ], "parameters": [ @@ -4283,6 +5689,154 @@ "description": "Service Unavailable" } } + }, + "get": { + "tags": [ + "Automation" + ], + "summary": "Get Job", + "operationId": "get_job_api_v1_jobs__job_id__get", + "security": [ + { + "HTTPBearer": [] + }, + { + "AccountSession": [] + } + ], + "parameters": [ + { + "name": "job_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Job Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Job" + } + } + } + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Gateway" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + } } }, "/api/v1/playlists": { @@ -4421,6 +5975,9 @@ "security": [ { "HTTPBearer": [] + }, + { + "AccountSession": [] } ] }, @@ -4565,6 +6122,9 @@ "security": [ { "HTTPBearer": [] + }, + { + "AccountSession": [] } ] } @@ -4579,6 +6139,9 @@ "security": [ { "HTTPBearer": [] + }, + { + "AccountSession": [] } ], "parameters": [ @@ -4734,6 +6297,9 @@ "security": [ { "HTTPBearer": [] + }, + { + "AccountSession": [] } ], "parameters": [ @@ -4874,6 +6440,9 @@ "security": [ { "HTTPBearer": [] + }, + { + "AccountSession": [] } ], "parameters": [ @@ -5150,9 +6719,2169 @@ "security": [ { "HTTPBearer": [] + }, + { + "AccountSession": [] } ] } + }, + "/api/v1/devices/{device_id}/official/connection": { + "get": { + "tags": [ + "Official" + ], + "summary": "Connection State", + "operationId": "connection_state_api_v1_devices__device_id__official_connection_get", + "security": [ + { + "HTTPBearer": [] + }, + { + "AccountSession": [] + } + ], + "parameters": [ + { + "name": "device_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Device Id" + } + }, + { + "name": "discover", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false, + "title": "Discover" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConnectionState" + } + } + } + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Gateway" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + } + }, + "post": { + "tags": [ + "Official" + ], + "summary": "Connection Login", + "operationId": "connection_login_api_v1_devices__device_id__official_connection_post", + "security": [ + { + "HTTPBearer": [] + }, + { + "AccountSession": [] + } + ], + "parameters": [ + { + "name": "device_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Device Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConnectionState" + } + } + } + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Gateway" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + } + }, + "put": { + "tags": [ + "Official" + ], + "summary": "Connection Select", + "operationId": "connection_select_api_v1_devices__device_id__official_connection_put", + "security": [ + { + "HTTPBearer": [] + }, + { + "AccountSession": [] + } + ], + "parameters": [ + { + "name": "device_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Device Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SelectRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConnectionState" + } + } + } + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Gateway" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + } + }, + "delete": { + "tags": [ + "Official" + ], + "summary": "Connection Disconnect", + "operationId": "connection_disconnect_api_v1_devices__device_id__official_connection_delete", + "security": [ + { + "HTTPBearer": [] + }, + { + "AccountSession": [] + } + ], + "parameters": [ + { + "name": "device_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Device Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConnectionState" + } + } + } + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Gateway" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + } + } + }, + "/api/v1/devices/{device_id}/widgets": { + "get": { + "tags": [ + "Devices" + ], + "summary": "Official Widgets", + "operationId": "official_widgets_api_v1_devices__device_id__widgets_get", + "security": [ + { + "HTTPBearer": [] + }, + { + "AccountSession": [] + } + ], + "parameters": [ + { + "name": "device_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Device Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WidgetState" + } + } + } + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Gateway" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + } + } + }, + "/api/v1/devices/{device_id}/current-content": { + "get": { + "tags": [ + "Devices" + ], + "summary": "Current Content", + "operationId": "current_content_api_v1_devices__device_id__current_content_get", + "security": [ + { + "HTTPBearer": [] + }, + { + "AccountSession": [] + } + ], + "parameters": [ + { + "name": "device_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Device Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CurrentDisplay" + } + } + } + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Gateway" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + } + } + }, + "/api/v1/providers/weather": { + "get": { + "tags": [ + "Providers" + ], + "summary": "Weather View", + "operationId": "weather_view_api_v1_providers_weather_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WeatherView" + } + } + } + }, + "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/settings/weather": { + "put": { + "tags": [ + "Settings" + ], + "summary": "Weather Config", + "operationId": "weather_config_api_v1_settings_weather_put", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WeatherConfig" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WeatherConfig" + } + } + } + }, + "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/assets/{asset_id}/source": { + "get": { + "tags": [ + "Templates" + ], + "summary": "Asset Source", + "operationId": "asset_source_api_v1_assets__asset_id__source_get", + "security": [ + { + "HTTPBearer": [] + }, + { + "AccountSession": [] + } + ], + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Asset Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Asset Source Api V1 Assets Asset Id Source Get" + } + } + } + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Gateway" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + } + } + }, + "/api/v1/jobs/{job_id}/run": { + "post": { + "tags": [ + "Automation" + ], + "summary": "Run Job", + "operationId": "run_job_api_v1_jobs__job_id__run_post", + "security": [ + { + "HTTPBearer": [] + }, + { + "AccountSession": [] + } + ], + "parameters": [ + { + "name": "job_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Job Id" + } + }, + { + "name": "idempotency-key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Idempotency-Key" + } + } + ], + "responses": { + "202": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Accepted" + } + } + } + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Gateway" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + } + } + }, + "/api/v1/jobs/{job_id}/preview": { + "post": { + "tags": [ + "Automation" + ], + "summary": "Preview Job", + "operationId": "preview_job_api_v1_jobs__job_id__preview_post", + "security": [ + { + "HTTPBearer": [] + }, + { + "AccountSession": [] + } + ], + "parameters": [ + { + "name": "job_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Job Id" + } + }, + { + "name": "idempotency-key", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Idempotency-Key" + } + } + ], + "responses": { + "202": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Accepted" + } + } + } + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Gateway" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + } + } + }, + "/api/v1/settings/image-url": { + "get": { + "tags": [ + "Settings" + ], + "summary": "Image Url Config", + "operationId": "image_url_config_api_v1_settings_image_url_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImageURLConfig" + } + } + } + }, + "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": [] + } + ] + }, + "put": { + "tags": [ + "Settings" + ], + "summary": "Set Image Url Config", + "operationId": "set_image_url_config_api_v1_settings_image_url_put", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImageURLConfig" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImageURLConfig" + } + } + } + }, + "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/providers/poetry": { + "get": { + "tags": [ + "Providers" + ], + "summary": "Daily Poetry", + "operationId": "daily_poetry_api_v1_providers_poetry_get", + "security": [ + { + "HTTPBearer": [] + }, + { + "AccountSession": [] + } + ], + "parameters": [ + { + "name": "date", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Date" + } + }, + { + "name": "poem_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Poem Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Daily Poetry Api V1 Providers Poetry Get" + } + } + } + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Conflict" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Content Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Internal Server Error" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Bad Gateway" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Service Unavailable" + } + } + } } }, "components": { @@ -5181,8 +8910,35 @@ ], "title": "Accepted" }, + "AccountView": { + "properties": { + "username": { + "type": "string", + "title": "Username" + }, + "csrf_token": { + "type": "string", + "title": "Csrf Token" + } + }, + "type": "object", + "required": [ + "username", + "csrf_token" + ], + "title": "AccountView" + }, "Asset": { "properties": { + "source": { + "type": "string", + "enum": [ + "uploaded", + "generated" + ], + "title": "Source", + "default": "uploaded" + }, "asset_id": { "type": "string", "title": "Asset Id" @@ -5277,6 +9033,49 @@ "type": "object", "title": "Capabilities" }, + "ConnectionState": { + "properties": { + "authorized": { + "type": "boolean", + "title": "Authorized" + }, + "username": { + "type": "string", + "title": "Username" + }, + "robot_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Robot Id" + }, + "mock": { + "type": "boolean", + "title": "Mock" + }, + "devices": { + "items": { + "$ref": "#/components/schemas/OfficialDevice" + }, + "type": "array", + "title": "Devices" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "authorized", + "username", + "robot_id", + "mock" + ], + "title": "ConnectionState" + }, "CurrentContent": { "properties": { "type": { @@ -5320,6 +9119,111 @@ ], "title": "CurrentContent" }, + "CurrentDisplay": { + "properties": { + "device_id": { + "type": "string", + "title": "Device Id" + }, + "image_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Image Id" + }, + "synced": { + "type": "boolean", + "title": "Synced" + }, + "source_known": { + "type": "boolean", + "title": "Source Known" + }, + "source": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Source" + }, + "asset_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Asset Id" + }, + "operation_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Operation Id" + }, + "preview_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Preview Url" + }, + "previous_image_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Previous Image Id" + }, + "overrides": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array", + "title": "Overrides" + } + }, + "type": "object", + "required": [ + "device_id", + "image_id", + "synced", + "source_known", + "source", + "asset_id", + "operation_id", + "preview_url", + "previous_image_id", + "overrides" + ], + "title": "CurrentDisplay" + }, "Device": { "properties": { "id": { @@ -5478,6 +9382,9 @@ { "$ref": "#/components/schemas/TemplateContent" }, + { + "$ref": "#/components/schemas/PhotoSourceContent" + }, { "$ref": "#/components/schemas/FutureContent" } @@ -5490,6 +9397,7 @@ "image": "#/components/schemas/ImageContent", "image_url": "#/components/schemas/URLContent", "markdown": "#/components/schemas/FutureContent", + "photo_source": "#/components/schemas/PhotoSourceContent", "template": "#/components/schemas/TemplateContent" } } @@ -5539,6 +9447,7 @@ "type": "string", "enum": [ "INVALID_REQUEST", + "OFFICIAL_AUTH_REQUIRED", "UNAUTHORIZED", "FORBIDDEN", "DEVICE_NOT_FOUND", @@ -5837,6 +9746,21 @@ ], "title": "ImageContent" }, + "ImageURLConfig": { + "properties": { + "allowed_hosts": { + "items": { + "type": "string" + }, + "type": "array", + "maxItems": 30, + "title": "Allowed Hosts" + } + }, + "additionalProperties": false, + "type": "object", + "title": "ImageURLConfig" + }, "Job": { "properties": { "enabled": { @@ -5871,6 +9795,9 @@ { "$ref": "#/components/schemas/TemplateContent" }, + { + "$ref": "#/components/schemas/PhotoSourceContent" + }, { "$ref": "#/components/schemas/FutureContent" } @@ -5882,6 +9809,7 @@ "image": "#/components/schemas/ImageContent", "image_url": "#/components/schemas/URLContent", "markdown": "#/components/schemas/FutureContent", + "photo_source": "#/components/schemas/PhotoSourceContent", "template": "#/components/schemas/TemplateContent" } } @@ -6003,6 +9931,9 @@ { "$ref": "#/components/schemas/TemplateContent" }, + { + "$ref": "#/components/schemas/PhotoSourceContent" + }, { "$ref": "#/components/schemas/FutureContent" } @@ -6014,6 +9945,7 @@ "image": "#/components/schemas/ImageContent", "image_url": "#/components/schemas/URLContent", "markdown": "#/components/schemas/FutureContent", + "photo_source": "#/components/schemas/PhotoSourceContent", "template": "#/components/schemas/TemplateContent" } } @@ -6053,6 +9985,104 @@ ], "title": "JobInput" }, + "Login": { + "properties": { + "username": { + "type": "string", + "maxLength": 80, + "minLength": 1, + "title": "Username" + }, + "password": { + "type": "string", + "maxLength": 256, + "minLength": 1, + "title": "Password" + }, + "remember": { + "type": "boolean", + "title": "Remember", + "default": true + } + }, + "type": "object", + "required": [ + "username", + "password" + ], + "title": "Login" + }, + "LoginRequest": { + "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": "LoginRequest" + }, + "OfficialDevice": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Model" + }, + "model_code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Model Code" + }, + "online": { + "type": "boolean", + "title": "Online", + "default": false + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "id", + "name" + ], + "title": "OfficialDevice" + }, "Operation": { "properties": { "operation_id": { @@ -6214,6 +10244,238 @@ "type": "object", "title": "Options" }, + "PasswordChange": { + "properties": { + "current_password": { + "type": "string", + "maxLength": 256, + "minLength": 1, + "title": "Current Password" + }, + "new_password": { + "type": "string", + "maxLength": 256, + "minLength": 8, + "title": "New Password" + } + }, + "type": "object", + "required": [ + "current_password", + "new_password" + ], + "title": "PasswordChange" + }, + "PhotoCatalog": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/PhotoItem" + }, + "type": "array", + "title": "Items" + }, + "total": { + "type": "integer", + "title": "Total" + }, + "offset": { + "type": "integer", + "title": "Offset" + }, + "limit": { + "type": "integer", + "title": "Limit" + }, + "catalog_limit": { + "type": "integer", + "title": "Catalog Limit" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "items", + "total", + "offset", + "limit", + "catalog_limit" + ], + "title": "PhotoCatalog" + }, + "PhotoItem": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "name": { + "type": "string", + "title": "Name" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "id", + "name" + ], + "title": "PhotoItem" + }, + "PhotoSourceContent": { + "properties": { + "type": { + "type": "string", + "const": "photo_source", + "title": "Type" + }, + "source_id": { + "type": "string", + "pattern": "^[a-z0-9-]{1,60}$", + "title": "Source Id" + }, + "item_id": { + "anyOf": [ + { + "type": "string", + "maxLength": 2048 + }, + { + "type": "null" + } + ], + "title": "Item Id" + }, + "fit": { + "$ref": "#/components/schemas/Fit", + "default": "contain" + }, + "profile": { + "$ref": "#/components/schemas/Profile", + "default": "color" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "type", + "source_id" + ], + "title": "PhotoSourceContent" + }, + "PhotoSourceInput": { + "properties": { + "name": { + "type": "string", + "maxLength": 120, + "minLength": 1, + "title": "Name" + }, + "kind": { + "type": "string", + "enum": [ + "local", + "webdav" + ], + "title": "Kind" + }, + "location": { + "type": "string", + "maxLength": 2048, + "minLength": 1, + "title": "Location" + }, + "recursive": { + "type": "boolean", + "title": "Recursive", + "default": true + }, + "enabled": { + "type": "boolean", + "title": "Enabled", + "default": true + }, + "credential_env": { + "anyOf": [ + { + "type": "string", + "pattern": "^FRAME_FLOW_SOURCE_[A-Z0-9_]+$" + }, + { + "type": "null" + } + ], + "title": "Credential Env" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "name", + "kind", + "location" + ], + "title": "PhotoSourceInput" + }, + "PhotoSourceView": { + "properties": { + "name": { + "type": "string", + "maxLength": 120, + "minLength": 1, + "title": "Name" + }, + "kind": { + "type": "string", + "enum": [ + "local", + "webdav" + ], + "title": "Kind" + }, + "location": { + "type": "string", + "maxLength": 2048, + "minLength": 1, + "title": "Location" + }, + "recursive": { + "type": "boolean", + "title": "Recursive", + "default": true + }, + "enabled": { + "type": "boolean", + "title": "Enabled", + "default": true + }, + "credential_env": { + "anyOf": [ + { + "type": "string", + "pattern": "^FRAME_FLOW_SOURCE_[A-Z0-9_]+$" + }, + { + "type": "null" + } + ], + "title": "Credential Env" + }, + "id": { + "type": "string", + "title": "Id" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "name", + "kind", + "location", + "id" + ], + "title": "PhotoSourceView" + }, "Playlist": { "properties": { "device_id": { @@ -6528,6 +10790,21 @@ ], "title": "Schedule" }, + "SelectRequest": { + "properties": { + "robot_id": { + "type": "integer", + "exclusiveMinimum": 0.0, + "title": "Robot Id" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "robot_id" + ], + "title": "SelectRequest" + }, "SettingsView": { "properties": { "version": { @@ -6759,9 +11036,180 @@ }, "type": "object", "title": "Version" + }, + "WeatherConfig": { + "properties": { + "provider": { + "type": "string", + "enum": [ + "mock", + "open-meteo" + ], + "title": "Provider", + "default": "mock" + }, + "location": { + "type": "string", + "maxLength": 120, + "minLength": 1, + "title": "Location", + "default": "\u4e0a\u6d77\u91d1\u5c71 / Shanghai Jinshan" + }, + "latitude": { + "type": "number", + "maximum": 90.0, + "minimum": -90.0, + "title": "Latitude", + "default": 30.74 + }, + "longitude": { + "type": "number", + "maximum": 180.0, + "minimum": -180.0, + "title": "Longitude", + "default": 121.34 + } + }, + "additionalProperties": false, + "type": "object", + "title": "WeatherConfig" + }, + "WeatherView": { + "properties": { + "config": { + "$ref": "#/components/schemas/WeatherConfig" + }, + "current": { + "additionalProperties": true, + "type": "object", + "title": "Current" + }, + "forecast": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array", + "title": "Forecast" + } + }, + "type": "object", + "required": [ + "config", + "current", + "forecast" + ], + "title": "WeatherView" + }, + "Widget": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "tags": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Tags" + }, + "arg_keys": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Arg Keys" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "id", + "name" + ], + "title": "Widget" + }, + "WidgetState": { + "properties": { + "source": { + "type": "string", + "enum": [ + "live", + "snapshot", + "mock" + ], + "title": "Source" + }, + "configured": { + "type": "boolean", + "title": "Configured" + }, + "status": { + "type": "string", + "title": "Status", + "default": "OK" + }, + "observed_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Observed At" + }, + "catalog": { + "items": { + "$ref": "#/components/schemas/Widget" + }, + "type": "array", + "title": "Catalog" + }, + "current": { + "anyOf": [ + { + "$ref": "#/components/schemas/Widget" + }, + { + "type": "null" + } + ] + }, + "current_verified": { + "type": "boolean", + "title": "Current Verified", + "default": false + }, + "writable": { + "type": "boolean", + "const": false, + "title": "Writable", + "default": false + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "source", + "configured", + "catalog" + ], + "title": "WidgetState" } }, "securitySchemes": { + "AccountSession": { + "type": "apiKey", + "in": "cookie", + "name": "frameflow_session" + }, "HTTPBearer": { "type": "http", "scheme": "bearer" diff --git a/pigway_frame_flow/api/app.py b/pigway_frame_flow/api/app.py index 062fe0b..d54d5d7 100644 --- a/pigway_frame_flow/api/app.py +++ b/pigway_frame_flow/api/app.py @@ -42,6 +42,7 @@ from pigway_frame_flow.models import ( JobInput, Operation, Options, + PhotoSourceContent, Playlist, PlaylistInput, Profile, @@ -57,6 +58,11 @@ from pigway_frame_flow.models import ( WeatherConfig, WeatherView, ) +from pigway_frame_flow.providers.photo_sources import ( + PhotoCatalog, + PhotoSourceInput, + PhotoSourceView, +) 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 @@ -426,7 +432,7 @@ def create_app(settings=None, start_worker=True): ) def upload( device_id: str, - payload: ImageContent | URLContent, + payload: ImageContent | URLContent | PhotoSourceContent, request: Request, display_now: bool = False, idempotency_key: str | None = Header(default=None), @@ -537,6 +543,57 @@ 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/photo-sources", + response_model=list[PhotoSourceView], + tags=["Photo sources"], + dependencies=[Depends(require("images:read"))], + ) + def photo_sources(): + return service.photo_sources.list() + + @app.put( + "/api/v1/photo-sources/{source_id}", + response_model=PhotoSourceView, + tags=["Photo sources"], + dependencies=[Depends(require("admin"))], + ) + def save_photo_source(source_id: str, payload: PhotoSourceInput): + if not re.fullmatch(r"[a-z0-9-]{1,60}", source_id): + raise FlowError(E.INVALID_REQUEST, "Invalid source ID") + return service.photo_sources.save(source_id, payload) + + @app.delete( + "/api/v1/photo-sources/{source_id}", + status_code=204, + tags=["Photo sources"], + dependencies=[Depends(require("admin"))], + ) + def delete_photo_source(source_id: str): + service.photo_sources.delete(source_id) + return Response(status_code=204) + + @app.get( + "/api/v1/photo-sources/{source_id}/images", + response_model=PhotoCatalog, + tags=["Photo sources"], + dependencies=[Depends(require("images:read"))], + ) + def source_images( + source_id: str, + offset: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=200), + refresh: bool = False, + ): + items = service.photo_sources.catalog(source_id, refresh=refresh) + return { + "items": items[offset : offset + limit], + "total": len(items), + "offset": offset, + "limit": limit, + "catalog_limit": 10000, + } + @app.delete( "/api/v1/devices/{device_id}/images/{image_id}", response_model=Accepted, diff --git a/pigway_frame_flow/display/service.py b/pigway_frame_flow/display/service.py index 9d120ca..313258c 100644 --- a/pigway_frame_flow/display/service.py +++ b/pigway_frame_flow/display/service.py @@ -12,9 +12,11 @@ from pigway_frame_flow.models import ( DisplayRequest, FutureContent, ImageContent, + PhotoSourceContent, TemplateContent, URLContent, ) +from pigway_frame_flow.providers.photo_sources import PhotoSources from pigway_frame_flow.renderers.images import convert from pigway_frame_flow.storage.database import encode, now_iso @@ -23,6 +25,7 @@ class DisplayService: """One persistent queue; injected driver registry. No vendor imports.""" def __init__(self, db, settings, registry: DriverRegistry, assets, renderer): + self.photo_sources = PhotoSources(db, settings, assets) self.db, self.settings, self.registry, self.assets, self.renderer = ( db, settings, @@ -49,6 +52,8 @@ class DisplayService: ) if isinstance(request.content, TemplateContent): self.renderer.info(request.content.template) + if isinstance(request.content, PhotoSourceContent): + self.photo_sources.get(request.content.source_id) if writing and not self.settings.mock and not self.settings.write_enabled: raise FlowError(E.WRITE_DISABLED, "Real device writes are disabled", 403) @@ -167,6 +172,9 @@ class DisplayService: (asset["asset_id"], encode(source)), ) return asset + if isinstance(content, PhotoSourceContent): + data, name = self.photo_sources.read(content.source_id, content.item_id) + return self.assets.put(convert(data, content.fit, content.profile), name) if isinstance(content, URLContent): data = self.assets.fetch_url(content.url) elif isinstance(content, ImageContent) and content.asset_id: diff --git a/pigway_frame_flow/models.py b/pigway_frame_flow/models.py index d74836e..2fcde53 100644 --- a/pigway_frame_flow/models.py +++ b/pigway_frame_flow/models.py @@ -60,13 +60,21 @@ class TemplateContent(StrictModel): profile: Profile = Profile.color +class PhotoSourceContent(StrictModel): + type: Literal["photo_source"] + source_id: str = Field(pattern=r"^[a-z0-9-]{1,60}$") + item_id: str | None = Field(default=None, max_length=2048) + fit: Fit = Fit.contain + profile: Profile = Profile.color + + class FutureContent(StrictModel): type: Literal["html", "markdown"] data: str = "" Content = Annotated[ - ImageContent | URLContent | TemplateContent | FutureContent, + ImageContent | URLContent | TemplateContent | PhotoSourceContent | FutureContent, Field(discriminator="type"), ] diff --git a/pigway_frame_flow/providers/photo_sources.py b/pigway_frame_flow/providers/photo_sources.py new file mode 100644 index 0000000..13a2a26 --- /dev/null +++ b/pigway_frame_flow/providers/photo_sources.py @@ -0,0 +1,335 @@ +"""Read-only image catalogs; provider credentials never enter SQLite.""" + +import base64 +import json +import os +import posixpath +import random +import stat +import time +from pathlib import Path +from typing import Literal +from urllib.parse import unquote, urljoin, urlsplit +from xml.etree import ElementTree + +from pydantic import Field + +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.settings import secret +from pigway_frame_flow.storage.database import encode + + +class PhotoSourceInput(StrictModel): + name: str = Field(min_length=1, max_length=120) + kind: Literal["local", "webdav"] + location: str = Field(min_length=1, max_length=2048) + recursive: bool = True + enabled: bool = True + credential_env: str | None = Field( + default=None, pattern=r"^FRAME_FLOW_SOURCE_[A-Z0-9_]+$" + ) + + +class PhotoSourceView(PhotoSourceInput): + id: str + + +class PhotoItem(StrictModel): + id: str + name: str + + +class PhotoCatalog(StrictModel): + items: list[PhotoItem] + total: int + offset: int + limit: int + catalog_limit: int + + +class PhotoSources: + extensions = {".jpg", ".jpeg", ".png", ".webp"} + + def __init__(self, db, settings, assets): + self.db, self.settings, self.assets = db, settings, assets + self.cache = {} + # 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"): + try: + self.headers( + PhotoSourceInput.model_validate( + {k: v for k, v in row.items() if k != "id"} + ) + ) + except FlowError: + pass + + def list(self): + return [ + dict(json.loads(row["payload"]), id=row["key"][13:]) + for row in self.db.all( + "SELECT key,payload FROM app_config WHERE key LIKE 'photo_source:%' ORDER BY key" + ) + ] + + def get(self, source_id): + row = self.db.one( + "SELECT payload FROM app_config WHERE key=?", ("photo_source:" + source_id,) + ) + if not row: + raise FlowError(E.NOT_FOUND, "Photo source not found", 404) + return PhotoSourceInput.model_validate_json(row["payload"]) + + def save(self, source_id, source): + if source.kind == "local": + self.local_directory(source.location) + if source.credential_env: + raise FlowError( + E.INVALID_REQUEST, "Local sources do not use credentials" + ) + else: + source = source.model_copy( + update={ + "recursive": False, + "location": source.location.rstrip("/") + "/", + } + ) + parsed = urlsplit(source.location) + if ( + parsed.scheme != "https" + or not parsed.hostname + or parsed.username + or parsed.password + or parsed.query + or parsed.fragment + or parsed.port not in (None, 443) + ): + raise FlowError( + E.INVALID_REQUEST, "WebDAV requires a plain HTTPS directory URL" + ) + if parsed.hostname.lower() not in self.settings.image_url_hosts: + raise FlowError( + E.FORBIDDEN, "Configure the allowed image host first", 403 + ) + self.db.execute( + "INSERT OR REPLACE INTO app_config(key,payload) VALUES(?,?)", + ("photo_source:" + source_id, encode(source.model_dump())), + ) + self.cache.pop(source_id, None) + return dict(source.model_dump(), id=source_id) + + def delete(self, source_id): + self.get(source_id) + for row in self.db.all("SELECT payload FROM jobs"): + if json.loads(row["payload"])["source"].get("source_id") == source_id: + raise FlowError(E.CONFLICT, "Remove jobs using this source first", 409) + for row in self.db.all( + "SELECT payload FROM operations WHERE status IN ('QUEUED','RENDERING','UPLOADING','SYNCING','UNKNOWN')" + ): + payload = json.loads(row["payload"]) + if payload.get("content", payload).get("source_id") == source_id: + raise FlowError(E.CONFLICT, "This source has pending operations", 409) + self.db.execute( + "DELETE FROM app_config WHERE key=?", ("photo_source:" + source_id,) + ) + self.cache.pop(source_id, None) + + def local_directory(self, location): + root = self.settings.photo_root.resolve() + directory = (root / location).resolve() + if Path(location).is_absolute() or not directory.is_relative_to(root): + raise FlowError( + E.FORBIDDEN, + "Photo directory must be inside the mounted photo root", + 403, + ) + if not directory.is_dir(): + raise FlowError( + E.NOT_FOUND, + "Photo directory is unavailable; check its read-only mount", + 404, + ) + return directory + + def headers(self, source): + if not source.credential_env: + return {} + try: + value = secret(source.credential_env) + except OSError: + raise FlowError( + E.CONFIG_REQUIRED, "WebDAV credential secret is unavailable" + ) from None + if value and value not in self.settings.additional_secrets: + self.settings.additional_secrets.append(value) + try: + credentials = json.loads(value) + username, password = credentials["username"], credentials["password"] + if ( + not isinstance(username, str) + or not isinstance(password, str) + or ":" in username + ): + raise ValueError() + except (ValueError, KeyError, TypeError): + raise FlowError( + E.CONFIG_REQUIRED, + "WebDAV credential secret must contain username and password", + ) from None + for sensitive in (value, username, password): + if sensitive and sensitive not in self.settings.additional_secrets: + self.settings.additional_secrets.append(sensitive) + encoded = base64.b64encode((username + ":" + password).encode()).decode() + if encoded not in self.settings.additional_secrets: + self.settings.additional_secrets.append(encoded) + return {"Authorization": "Basic " + encoded} + + def catalog(self, source_id, refresh=False): + source = self.get(source_id) + if not source.enabled: + raise FlowError(E.CONFLICT, "Photo source is disabled", 409) + cached = self.cache.get(source_id) + if cached and not refresh and time.monotonic() - cached[0] < 60: + return cached[1] + items = [] + if source.kind == "local": + directory = self.local_directory(source.location) + root = self.settings.photo_root.resolve() + # Do not follow symlink directories or import Synology's thumbnail trees. + for folder, dirs, files in os.walk(directory, followlinks=False): + dirs[:] = ( + sorted( + d + for d in dirs + if not d.startswith(".") + and not d.startswith("@") + and not Path(folder, d).is_symlink() + ) + if source.recursive + else [] + ) + for name in sorted(files): + path = Path(folder, name) + if ( + name.startswith(".") + or path.suffix.lower() not in self.extensions + or path.is_symlink() + ): + continue + try: + if path.stat().st_size > self.settings.max_bytes: + continue + relative = path.relative_to(root).as_posix() + items.append({"id": relative, "name": name}) + except OSError: + continue + if len(items) >= 10000: + break + if len(items) >= 10000: + break + else: + data = self.assets.fetch_url( + source.location, + method="PROPFIND", + headers={**self.headers(source), "Depth": "1"}, + limit=2 * 1024 * 1024, + accepted_status=(207,), + ) + if ( + b"\x00" in data + or b"= 10000: + break + except ElementTree.ParseError: + raise FlowError(E.INVALID_REQUEST, "Invalid WebDAV catalog") from None + self.cache[source_id] = (time.monotonic(), items) + return items + + def read(self, source_id, item_id=None): + source = self.get(source_id) + items = self.catalog(source_id) + if not items: + raise FlowError( + E.NOT_FOUND, "Photo source contains no supported images", 404 + ) + item = ( + next((x for x in items if x["id"] == item_id), None) + if item_id + else random.SystemRandom().choice(items) + ) + if item is None: + raise FlowError(E.NOT_FOUND, "Photo is not in this source", 404) + if source.kind == "local": + root = self.settings.photo_root.resolve() + path = root / item["id"] + if path.is_symlink() or not path.resolve().is_relative_to( + self.local_directory(source.location) + ): + raise FlowError(E.FORBIDDEN, "Photo path is outside its source", 403) + try: + # Open every component without following links; a renamed parent + # must not turn a previously cataloged image into a secret-file read. + parent_fd = os.open(root, os.O_RDONLY | os.O_DIRECTORY) + try: + components = path.relative_to(root).parts + for component in components[:-1]: + child_fd = os.open( + component, + os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, + dir_fd=parent_fd, + ) + os.close(parent_fd) + parent_fd = child_fd + file_fd = os.open( + components[-1], + os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, + dir_fd=parent_fd, + ) + with os.fdopen(file_fd, "rb") as handle: + if not stat.S_ISREG(os.fstat(handle.fileno()).st_mode): + raise FlowError( + E.FORBIDDEN, "Photo must be a regular file", 403 + ) + data = handle.read(self.settings.max_bytes + 1) + finally: + os.close(parent_fd) + except OSError: + raise FlowError(E.NOT_FOUND, "Photo is unavailable", 404) from None + else: + data = self.assets.fetch_url(item["id"], headers=self.headers(source)) + if len(data) > self.settings.max_bytes: + raise FlowError(E.INVALID_REQUEST, "Image exceeds size limit") + return data, item["name"] diff --git a/pigway_frame_flow/renderers/images.py b/pigway_frame_flow/renderers/images.py index ebcd093..c5627df 100644 --- a/pigway_frame_flow/renderers/images.py +++ b/pigway_frame_flow/renderers/images.py @@ -114,7 +114,10 @@ class Assets: raise FlowError(E.INVALID_REQUEST, "Image exceeds 20 MiB") return data - def fetch_url(self, url): + def fetch_url( + self, url, *, method="GET", headers=None, limit=None, accepted_status=(200,) + ): + limit = limit or self.settings.max_bytes parsed = urlsplit(url) if ( parsed.scheme != "https" @@ -153,16 +156,17 @@ class Assets: connection = PinnedHTTPS(parsed.hostname, timeout=10) try: connection.request( - "GET", + method, (parsed.path or "/") + ("?" + parsed.query if parsed.query else ""), + headers=headers or {}, ) response = connection.getresponse() - if response.status != 200: + if response.status not in accepted_status: raise FlowError( E.INVALID_REQUEST, "Image URL returned an invalid response" ) - data = response.read(self.settings.max_bytes + 1) - if len(data) > self.settings.max_bytes: + data = response.read(limit + 1) + if len(data) > limit: raise FlowError(E.INVALID_REQUEST, "Image exceeds 20 MiB") return data finally: diff --git a/pigway_frame_flow/settings.py b/pigway_frame_flow/settings.py index adf8b7f..09a5a09 100644 --- a/pigway_frame_flow/settings.py +++ b/pigway_frame_flow/settings.py @@ -48,6 +48,9 @@ class Settings: "FONT_PATH", "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc" ) ) + photo_root: Path = field( + default_factory=lambda: Path(os.getenv("PHOTO_ROOT", "/photos")) + ) max_bytes: int = 20 * 1024 * 1024 idempotency_hours: int = 168 sync_timeout: int = 600 diff --git a/pigway_frame_flow/web/features.js b/pigway_frame_flow/web/features.js index 0a718e4..b2374c4 100644 --- a/pigway_frame_flow/web/features.js +++ b/pigway_frame_flow/web/features.js @@ -29,7 +29,8 @@ function displayDialog(content){ showModal(`

${t('displayOptions')}

`); $('#display-options').onsubmit=busy(async e=>{e.preventDefault();const restore=$('#override-restore').checked;const options={duration:restore?Number($('#override-duration').value):0,restore_previous:restore,priority:Number($('#override-priority').value)};await accepted('/api/v1/display',{device_id:state.device,content,options});$('#modal').close();await navigate('history');}); } -function enhanceGallery(){ +async function enhanceGallery(){ + await photoSourcesPanel(); const grid=$('.gallery');if(!grid)return; const controls=document.createElement('div');controls.className='form-grid';controls.innerHTML=`
`;grid.before(controls); const cards=[...grid.querySelectorAll('.image-card')];$('#gallery-search').oninput=()=>{const query=$('#gallery-search').value.toLowerCase();cards.forEach(card=>card.hidden=!card.textContent.toLowerCase().includes(query));};$('#gallery-sort').onchange=()=>{const order=$('#gallery-sort').value;const indexes=cards.map((card,index)=>({card,item:state.gallery[index]}));indexes.sort((a,b)=>order==='name'?a.item.name.localeCompare(b.item.name):order==='oldest'?String(a.item.created_at).localeCompare(String(b.item.created_at)):String(b.item.created_at).localeCompare(String(a.item.created_at)));indexes.forEach(x=>grid.append(x.card));}; @@ -47,13 +48,14 @@ async function enhanceAutomation(){ } async function jobSourceEditor(job){ const raw=$('#job-source');const advanced=document.createElement('details');advanced.innerHTML=`${t('advanced')}`;raw.parentElement.before(advanced);advanced.append(raw.parentElement); - const box=document.createElement('div');advanced.before(box);let source=structuredClone(job.source);let images=[],assets=[];try{[images,assets]=await Promise.all([api('/api/v1/devices/'+state.device+'/images'),api('/api/v1/assets')]);}catch(e){toast(e.message);}if(!raw.isConnected)return; - box.innerHTML=`
`; + const box=document.createElement('div');advanced.before(box);let source=structuredClone(job.source);let images=[],assets=[],photoSources=[];try{[images,assets,photoSources]=await Promise.all([api('/api/v1/devices/'+state.device+'/images'),api('/api/v1/assets'),api('/api/v1/photo-sources')]);}catch(e){toast(e.message);}if(!raw.isConnected)return; + box.innerHTML=`
`; $('#job-content-type').value=source.type;const old=$('#job-template')?.parentElement;if(old)old.remove(); function update(){raw.value=JSON.stringify(source,null,2);} function fields(){const type=$('#job-content-type').value;const panel=$('#job-content-fields');if(type==='restore'){source={type};panel.innerHTML='';$('#job-restore').checked=false;$('#job-duration').value=0;update();return;} if(type==='template'){const selected=state.templates.find(tpl=>tpl.id===source.template)||state.templates[0];source={type,template:selected.id,data:source.type==='template'?source.data||{}:{},profile:source.profile||'color'};panel.innerHTML=`
`;$('#job-source-profile').value=source.profile;$('#job-source-profile').onchange=()=>{source.profile=$('#job-source-profile').value;update();};const defaults={...selected.defaults,...source.data};const keys=[...new Set([...Object.keys(defaults).filter(k=>typeof defaults[k]!=='object'||Array.isArray(defaults[k])),...(['calendar','daily-dashboard'].includes(selected.id)?['events']:[])])];$('#job-data-fields').innerHTML=keys.map(k=>`
`).join('');$('#job-template').onchange=()=>{source={type:'template',template:$('#job-template').value,data:{}};fields();};panel.querySelectorAll('[data-job-field]').forEach(el=>el.oninput=()=>{source.data[el.dataset.jobField]=el.dataset.jobField==='events'?el.value.split('\n').filter(x=>x.trim()):el.value;update();});update();} else if(type==='image'){const selected=source.image_id?'cloud:'+source.image_id:source.asset_id?'local:'+source.asset_id:'';panel.innerHTML=`
`;$('#job-image').value=selected;$('#job-image').onchange=()=>{const [kind,id]=$('#job-image').value.split(':');source=kind==='cloud'?{type:'image',image_id:Number(id)}:{type:'image',asset_id:id};update();};} + else if(type==='photo_source'){panel.innerHTML=`

${t('sourceJobHint')}

`;$('#job-photo-source').value=source.source_id||'';$('#job-photo-source').onchange=()=>{source={type:'photo_source',source_id:$('#job-photo-source').value,fit:'contain',profile:'color'};update();};} else{panel.innerHTML=`
`;$('#job-url').oninput=()=>{source={type:'image_url',url:$('#job-url').value};update();};}} $('#job-content-type').onchange=()=>{source={type:$('#job-content-type').value};update();fields();};raw.oninput=()=>{try{source=JSON.parse(raw.value);raw.setCustomValidity('');$('#job-content-type').value=source.type;fields();}catch{raw.setCustomValidity(t('INVALID_REQUEST'));}};fields(); } @@ -102,3 +104,32 @@ async function officialConnectionPanel(info){ }; try{render(await api(path));}catch{panel.textContent=zh?'官方账号连接由管理员管理':'Official account connection is managed by an administrator';} } + +async function photoSourcesPanel(){ + const section=document.createElement('section');section.className='card photo-sources-panel';$('#main').append(section); + const sources=await api('/api/v1/photo-sources');if(!section.isConnected)return; + section.innerHTML=`

${t('photoSources')}

${t('sourceHint')}

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

${esc(s.name)}

${esc(s.kind)} · ${t(s.enabled?'enabled':'disabled')}

`).join(''):`

${t('noSources')}

`}
`; + 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');})); + section.querySelectorAll('[data-source-random]').forEach(b=>b.onclick=busy(()=>previewSource(b.dataset.sourceRandom))); + 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(`

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

${t('sourceLocationHint')}

${t('sourceCredentialHint')}

`); + $('#source-kind').value=source?.kind||'local';const update=()=>{const dav=$('#source-kind').value==='webdav';$('#source-credential').disabled=!dav;$('#source-recursive').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,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; + box.innerHTML=`

${t('sourceImages')} · ${result.total}${result.total>=result.catalog_limit?' (limit)':''}

${t('sourceBrowseHint')}

${result.items.map((x,i)=>``).join('')||t('noSources')}
${buttons('source-refresh','refresh')}
`; + box.querySelectorAll('[data-source-item]').forEach(b=>b.onclick=busy(()=>previewSource(id,result.items[Number(b.dataset.sourceItem)].id)));bind('source-prev',()=>browseSource(id,Math.max(0,offset-50)));bind('source-next',()=>browseSource(id,offset+50));bind('source-refresh',()=>browseSource(id,0,true)); +} +async function previewSource(id,item_id=null){ + const acceptedResult=await api('/api/v1/devices/'+state.device+'/upload',{method:'POST',body:{type:'photo_source',source_id:id,item_id,fit:'contain',profile:'color'},headers:{'Idempotency-Key':clientUUID()}});const op=await waitOperation(acceptedResult.operation_id); + showModal(`

${t('preview')}

${t('preview')}

${t('sourcePreviewHint')}

`);await blobImage($('#source-preview'),op.result.url);$('#source-display').disabled=!liveWriteAllowed();bind('source-display',()=>displayDialog({type:'image',asset_id:op.result.asset_id})); +} +function sourceJobForm(id){ + showModal(`

${t('sourceJob')}

${t('sourceJobHint')}

`);$('#source-job-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/jobs',{method:'POST',body:{name:$('#source-job-name').value,device_id:state.device,enabled:$('#source-job-enabled').checked,schedule:{type:'cron',cron:$('#source-job-cron').value},source:{type:'photo_source',source_id:id,fit:'contain',profile:'color'}}});$('#modal').close();await navigate('automation');}); +} diff --git a/pigway_frame_flow/web/i18n.js b/pigway_frame_flow/web/i18n.js index 1565d59..96b2ed3 100644 --- a/pigway_frame_flow/web/i18n.js +++ b/pigway_frame_flow/web/i18n.js @@ -16,3 +16,6 @@ Object.assign(window.MESSAGES.zh,{poetryNotice:'Frame Flow 每日诗词:内置 Object.assign(MESSAGES.zh,{officialContentTitle:'KoKonna 官方小组件与订阅',officialContentDescription:'官方产品资料列出的内容类型;下列功能与 Frame Flow 自建模板分开管理。',officialCalendarWeather:'日历与当地天气',officialWordAlmanacTravel:'每日一词、传统黄历、全球旅行推荐',officialNews:'每日新闻',officialCreative:'AI 语音 / 文字 / 涂鸦 / 照片转艺术;照片轮播',officialSubscriptionUnavailable:'公开 OpenAPI 尚未列出订阅目录、当前订阅、开通或取消订阅接口。官方订阅请继续在官方小程序中管理;Frame Flow 不会读取或修改这些设置。你使用的每日诗词属于官方小程序内容,当前无法通过公开 API 确认完整类型列表。',officialProductDocs:'官方内容介绍 ↗',officialAPIDocs:'官方 OpenAPI ↗'}); Object.assign(MESSAGES.en,{officialContentTitle:'KoKonna official widgets and subscriptions',officialContentDescription:'Content types listed in official product materials; managed separately from Frame Flow templates.',officialCalendarWeather:'Calendar and local weather',officialWordAlmanacTravel:'Word of the day, traditional almanac and global travel recommendations',officialNews:'Daily news',officialCreative:'AI voice / text / sketch / photo-to-art; photo rotation',officialSubscriptionUnavailable:'Public OpenAPI does not list subscription discovery, current subscription, subscribe or unsubscribe endpoints. Manage official subscriptions in the official mini-program. Frame Flow cannot read or modify these settings. Your daily-poetry subscription is mini-program content; its complete catalog cannot currently be verified through public API.',officialProductDocs:'Official content overview ↗',officialAPIDocs:'Official OpenAPI ↗'}); + +Object.assign(MESSAGES.zh,{photoSources:'照片数据源',sourceHint:'从只读 NAS 目录或 WebDAV 选图;预览不会改变画框。',addSource:'添加数据源',browse:'浏览照片',randomPreview:'随机选图预览',sourceJob:'定时随机照片',noSources:'暂无照片,请添加数据源或检查目录。',sourceKind:'数据源类型',nasDirectory:'NAS 本地目录',sourceLocation:'目录 / WebDAV URL',sourceLocationHint:'本地填写挂载照片根目录下的相对路径,如 . 或 family。WebDAV 填写 HTTPS 目录 URL,并先在设置中允许该域名。',sourceCredential:'WebDAV 凭据环境变量名',sourceCredentialHint:'仅填写变量名,禁止填写密码。管理员通过环境变量或 Secret 配置 JSON username/password。',sourceRecursive:'包含子目录(本地目录)',sourceImages:'可用照片',sourceBrowseHint:'按需读取目录,点击文件名才生成预览;不会下载全部原图。',previousPage:'上一页',nextPage:'下一页',sourcePreviewHint:'这是已选中照片的预览,显示时使用同一张照片。',randomPhoto:'随机照片',sourceJobHint:'每次执行重新随机选图。默认暂停;启用后会按时改变画框显示。'}); +Object.assign(MESSAGES.en,{photoSources:'Photo sources',sourceHint:'Choose photos from a read-only NAS folder or WebDAV. Preview does not change the frame.',addSource:'Add source',browse:'Browse photos',randomPreview:'Preview random photo',sourceJob:'Schedule random photos',noSources:'No photos yet. Add a source or check the folder.',sourceKind:'Source type',nasDirectory:'NAS local folder',sourceLocation:'Folder / WebDAV URL',sourceLocationHint:'Local: relative folder under the mounted photo root, e.g. . or family. WebDAV: HTTPS directory URL; allow its hostname in Settings first.',sourceCredential:'WebDAV credential environment name',sourceCredentialHint:'Enter a variable name, never a password. Admin configures JSON username/password via environment or Secret.',sourceRecursive:'Include subfolders (local)',sourceImages:'Available photos',sourceBrowseHint:'Catalog loaded on demand. Click a filename to preview; originals are not downloaded in bulk.',previousPage:'Previous page',nextPage:'Next page',sourcePreviewHint:'Display uses this exact previewed photo.',randomPhoto:'Random photo',sourceJobHint:'Choose a fresh random photo each run. Paused by default; enabling changes the frame on schedule.'}); diff --git a/pigway_frame_flow/web/style.css b/pigway_frame_flow/web/style.css index 6c48e54..83202ad 100644 --- a/pigway_frame_flow/web/style.css +++ b/pigway_frame_flow/web/style.css @@ -48,3 +48,5 @@ input[type="file"]::file-selector-button{margin-inline-end:12px} .official-account-panel{margin:20px 0;padding:20px;border:1px solid var(--border);border-radius:12px}.official-account-panel>p{margin:14px 0}#official-device-picker{margin-top:18px} .history-table{min-width:920px;table-layout:fixed}.history-table th:nth-child(1){width:145px}.history-table th:nth-child(2){width:100px}.history-table th:nth-child(3){width:100px}.history-table th:nth-child(5){width:100px}.history-table th:last-child{width:120px}.history-table td{overflow-wrap:anywhere}.history-table .history-actions{vertical-align:middle;white-space:nowrap}.history-actions button{white-space:nowrap} + +.photo-sources-panel{margin-top:24px}.source-list{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,280px),1fr));gap:20px}.source-card{border:1px solid var(--border);border-radius:12px;padding:18px}.source-card h4{margin:0 0 12px}.source-card .actions{margin-top:16px}.source-items{display:flex;flex-wrap:wrap;gap:12px;margin:18px 0}.source-items button{max-width:100%;overflow-wrap:anywhere}#source-browser>h4{margin-top:24px} diff --git a/scripts/ui-photo-sources.cjs b/scripts/ui-photo-sources.cjs new file mode 100644 index 0000000..3a21998 --- /dev/null +++ b/scripts/ui-photo-sources.cjs @@ -0,0 +1,3 @@ +/* Local mock server only; no vendor/cloud writes. PHOTO_ROOT must contain a fixture image. */ +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();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));const id='ui-photo-'+Date.now();let jobId;let checks=0;try{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('UI photo fixture');await page.locator('#source-location').fill('.');await page.locator('#photo-source-form button[type="submit"]').click();await page.locator(`[data-source-browse="${id}"]`).waitFor();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(`[data-source-browse="${id}"]`).click();await page.locator('[data-source-item]').first().waitFor();assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));await page.locator(`[data-source-edit="${id}"]`).click();assert(await page.locator('#source-id').inputValue()===id);await page.locator('#modal-cancel').click();checks++;}await page.locator(`[data-source-random="${id}"]`).click();await page.locator('#source-preview').waitFor();await page.waitForFunction(()=>document.querySelector('#source-preview')?.naturalWidth>0);const before=await page.evaluate(()=>api('/api/v1/devices/'+state.device));await page.locator('#modal-cancel').click();await page.locator(`[data-source-job="${id}"]`).click();assert(!await page.locator('#source-job-enabled').isChecked());await page.locator('#source-job-name').fill(id);await page.locator('#source-job-form button[type="submit"]').click();await page.locator('#new-job').waitFor();jobId=await page.evaluate(async id=>(await api('/api/v1/jobs')).find(j=>j.name===id).id,id);await page.evaluate(async jobId=>{const job=(await api('/api/v1/jobs')).find(j=>j.id===jobId);await jobEditor(job);},jobId);await page.locator('#job-photo-source').waitFor();assert(await page.locator('#job-content-type').inputValue()==='photo_source');assert(await page.locator('#job-photo-source').inputValue()===id);await page.locator('#modal-cancel').click();const after=await page.evaluate(()=>api('/api/v1/devices/'+state.device));assert(before.current_content.id===after.current_content.id);checks+=3;console.log('Photo sources browser: '+checks+' passed (8 language/size/theme profiles, preview and scheduler editing)');}finally{await page.evaluate(async({id,jobId})=>{if(jobId)await api('/api/v1/jobs/'+jobId,{method:'DELETE'});await api('/api/v1/photo-sources/'+id,{method:'DELETE'});},{id,jobId});await browser.close();}})().catch(e=>{console.error(e);process.exit(1)}); diff --git a/tests/contract.json b/tests/contract.json index 5b136eb..f588567 100644 --- a/tests/contract.json +++ b/tests/contract.json @@ -317,6 +317,68 @@ "503" ] }, + "/api/v1/photo-sources": { + "get": [ + "200", + "400", + "401", + "403", + "404", + "409", + "413", + "422", + "429", + "500", + "502", + "503" + ] + }, + "/api/v1/photo-sources/{source_id}": { + "put": [ + "200", + "400", + "401", + "403", + "404", + "409", + "413", + "422", + "429", + "500", + "502", + "503" + ], + "delete": [ + "204", + "400", + "401", + "403", + "404", + "409", + "413", + "422", + "429", + "500", + "502", + "503" + ] + }, + "/api/v1/photo-sources/{source_id}/images": { + "get": [ + "200", + "400", + "401", + "403", + "404", + "409", + "413", + "422", + "429", + "500", + "502", + "503" + ] + }, "/api/v1/devices/{device_id}/images/{image_id}": { "delete": [ "202", @@ -625,6 +687,80 @@ "503" ] }, + "/api/v1/devices/{device_id}/official/connection": { + "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" + ], + "put": [ + "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/devices/{device_id}/widgets": { + "get": [ + "200", + "400", + "401", + "403", + "404", + "409", + "413", + "422", + "429", + "500", + "502", + "503" + ] + }, "/api/v1/devices/{device_id}/current-content": { "get": [ "200", @@ -766,79 +902,5 @@ "502", "503" ] - }, - "/api/v1/devices/{device_id}/widgets": { - "get": [ - "200", - "400", - "401", - "403", - "404", - "409", - "413", - "422", - "429", - "500", - "502", - "503" - ] - }, - "/api/v1/devices/{device_id}/official/connection": { - "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" - ], - "put": [ - "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" - ] } } diff --git a/tests/schema-contract.json b/tests/schema-contract.json index 12125ed..eb82ca8 100644 --- a/tests/schema-contract.json +++ b/tests/schema-contract.json @@ -355,6 +355,9 @@ { "$ref": "#/components/schemas/TemplateContent" }, + { + "$ref": "#/components/schemas/PhotoSourceContent" + }, { "$ref": "#/components/schemas/FutureContent" } @@ -367,6 +370,7 @@ "image": "#/components/schemas/ImageContent", "image_url": "#/components/schemas/URLContent", "markdown": "#/components/schemas/FutureContent", + "photo_source": "#/components/schemas/PhotoSourceContent", "template": "#/components/schemas/TemplateContent" } } @@ -752,5 +756,215 @@ "error" ], "title": "ErrorResponse" + }, + "PhotoSourceContent": { + "properties": { + "type": { + "type": "string", + "const": "photo_source", + "title": "Type" + }, + "source_id": { + "type": "string", + "pattern": "^[a-z0-9-]{1,60}$", + "title": "Source Id" + }, + "item_id": { + "anyOf": [ + { + "type": "string", + "maxLength": 2048 + }, + { + "type": "null" + } + ], + "title": "Item Id" + }, + "fit": { + "$ref": "#/components/schemas/Fit", + "default": "contain" + }, + "profile": { + "$ref": "#/components/schemas/Profile", + "default": "color" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "type", + "source_id" + ], + "title": "PhotoSourceContent" + }, + "PhotoSourceInput": { + "properties": { + "name": { + "type": "string", + "maxLength": 120, + "minLength": 1, + "title": "Name" + }, + "kind": { + "type": "string", + "enum": [ + "local", + "webdav" + ], + "title": "Kind" + }, + "location": { + "type": "string", + "maxLength": 2048, + "minLength": 1, + "title": "Location" + }, + "recursive": { + "type": "boolean", + "title": "Recursive", + "default": true + }, + "enabled": { + "type": "boolean", + "title": "Enabled", + "default": true + }, + "credential_env": { + "anyOf": [ + { + "type": "string", + "pattern": "^FRAME_FLOW_SOURCE_[A-Z0-9_]+$" + }, + { + "type": "null" + } + ], + "title": "Credential Env" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "name", + "kind", + "location" + ], + "title": "PhotoSourceInput" + }, + "PhotoSourceView": { + "properties": { + "name": { + "type": "string", + "maxLength": 120, + "minLength": 1, + "title": "Name" + }, + "kind": { + "type": "string", + "enum": [ + "local", + "webdav" + ], + "title": "Kind" + }, + "location": { + "type": "string", + "maxLength": 2048, + "minLength": 1, + "title": "Location" + }, + "recursive": { + "type": "boolean", + "title": "Recursive", + "default": true + }, + "enabled": { + "type": "boolean", + "title": "Enabled", + "default": true + }, + "credential_env": { + "anyOf": [ + { + "type": "string", + "pattern": "^FRAME_FLOW_SOURCE_[A-Z0-9_]+$" + }, + { + "type": "null" + } + ], + "title": "Credential Env" + }, + "id": { + "type": "string", + "title": "Id" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "name", + "kind", + "location", + "id" + ], + "title": "PhotoSourceView" + }, + "PhotoItem": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "name": { + "type": "string", + "title": "Name" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "id", + "name" + ], + "title": "PhotoItem" + }, + "PhotoCatalog": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/PhotoItem" + }, + "type": "array", + "title": "Items" + }, + "total": { + "type": "integer", + "title": "Total" + }, + "offset": { + "type": "integer", + "title": "Offset" + }, + "limit": { + "type": "integer", + "title": "Limit" + }, + "catalog_limit": { + "type": "integer", + "title": "Catalog Limit" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "items", + "total", + "offset", + "limit", + "catalog_limit" + ], + "title": "PhotoCatalog" } } diff --git a/tests/test_external_photo_sources.py b/tests/test_external_photo_sources.py new file mode 100644 index 0000000..9ae775f --- /dev/null +++ b/tests/test_external_photo_sources.py @@ -0,0 +1,206 @@ +import json +from io import BytesIO + +import pytest +from PIL import Image + +from pigway_frame_flow.errors import FlowError +from pigway_frame_flow.providers.photo_sources import PhotoSourceInput + + +def photo(path): + path.parent.mkdir(parents=True, exist_ok=True) + Image.new("RGB", (160, 200), "blue").save(path) + return path.read_bytes() + + +@pytest.fixture +def photos(settings, service): + settings.photo_root = settings.data_dir.parent / "photos" + photo(settings.photo_root / "family" / "a.jpg") + photo(settings.photo_root / "family" / "nested" / "b.png") + photo(settings.photo_root / "family" / "@eaDir" / "thumb.jpg") + (settings.photo_root / "family" / "note.txt").write_text("not a photo") + service.photo_sources.save( + "family", PhotoSourceInput(name="Family", kind="local", location="family") + ) + return service.photo_sources + + +def test_local_catalog_filters_and_paginates(client, photos): + out = client.get("/api/v1/photo-sources/family/images?limit=1").json() + assert out["total"] == 2 and len(out["items"]) == 1 + assert ( + client.get("/api/v1/photo-sources/family/images?offset=1").json()["items"][0][ + "name" + ] + == "b.png" + ) + assert client.get("/api/v1/photo-sources").json()[0]["id"] == "family" + + +@pytest.mark.parametrize("location", ["../", "/etc"]) +def test_path_escape_forbidden(client, photos, location): + r = client.put( + "/api/v1/photo-sources/escape", + json={"name": "escape", "kind": "local", "location": location}, + ) + assert r.status_code == 403 + + +def test_symlinks_and_post_catalog_swap_are_blocked(photos, settings): + outside = settings.data_dir.parent / "outside.jpg" + photo(outside) + (settings.photo_root / "family" / "link.jpg").symlink_to(outside) + items = photos.catalog("family", refresh=True) + assert len(items) == 2 + path = settings.photo_root / "family" / "a.jpg" + path.unlink() + path.symlink_to(outside) + with pytest.raises(FlowError): + photos.read("family", "family/a.jpg") + + +def test_random_preview_is_local_and_display_uses_same_asset(client, service, photos): + before = service.registry.get("living-room").get_current_image() + r = client.post( + "/api/v1/devices/living-room/upload", + json={"type": "photo_source", "source_id": "family"}, + ) + assert r.status_code == 202 + assert service.process_one() + op = client.get("/api/v1/operations/" + r.json()["operation_id"]).json() + assert op["status"] == "COMPLETED" + assert service.registry.get("living-room").get_current_image() == before + image = client.get(op["result"]["url"]) + assert image.status_code == 200 + assert Image.open(BytesIO(image.content)).size == (1200, 1600) + r = client.post( + "/api/v1/display", + json={"content": {"type": "image", "asset_id": op["result"]["asset_id"]}}, + ) + assert r.status_code == 202 and service.process_one() + + +def test_scheduler_source_persistence_and_delete_protection(app, client, photos): + payload = { + "name": "Random family photo", + "enabled": False, + "schedule": {"type": "daily", "time": "08:00"}, + "source": {"type": "photo_source", "source_id": "family"}, + } + job = client.post("/api/v1/jobs", json=payload) + assert job.status_code == 201 + assert app.state.scheduler.jobs()[0]["source"]["source_id"] == "family" + assert client.delete("/api/v1/photo-sources/family").status_code == 409 + assert client.delete("/api/v1/jobs/" + job.json()["id"]).status_code == 204 + assert client.delete("/api/v1/photo-sources/family").status_code == 204 + + +def test_empty_disabled_unknown_item(photos, settings): + with pytest.raises(FlowError): + photos.read("family", "../outside.jpg") + photos.save( + "family", + PhotoSourceInput(name="Family", kind="local", location="family", enabled=False), + ) + with pytest.raises(FlowError): + photos.read("family") + folder = settings.photo_root / "empty" + folder.mkdir() + photos.save("empty", PhotoSourceInput(name="Empty", kind="local", location="empty")) + with pytest.raises(FlowError): + photos.read("empty") + + +def test_webdav_metadata_cached_credentials_not_persisted( + service, settings, monkeypatch +): + settings.image_url_hosts = ["dav.example"] + credential = "FRAME_FLOW_SOURCE_TEST" + monkeypatch.setenv( + credential, json.dumps({"username": "test-user", "password": "test-password"}) + ) + provider = service.photo_sources + provider.save( + "dav", + PhotoSourceInput( + name="Cloud", + kind="webdav", + location="https://dav.example/photos", + credential_env=credential, + ), + ) + calls = [] + xml = b"""/photos/a.jpg/photos/a.jpghttps://evil.example/a.jpg/photos/%2e%2e/private.jpg/photos/sub""" + + def fetch(url, **kwargs): + calls.append((url, kwargs)) + return xml if kwargs.get("method") == "PROPFIND" else b"photo" + + monkeypatch.setattr(service.assets, "fetch_url", fetch) + items = provider.catalog("dav") + assert len(items) == 1 + assert provider.catalog("dav") == items and len(calls) == 1 + assert calls[0][1]["headers"]["Depth"] == "1" + assert provider.read("dav")[0] == b"photo" + assert provider.get("dav").recursive is False + stored = service.db.one( + "SELECT payload FROM app_config WHERE key='photo_source:dav'" + )["payload"] + assert "test-password" not in stored and "test-user" not in stored + assert "test-password" not in json.dumps( + settings.sanitize({"value": "test-password"}) + ) + + +@pytest.mark.parametrize("xml", [b"", b"