${esc(s.name)}
${esc(s.kind)} · ${t(s.enabled?'enabled':'disabled')}
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('sourceJobHint')}
${t('sourceHint')}
${esc(s.kind)} · ${t(s.enabled?'enabled':'disabled')}
${t('noSources')}
`}${t('sourceBrowseHint')}
${t('sourcePreviewHint')}