Add read-only NAS and WebDAV photo sources with random scheduling

This commit is contained in:
Pigway
2026-10-05 00:52:39 +08:00
parent b530e32c65
commit f5ab1ade77
18 changed files with 5578 additions and 84 deletions
+6
View File
@@ -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
+7
View File
@@ -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 的目录。
+12
View File
@@ -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
+85
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+58 -1
View File
@@ -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,
+8
View File
@@ -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:
+9 -1
View File
@@ -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"]
+9 -5
View File
@@ -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:
+3
View File
@@ -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
+34 -3
View File
@@ -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');});
}
+3
View File
@@ -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.'});
+2
View File
@@ -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}
+3
View File
@@ -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
View File
@@ -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"
]
} }
} }
+214
View File
@@ -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"
} }
} }
+206
View File
@@ -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