Add read-only NAS and WebDAV photo sources with random scheduling
This commit is contained in:
@@ -14,3 +14,9 @@ CORS_ALLOWED_ORIGINS=
|
|||||||
IMAGE_URL_ALLOWED_HOSTS=
|
IMAGE_URL_ALLOWED_HOSTS=
|
||||||
BUILD_COMMIT=development
|
BUILD_COMMIT=development
|
||||||
BUILD_TIME=unknown
|
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
|
||||||
|
|||||||
@@ -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;
|
`/api/v1/devices/{device_id}/official/connection`. Passwords are never persisted;
|
||||||
server-side sessions are stored as restricted secret files. No official widget
|
server-side sessions are stored as restricted secret files. No official widget
|
||||||
mutation or other extra RPC write is enabled by connecting.
|
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 的目录。
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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.
|
||||||
+4448
File diff suppressed because it is too large
Load Diff
@@ -42,6 +42,7 @@ from pigway_frame_flow.models import (
|
|||||||
JobInput,
|
JobInput,
|
||||||
Operation,
|
Operation,
|
||||||
Options,
|
Options,
|
||||||
|
PhotoSourceContent,
|
||||||
Playlist,
|
Playlist,
|
||||||
PlaylistInput,
|
PlaylistInput,
|
||||||
Profile,
|
Profile,
|
||||||
@@ -57,6 +58,11 @@ from pigway_frame_flow.models import (
|
|||||||
WeatherConfig,
|
WeatherConfig,
|
||||||
WeatherView,
|
WeatherView,
|
||||||
)
|
)
|
||||||
|
from pigway_frame_flow.providers.photo_sources import (
|
||||||
|
PhotoCatalog,
|
||||||
|
PhotoSourceInput,
|
||||||
|
PhotoSourceView,
|
||||||
|
)
|
||||||
from pigway_frame_flow.providers.weather import ConfigurableWeatherProvider
|
from pigway_frame_flow.providers.weather import ConfigurableWeatherProvider
|
||||||
from pigway_frame_flow.renderers.images import Assets, convert
|
from pigway_frame_flow.renderers.images import Assets, convert
|
||||||
from pigway_frame_flow.renderers.templates import TemplateRenderer
|
from pigway_frame_flow.renderers.templates import TemplateRenderer
|
||||||
@@ -426,7 +432,7 @@ def create_app(settings=None, start_worker=True):
|
|||||||
)
|
)
|
||||||
def upload(
|
def upload(
|
||||||
device_id: str,
|
device_id: str,
|
||||||
payload: ImageContent | URLContent,
|
payload: ImageContent | URLContent | PhotoSourceContent,
|
||||||
request: Request,
|
request: Request,
|
||||||
display_now: bool = False,
|
display_now: bool = False,
|
||||||
idempotency_key: str | None = Header(default=None),
|
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):
|
def asset_file(asset_id: str):
|
||||||
return FileResponse(assets.path(asset_id), media_type="image/jpeg")
|
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(
|
@app.delete(
|
||||||
"/api/v1/devices/{device_id}/images/{image_id}",
|
"/api/v1/devices/{device_id}/images/{image_id}",
|
||||||
response_model=Accepted,
|
response_model=Accepted,
|
||||||
|
|||||||
@@ -12,9 +12,11 @@ from pigway_frame_flow.models import (
|
|||||||
DisplayRequest,
|
DisplayRequest,
|
||||||
FutureContent,
|
FutureContent,
|
||||||
ImageContent,
|
ImageContent,
|
||||||
|
PhotoSourceContent,
|
||||||
TemplateContent,
|
TemplateContent,
|
||||||
URLContent,
|
URLContent,
|
||||||
)
|
)
|
||||||
|
from pigway_frame_flow.providers.photo_sources import PhotoSources
|
||||||
from pigway_frame_flow.renderers.images import convert
|
from pigway_frame_flow.renderers.images import convert
|
||||||
from pigway_frame_flow.storage.database import encode, now_iso
|
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."""
|
"""One persistent queue; injected driver registry. No vendor imports."""
|
||||||
|
|
||||||
def __init__(self, db, settings, registry: DriverRegistry, assets, renderer):
|
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 = (
|
self.db, self.settings, self.registry, self.assets, self.renderer = (
|
||||||
db,
|
db,
|
||||||
settings,
|
settings,
|
||||||
@@ -49,6 +52,8 @@ class DisplayService:
|
|||||||
)
|
)
|
||||||
if isinstance(request.content, TemplateContent):
|
if isinstance(request.content, TemplateContent):
|
||||||
self.renderer.info(request.content.template)
|
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:
|
if writing and not self.settings.mock and not self.settings.write_enabled:
|
||||||
raise FlowError(E.WRITE_DISABLED, "Real device writes are disabled", 403)
|
raise FlowError(E.WRITE_DISABLED, "Real device writes are disabled", 403)
|
||||||
|
|
||||||
@@ -167,6 +172,9 @@ class DisplayService:
|
|||||||
(asset["asset_id"], encode(source)),
|
(asset["asset_id"], encode(source)),
|
||||||
)
|
)
|
||||||
return asset
|
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):
|
if isinstance(content, URLContent):
|
||||||
data = self.assets.fetch_url(content.url)
|
data = self.assets.fetch_url(content.url)
|
||||||
elif isinstance(content, ImageContent) and content.asset_id:
|
elif isinstance(content, ImageContent) and content.asset_id:
|
||||||
|
|||||||
@@ -60,13 +60,21 @@ class TemplateContent(StrictModel):
|
|||||||
profile: Profile = Profile.color
|
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):
|
class FutureContent(StrictModel):
|
||||||
type: Literal["html", "markdown"]
|
type: Literal["html", "markdown"]
|
||||||
data: str = ""
|
data: str = ""
|
||||||
|
|
||||||
|
|
||||||
Content = Annotated[
|
Content = Annotated[
|
||||||
ImageContent | URLContent | TemplateContent | FutureContent,
|
ImageContent | URLContent | TemplateContent | PhotoSourceContent | FutureContent,
|
||||||
Field(discriminator="type"),
|
Field(discriminator="type"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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"<!DOCTYPE" in data.upper()
|
||||||
|
or b"<!ENTITY" in data.upper()
|
||||||
|
):
|
||||||
|
raise FlowError(E.INVALID_REQUEST, "Unsafe WebDAV XML")
|
||||||
|
try:
|
||||||
|
document = ElementTree.fromstring(data)
|
||||||
|
base = urlsplit(source.location)
|
||||||
|
prefix = posixpath.normpath(unquote(base.path)).rstrip("/") + "/"
|
||||||
|
seen = set()
|
||||||
|
for response in document.findall("{DAV:}response"):
|
||||||
|
href = response.findtext("{DAV:}href")
|
||||||
|
if not href or response.find(".//{DAV:}collection") is not None:
|
||||||
|
continue
|
||||||
|
url = urljoin(source.location, href)
|
||||||
|
parsed = urlsplit(url)
|
||||||
|
if (
|
||||||
|
(parsed.scheme, parsed.netloc) != (base.scheme, base.netloc)
|
||||||
|
or not posixpath.normpath(unquote(parsed.path)).startswith(
|
||||||
|
prefix
|
||||||
|
)
|
||||||
|
or "\\" in unquote(parsed.path)
|
||||||
|
or url in seen
|
||||||
|
or parsed.query
|
||||||
|
or parsed.fragment
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
if Path(parsed.path).suffix.lower() in self.extensions:
|
||||||
|
seen.add(url)
|
||||||
|
items.append(
|
||||||
|
{"id": url, "name": unquote(Path(parsed.path).name)}
|
||||||
|
)
|
||||||
|
if len(items) >= 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"]
|
||||||
@@ -114,7 +114,10 @@ class Assets:
|
|||||||
raise FlowError(E.INVALID_REQUEST, "Image exceeds 20 MiB")
|
raise FlowError(E.INVALID_REQUEST, "Image exceeds 20 MiB")
|
||||||
return data
|
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)
|
parsed = urlsplit(url)
|
||||||
if (
|
if (
|
||||||
parsed.scheme != "https"
|
parsed.scheme != "https"
|
||||||
@@ -153,16 +156,17 @@ class Assets:
|
|||||||
connection = PinnedHTTPS(parsed.hostname, timeout=10)
|
connection = PinnedHTTPS(parsed.hostname, timeout=10)
|
||||||
try:
|
try:
|
||||||
connection.request(
|
connection.request(
|
||||||
"GET",
|
method,
|
||||||
(parsed.path or "/") + ("?" + parsed.query if parsed.query else ""),
|
(parsed.path or "/") + ("?" + parsed.query if parsed.query else ""),
|
||||||
|
headers=headers or {},
|
||||||
)
|
)
|
||||||
response = connection.getresponse()
|
response = connection.getresponse()
|
||||||
if response.status != 200:
|
if response.status not in accepted_status:
|
||||||
raise FlowError(
|
raise FlowError(
|
||||||
E.INVALID_REQUEST, "Image URL returned an invalid response"
|
E.INVALID_REQUEST, "Image URL returned an invalid response"
|
||||||
)
|
)
|
||||||
data = response.read(self.settings.max_bytes + 1)
|
data = response.read(limit + 1)
|
||||||
if len(data) > self.settings.max_bytes:
|
if len(data) > limit:
|
||||||
raise FlowError(E.INVALID_REQUEST, "Image exceeds 20 MiB")
|
raise FlowError(E.INVALID_REQUEST, "Image exceeds 20 MiB")
|
||||||
return data
|
return data
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ class Settings:
|
|||||||
"FONT_PATH", "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc"
|
"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
|
max_bytes: int = 20 * 1024 * 1024
|
||||||
idempotency_hours: int = 168
|
idempotency_hours: int = 168
|
||||||
sync_timeout: int = 600
|
sync_timeout: int = 600
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ function displayDialog(content){
|
|||||||
showModal(`<h2>${t('displayOptions')}</h2><form id="display-options"><label class="check-row"><input id="override-restore" type="checkbox">${t('restoreAfter')}</label><div class="form-grid"><div class="field"><label for="override-duration">${t('duration')}</label><input id="override-duration" type="number" min="1" max="604800" value="1800"></div><div class="field"><label for="override-priority">${t('priority')}</label><input id="override-priority" type="number" min="0" max="100" value="50"></div></div><div class="modal-actions">${buttons('modal-cancel','cancel')}<button id="confirm-display" type="submit" class="primary">${t('display')}</button></div></form>`);
|
showModal(`<h2>${t('displayOptions')}</h2><form id="display-options"><label class="check-row"><input id="override-restore" type="checkbox">${t('restoreAfter')}</label><div class="form-grid"><div class="field"><label for="override-duration">${t('duration')}</label><input id="override-duration" type="number" min="1" max="604800" value="1800"></div><div class="field"><label for="override-priority">${t('priority')}</label><input id="override-priority" type="number" min="0" max="100" value="50"></div></div><div class="modal-actions">${buttons('modal-cancel','cancel')}<button id="confirm-display" type="submit" class="primary">${t('display')}</button></div></form>`);
|
||||||
$('#display-options').onsubmit=busy(async e=>{e.preventDefault();const restore=$('#override-restore').checked;const options={duration:restore?Number($('#override-duration').value):0,restore_previous:restore,priority:Number($('#override-priority').value)};await accepted('/api/v1/display',{device_id:state.device,content,options});$('#modal').close();await navigate('history');});
|
$('#display-options').onsubmit=busy(async e=>{e.preventDefault();const restore=$('#override-restore').checked;const options={duration:restore?Number($('#override-duration').value):0,restore_previous:restore,priority:Number($('#override-priority').value)};await accepted('/api/v1/display',{device_id:state.device,content,options});$('#modal').close();await navigate('history');});
|
||||||
}
|
}
|
||||||
function enhanceGallery(){
|
async function enhanceGallery(){
|
||||||
|
await photoSourcesPanel();
|
||||||
const grid=$('.gallery');if(!grid)return;
|
const grid=$('.gallery');if(!grid)return;
|
||||||
const controls=document.createElement('div');controls.className='form-grid';controls.innerHTML=`<div class="field"><label for="gallery-search">${t('searchImages')}</label><input id="gallery-search" type="search"></div><div class="field"><label for="gallery-sort">${t('mode')}</label><select id="gallery-sort"><option value="newest">${t('sortNewest')}</option><option value="oldest">${t('sortOldest')}</option><option value="name">${t('sortName')}</option></select></div>`;grid.before(controls);
|
const controls=document.createElement('div');controls.className='form-grid';controls.innerHTML=`<div class="field"><label for="gallery-search">${t('searchImages')}</label><input id="gallery-search" type="search"></div><div class="field"><label for="gallery-sort">${t('mode')}</label><select id="gallery-sort"><option value="newest">${t('sortNewest')}</option><option value="oldest">${t('sortOldest')}</option><option value="name">${t('sortName')}</option></select></div>`;grid.before(controls);
|
||||||
const cards=[...grid.querySelectorAll('.image-card')];$('#gallery-search').oninput=()=>{const query=$('#gallery-search').value.toLowerCase();cards.forEach(card=>card.hidden=!card.textContent.toLowerCase().includes(query));};$('#gallery-sort').onchange=()=>{const order=$('#gallery-sort').value;const indexes=cards.map((card,index)=>({card,item:state.gallery[index]}));indexes.sort((a,b)=>order==='name'?a.item.name.localeCompare(b.item.name):order==='oldest'?String(a.item.created_at).localeCompare(String(b.item.created_at)):String(b.item.created_at).localeCompare(String(a.item.created_at)));indexes.forEach(x=>grid.append(x.card));};
|
const cards=[...grid.querySelectorAll('.image-card')];$('#gallery-search').oninput=()=>{const query=$('#gallery-search').value.toLowerCase();cards.forEach(card=>card.hidden=!card.textContent.toLowerCase().includes(query));};$('#gallery-sort').onchange=()=>{const order=$('#gallery-sort').value;const indexes=cards.map((card,index)=>({card,item:state.gallery[index]}));indexes.sort((a,b)=>order==='name'?a.item.name.localeCompare(b.item.name):order==='oldest'?String(a.item.created_at).localeCompare(String(b.item.created_at)):String(b.item.created_at).localeCompare(String(a.item.created_at)));indexes.forEach(x=>grid.append(x.card));};
|
||||||
@@ -47,13 +48,14 @@ async function enhanceAutomation(){
|
|||||||
}
|
}
|
||||||
async function jobSourceEditor(job){
|
async function jobSourceEditor(job){
|
||||||
const raw=$('#job-source');const advanced=document.createElement('details');advanced.innerHTML=`<summary>${t('advanced')}</summary>`;raw.parentElement.before(advanced);advanced.append(raw.parentElement);
|
const raw=$('#job-source');const advanced=document.createElement('details');advanced.innerHTML=`<summary>${t('advanced')}</summary>`;raw.parentElement.before(advanced);advanced.append(raw.parentElement);
|
||||||
const box=document.createElement('div');advanced.before(box);let source=structuredClone(job.source);let images=[],assets=[];try{[images,assets]=await Promise.all([api('/api/v1/devices/'+state.device+'/images'),api('/api/v1/assets')]);}catch(e){toast(e.message);}if(!raw.isConnected)return;
|
const box=document.createElement('div');advanced.before(box);let source=structuredClone(job.source);let images=[],assets=[],photoSources=[];try{[images,assets,photoSources]=await Promise.all([api('/api/v1/devices/'+state.device+'/images'),api('/api/v1/assets'),api('/api/v1/photo-sources')]);}catch(e){toast(e.message);}if(!raw.isConnected)return;
|
||||||
box.innerHTML=`<div class="field"><label for="job-content-type">${t('sourceType')}</label><select id="job-content-type">${['template','image','image_url','restore'].map(k=>`<option value="${k}">${k==='template'?t('template'):k==='image'?t('imageChoice'):k==='restore'?t('restoreSource'):t('imageURL')}</option>`).join('')}</select></div><div id="job-content-fields"></div>`;
|
box.innerHTML=`<div class="field"><label for="job-content-type">${t('sourceType')}</label><select id="job-content-type">${['template','image','image_url','photo_source','restore'].map(k=>`<option value="${k}">${k==='template'?t('template'):k==='image'?t('imageChoice'):k==='restore'?t('restoreSource'):k==='photo_source'?t('photoSources'):t('imageURL')}</option>`).join('')}</select></div><div id="job-content-fields"></div>`;
|
||||||
$('#job-content-type').value=source.type;const old=$('#job-template')?.parentElement;if(old)old.remove();
|
$('#job-content-type').value=source.type;const old=$('#job-template')?.parentElement;if(old)old.remove();
|
||||||
function update(){raw.value=JSON.stringify(source,null,2);}
|
function update(){raw.value=JSON.stringify(source,null,2);}
|
||||||
function fields(){const type=$('#job-content-type').value;const panel=$('#job-content-fields');if(type==='restore'){source={type};panel.innerHTML='';$('#job-restore').checked=false;$('#job-duration').value=0;update();return;}
|
function fields(){const type=$('#job-content-type').value;const panel=$('#job-content-fields');if(type==='restore'){source={type};panel.innerHTML='';$('#job-restore').checked=false;$('#job-duration').value=0;update();return;}
|
||||||
if(type==='template'){const selected=state.templates.find(tpl=>tpl.id===source.template)||state.templates[0];source={type,template:selected.id,data:source.type==='template'?source.data||{}:{},profile:source.profile||'color'};panel.innerHTML=`<div class="field"><label for="job-template">${t('template')}</label><select id="job-template">${state.templates.map(x=>`<option value="${x.id}" ${selected.id===x.id?'selected':''}>${esc(x.name)}</option>`).join('')}</select></div><div class="field"><label for="job-source-profile">${t('profile')}</label><select id="job-source-profile"><option value="color">${t('color')}</option><option value="grayscale">${t('grayscale')}</option></select></div><div id="job-data-fields"></div>`;$('#job-source-profile').value=source.profile;$('#job-source-profile').onchange=()=>{source.profile=$('#job-source-profile').value;update();};const defaults={...selected.defaults,...source.data};const keys=[...new Set([...Object.keys(defaults).filter(k=>typeof defaults[k]!=='object'||Array.isArray(defaults[k])),...(['calendar','daily-dashboard'].includes(selected.id)?['events']:[])])];$('#job-data-fields').innerHTML=keys.map(k=>`<div class="field"><label for="job-field-${k}">${esc({title:state.lang==='zh'?'标题':'Title',message:state.lang==='zh'?'正文':'Message',quote:state.lang==='zh'?'一句话':'Quote',author:state.lang==='zh'?'作者':'Author',events:t('eventsHelp')}[k]||k)}</label><textarea id="job-field-${k}" data-job-field="${k}" rows="2">${esc(Array.isArray(defaults[k])?defaults[k].map(x=>typeof x==='object'?x.title:x).join('\n'):defaults[k]||'')}</textarea></div>`).join('');$('#job-template').onchange=()=>{source={type:'template',template:$('#job-template').value,data:{}};fields();};panel.querySelectorAll('[data-job-field]').forEach(el=>el.oninput=()=>{source.data[el.dataset.jobField]=el.dataset.jobField==='events'?el.value.split('\n').filter(x=>x.trim()):el.value;update();});update();}
|
if(type==='template'){const selected=state.templates.find(tpl=>tpl.id===source.template)||state.templates[0];source={type,template:selected.id,data:source.type==='template'?source.data||{}:{},profile:source.profile||'color'};panel.innerHTML=`<div class="field"><label for="job-template">${t('template')}</label><select id="job-template">${state.templates.map(x=>`<option value="${x.id}" ${selected.id===x.id?'selected':''}>${esc(x.name)}</option>`).join('')}</select></div><div class="field"><label for="job-source-profile">${t('profile')}</label><select id="job-source-profile"><option value="color">${t('color')}</option><option value="grayscale">${t('grayscale')}</option></select></div><div id="job-data-fields"></div>`;$('#job-source-profile').value=source.profile;$('#job-source-profile').onchange=()=>{source.profile=$('#job-source-profile').value;update();};const defaults={...selected.defaults,...source.data};const keys=[...new Set([...Object.keys(defaults).filter(k=>typeof defaults[k]!=='object'||Array.isArray(defaults[k])),...(['calendar','daily-dashboard'].includes(selected.id)?['events']:[])])];$('#job-data-fields').innerHTML=keys.map(k=>`<div class="field"><label for="job-field-${k}">${esc({title:state.lang==='zh'?'标题':'Title',message:state.lang==='zh'?'正文':'Message',quote:state.lang==='zh'?'一句话':'Quote',author:state.lang==='zh'?'作者':'Author',events:t('eventsHelp')}[k]||k)}</label><textarea id="job-field-${k}" data-job-field="${k}" rows="2">${esc(Array.isArray(defaults[k])?defaults[k].map(x=>typeof x==='object'?x.title:x).join('\n'):defaults[k]||'')}</textarea></div>`).join('');$('#job-template').onchange=()=>{source={type:'template',template:$('#job-template').value,data:{}};fields();};panel.querySelectorAll('[data-job-field]').forEach(el=>el.oninput=()=>{source.data[el.dataset.jobField]=el.dataset.jobField==='events'?el.value.split('\n').filter(x=>x.trim()):el.value;update();});update();}
|
||||||
else if(type==='image'){const selected=source.image_id?'cloud:'+source.image_id:source.asset_id?'local:'+source.asset_id:'';panel.innerHTML=`<div class="field"><label for="job-image">${t('imageChoice')}</label><select id="job-image" required><option value="">—</option>${images.map(i=>`<option value="cloud:${i.image_id}">${esc(i.name)} · #${i.image_id}</option>`).join('')}${assets.map(a=>`<option value="local:${a.asset_id}">${esc(a.name)} · LOCAL</option>`).join('')}</select></div>`;$('#job-image').value=selected;$('#job-image').onchange=()=>{const [kind,id]=$('#job-image').value.split(':');source=kind==='cloud'?{type:'image',image_id:Number(id)}:{type:'image',asset_id:id};update();};}
|
else if(type==='image'){const selected=source.image_id?'cloud:'+source.image_id:source.asset_id?'local:'+source.asset_id:'';panel.innerHTML=`<div class="field"><label for="job-image">${t('imageChoice')}</label><select id="job-image" required><option value="">—</option>${images.map(i=>`<option value="cloud:${i.image_id}">${esc(i.name)} · #${i.image_id}</option>`).join('')}${assets.map(a=>`<option value="local:${a.asset_id}">${esc(a.name)} · LOCAL</option>`).join('')}</select></div>`;$('#job-image').value=selected;$('#job-image').onchange=()=>{const [kind,id]=$('#job-image').value.split(':');source=kind==='cloud'?{type:'image',image_id:Number(id)}:{type:'image',asset_id:id};update();};}
|
||||||
|
else if(type==='photo_source'){panel.innerHTML=`<div class="field"><label for="job-photo-source">${t('photoSources')}</label><select id="job-photo-source" required><option value="">—</option>${photoSources.map(x=>`<option value="${esc(x.id)}">${esc(x.name)}</option>`).join('')}</select><p class="hint">${t('sourceJobHint')}</p></div>`;$('#job-photo-source').value=source.source_id||'';$('#job-photo-source').onchange=()=>{source={type:'photo_source',source_id:$('#job-photo-source').value,fit:'contain',profile:'color'};update();};}
|
||||||
else{panel.innerHTML=`<div class="field"><label for="job-url">${t('imageURL')}</label><input id="job-url" type="url" placeholder="https://…" value="${esc(source.type==='image_url'?source.url:'')}" required></div>`;$('#job-url').oninput=()=>{source={type:'image_url',url:$('#job-url').value};update();};}}
|
else{panel.innerHTML=`<div class="field"><label for="job-url">${t('imageURL')}</label><input id="job-url" type="url" placeholder="https://…" value="${esc(source.type==='image_url'?source.url:'')}" required></div>`;$('#job-url').oninput=()=>{source={type:'image_url',url:$('#job-url').value};update();};}}
|
||||||
$('#job-content-type').onchange=()=>{source={type:$('#job-content-type').value};update();fields();};raw.oninput=()=>{try{source=JSON.parse(raw.value);raw.setCustomValidity('');$('#job-content-type').value=source.type;fields();}catch{raw.setCustomValidity(t('INVALID_REQUEST'));}};fields();
|
$('#job-content-type').onchange=()=>{source={type:$('#job-content-type').value};update();fields();};raw.oninput=()=>{try{source=JSON.parse(raw.value);raw.setCustomValidity('');$('#job-content-type').value=source.type;fields();}catch{raw.setCustomValidity(t('INVALID_REQUEST'));}};fields();
|
||||||
}
|
}
|
||||||
@@ -102,3 +104,32 @@ async function officialConnectionPanel(info){
|
|||||||
};
|
};
|
||||||
try{render(await api(path));}catch{panel.textContent=zh?'官方账号连接由管理员管理':'Official account connection is managed by an administrator';}
|
try{render(await api(path));}catch{panel.textContent=zh?'官方账号连接由管理员管理':'Official account connection is managed by an administrator';}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function photoSourcesPanel(){
|
||||||
|
const section=document.createElement('section');section.className='card photo-sources-panel';$('#main').append(section);
|
||||||
|
const sources=await api('/api/v1/photo-sources');if(!section.isConnected)return;
|
||||||
|
section.innerHTML=`<div class="section-heading"><div><h3>${t('photoSources')}</h3><p class="hint">${t('sourceHint')}</p></div>${buttons('add-photo-source','addSource')}</div><div class="source-list">${sources.length?sources.map(s=>`<article class="source-card"><h4>${esc(s.name)}</h4><p class="hint">${esc(s.kind)} · ${t(s.enabled?'enabled':'disabled')}</p><div class="actions"><button data-source-browse="${esc(s.id)}">${t('browse')}</button><button data-source-random="${esc(s.id)}" ${s.enabled?'':'disabled'}>${t('randomPreview')}</button><button data-source-job="${esc(s.id)}">${t('sourceJob')}</button><button data-source-edit="${esc(s.id)}">${t('edit')}</button><button class="danger" data-source-delete="${esc(s.id)}">${t('remove')}</button></div></article>`).join(''):`<p class="hint">${t('noSources')}</p>`}</div><div id="source-browser"></div>`;
|
||||||
|
bind('add-photo-source',()=>photoSourceForm());
|
||||||
|
section.querySelectorAll('[data-source-edit]').forEach(b=>b.onclick=()=>photoSourceForm(sources.find(s=>s.id===b.dataset.sourceEdit)));
|
||||||
|
section.querySelectorAll('[data-source-delete]').forEach(b=>b.onclick=()=>confirmDialog(t('removeConfirm'),async()=>{await api('/api/v1/photo-sources/'+b.dataset.sourceDelete,{method:'DELETE'});await navigate('gallery');}));
|
||||||
|
section.querySelectorAll('[data-source-random]').forEach(b=>b.onclick=busy(()=>previewSource(b.dataset.sourceRandom)));
|
||||||
|
section.querySelectorAll('[data-source-browse]').forEach(b=>b.onclick=busy(()=>browseSource(b.dataset.sourceBrowse)));
|
||||||
|
section.querySelectorAll('[data-source-job]').forEach(b=>b.onclick=()=>sourceJobForm(b.dataset.sourceJob));
|
||||||
|
}
|
||||||
|
function photoSourceForm(source=null){
|
||||||
|
showModal(`<h2>${t(source?'edit':'addSource')}</h2><form id="photo-source-form"><div class="field"><label for="source-id">ID</label><input id="source-id" pattern="[a-z0-9-]{1,60}" required value="${esc(source?.id||'')}" ${source?'readonly':''}></div><div class="field"><label for="source-name">${t('name')}</label><input id="source-name" required maxlength="120" value="${esc(source?.name||'')}"></div><div class="field"><label for="source-kind">${t('sourceKind')}</label><select id="source-kind"><option value="local">${t('nasDirectory')}</option><option value="webdav">WebDAV</option></select></div><div class="field"><label for="source-location">${t('sourceLocation')}</label><input id="source-location" required maxlength="2048" value="${esc(source?.location||'.')}"><p class="hint">${t('sourceLocationHint')}</p></div><div class="field"><label for="source-credential">${t('sourceCredential')}</label><input id="source-credential" pattern="FRAME_FLOW_SOURCE_[A-Z0-9_]+" value="${esc(source?.credential_env||'')}" placeholder="FRAME_FLOW_SOURCE_CLOUD"><p class="hint">${t('sourceCredentialHint')}</p></div><div class="field"><label class="check-row"><input id="source-recursive" type="checkbox" ${source?.recursive===false?'':'checked'}>${t('sourceRecursive')}</label><label class="check-row"><input id="source-enabled" type="checkbox" ${source?.enabled===false?'':'checked'}>${t('enabled')}</label></div><div class="modal-actions">${buttons('modal-cancel','cancel')}<button type="submit" class="primary">${t('save')}</button></div></form>`);
|
||||||
|
$('#source-kind').value=source?.kind||'local';const update=()=>{const dav=$('#source-kind').value==='webdav';$('#source-credential').disabled=!dav;$('#source-recursive').disabled=dav;};update();$('#source-kind').onchange=update;
|
||||||
|
$('#photo-source-form').onsubmit=busy(async e=>{e.preventDefault();const dav=$('#source-kind').value==='webdav';await api('/api/v1/photo-sources/'+$('#source-id').value,{method:'PUT',body:{name:$('#source-name').value,kind:$('#source-kind').value,location:$('#source-location').value,recursive:!dav&&$('#source-recursive').checked,enabled:$('#source-enabled').checked,credential_env:dav?($('#source-credential').value||null):null}});$('#modal').close();await navigate('gallery');});
|
||||||
|
}
|
||||||
|
async function browseSource(id,offset=0,refresh=false){
|
||||||
|
const result=await api('/api/v1/photo-sources/'+id+'/images?offset='+offset+'&limit=50&refresh='+refresh);const box=$('#source-browser');if(!box)return;
|
||||||
|
box.innerHTML=`<h4>${t('sourceImages')} · ${result.total}${result.total>=result.catalog_limit?' (limit)':''}</h4><p class="hint">${t('sourceBrowseHint')}</p><div class="source-items">${result.items.map((x,i)=>`<button data-source-item="${i}">${esc(x.name)}</button>`).join('')||t('noSources')}</div><div class="actions"><button id="source-prev" ${offset?'':'disabled'}>${t('previousPage')}</button><button id="source-next" ${offset+result.items.length<result.total?'':'disabled'}>${t('nextPage')}</button>${buttons('source-refresh','refresh')}</div>`;
|
||||||
|
box.querySelectorAll('[data-source-item]').forEach(b=>b.onclick=busy(()=>previewSource(id,result.items[Number(b.dataset.sourceItem)].id)));bind('source-prev',()=>browseSource(id,Math.max(0,offset-50)));bind('source-next',()=>browseSource(id,offset+50));bind('source-refresh',()=>browseSource(id,0,true));
|
||||||
|
}
|
||||||
|
async function previewSource(id,item_id=null){
|
||||||
|
const acceptedResult=await api('/api/v1/devices/'+state.device+'/upload',{method:'POST',body:{type:'photo_source',source_id:id,item_id,fit:'contain',profile:'color'},headers:{'Idempotency-Key':clientUUID()}});const op=await waitOperation(acceptedResult.operation_id);
|
||||||
|
showModal(`<h2>${t('preview')}</h2><img id="source-preview" class="preview" alt="${t('preview')}"><p class="hint">${t('sourcePreviewHint')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}${buttons('source-display','display',true)}</div>`);await blobImage($('#source-preview'),op.result.url);$('#source-display').disabled=!liveWriteAllowed();bind('source-display',()=>displayDialog({type:'image',asset_id:op.result.asset_id}));
|
||||||
|
}
|
||||||
|
function sourceJobForm(id){
|
||||||
|
showModal(`<h2>${t('sourceJob')}</h2><form id="source-job-form"><div class="field"><label for="source-job-name">${t('name')}</label><input id="source-job-name" required maxlength="120" value="${t('randomPhoto')}"></div><div class="field"><label for="source-job-cron">Cron · Asia/Shanghai</label><input id="source-job-cron" required value="0 8 * * *"><p class="hint">${t('sourceJobHint')}</p></div><div class="field"><label class="check-row"><input id="source-job-enabled" type="checkbox">${t('enabled')}</label></div><div class="modal-actions">${buttons('modal-cancel','cancel')}<button class="primary" type="submit">${t('save')}</button></div></form>`);$('#source-job-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/jobs',{method:'POST',body:{name:$('#source-job-name').value,device_id:state.device,enabled:$('#source-job-enabled').checked,schedule:{type:'cron',cron:$('#source-job-cron').value},source:{type:'photo_source',source_id:id,fit:'contain',profile:'color'}}});$('#modal').close();await navigate('automation');});
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,3 +16,6 @@ Object.assign(window.MESSAGES.zh,{poetryNotice:'Frame Flow 每日诗词:内置
|
|||||||
|
|
||||||
Object.assign(MESSAGES.zh,{officialContentTitle:'KoKonna 官方小组件与订阅',officialContentDescription:'官方产品资料列出的内容类型;下列功能与 Frame Flow 自建模板分开管理。',officialCalendarWeather:'日历与当地天气',officialWordAlmanacTravel:'每日一词、传统黄历、全球旅行推荐',officialNews:'每日新闻',officialCreative:'AI 语音 / 文字 / 涂鸦 / 照片转艺术;照片轮播',officialSubscriptionUnavailable:'公开 OpenAPI 尚未列出订阅目录、当前订阅、开通或取消订阅接口。官方订阅请继续在官方小程序中管理;Frame Flow 不会读取或修改这些设置。你使用的每日诗词属于官方小程序内容,当前无法通过公开 API 确认完整类型列表。',officialProductDocs:'官方内容介绍 ↗',officialAPIDocs:'官方 OpenAPI ↗'});
|
Object.assign(MESSAGES.zh,{officialContentTitle:'KoKonna 官方小组件与订阅',officialContentDescription:'官方产品资料列出的内容类型;下列功能与 Frame Flow 自建模板分开管理。',officialCalendarWeather:'日历与当地天气',officialWordAlmanacTravel:'每日一词、传统黄历、全球旅行推荐',officialNews:'每日新闻',officialCreative:'AI 语音 / 文字 / 涂鸦 / 照片转艺术;照片轮播',officialSubscriptionUnavailable:'公开 OpenAPI 尚未列出订阅目录、当前订阅、开通或取消订阅接口。官方订阅请继续在官方小程序中管理;Frame Flow 不会读取或修改这些设置。你使用的每日诗词属于官方小程序内容,当前无法通过公开 API 确认完整类型列表。',officialProductDocs:'官方内容介绍 ↗',officialAPIDocs:'官方 OpenAPI ↗'});
|
||||||
Object.assign(MESSAGES.en,{officialContentTitle:'KoKonna official widgets and subscriptions',officialContentDescription:'Content types listed in official product materials; managed separately from Frame Flow templates.',officialCalendarWeather:'Calendar and local weather',officialWordAlmanacTravel:'Word of the day, traditional almanac and global travel recommendations',officialNews:'Daily news',officialCreative:'AI voice / text / sketch / photo-to-art; photo rotation',officialSubscriptionUnavailable:'Public OpenAPI does not list subscription discovery, current subscription, subscribe or unsubscribe endpoints. Manage official subscriptions in the official mini-program. Frame Flow cannot read or modify these settings. Your daily-poetry subscription is mini-program content; its complete catalog cannot currently be verified through public API.',officialProductDocs:'Official content overview ↗',officialAPIDocs:'Official OpenAPI ↗'});
|
Object.assign(MESSAGES.en,{officialContentTitle:'KoKonna official widgets and subscriptions',officialContentDescription:'Content types listed in official product materials; managed separately from Frame Flow templates.',officialCalendarWeather:'Calendar and local weather',officialWordAlmanacTravel:'Word of the day, traditional almanac and global travel recommendations',officialNews:'Daily news',officialCreative:'AI voice / text / sketch / photo-to-art; photo rotation',officialSubscriptionUnavailable:'Public OpenAPI does not list subscription discovery, current subscription, subscribe or unsubscribe endpoints. Manage official subscriptions in the official mini-program. Frame Flow cannot read or modify these settings. Your daily-poetry subscription is mini-program content; its complete catalog cannot currently be verified through public API.',officialProductDocs:'Official content overview ↗',officialAPIDocs:'Official OpenAPI ↗'});
|
||||||
|
|
||||||
|
Object.assign(MESSAGES.zh,{photoSources:'照片数据源',sourceHint:'从只读 NAS 目录或 WebDAV 选图;预览不会改变画框。',addSource:'添加数据源',browse:'浏览照片',randomPreview:'随机选图预览',sourceJob:'定时随机照片',noSources:'暂无照片,请添加数据源或检查目录。',sourceKind:'数据源类型',nasDirectory:'NAS 本地目录',sourceLocation:'目录 / WebDAV URL',sourceLocationHint:'本地填写挂载照片根目录下的相对路径,如 . 或 family。WebDAV 填写 HTTPS 目录 URL,并先在设置中允许该域名。',sourceCredential:'WebDAV 凭据环境变量名',sourceCredentialHint:'仅填写变量名,禁止填写密码。管理员通过环境变量或 Secret 配置 JSON username/password。',sourceRecursive:'包含子目录(本地目录)',sourceImages:'可用照片',sourceBrowseHint:'按需读取目录,点击文件名才生成预览;不会下载全部原图。',previousPage:'上一页',nextPage:'下一页',sourcePreviewHint:'这是已选中照片的预览,显示时使用同一张照片。',randomPhoto:'随机照片',sourceJobHint:'每次执行重新随机选图。默认暂停;启用后会按时改变画框显示。'});
|
||||||
|
Object.assign(MESSAGES.en,{photoSources:'Photo sources',sourceHint:'Choose photos from a read-only NAS folder or WebDAV. Preview does not change the frame.',addSource:'Add source',browse:'Browse photos',randomPreview:'Preview random photo',sourceJob:'Schedule random photos',noSources:'No photos yet. Add a source or check the folder.',sourceKind:'Source type',nasDirectory:'NAS local folder',sourceLocation:'Folder / WebDAV URL',sourceLocationHint:'Local: relative folder under the mounted photo root, e.g. . or family. WebDAV: HTTPS directory URL; allow its hostname in Settings first.',sourceCredential:'WebDAV credential environment name',sourceCredentialHint:'Enter a variable name, never a password. Admin configures JSON username/password via environment or Secret.',sourceRecursive:'Include subfolders (local)',sourceImages:'Available photos',sourceBrowseHint:'Catalog loaded on demand. Click a filename to preview; originals are not downloaded in bulk.',previousPage:'Previous page',nextPage:'Next page',sourcePreviewHint:'Display uses this exact previewed photo.',randomPhoto:'Random photo',sourceJobHint:'Choose a fresh random photo each run. Paused by default; enabling changes the frame on schedule.'});
|
||||||
|
|||||||
@@ -48,3 +48,5 @@ input[type="file"]::file-selector-button{margin-inline-end:12px}
|
|||||||
.official-account-panel{margin:20px 0;padding:20px;border:1px solid var(--border);border-radius:12px}.official-account-panel>p{margin:14px 0}#official-device-picker{margin-top:18px}
|
.official-account-panel{margin:20px 0;padding:20px;border:1px solid var(--border);border-radius:12px}.official-account-panel>p{margin:14px 0}#official-device-picker{margin-top:18px}
|
||||||
|
|
||||||
.history-table{min-width:920px;table-layout:fixed}.history-table th:nth-child(1){width:145px}.history-table th:nth-child(2){width:100px}.history-table th:nth-child(3){width:100px}.history-table th:nth-child(5){width:100px}.history-table th:last-child{width:120px}.history-table td{overflow-wrap:anywhere}.history-table .history-actions{vertical-align:middle;white-space:nowrap}.history-actions button{white-space:nowrap}
|
.history-table{min-width:920px;table-layout:fixed}.history-table th:nth-child(1){width:145px}.history-table th:nth-child(2){width:100px}.history-table th:nth-child(3){width:100px}.history-table th:nth-child(5){width:100px}.history-table th:last-child{width:120px}.history-table td{overflow-wrap:anywhere}.history-table .history-actions{vertical-align:middle;white-space:nowrap}.history-actions button{white-space:nowrap}
|
||||||
|
|
||||||
|
.photo-sources-panel{margin-top:24px}.source-list{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,280px),1fr));gap:20px}.source-card{border:1px solid var(--border);border-radius:12px;padding:18px}.source-card h4{margin:0 0 12px}.source-card .actions{margin-top:16px}.source-items{display:flex;flex-wrap:wrap;gap:12px;margin:18px 0}.source-items button{max-width:100%;overflow-wrap:anywhere}#source-browser>h4{margin-top:24px}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
/* Local mock server only; no vendor/cloud writes. PHOTO_ROOT must contain a fixture image. */
|
||||||
|
const assert=require('assert'),{chromium}=require('playwright');
|
||||||
|
(async()=>{const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});const page=await browser.newPage();await page.goto('http://127.0.0.1:18787');await page.locator('#username').fill(process.env.FRAME_FLOW_TEST_USERNAME);await page.locator('#password').fill(process.env.FRAME_FLOW_TEST_PASSWORD);await page.locator('#login-form button').click();await page.locator('.hero').waitFor();assert(await page.evaluate(()=>state.settings.mock));const id='ui-photo-'+Date.now();let jobId;let checks=0;try{await page.locator('[data-page="gallery"]').click();await page.locator('#add-photo-source').click();await page.locator('#source-id').fill(id);await page.locator('#source-name').fill('UI photo fixture');await page.locator('#source-location').fill('.');await page.locator('#photo-source-form button[type="submit"]').click();await page.locator(`[data-source-browse="${id}"]`).waitFor();for(const lang of ['zh','en'])for(const width of [1440,390])for(const theme of ['dark','light']){await page.setViewportSize({width,height:1000});await page.evaluate(({lang,theme})=>{state.lang=lang;document.documentElement.dataset.theme=theme;shell();},{lang,theme});await page.locator('[data-page="gallery"]').click();await page.locator(`[data-source-browse="${id}"]`).click();await page.locator('[data-source-item]').first().waitFor();assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));await page.locator(`[data-source-edit="${id}"]`).click();assert(await page.locator('#source-id').inputValue()===id);await page.locator('#modal-cancel').click();checks++;}await page.locator(`[data-source-random="${id}"]`).click();await page.locator('#source-preview').waitFor();await page.waitForFunction(()=>document.querySelector('#source-preview')?.naturalWidth>0);const before=await page.evaluate(()=>api('/api/v1/devices/'+state.device));await page.locator('#modal-cancel').click();await page.locator(`[data-source-job="${id}"]`).click();assert(!await page.locator('#source-job-enabled').isChecked());await page.locator('#source-job-name').fill(id);await page.locator('#source-job-form button[type="submit"]').click();await page.locator('#new-job').waitFor();jobId=await page.evaluate(async id=>(await api('/api/v1/jobs')).find(j=>j.name===id).id,id);await page.evaluate(async jobId=>{const job=(await api('/api/v1/jobs')).find(j=>j.id===jobId);await jobEditor(job);},jobId);await page.locator('#job-photo-source').waitFor();assert(await page.locator('#job-content-type').inputValue()==='photo_source');assert(await page.locator('#job-photo-source').inputValue()===id);await page.locator('#modal-cancel').click();const after=await page.evaluate(()=>api('/api/v1/devices/'+state.device));assert(before.current_content.id===after.current_content.id);checks+=3;console.log('Photo sources browser: '+checks+' passed (8 language/size/theme profiles, preview and scheduler editing)');}finally{await page.evaluate(async({id,jobId})=>{if(jobId)await api('/api/v1/jobs/'+jobId,{method:'DELETE'});await api('/api/v1/photo-sources/'+id,{method:'DELETE'});},{id,jobId});await browser.close();}})().catch(e=>{console.error(e);process.exit(1)});
|
||||||
+136
-74
@@ -317,6 +317,68 @@
|
|||||||
"503"
|
"503"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"/api/v1/photo-sources": {
|
||||||
|
"get": [
|
||||||
|
"200",
|
||||||
|
"400",
|
||||||
|
"401",
|
||||||
|
"403",
|
||||||
|
"404",
|
||||||
|
"409",
|
||||||
|
"413",
|
||||||
|
"422",
|
||||||
|
"429",
|
||||||
|
"500",
|
||||||
|
"502",
|
||||||
|
"503"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"/api/v1/photo-sources/{source_id}": {
|
||||||
|
"put": [
|
||||||
|
"200",
|
||||||
|
"400",
|
||||||
|
"401",
|
||||||
|
"403",
|
||||||
|
"404",
|
||||||
|
"409",
|
||||||
|
"413",
|
||||||
|
"422",
|
||||||
|
"429",
|
||||||
|
"500",
|
||||||
|
"502",
|
||||||
|
"503"
|
||||||
|
],
|
||||||
|
"delete": [
|
||||||
|
"204",
|
||||||
|
"400",
|
||||||
|
"401",
|
||||||
|
"403",
|
||||||
|
"404",
|
||||||
|
"409",
|
||||||
|
"413",
|
||||||
|
"422",
|
||||||
|
"429",
|
||||||
|
"500",
|
||||||
|
"502",
|
||||||
|
"503"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"/api/v1/photo-sources/{source_id}/images": {
|
||||||
|
"get": [
|
||||||
|
"200",
|
||||||
|
"400",
|
||||||
|
"401",
|
||||||
|
"403",
|
||||||
|
"404",
|
||||||
|
"409",
|
||||||
|
"413",
|
||||||
|
"422",
|
||||||
|
"429",
|
||||||
|
"500",
|
||||||
|
"502",
|
||||||
|
"503"
|
||||||
|
]
|
||||||
|
},
|
||||||
"/api/v1/devices/{device_id}/images/{image_id}": {
|
"/api/v1/devices/{device_id}/images/{image_id}": {
|
||||||
"delete": [
|
"delete": [
|
||||||
"202",
|
"202",
|
||||||
@@ -625,6 +687,80 @@
|
|||||||
"503"
|
"503"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"/api/v1/devices/{device_id}/official/connection": {
|
||||||
|
"get": [
|
||||||
|
"200",
|
||||||
|
"400",
|
||||||
|
"401",
|
||||||
|
"403",
|
||||||
|
"404",
|
||||||
|
"409",
|
||||||
|
"413",
|
||||||
|
"422",
|
||||||
|
"429",
|
||||||
|
"500",
|
||||||
|
"502",
|
||||||
|
"503"
|
||||||
|
],
|
||||||
|
"post": [
|
||||||
|
"200",
|
||||||
|
"400",
|
||||||
|
"401",
|
||||||
|
"403",
|
||||||
|
"404",
|
||||||
|
"409",
|
||||||
|
"413",
|
||||||
|
"422",
|
||||||
|
"429",
|
||||||
|
"500",
|
||||||
|
"502",
|
||||||
|
"503"
|
||||||
|
],
|
||||||
|
"put": [
|
||||||
|
"200",
|
||||||
|
"400",
|
||||||
|
"401",
|
||||||
|
"403",
|
||||||
|
"404",
|
||||||
|
"409",
|
||||||
|
"413",
|
||||||
|
"422",
|
||||||
|
"429",
|
||||||
|
"500",
|
||||||
|
"502",
|
||||||
|
"503"
|
||||||
|
],
|
||||||
|
"delete": [
|
||||||
|
"200",
|
||||||
|
"400",
|
||||||
|
"401",
|
||||||
|
"403",
|
||||||
|
"404",
|
||||||
|
"409",
|
||||||
|
"413",
|
||||||
|
"422",
|
||||||
|
"429",
|
||||||
|
"500",
|
||||||
|
"502",
|
||||||
|
"503"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"/api/v1/devices/{device_id}/widgets": {
|
||||||
|
"get": [
|
||||||
|
"200",
|
||||||
|
"400",
|
||||||
|
"401",
|
||||||
|
"403",
|
||||||
|
"404",
|
||||||
|
"409",
|
||||||
|
"413",
|
||||||
|
"422",
|
||||||
|
"429",
|
||||||
|
"500",
|
||||||
|
"502",
|
||||||
|
"503"
|
||||||
|
]
|
||||||
|
},
|
||||||
"/api/v1/devices/{device_id}/current-content": {
|
"/api/v1/devices/{device_id}/current-content": {
|
||||||
"get": [
|
"get": [
|
||||||
"200",
|
"200",
|
||||||
@@ -766,79 +902,5 @@
|
|||||||
"502",
|
"502",
|
||||||
"503"
|
"503"
|
||||||
]
|
]
|
||||||
},
|
|
||||||
"/api/v1/devices/{device_id}/widgets": {
|
|
||||||
"get": [
|
|
||||||
"200",
|
|
||||||
"400",
|
|
||||||
"401",
|
|
||||||
"403",
|
|
||||||
"404",
|
|
||||||
"409",
|
|
||||||
"413",
|
|
||||||
"422",
|
|
||||||
"429",
|
|
||||||
"500",
|
|
||||||
"502",
|
|
||||||
"503"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"/api/v1/devices/{device_id}/official/connection": {
|
|
||||||
"get": [
|
|
||||||
"200",
|
|
||||||
"400",
|
|
||||||
"401",
|
|
||||||
"403",
|
|
||||||
"404",
|
|
||||||
"409",
|
|
||||||
"413",
|
|
||||||
"422",
|
|
||||||
"429",
|
|
||||||
"500",
|
|
||||||
"502",
|
|
||||||
"503"
|
|
||||||
],
|
|
||||||
"post": [
|
|
||||||
"200",
|
|
||||||
"400",
|
|
||||||
"401",
|
|
||||||
"403",
|
|
||||||
"404",
|
|
||||||
"409",
|
|
||||||
"413",
|
|
||||||
"422",
|
|
||||||
"429",
|
|
||||||
"500",
|
|
||||||
"502",
|
|
||||||
"503"
|
|
||||||
],
|
|
||||||
"put": [
|
|
||||||
"200",
|
|
||||||
"400",
|
|
||||||
"401",
|
|
||||||
"403",
|
|
||||||
"404",
|
|
||||||
"409",
|
|
||||||
"413",
|
|
||||||
"422",
|
|
||||||
"429",
|
|
||||||
"500",
|
|
||||||
"502",
|
|
||||||
"503"
|
|
||||||
],
|
|
||||||
"delete": [
|
|
||||||
"200",
|
|
||||||
"400",
|
|
||||||
"401",
|
|
||||||
"403",
|
|
||||||
"404",
|
|
||||||
"409",
|
|
||||||
"413",
|
|
||||||
"422",
|
|
||||||
"429",
|
|
||||||
"500",
|
|
||||||
"502",
|
|
||||||
"503"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -355,6 +355,9 @@
|
|||||||
{
|
{
|
||||||
"$ref": "#/components/schemas/TemplateContent"
|
"$ref": "#/components/schemas/TemplateContent"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/PhotoSourceContent"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"$ref": "#/components/schemas/FutureContent"
|
"$ref": "#/components/schemas/FutureContent"
|
||||||
}
|
}
|
||||||
@@ -367,6 +370,7 @@
|
|||||||
"image": "#/components/schemas/ImageContent",
|
"image": "#/components/schemas/ImageContent",
|
||||||
"image_url": "#/components/schemas/URLContent",
|
"image_url": "#/components/schemas/URLContent",
|
||||||
"markdown": "#/components/schemas/FutureContent",
|
"markdown": "#/components/schemas/FutureContent",
|
||||||
|
"photo_source": "#/components/schemas/PhotoSourceContent",
|
||||||
"template": "#/components/schemas/TemplateContent"
|
"template": "#/components/schemas/TemplateContent"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -752,5 +756,215 @@
|
|||||||
"error"
|
"error"
|
||||||
],
|
],
|
||||||
"title": "ErrorResponse"
|
"title": "ErrorResponse"
|
||||||
|
},
|
||||||
|
"PhotoSourceContent": {
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"const": "photo_source",
|
||||||
|
"title": "Type"
|
||||||
|
},
|
||||||
|
"source_id": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-z0-9-]{1,60}$",
|
||||||
|
"title": "Source Id"
|
||||||
|
},
|
||||||
|
"item_id": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 2048
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Item Id"
|
||||||
|
},
|
||||||
|
"fit": {
|
||||||
|
"$ref": "#/components/schemas/Fit",
|
||||||
|
"default": "contain"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"$ref": "#/components/schemas/Profile",
|
||||||
|
"default": "color"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false,
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"type",
|
||||||
|
"source_id"
|
||||||
|
],
|
||||||
|
"title": "PhotoSourceContent"
|
||||||
|
},
|
||||||
|
"PhotoSourceInput": {
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 120,
|
||||||
|
"minLength": 1,
|
||||||
|
"title": "Name"
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"local",
|
||||||
|
"webdav"
|
||||||
|
],
|
||||||
|
"title": "Kind"
|
||||||
|
},
|
||||||
|
"location": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 2048,
|
||||||
|
"minLength": 1,
|
||||||
|
"title": "Location"
|
||||||
|
},
|
||||||
|
"recursive": {
|
||||||
|
"type": "boolean",
|
||||||
|
"title": "Recursive",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
"title": "Enabled",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"credential_env": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^FRAME_FLOW_SOURCE_[A-Z0-9_]+$"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Credential Env"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false,
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"name",
|
||||||
|
"kind",
|
||||||
|
"location"
|
||||||
|
],
|
||||||
|
"title": "PhotoSourceInput"
|
||||||
|
},
|
||||||
|
"PhotoSourceView": {
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 120,
|
||||||
|
"minLength": 1,
|
||||||
|
"title": "Name"
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"local",
|
||||||
|
"webdav"
|
||||||
|
],
|
||||||
|
"title": "Kind"
|
||||||
|
},
|
||||||
|
"location": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 2048,
|
||||||
|
"minLength": 1,
|
||||||
|
"title": "Location"
|
||||||
|
},
|
||||||
|
"recursive": {
|
||||||
|
"type": "boolean",
|
||||||
|
"title": "Recursive",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
"title": "Enabled",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"credential_env": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^FRAME_FLOW_SOURCE_[A-Z0-9_]+$"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Credential Env"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Id"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false,
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"name",
|
||||||
|
"kind",
|
||||||
|
"location",
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"title": "PhotoSourceView"
|
||||||
|
},
|
||||||
|
"PhotoItem": {
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Id"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Name"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false,
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"name"
|
||||||
|
],
|
||||||
|
"title": "PhotoItem"
|
||||||
|
},
|
||||||
|
"PhotoCatalog": {
|
||||||
|
"properties": {
|
||||||
|
"items": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/PhotoItem"
|
||||||
|
},
|
||||||
|
"type": "array",
|
||||||
|
"title": "Items"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "integer",
|
||||||
|
"title": "Total"
|
||||||
|
},
|
||||||
|
"offset": {
|
||||||
|
"type": "integer",
|
||||||
|
"title": "Offset"
|
||||||
|
},
|
||||||
|
"limit": {
|
||||||
|
"type": "integer",
|
||||||
|
"title": "Limit"
|
||||||
|
},
|
||||||
|
"catalog_limit": {
|
||||||
|
"type": "integer",
|
||||||
|
"title": "Catalog Limit"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false,
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"items",
|
||||||
|
"total",
|
||||||
|
"offset",
|
||||||
|
"limit",
|
||||||
|
"catalog_limit"
|
||||||
|
],
|
||||||
|
"title": "PhotoCatalog"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import json
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from pigway_frame_flow.errors import FlowError
|
||||||
|
from pigway_frame_flow.providers.photo_sources import PhotoSourceInput
|
||||||
|
|
||||||
|
|
||||||
|
def photo(path):
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
Image.new("RGB", (160, 200), "blue").save(path)
|
||||||
|
return path.read_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def photos(settings, service):
|
||||||
|
settings.photo_root = settings.data_dir.parent / "photos"
|
||||||
|
photo(settings.photo_root / "family" / "a.jpg")
|
||||||
|
photo(settings.photo_root / "family" / "nested" / "b.png")
|
||||||
|
photo(settings.photo_root / "family" / "@eaDir" / "thumb.jpg")
|
||||||
|
(settings.photo_root / "family" / "note.txt").write_text("not a photo")
|
||||||
|
service.photo_sources.save(
|
||||||
|
"family", PhotoSourceInput(name="Family", kind="local", location="family")
|
||||||
|
)
|
||||||
|
return service.photo_sources
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_catalog_filters_and_paginates(client, photos):
|
||||||
|
out = client.get("/api/v1/photo-sources/family/images?limit=1").json()
|
||||||
|
assert out["total"] == 2 and len(out["items"]) == 1
|
||||||
|
assert (
|
||||||
|
client.get("/api/v1/photo-sources/family/images?offset=1").json()["items"][0][
|
||||||
|
"name"
|
||||||
|
]
|
||||||
|
== "b.png"
|
||||||
|
)
|
||||||
|
assert client.get("/api/v1/photo-sources").json()[0]["id"] == "family"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("location", ["../", "/etc"])
|
||||||
|
def test_path_escape_forbidden(client, photos, location):
|
||||||
|
r = client.put(
|
||||||
|
"/api/v1/photo-sources/escape",
|
||||||
|
json={"name": "escape", "kind": "local", "location": location},
|
||||||
|
)
|
||||||
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_symlinks_and_post_catalog_swap_are_blocked(photos, settings):
|
||||||
|
outside = settings.data_dir.parent / "outside.jpg"
|
||||||
|
photo(outside)
|
||||||
|
(settings.photo_root / "family" / "link.jpg").symlink_to(outside)
|
||||||
|
items = photos.catalog("family", refresh=True)
|
||||||
|
assert len(items) == 2
|
||||||
|
path = settings.photo_root / "family" / "a.jpg"
|
||||||
|
path.unlink()
|
||||||
|
path.symlink_to(outside)
|
||||||
|
with pytest.raises(FlowError):
|
||||||
|
photos.read("family", "family/a.jpg")
|
||||||
|
|
||||||
|
|
||||||
|
def test_random_preview_is_local_and_display_uses_same_asset(client, service, photos):
|
||||||
|
before = service.registry.get("living-room").get_current_image()
|
||||||
|
r = client.post(
|
||||||
|
"/api/v1/devices/living-room/upload",
|
||||||
|
json={"type": "photo_source", "source_id": "family"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 202
|
||||||
|
assert service.process_one()
|
||||||
|
op = client.get("/api/v1/operations/" + r.json()["operation_id"]).json()
|
||||||
|
assert op["status"] == "COMPLETED"
|
||||||
|
assert service.registry.get("living-room").get_current_image() == before
|
||||||
|
image = client.get(op["result"]["url"])
|
||||||
|
assert image.status_code == 200
|
||||||
|
assert Image.open(BytesIO(image.content)).size == (1200, 1600)
|
||||||
|
r = client.post(
|
||||||
|
"/api/v1/display",
|
||||||
|
json={"content": {"type": "image", "asset_id": op["result"]["asset_id"]}},
|
||||||
|
)
|
||||||
|
assert r.status_code == 202 and service.process_one()
|
||||||
|
|
||||||
|
|
||||||
|
def test_scheduler_source_persistence_and_delete_protection(app, client, photos):
|
||||||
|
payload = {
|
||||||
|
"name": "Random family photo",
|
||||||
|
"enabled": False,
|
||||||
|
"schedule": {"type": "daily", "time": "08:00"},
|
||||||
|
"source": {"type": "photo_source", "source_id": "family"},
|
||||||
|
}
|
||||||
|
job = client.post("/api/v1/jobs", json=payload)
|
||||||
|
assert job.status_code == 201
|
||||||
|
assert app.state.scheduler.jobs()[0]["source"]["source_id"] == "family"
|
||||||
|
assert client.delete("/api/v1/photo-sources/family").status_code == 409
|
||||||
|
assert client.delete("/api/v1/jobs/" + job.json()["id"]).status_code == 204
|
||||||
|
assert client.delete("/api/v1/photo-sources/family").status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_disabled_unknown_item(photos, settings):
|
||||||
|
with pytest.raises(FlowError):
|
||||||
|
photos.read("family", "../outside.jpg")
|
||||||
|
photos.save(
|
||||||
|
"family",
|
||||||
|
PhotoSourceInput(name="Family", kind="local", location="family", enabled=False),
|
||||||
|
)
|
||||||
|
with pytest.raises(FlowError):
|
||||||
|
photos.read("family")
|
||||||
|
folder = settings.photo_root / "empty"
|
||||||
|
folder.mkdir()
|
||||||
|
photos.save("empty", PhotoSourceInput(name="Empty", kind="local", location="empty"))
|
||||||
|
with pytest.raises(FlowError):
|
||||||
|
photos.read("empty")
|
||||||
|
|
||||||
|
|
||||||
|
def test_webdav_metadata_cached_credentials_not_persisted(
|
||||||
|
service, settings, monkeypatch
|
||||||
|
):
|
||||||
|
settings.image_url_hosts = ["dav.example"]
|
||||||
|
credential = "FRAME_FLOW_SOURCE_TEST"
|
||||||
|
monkeypatch.setenv(
|
||||||
|
credential, json.dumps({"username": "test-user", "password": "test-password"})
|
||||||
|
)
|
||||||
|
provider = service.photo_sources
|
||||||
|
provider.save(
|
||||||
|
"dav",
|
||||||
|
PhotoSourceInput(
|
||||||
|
name="Cloud",
|
||||||
|
kind="webdav",
|
||||||
|
location="https://dav.example/photos",
|
||||||
|
credential_env=credential,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
calls = []
|
||||||
|
xml = b"""<d:multistatus xmlns:d="DAV:"><d:response><d:href>/photos/a.jpg</d:href></d:response><d:response><d:href>/photos/a.jpg</d:href></d:response><d:response><d:href>https://evil.example/a.jpg</d:href></d:response><d:response><d:href>/photos/%2e%2e/private.jpg</d:href></d:response><d:response><d:href>/photos/sub</d:href><d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response></d:multistatus>"""
|
||||||
|
|
||||||
|
def fetch(url, **kwargs):
|
||||||
|
calls.append((url, kwargs))
|
||||||
|
return xml if kwargs.get("method") == "PROPFIND" else b"photo"
|
||||||
|
|
||||||
|
monkeypatch.setattr(service.assets, "fetch_url", fetch)
|
||||||
|
items = provider.catalog("dav")
|
||||||
|
assert len(items) == 1
|
||||||
|
assert provider.catalog("dav") == items and len(calls) == 1
|
||||||
|
assert calls[0][1]["headers"]["Depth"] == "1"
|
||||||
|
assert provider.read("dav")[0] == b"photo"
|
||||||
|
assert provider.get("dav").recursive is False
|
||||||
|
stored = service.db.one(
|
||||||
|
"SELECT payload FROM app_config WHERE key='photo_source:dav'"
|
||||||
|
)["payload"]
|
||||||
|
assert "test-password" not in stored and "test-user" not in stored
|
||||||
|
assert "test-password" not in json.dumps(
|
||||||
|
settings.sanitize({"value": "test-password"})
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("xml", [b"<!DOCTYPE x><x/>", b"<broken"])
|
||||||
|
def test_webdav_bad_xml(service, settings, monkeypatch, xml):
|
||||||
|
settings.image_url_hosts = ["dav.example"]
|
||||||
|
provider = service.photo_sources
|
||||||
|
provider.save(
|
||||||
|
"dav",
|
||||||
|
PhotoSourceInput(
|
||||||
|
name="Cloud", kind="webdav", location="https://dav.example/photos/"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(service.assets, "fetch_url", lambda *a, **kw: xml)
|
||||||
|
with pytest.raises(FlowError):
|
||||||
|
provider.catalog("dav")
|
||||||
|
|
||||||
|
|
||||||
|
def test_photo_source_permissions_and_openapi(client, photos):
|
||||||
|
schema = client.get("/openapi.json").json()
|
||||||
|
assert "PhotoSourceContent" in schema["components"]["schemas"]
|
||||||
|
assert schema["paths"]["/api/v1/photo-sources"]["get"]["responses"]["200"][
|
||||||
|
"content"
|
||||||
|
]["application/json"]["schema"]["items"]["$ref"].endswith("PhotoSourceView")
|
||||||
|
client.headers["Authorization"] = "Bearer incorrect"
|
||||||
|
assert client.get("/api/v1/photo-sources").status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_scheduled_random_photo_runs_once_with_restore(app, service, photos):
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pigway_frame_flow.models import JobInput
|
||||||
|
|
||||||
|
scheduler = app.state.scheduler
|
||||||
|
original = service.registry.get("living-room").get_current_image()
|
||||||
|
job = scheduler.save_job(
|
||||||
|
JobInput(
|
||||||
|
name="Random",
|
||||||
|
schedule={"type": "daily", "time": "08:00"},
|
||||||
|
source={"type": "photo_source", "source_id": "family"},
|
||||||
|
display_duration=60,
|
||||||
|
restore_previous=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
at = datetime.fromisoformat(job["next_run"])
|
||||||
|
scheduler.tick(at)
|
||||||
|
scheduler.tick(at)
|
||||||
|
rows = service.db.all("SELECT * FROM operations WHERE client_id='scheduler'")
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert service.process_one()
|
||||||
|
op = service.operation(rows[0]["id"])
|
||||||
|
assert op["status"] in ("SYNCING", "COMPLETED")
|
||||||
|
assert service.state("living-room")["previous_image_id"] == original
|
||||||
Reference in New Issue
Block a user