Add optional photo providers, layout studio and scoped integrations

This commit is contained in:
Pigway
2026-10-08 23:18:11 +08:00
parent 3643fae86a
commit 69a0841868
38 changed files with 2843 additions and 41 deletions
+7 -6
View File
@@ -169,19 +169,20 @@ validates Compose, starts a container, checks health/ready and restarts for pers
Cloud acknowledgements do not prove physical display pixels. Model fields may be
omitted and are explicitly marked configured. No native slideshow membership API,
no cloud upload-only API, no automatic retries after uncertain writes. Cache has no
automatic quota/GC in 0.1.0; monitor data volume. Rendering assets are local, limited
no cloud upload-only API, no automatic retries after uncertain writes. Opt-in local retention protects manual uploads, stars and active references; its quota is a cleanup target, not a hard disk limit. Rendering assets are local, limited
to 25M pixels/20 MiB. This release uses a single worker and is not multi-replica.
Gallery reads have no pagination because upstream documents none. Web UI hides
secrets and lazy-loads thumbnails; generating a first thumbnail fetches one original.
Multi-client provisioning UI/webhook delivery are not implemented. Docker build/NAS
Scoped client provisioning and bounded signed webhook delivery are available in Connections. Docker build/NAS
status is recorded honestly in docs/ACCEPTANCE.md.
## Roadmap
Additional drivers; scoped token administration; signed webhooks; cache retention;
face-aware cropping; public weather provider; Google Calendar, Home Assistant, MQTT,
NAS monitoring and AI integrations through the same API. External integration is optional.
Native OAuth login/refresh, full ICS recurrence, MQTT, AI image generation/daily art, additional hardware adapters and device transition producers remain pending. See [optional capabilities and exact limits](docs/EXTENSIONS.md).
## Layout Studio and optional integrations
Bilingual Layout Studio imports 1–12 images, edits bounded regions and renders a collage or poster. Optional OpenAI-compatible AI plans receive text and dimensions only. Connections configures provider credentials, calendars/state, retention, client scopes and webhooks. Photo Sources includes NAS, WebDAV, Google Drive, AliPan and Baidu readers. Authorization is separate from implementation; unconnected providers return explicit errors. See [setup and limitations](docs/EXTENSIONS.md).
## Template editing
+55
View File
@@ -0,0 +1,55 @@
# Optional integrations / 可选连接
Frame Flow continues to run without AI, a cloud drive, calendar or Home Assistant. API-key frame control and official-account widget subscriptions remain separate integrations.
## Layout Studio / 排版工作室
Choose 1–12 local photos or import several JPG/PNG/WebP files at once. Imports preserve the original aspect ratio. Choose portrait (1200×1600) or landscape (1600×1200), grid/hero/poster, title and footer text. Generate a layout, drag regions or edit their coordinates and dimensions, then generate the final preview. Overlapping or out-of-bounds regions are rejected. Preview and download do not write to a frame; Display uses the existing queued Display Service.
可一次导入 1–12 张照片,选择横竖向及拼版样式,拖动图片或填写坐标调整位置。生成预览只生成本地图片;点击显示并确认后才上传画框。模板页的多图拼版和海报进入排版工作室。
AI layout is optional. Connections accepts a full OpenAI-compatible `https://…/v1/chat/completions` endpoint, model and API key. The endpoint must be public HTTPS. **Only layout text, asset IDs and dimensions are sent; photo pixels are not sent.** The returned JSON is validated against the same bounded layout schema. Failures are explicit; the user may select local layout. This is layout planning, not AI image generation. AI providers require separate user configuration and have not been tested against a paid account.
## Native cloud drives / 原生网盘
Photo Sources supports NAS directories, WebDAV, Google Drive, AliPan and Baidu Netdisk. Native drive connections require an already-authorized OAuth access token. Supply credentials through `FRAME_FLOW_SOURCE_*` (JSON) or the source credential form. They are stored only in `/config/source-<id>.secret` with mode 0600 and never returned in catalogs.
- Google Drive: location is the folder ID; JSON `{"access_token":"…"}`. Use a read-only Drive scope.
- AliPan: location is the parent file ID (`root` if appropriate); JSON `{"access_token":"…","drive_id":"…"}`. Your developer app must have the necessary approved read permissions.
- Baidu: location is an absolute cloud folder path; JSON `{"access_token":"…"}`. Permissions depend on the authorized developer app.
Queries are read-only, paginated, bounded to 200 pages and the configured catalog limit. Downloads are limited to selected images and approved provider download hosts. Tokens expire: browser OAuth authorization and refresh-token rotation are **not implemented yet**. Expired tokens produce an explicit connection error. No account is connected merely by adding the connector. Google access also depends on network reachability from the NAS. Folder date captions, orientation metadata filtering and Synology People filtering currently require a local NAS source.
## Photo filtering / 照片筛选
Local Synology sources support shared or personal People space. Personal space requires a matching read-only photo-root mount and user permission; selecting the API space does not grant filesystem access. Optional thumbnail sharpness, brightness and perceptual similarity filters never change or delete source photos. These are heuristics, not closed-eye detection. All filters default off. Similarity history retains the last 200 selections for each source.
## Calendar and monitoring / 日历与监控
Connections configures Google Calendar, basic non-recurring ICS, Home Assistant state and HTTP JSON providers. Credentials are mode-0600 files. Public endpoints require an allowed HTTPS host; fixed LAN endpoints require explicit administrator opt-in. Results cache for five minutes. Create an initially disabled daily dashboard task, review its preview, then enable it. Google Calendar reads the next seven days. Current ICS recurring events return `UNSUPPORTED_CAPABILITY`; they are not silently omitted.
Home Assistant is read-only; NAS/Pi/server monitoring expects an external read-only JSON endpoint. No Docker socket or host monitoring privileges are added. These integrations do not require Ticktar.
## Official widgets / 官方小组件
Official-account authentication is required independently of the KoKonna API key. Only the authenticated catalog can be selected. Apply/cancel is an administrator action using the persistent operation queue, idempotency and the existing write guard. Cancellation does not automatically restore a previous photograph. Writes are sent once, followed by a read-only selection check. Timeout or failed confirmation becomes `UNKNOWN` and blocks subsequent writes until read-only reconciliation or explicit acknowledgement. No automated test uses a real device.
## Retention / 本地保留
Connections provides an opt-in generated-image/cache retention policy, disabled by default. Preview cleanup before enabling it. Manual imports, stars, active/unknown operation references, job/template references and known current/restore/override asset mappings are protected. This policy never deletes NAS originals or cloud images. The configured quota is a cleanup target, not a hard filesystem limit; protected files can exceed it. Unregistered cache files are not currently counted.
## Client scopes and Webhooks / 客户端权限与回调
The web UI continues using account/password sessions. Administrator-created external client tokens are shown once, stored as SHA-256 hashes and revocable. Scope middleware is shared with existing APIs.
Webhooks require explicitly allowed public HTTPS callback hosts. Each event has a stable ID; clients must deduplicate it. Signature header `X-Frame-Flow-Signature: sha256=<hex>` is HMAC-SHA256 over the exact UTF-8 request bytes. A signing key is shown once and stored in a mode-0600 file. Delivery state is durable, runs on a separate worker, and retries at most three times. Events before subscription creation are not replayed. Currently emitted events are `operation.completed`, `operation.failed` and `display.changed`; device transition producers remain pending. A received callback never initiates a frame write automatically.
## Other drivers / 其他画框
`devices.yml` may configure `driver: generic_http_frame`, `endpoint`, `token_env`, `allow_insecure_lan` and `enabled`. The adapter uses `/device`, `/images`, `/display` and `/images/{id}/content` with standardized Frame Flow device/image models. It retains the global write guard and never retries uncertain writes. Hardware interoperability needs a matching external implementation; no other vendor hardware is implied to be connected.
## Still pending / 尚未完成
Native OAuth login/refresh; AI image generation/daily art; MQTT transport; full ICS recurrence; Home Assistant display driver; device-transition event polling; browser visual acceptance and third-party live authorization. Existing cloud/frame integrations remain operational independently of these optional capabilities.
Official references: [Google Drive list](https://developers.google.com/workspace/drive/api/reference/rest/v3/files/list), [Google Calendar events](https://developers.google.com/workspace/calendar/api/v3/reference/events/list), [Home Assistant REST](https://developers.home-assistant.io/docs/api/rest/), [AliPan developer docs](https://www.yuque.com/aliyundrive/zpfszx), [Baidu developer docs](https://pan.baidu.com/union/doc/nksg0sat9).
+12 -3
View File
@@ -98,6 +98,8 @@ def create_app(settings=None, start_worker=True):
weather = ConfigurableWeatherProvider(db)
renderer = TemplateRenderer(db, settings, weather, assets)
service = DisplayService(db, settings, registry, assets, renderer)
from pigway_frame_flow.extensions.api import install_extensions
scheduler = Scheduler(service)
seed_client(db, settings.api_token)
seed_accounts(db, settings.config_dir)
@@ -123,6 +125,7 @@ def create_app(settings=None, start_worker=True):
lifespan=lifespan,
responses=error_responses,
)
install_extensions(app, service)
install_accounts(app, db)
app.state.service = service
app.state.scheduler = scheduler
@@ -483,7 +486,9 @@ def create_app(settings=None, start_worker=True):
request: Request,
idempotency_key: str | None = Header(default=None),
):
renderer.info(payload.template)
service.validate(
DisplayRequest(device_id=settings.device_id, content=payload), writing=False
)
return submit(
request,
"render",
@@ -701,8 +706,12 @@ def create_app(settings=None, start_worker=True):
tags=["Photo sources"],
dependencies=[Depends(require("admin"))],
)
def photos_people():
return service.photo_sources.synology.people()
def photos_people(space: Literal["shared", "personal"] = "shared"):
return (
service.photo_sources.synology.people()
if space == "shared"
else service.photo_sources.synology.people(space)
)
@app.get(
"/api/v1/photo-sources",
+11 -2
View File
@@ -32,10 +32,15 @@ class DeviceRegistry:
for id, entry in devices.items():
if not entry.get("enabled", True):
continue
if entry.get("driver") != "kokonna":
raise ValueError("Unsupported configured device driver")
if not __import__("re").fullmatch(r"[a-z0-9-]{1,60}", id):
raise ValueError("Invalid configured device ID")
if entry.get("driver") == "generic_http_frame":
from pigway_frame_flow.drivers.generic_http import GenericHTTPDriver
self.drivers[id] = GenericHTTPDriver(id, entry, settings)
continue
if entry.get("driver") != "kokonna":
raise ValueError("Unsupported configured device driver")
client: MockClient | KokonnaClient
if settings.mock:
root = settings.data_dir / "cache" / id
@@ -71,4 +76,8 @@ class DeviceRegistry:
def official_widgets(self, id: str):
self.get(id)
if id not in self.official:
raise FlowError(
E.UNSUPPORTED_CAPABILITY, "This driver has no official widgets", 422
)
return self.official[id].widgets()
+86
View File
@@ -15,6 +15,7 @@ from pigway_frame_flow.models import (
FutureContent,
ImageContent,
PhotoSourceContent,
ProviderContent,
TemplateContent,
URLContent,
)
@@ -37,6 +38,13 @@ class DisplayService:
assets,
renderer,
)
from typing import Callable
self.operation_handlers: dict[str, Callable] = {}
self.reconciliation_handlers: dict[str, Callable] = {}
from pigway_frame_flow.providers.external import ExternalProviders
self.providers = ExternalProviders(self)
self.lock = threading.RLock()
self.retention = Retention(self)
self.cloud_library = CloudLibrary(self)
@@ -56,8 +64,23 @@ class DisplayService:
raise FlowError(
E.UNSUPPORTED_CAPABILITY, "Content type is unsupported", 422
)
if isinstance(request.content, ProviderContent):
self.providers.config(request.content.provider_id)
self.renderer.info(request.content.template)
if isinstance(request.content, TemplateContent):
self.renderer.info(request.content.template)
if request.content.template in ("collage", "poster"):
from pigway_frame_flow.extensions.layout import LayoutPlan
try:
plan = LayoutPlan.model_validate(request.content.data.get("layout"))
except ValueError:
raise FlowError(
E.INVALID_REQUEST,
"Create a valid layout in Layout Studio first",
) from None
for region in plan.regions:
self.assets.path(region.asset_id)
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:
@@ -168,6 +191,18 @@ class DisplayService:
return self.db.one("SELECT * FROM device_state WHERE device_id=?", (id,))
def content_asset(self, content, device_id=None, operation_id=None):
if isinstance(content, ProviderContent):
values = {**content.data, **self.providers.get(content.provider_id)}
return self.content_asset(
TemplateContent(
type="template",
template=content.template,
data=values,
profile=content.profile,
),
device_id,
operation_id,
)
if isinstance(content, TemplateContent):
values = self.renderer.resolve(content.template, content.data)
data = self.renderer.render(content.template, values, content.profile)
@@ -219,6 +254,53 @@ class DisplayService:
data, name, caption = self.photo_sources.read(
content.source_id, item["id"], target_size=size
)
if (
source.minimum_sharpness
or source.minimum_brightness
or source.maximum_brightness < 255
or source.avoid_duplicates
):
from pigway_frame_flow.extensions.quality import (
passes,
photo_quality,
)
quality = photo_quality(data)
if not passes(quality, source):
raise FlowError(
E.INVALID_REQUEST,
"Photo fails configured quality thresholds",
)
previous_quality = self.db.one(
"SELECT payload FROM app_config WHERE key=?",
("recent-photo:" + content.source_id,),
)
fingerprints = (
json.loads(previous_quality["payload"])
if previous_quality
else []
)
if source.avoid_duplicates and any(
int(quality["fingerprint"], 16)
.__xor__(int(old, 16))
.bit_count()
<= 4
for old in fingerprints
):
raise FlowError(
E.CONFLICT,
"Photo resembles a recently selected photo",
409,
)
self.db.execute(
"INSERT OR REPLACE INTO app_config VALUES(?,?)",
(
"recent-photo:" + content.source_id,
encode(
(fingerprints + [quality["fingerprint"]])[-200:]
),
),
)
rendered = (
render_captioned_photo(
data,
@@ -376,6 +458,8 @@ class DisplayService:
def _execute(self, op, payload):
id, device_id, action = op["id"], op["device_id"], op["action"]
driver = self.registry.get(device_id)
if action in self.operation_handlers:
return self.operation_handlers[action](op, payload)
if action in ("render", "local-upload"):
self.update(id, "RENDERING")
from pydantic import TypeAdapter
@@ -699,6 +783,8 @@ class DisplayService:
def reconcile(self, id, acknowledge=False):
with self.lock:
op = self.operation(id)
if op["action"] in self.reconciliation_handlers:
return self.reconciliation_handlers[op["action"]](op, acknowledge)
driver = self.registry.get(op["device_id"])
device = driver.device(refresh=True)
images = driver.images()
+110
View File
@@ -0,0 +1,110 @@
"""Opt-in standard HTTP frame protocol for privately managed devices."""
import requests
from pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.models import Device, GalleryImage
from pigway_frame_flow.settings import secret
class GenericHTTPDriver:
def __init__(self, id, entry, settings):
from urllib.parse import urlsplit
self.id, self.entry, self.settings = id, entry, settings
self.endpoint = entry.get("endpoint", "").rstrip("/")
parsed = urlsplit(self.endpoint)
if (
parsed.scheme not in ("https", "http")
or not parsed.hostname
or parsed.username
or parsed.password
or parsed.query
or parsed.fragment
):
raise ValueError("Invalid generic frame endpoint")
if parsed.scheme == "http" and not entry.get("allow_insecure_lan", False):
raise ValueError("HTTP frames require explicit allow_insecure_lan")
self.token = secret(entry.get("token_env", "FRAME_FLOW_HTTP_FRAME_TOKEN"))
settings.additional_secrets.append(self.token)
def call(self, method, path, **kwargs):
writing = method != "GET"
if writing and not self.settings.write_enabled:
raise FlowError(E.WRITE_DISABLED, "Device writes are disabled", 403)
try:
response = requests.request(
method,
self.endpoint + path,
headers={"Authorization": "Bearer " + self.token} if self.token else {},
timeout=10,
allow_redirects=False,
**kwargs,
)
response.raise_for_status()
return response
except requests.RequestException:
raise FlowError(
E.WRITE_RESULT_UNKNOWN if writing else E.CLOUD_UNAVAILABLE,
"HTTP frame request failed",
409 if writing else 502,
) from None
def invalidate(self):
pass
def device(self, refresh=False):
raw = self.call("GET", "/device").json()
raw["id"] = self.id
raw["driver"] = "generic_http_frame"
return Device.model_validate(raw)
def get_current_image(self):
return self.device().current_content.id
def health(self):
try:
self.device()
return True
except FlowError:
return False
def images(self):
return [
GalleryImage.model_validate(x) for x in self.call("GET", "/images").json()
]
def upload(self, path, name):
with path.open("rb") as f:
return int(
self.call(
"POST", "/images", files={"file": (name, f, "image/jpeg")}
).json()["image_id"]
)
def display(self, image_id):
self.call("POST", "/display", json={"image_id": image_id})
def delete(self, image_id):
self.call("DELETE", f"/images/{int(image_id)}")
def download(self, image_id):
response = self.call("GET", f"/images/{int(image_id)}/content", stream=True)
try:
parts = []
total = 0
for chunk in response.iter_content(65536):
total += len(chunk)
if total > self.settings.max_bytes:
raise FlowError(E.INVALID_REQUEST, "Image exceeds size limit")
parts.append(chunk)
return b"".join(parts)
finally:
response.close()
def display_by_name(self, name):
matches = [x for x in self.images() if x.name == name]
if len(matches) != 1:
raise FlowError(E.CONFLICT, "Filename must match exactly one image", 409)
self.display(matches[0].image_id)
+292
View File
@@ -0,0 +1,292 @@
"""Optional capabilities share application auth, assets and operation pipeline."""
import hashlib
import json
import secrets
from urllib.parse import urlsplit
from fastapi import Depends, File, UploadFile
from pydantic import Field, SecretStr
from pigway_frame_flow.core.auth import ALL_SCOPES, require
from pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.extensions.layout import LayoutInput, LayoutPlan, LayoutStudio
from pigway_frame_flow.extensions.storage import LocalRetention, StoragePolicy
from pigway_frame_flow.models import StrictModel
from pigway_frame_flow.storage.database import encode, now_iso
class AIConfig(StrictModel):
endpoint: str = Field(default="", max_length=2048)
model: str = Field(default="", max_length=120)
class AISecret(StrictModel):
key: SecretStr = Field(min_length=1, max_length=8192)
class ClientInput(StrictModel):
client_id: str = Field(pattern=r"^[a-z0-9-]{1,60}$")
name: str = Field(min_length=1, max_length=120)
permissions: list[str] = Field(min_length=1, max_length=20)
def install_extensions(app, service):
from pigway_frame_flow.extensions.official import install_official
install_official(app, service)
from pigway_frame_flow.extensions.webhooks import install_webhooks
install_webhooks(app, service)
from pigway_frame_flow.extensions.providers import install_providers
install_providers(app, service)
db, settings = service.db, service.settings
studio = LayoutStudio(service.renderer)
service.local_retention = LocalRetention(service)
@app.post(
"/api/v1/layout/images",
status_code=201,
tags=["Layout"],
dependencies=[Depends(require("render:write"))],
)
async def import_images(files: list[UploadFile] = File(...)):
from io import BytesIO
from PIL import Image, ImageOps
from pigway_frame_flow.renderers.images import MAX_IMAGE_PIXELS
if not 1 <= len(files) <= 12:
raise FlowError(E.INVALID_REQUEST, "Choose between one and twelve images")
prepared = []
for file in files:
data = await file.read(settings.max_bytes + 1)
if len(data) > settings.max_bytes:
raise FlowError(E.INVALID_REQUEST, "Image exceeds size limit")
try:
with Image.open(BytesIO(data)) as image:
if (
image.format not in {"JPEG", "PNG", "WEBP"}
or image.width * image.height > MAX_IMAGE_PIXELS
):
raise ValueError()
rgba = ImageOps.exif_transpose(image).convert("RGBA")
rgb = Image.new("RGB", rgba.size, "white")
rgb.paste(rgba, mask=rgba.getchannel("A"))
out = BytesIO()
rgb.save(out, "JPEG", quality=93, optimize=True)
prepared.append((out.getvalue(), file.filename or "photo.jpg"))
except (OSError, ValueError, Image.DecompressionBombError):
raise FlowError(E.INVALID_REQUEST, "Invalid image") from None
result = []
for data, name in prepared:
asset = service.assets.put(data, name)
db.execute(
"INSERT INTO asset_metadata(asset_id,origin) VALUES(?,'manual')",
(asset["asset_id"],),
)
result.append(asset)
return result
@app.post(
"/api/v1/layout/plan",
response_model=LayoutPlan,
tags=["Layout"],
dependencies=[Depends(require("render:write"))],
)
def layout_plan(payload: LayoutInput):
return studio.plan(payload)
@app.get(
"/api/v1/settings/ai-layout",
tags=["Layout"],
dependencies=[Depends(require("admin"))],
)
def ai_config():
row = db.one("SELECT payload FROM app_config WHERE key='ai_layout'")
from pigway_frame_flow.settings import secret
return {
**(json.loads(row["payload"]) if row else AIConfig().model_dump()),
"key_configured": bool(
secret("FRAME_FLOW_AI_KEY")
or (settings.config_dir / "ai-layout.secret").exists()
),
"sends_photos": False,
}
@app.put(
"/api/v1/settings/ai-layout",
response_model=AIConfig,
tags=["Layout"],
dependencies=[Depends(require("admin"))],
)
def save_ai(payload: AIConfig):
if payload.endpoint:
parsed = urlsplit(payload.endpoint)
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, "AI endpoint must be a plain HTTPS URL"
)
if parsed.hostname not in settings.image_url_hosts:
settings.image_url_hosts.append(parsed.hostname)
db.execute(
"INSERT OR REPLACE INTO app_config VALUES('image_url_hosts',?)",
(encode(settings.image_url_hosts),),
)
db.execute(
"INSERT OR REPLACE INTO app_config VALUES('ai_layout',?)",
(encode(payload.model_dump()),),
)
return payload
@app.put(
"/api/v1/settings/ai-layout/key",
status_code=204,
tags=["Layout"],
dependencies=[Depends(require("admin"))],
)
def save_ai_key(payload: AISecret):
from pigway_frame_flow.extensions.secrets import save_secret
save_secret(
settings.config_dir / "ai-layout.secret", payload.key.get_secret_value()
)
settings.additional_secrets.append(payload.key.get_secret_value())
@app.get(
"/api/v1/storage", tags=["Storage"], dependencies=[Depends(require("admin"))]
)
def storage():
return service.local_retention.stats()
@app.put(
"/api/v1/storage/policy",
response_model=StoragePolicy,
tags=["Storage"],
dependencies=[Depends(require("admin"))],
)
def storage_policy(payload: StoragePolicy):
db.execute(
"INSERT OR REPLACE INTO app_config VALUES('local_retention',?)",
(encode(payload.model_dump()),),
)
return payload
@app.post(
"/api/v1/storage/cleanup",
tags=["Storage"],
dependencies=[Depends(require("admin"))],
)
def cleanup(dry_run: bool = True):
return service.local_retention.sweep(force=True, dry_run=dry_run)
@app.get(
"/api/v1/clients", tags=["Clients"], dependencies=[Depends(require("admin"))]
)
def clients():
return [
{
k: json.loads(row[k]) if k == "permissions" else row[k]
for k in [
"client_id",
"name",
"permissions",
"created_at",
"last_used_at",
"enabled",
]
}
for row in db.all("SELECT * FROM clients")
]
@app.post(
"/api/v1/clients",
status_code=201,
tags=["Clients"],
dependencies=[Depends(require("admin"))],
)
def create_client(payload: ClientInput):
if payload.client_id == "admin" or db.one(
"SELECT client_id FROM clients WHERE client_id=?", (payload.client_id,)
):
raise FlowError(E.CONFLICT, "Client already exists", 409)
if set(payload.permissions) - set(ALL_SCOPES):
raise FlowError(E.INVALID_REQUEST, "Unknown permission scope")
token = secrets.token_urlsafe(32)
settings.additional_secrets.append(token)
db.execute(
"INSERT INTO clients VALUES(?,?,?,?,?,?,?)",
(
payload.client_id,
hashlib.sha256(token.encode()).hexdigest(),
payload.name,
encode(payload.permissions),
now_iso(),
None,
1,
),
)
# Deliberate one-time delivery to the administrator; never list the token again.
return {"client_id": payload.client_id, "token": token}
@app.delete(
"/api/v1/clients/{client_id}",
status_code=204,
tags=["Clients"],
dependencies=[Depends(require("admin"))],
)
def revoke_client(client_id: str):
if client_id == "admin":
raise FlowError(
E.FORBIDDEN, "The deployment administrator cannot be revoked here", 403
)
db.execute("UPDATE clients SET enabled=0 WHERE client_id=?", (client_id,))
@app.put(
"/api/v1/photo-sources/{source_id}/credentials",
status_code=204,
tags=["Photo sources"],
dependencies=[Depends(require("admin"))],
)
def drive_credentials(source_id: str, payload: AISecret):
source = service.photo_sources.get(source_id)
if source.kind not in ("google-drive", "alipan", "baidu"):
raise FlowError(
E.UNSUPPORTED_CAPABILITY,
"Only native cloud drive sources use OAuth credentials here",
422,
)
try:
raw = json.loads(payload.key.get_secret_value())
if (
not isinstance(raw, dict)
or not isinstance(raw.get("access_token"), str)
or not raw["access_token"]
):
raise ValueError()
except (ValueError, TypeError):
raise FlowError(
E.INVALID_REQUEST, "Credentials must be JSON with access_token"
) from None
from pigway_frame_flow.extensions.secrets import save_secret
save_secret(
settings.config_dir / ("source-" + source_id + ".secret"),
payload.key.get_secret_value(),
)
for k in ("access_token", "refresh_token", "client_secret"):
if raw.get(k):
settings.additional_secrets.append(str(raw[k]))
service.photo_sources.cache.pop(source_id, None)
+227
View File
@@ -0,0 +1,227 @@
"""Bounded layout plans shared by deterministic and optional AI renderers."""
import json
from io import BytesIO
from typing import Literal
from PIL import Image, ImageDraw, ImageOps
from pydantic import Field, model_validator
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
class Region(StrictModel):
asset_id: str = Field(pattern=r"^[a-f0-9]{32}$")
x: int = Field(ge=0)
y: int = Field(ge=0)
width: int = Field(ge=100)
height: int = Field(ge=100)
class LayoutInput(StrictModel):
asset_ids: list[str] = Field(min_length=1, max_length=12)
title: str = Field(default="", max_length=100)
message: str = Field(default="", max_length=500)
orientation: Literal["portrait", "landscape"] = "portrait"
style: Literal["grid", "hero", "poster"] = "grid"
use_ai: bool = False
prompt: str = Field(default="", max_length=1500)
@model_validator(mode="after")
def ids(self):
import re
if len(set(self.asset_ids)) != len(self.asset_ids) or any(
not re.fullmatch(r"[a-f0-9]{32}", x) for x in self.asset_ids
):
raise ValueError("Choose distinct local images")
return self
class LayoutPlan(StrictModel):
width: int = Field(ge=1200, le=1600)
height: int = Field(ge=1200, le=1600)
title: str = Field(default="", max_length=100)
message: str = Field(default="", max_length=500)
regions: list[Region] = Field(min_length=1, max_length=12)
engine: Literal["local", "ai"] = "local"
@model_validator(mode="after")
def bounds(self):
if (self.width, self.height) not in [(1200, 1600), (1600, 1200)]:
raise ValueError("Unsupported canvas")
for i, region in enumerate(self.regions):
if (
region.x + region.width > self.width
or region.y + region.height > self.height - 120
or region.y < 150
):
raise ValueError("Photo region outside safe photo area")
for other in self.regions[:i]:
if (
region.x < other.x + other.width
and other.x < region.x + region.width
and region.y < other.y + other.height
and other.y < region.y + region.height
):
raise ValueError("Photo regions overlap")
return self
def local_plan(request: LayoutInput):
import math
width, height = (1200, 1600) if request.orientation == "portrait" else (1600, 1200)
gap, top, bottom = 24, 170, height - 140
n = len(request.asset_ids)
regions = []
if request.style in ("hero", "poster") and n > 1:
hero_height = int((bottom - top) * (0.45 if n > 7 else 0.62))
regions.append(
Region(
asset_id=request.asset_ids[0],
x=gap,
y=top,
width=width - 2 * gap,
height=hero_height - gap,
)
)
top += hero_height
ids = request.asset_ids[1:]
else:
ids = request.asset_ids
columns = min(3, max(1, math.ceil(math.sqrt(len(ids) * width / (bottom - top)))))
rows = math.ceil(len(ids) / columns)
cell_w, cell_h = (
(width - gap * (columns + 1)) // columns,
(bottom - top - gap * (rows - 1)) // rows,
)
for i, asset_id in enumerate(ids):
regions.append(
Region(
asset_id=asset_id,
x=gap + (i % columns) * (cell_w + gap),
y=top + (i // columns) * (cell_h + gap),
width=cell_w,
height=cell_h,
)
)
return LayoutPlan(
width=width,
height=height,
title=request.title,
message=request.message,
regions=regions,
)
class LayoutStudio:
def __init__(self, renderer):
self.renderer = renderer
def plan(self, request):
plan = local_plan(request)
metadata = []
for asset_id in request.asset_ids:
with Image.open(self.renderer.assets.path(asset_id)) as image:
metadata.append(
{"asset_id": asset_id, "width": image.width, "height": image.height}
)
if not request.use_ai:
return plan
db = self.renderer.db
row = db.one("SELECT payload FROM app_config WHERE key='ai_layout'")
config = json.loads(row["payload"]) if row else {}
key = secret("FRAME_FLOW_AI_KEY")
key_path = self.renderer.settings.config_dir / "ai-layout.secret"
if not key and key_path.exists():
key = key_path.read_text().strip()
if not key or not config.get("endpoint") or not config.get("model"):
raise FlowError(
E.CONFIG_REQUIRED,
"Configure the AI layout endpoint, model and key first",
409,
)
self.renderer.settings.additional_secrets.append(key)
body = {
"model": config["model"],
"response_format": {"type": "json_object"},
"messages": [
{
"role": "system",
"content": "Return ONLY a JSON layout following this example. Keep canvas size, all asset ids exactly once, non-overlapping photo rectangles y>=150 and bottom<=height-120. Keep title/message unchanged. Never include code, URLs or HTML. Example: "
+ plan.model_dump_json(),
},
{
"role": "user",
"content": json.dumps(
{
"images": metadata,
"style": request.style,
"instruction": request.prompt,
},
ensure_ascii=False,
),
},
],
"max_tokens": 2000,
}
try:
data = self.renderer.assets.fetch_url(
config["endpoint"],
method="POST",
headers={
"Authorization": "Bearer " + key,
"Content-Type": "application/json",
},
body=json.dumps(body).encode(),
limit=256 * 1024,
)
result = json.loads(data)["choices"][0]["message"]["content"]
candidate = LayoutPlan.model_validate_json(result)
if (
sorted(x.asset_id for x in candidate.regions)
!= sorted(request.asset_ids)
or (candidate.width, candidate.height) != (plan.width, plan.height)
or candidate.title != request.title
or candidate.message != request.message
):
raise ValueError()
candidate.engine = "ai"
return candidate
except Exception:
raise FlowError(
E.RENDER_FAILED,
"AI layout failed or returned an invalid plan; choose local layout to continue",
502,
) from None
def render(self, data, profile):
plan = LayoutPlan.model_validate(data["layout"])
canvas = Image.new("RGB", (plan.width, plan.height), "white")
for region in plan.regions:
with Image.open(self.renderer.assets.path(region.asset_id)) as source:
image = ImageOps.exif_transpose(source).convert("RGB")
image = ImageOps.fit(
image,
(region.width, region.height),
method=Image.Resampling.LANCZOS,
)
canvas.paste(image, (region.x, region.y))
draw = ImageDraw.Draw(canvas)
for text, y, size in [
(plan.title, 35, 54),
(plan.message, plan.height - 100, 30),
]:
font = self.renderer.font(size)
while text and draw.textlength(text, font=font) > plan.width - 48:
text = text[:-1]
draw.text((24, y), text, font=font, fill="black")
if profile == "grayscale":
canvas = ImageOps.grayscale(canvas).convert("RGB")
out = BytesIO()
canvas.save(out, "JPEG", quality=93, optimize=True)
return out.getvalue()
+171
View File
@@ -0,0 +1,171 @@
"""Explicit widget mutations, queued once with conservative uncertain-write handling."""
from fastapi import Depends, Header, Request
from pydantic import Field
from pigway_frame_flow.core.auth import require
from pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.models import Accepted, StrictModel
from pigway_frame_flow.storage.database import encode
class WidgetSelection(StrictModel):
cover_id: int | None = Field(default=None, gt=0)
args: dict[str, str] = Field(default_factory=dict, max_length=30)
def install_official(app, service):
import json
for device_id, connection in service.registry.official.items():
row = service.db.one(
"SELECT payload FROM app_config WHERE key=?", ("mock-widget:" + device_id,)
)
if connection.reader.mock and row:
connection.reader.mock_selection = json.loads(row["payload"])["cover_id"]
def check(device_id, payload):
service.registry.get(device_id)
connection = service.registry.official.get(device_id)
if not connection:
raise FlowError(
E.UNSUPPORTED_CAPABILITY, "This driver has no official widgets", 422
)
if not service.settings.mock and not service.settings.write_enabled:
raise FlowError(E.WRITE_DISABLED, "Device writes are disabled", 403)
view = connection.widgets()
if not view.configured:
raise FlowError(
E.OFFICIAL_AUTH_REQUIRED,
"Connect and associate an official account first",
401,
)
if payload.cover_id is not None:
widget = next((x for x in view.catalog if x.id == payload.cover_id), None)
if not widget:
raise FlowError(
E.INVALID_REQUEST, "Widget is not in the authenticated catalog"
)
if set(payload.args) - set(widget.arg_keys):
raise FlowError(E.INVALID_REQUEST, "Unknown widget parameter")
if any(len(k) > 120 or len(v) > 2000 for k, v in payload.args.items()):
raise FlowError(E.INVALID_REQUEST, "Widget parameter is too long")
elif payload.args:
raise FlowError(
E.INVALID_REQUEST, "Cancellation does not accept parameters"
)
return connection
@app.post(
"/api/v1/devices/{device_id}/widgets/selection",
response_model=Accepted,
status_code=202,
tags=["Official"],
dependencies=[Depends(require("admin"))],
)
def select(
device_id: str,
payload: WidgetSelection,
request: Request,
idempotency_key: str | None = Header(default=None),
):
check(device_id, payload)
id = service.submit(
"official-widget",
payload.model_dump(),
device_id,
request.state.client_id,
request.state.request_id,
idempotency_key,
)
return Accepted(operation_id=id, job_id=id, status="accepted")
def execute(op, payload):
selection = WidgetSelection.model_validate(payload)
connection = check(op["device_id"], selection)
service.update(op["id"], "UPLOADING")
service.db.execute(
"UPDATE operations SET write_started=1 WHERE id=?", (op["id"],)
)
with connection.lock:
if connection.reader.mock:
connection.reader.mock_selection = selection.cover_id
service.db.execute(
"INSERT OR REPLACE INTO app_config VALUES(?,?)",
("mock-widget:" + op["device_id"], encode(selection.model_dump())),
)
else:
connection.reader.rpc.set_widget(
connection.reader._robot_id, selection.cover_id, selection.args
)
connection.reader._cached = None
try:
view = connection.widgets()
except Exception:
raise FlowError(
E.WRITE_RESULT_UNKNOWN,
"Widget write sent but read-only confirmation failed",
409,
) from None
if (
not view.current_verified
or (view.current.id if view.current else None) != selection.cover_id
):
raise FlowError(
E.WRITE_RESULT_UNKNOWN,
"Widget write acknowledged but selection is not confirmed",
409,
)
service.update(op["id"], "COMPLETED", {"cover_id": selection.cover_id})
service.db.history(op, "COMPLETED")
service.db.event(
"display.changed",
{
"device_id": op["device_id"],
"operation_id": op["id"],
"cover_id": selection.cover_id,
},
)
service.operation_handlers["official-widget"] = execute
def reconcile(op, acknowledge):
id = op["operation_id"]
row = service.db.one("SELECT payload FROM operations WHERE id=?", (id,))
expected_cover = json.loads(row["payload"])["cover_id"]
connection = service.registry.official[op["device_id"]]
with connection.lock:
connection.reader._cached = None
view = connection.widgets()
confirmed = (
view.current_verified
and (view.current.id if view.current else None) == expected_cover
)
resolved = op["status"] != "UNKNOWN"
if op["status"] == "UNKNOWN" and (confirmed or acknowledge):
service.update(
id,
"COMPLETED" if confirmed else "FAILED",
{"cover_id": expected_cover} if confirmed else None,
)
service.db.history(
dict(
id=id,
client_id="admin",
request_id=op["request_id"],
device_id=op["device_id"],
action="reconcile",
),
"CONFIRMED" if confirmed else "ACKNOWLEDGED",
)
resolved = True
return dict(
operation_id=id,
resolved=resolved,
current_image_id=None,
expected_image_id=None,
cloud_images=[],
)
service.reconciliation_handlers["official-widget"] = reconcile
+51
View File
@@ -0,0 +1,51 @@
from fastapi import Depends
from pigway_frame_flow.core.auth import require
from pigway_frame_flow.extensions.api import AISecret
from pigway_frame_flow.extensions.secrets import save_secret
from pigway_frame_flow.providers.external import (
ExternalProviderConfig,
)
def install_providers(app, service):
providers = service.providers
@app.get(
"/api/v1/providers/external",
tags=["Providers"],
dependencies=[Depends(require("devices:read"))],
)
def list_providers():
return providers.list()
@app.put(
"/api/v1/providers/external/{id}",
tags=["Providers"],
dependencies=[Depends(require("admin"))],
)
def configure(id: str, payload: ExternalProviderConfig):
return providers.save(id, payload)
@app.get(
"/api/v1/providers/external/{id}/data",
tags=["Providers"],
dependencies=[Depends(require("devices:read"))],
)
def provider_data(id: str):
return providers.get(id)
@app.put(
"/api/v1/providers/external/{id}/key",
status_code=204,
tags=["Providers"],
dependencies=[Depends(require("admin"))],
)
def provider_key(id: str, payload: AISecret):
providers.config(id)
save_secret(
service.settings.config_dir / ("provider-" + id + ".secret"),
payload.key.get_secret_value(),
)
service.settings.additional_secrets.append(payload.key.get_secret_value())
providers.cache.pop(id, None)
+39
View File
@@ -0,0 +1,39 @@
"""Small-thumbnail quality heuristics. Never delete or modify source photos."""
import hashlib
from io import BytesIO
from PIL import Image, ImageFilter, ImageOps, ImageStat
def photo_quality(data):
with Image.open(BytesIO(data)) as source:
image = ImageOps.exif_transpose(source).convert("L")
image.thumbnail((256, 256))
brightness = ImageStat.Stat(image).mean[0]
edges = image.filter(ImageFilter.FIND_EDGES)
sharpness = ImageStat.Stat(
edges.crop((2, 2, max(3, image.width - 2), max(3, image.height - 2)))
).var[0]
small = image.resize((9, 8))
pixels = list(small.tobytes())
bits = "".join(
"1" if pixels[y * 9 + x] > pixels[y * 9 + x + 1] else "0"
for y in range(8)
for x in range(8)
)
return {
"brightness": round(brightness, 2),
"sharpness": round(sharpness, 2),
"fingerprint": f"{int(bits, 2):016x}",
"sha256": hashlib.sha256(data).hexdigest(),
}
def passes(result, source):
return (
result["sharpness"] >= source.minimum_sharpness
and source.minimum_brightness
<= result["brightness"]
<= source.maximum_brightness
)
+18
View File
@@ -0,0 +1,18 @@
"""Atomic mode-0600 secrets; filenames supplied by application code, never callers."""
import os
import tempfile
from pathlib import Path
def save_secret(path, value):
with tempfile.NamedTemporaryFile(mode="w", dir=path.parent, delete=False) as file:
temporary = Path(file.name)
try:
file.write(value)
file.flush()
os.fsync(file.fileno())
temporary.chmod(0o600)
os.replace(temporary, path)
finally:
temporary.unlink(missing_ok=True)
+135
View File
@@ -0,0 +1,135 @@
"""Opt-in local retention, protecting stars, current content and durable references."""
import re
import time
from datetime import UTC, datetime, timedelta
from pydantic import Field
from pigway_frame_flow.models import StrictModel
from pigway_frame_flow.storage.database import encode, now_iso
class StoragePolicy(StrictModel):
enabled: bool = False
generated_days: int = Field(default=7, ge=1, le=3650)
cache_days: int = Field(default=2, ge=1, le=3650)
max_bytes: int = Field(default=1024 * 1024 * 1024, ge=1024 * 1024, le=1024**4)
class LocalRetention:
def __init__(self, service):
self.service = service
self.last = 0.0
def config(self):
row = self.service.db.one(
"SELECT payload FROM app_config WHERE key='local_retention'"
)
return (
StoragePolicy.model_validate_json(row["payload"])
if row
else StoragePolicy()
)
def protected(self):
db = self.service.db
ids = {
row["asset_id"]
for row in db.all("SELECT asset_id FROM asset_metadata WHERE starred=1")
}
payloads = []
for query in [
"SELECT payload FROM jobs",
"SELECT payload FROM template_config",
"SELECT payload FROM operations WHERE status IN ('QUEUED','RENDERING','UPLOADING','SYNCING','UNKNOWN')",
]:
payloads += [row["payload"] for row in db.all(query)]
for payload in payloads:
ids.update(re.findall(r"[a-f0-9]{32}", payload))
images: set[tuple[str, int]] = set()
for row in db.all("SELECT * FROM device_state"):
images.update(
(row["device_id"], row[key])
for key in ["previous_image_id", "base_image_id", "managed_image_id"]
if row[key]
)
images.update(
(row["device_id"], row["image_id"])
for row in db.all("SELECT device_id,image_id FROM overrides")
)
for device_id, image_id in images:
row = db.one(
"SELECT asset_id FROM image_sources WHERE device_id=? AND image_id=?",
(device_id, image_id),
)
if row:
ids.add(row["asset_id"])
return ids
def inspect(self):
rows = []
for row in self.service.db.all("SELECT * FROM assets ORDER BY created_at"):
path = self.service.assets.path(row["id"])
if path.exists():
rows.append((row, path, path.stat().st_size))
return rows
def stats(self):
rows = self.inspect()
protected = self.protected()
return {
"policy": self.config().model_dump(),
"total_bytes": sum(x[2] for x in rows),
"asset_count": len(rows),
"protected_count": sum(x[0]["id"] in protected for x in rows),
}
def sweep(self, force=False, dry_run=False):
if not force and time.monotonic() - self.last < 3600:
return {}
if not dry_run:
self.last = time.monotonic()
config = self.config()
if not config.enabled:
return {"deleted": 0, "bytes_freed": 0, "disabled": True}
with self.service.lock:
protected = self.protected()
rows = self.inspect()
total = sum(x[2] for x in rows)
removed = []
freed = 0
for row, path, size in rows:
# Manual uploaded photos are never removed by automatic retention.
if row["id"] in protected or row["kind"] not in ("generated", "cache"):
continue
days = (
config.cache_days
if row["kind"] == "cache"
else config.generated_days
)
expired = datetime.fromisoformat(row["created_at"]) < datetime.now(
UTC
) - timedelta(days=days)
if not expired and total <= config.max_bytes:
continue
removed.append(row["id"])
total -= size
freed += size
if not dry_run:
path.unlink(missing_ok=True)
for table, key in [
("asset_metadata", "asset_id"),
("asset_sources", "asset_id"),
("assets", "id"),
]:
self.service.db.execute(
f"DELETE FROM {table} WHERE {key}=?", (row["id"],)
)
result = {"deleted": len(removed), "bytes_freed": freed, "dry_run": dry_run}
if not dry_run:
self.service.db.execute(
"INSERT OR REPLACE INTO app_config VALUES('local_retention_last',?)",
(encode({**result, "timestamp": now_iso()}),),
)
return result
+196
View File
@@ -0,0 +1,196 @@
"""Signed, bounded, durable event delivery; independent of frame writes."""
import hashlib
import hmac
import json
import secrets
import time
from datetime import UTC, datetime, timedelta
from uuid import uuid4
from fastapi import Depends
from pydantic import Field
from pigway_frame_flow.core.auth import require
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.storage.database import encode, now_iso
class WebhookInput(StrictModel):
callback_url: str = Field(max_length=2048)
event_types: list[str] = Field(min_length=1, max_length=20)
enabled: bool = True
class Webhooks:
def __init__(self, service):
self.service = service
self.last = 0.0
service.db.conn.executescript("""CREATE TABLE IF NOT EXISTS webhook_deliveries (
subscription_id TEXT,event_id TEXT,status TEXT,attempts INTEGER DEFAULT 0,next_at TEXT,error_code TEXT,
PRIMARY KEY(subscription_id,event_id));""")
def tick(self):
if time.monotonic() - self.last < 60:
return
self.last = time.monotonic()
db = self.service.db
for sub in db.all("SELECT * FROM webhook_subscriptions WHERE enabled=1"):
types = json.loads(sub["event_types"])
for event in db.all(
"SELECT * FROM events e WHERE created_at>=? AND NOT EXISTS (SELECT 1 FROM webhook_deliveries d WHERE d.event_id=e.id AND d.subscription_id=?) ORDER BY created_at LIMIT 500",
(sub["created_at"], sub["id"]),
):
if event["event_type"] not in types:
continue
db.execute(
"INSERT OR IGNORE INTO webhook_deliveries VALUES(?,?,'QUEUED',0,?,NULL)",
(sub["id"], event["id"], now_iso()),
)
for row in db.all(
"SELECT d.*,s.callback_url FROM webhook_deliveries d JOIN webhook_subscriptions s ON s.id=d.subscription_id WHERE s.enabled=1 AND d.status IN ('QUEUED','RETRY') AND d.next_at<=? ORDER BY d.next_at LIMIT 2",
(now_iso(),),
):
event = db.one("SELECT * FROM events WHERE id=?", (row["event_id"],))
if not event:
continue
keyfile = self.service.settings.config_dir / (
"webhook-" + row["subscription_id"] + ".secret"
)
try:
key = keyfile.read_text().strip()
payload = encode(
{
"id": event["id"],
"type": event["event_type"],
"timestamp": event["created_at"],
"data": json.loads(event["payload"]),
}
).encode()
signature = hmac.new(key.encode(), payload, hashlib.sha256).hexdigest()
self.service.assets.fetch_url(
row["callback_url"],
method="POST",
body=payload,
headers={
"Content-Type": "application/json",
"X-Frame-Flow-Event-ID": event["id"],
"X-Frame-Flow-Signature": "sha256=" + signature,
},
limit=64 * 1024,
accepted_status=(200, 201, 202, 204),
)
status, error = "DELIVERED", None
except Exception:
status, error = (
("FAILED" if row["attempts"] >= 2 else "RETRY"),
"DELIVERY_FAILED",
)
db.execute(
"UPDATE webhook_deliveries SET status=?,attempts=attempts+1,next_at=?,error_code=? WHERE subscription_id=? AND event_id=?",
(
status,
(
datetime.now(UTC) + timedelta(seconds=60 * 2 ** row["attempts"])
).isoformat(),
error,
row["subscription_id"],
row["event_id"],
),
)
def install_webhooks(app, service):
worker = Webhooks(service)
service.webhooks = worker
db = service.db
@app.get(
"/api/v1/webhooks", tags=["Webhooks"], dependencies=[Depends(require("admin"))]
)
def list_hooks():
return [dict(row) for row in db.all("SELECT * FROM webhook_subscriptions")]
@app.post(
"/api/v1/webhooks",
status_code=201,
tags=["Webhooks"],
dependencies=[Depends(require("admin"))],
)
def add_hook(payload: WebhookInput):
from urllib.parse import urlsplit
parsed = urlsplit(payload.callback_url)
if (
parsed.scheme != "https"
or not parsed.hostname
or parsed.username
or parsed.password
or parsed.port not in (None, 443)
or parsed.hostname not in service.settings.image_url_hosts
):
raise FlowError(
E.INVALID_REQUEST, "Webhook requires an explicitly allowed HTTPS host"
)
allowed = {
"operation.completed",
"operation.failed",
"display.changed",
"device.online",
"device.offline",
"device.low_battery",
}
if set(payload.event_types) - allowed:
raise FlowError(E.INVALID_REQUEST, "Unsupported event type")
id = uuid4().hex
key = secrets.token_urlsafe(32)
import os
path = service.settings.config_dir / ("webhook-" + id + ".secret")
with os.fdopen(
os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), "w"
) as f:
f.write(key)
db.execute(
"INSERT INTO webhook_subscriptions VALUES(?,?,?,?,?,?)",
(
id,
"admin",
payload.callback_url,
encode(payload.event_types),
int(payload.enabled),
now_iso(),
),
)
service.settings.additional_secrets.append(key)
return {"id": id, "signing_secret": key}
@app.delete(
"/api/v1/webhooks/{id}",
status_code=204,
tags=["Webhooks"],
dependencies=[Depends(require("admin"))],
)
def delete_hook(id: str):
if not db.one("SELECT id FROM webhook_subscriptions WHERE id=?", (id,)):
raise FlowError(E.NOT_FOUND, "Webhook not found", 404)
db.execute("DELETE FROM webhook_subscriptions WHERE id=?", (id,))
(service.settings.config_dir / ("webhook-" + id + ".secret")).unlink(
missing_ok=True
)
@app.get(
"/api/v1/webhooks/{id}/deliveries",
tags=["Webhooks"],
dependencies=[Depends(require("admin"))],
)
def deliveries(id: str):
return [
dict(row)
for row in db.all(
"SELECT * FROM webhook_deliveries WHERE subscription_id=? ORDER BY next_at DESC LIMIT 100",
(id,),
)
]
@@ -95,14 +95,19 @@ def capabilities() -> tuple[OfficialCapability, ...]:
return tuple(
OfficialCapability(
method=method,
implemented=method in READ_OPERATIONS or method == "app.login",
implemented=method in READ_OPERATIONS
or method in {"app.login", "appFrame.setFrameCover"},
effect="read"
if method in READ_OPERATIONS
else "authentication"
if method == "app.login"
else "write"
if method == "appFrame.setFrameCover"
else "unverified",
evidence="live_verified"
if method in READ_OPERATIONS or method == "app.login"
else "mock_verified"
if method == "appFrame.setFrameCover"
else "apk_discovered",
)
for method in DISCOVERED_METHODS
@@ -17,8 +17,15 @@ class OfficialRPC:
def authorized(self) -> bool:
return bool(self._token)
def _call(self, method: str, params: dict) -> dict:
if method not in READ_OPERATIONS and method != "app.login":
def _call(self, method: str, params: dict, *, allow_write=False) -> dict:
if method == "appFrame.setFrameCover" and not allow_write:
raise FlowError(
E.UNSUPPORTED_CAPABILITY, "Use the explicit widget operation", 422
)
if method not in READ_OPERATIONS and method not in (
"app.login",
"appFrame.setFrameCover",
):
raise FlowError(
E.UNSUPPORTED_CAPABILITY, "Official operation is not enabled", 422
)
@@ -62,6 +69,10 @@ class OfficialRPC:
except FlowError:
raise
except (requests.RequestException, ValueError, AttributeError):
if method == "appFrame.setFrameCover":
raise FlowError(
E.WRITE_RESULT_UNKNOWN, "Official widget write result unknown", 409
) from None
raise FlowError(
E.CLOUD_UNAVAILABLE, "Official service query failed", 502
) from None
@@ -86,3 +97,10 @@ class OfficialRPC:
def widgets(self, robot_id: int) -> dict:
return self._call("appFrame.getFrameCovers", {"robotId": robot_id})
def set_widget(self, robot_id, cover_id, args):
return self._call(
"appFrame.setFrameCover",
{"robotId": robot_id, "coverId": cover_id, "args": args},
allow_write=True,
)
@@ -15,6 +15,7 @@ class WidgetReader:
self.rpc = OfficialRPC(token)
self._robot_id = robot_id
self.mock = mock
self.mock_selection = 188
self._cached = None
self._cached_at = 0.0
@@ -37,7 +38,8 @@ class WidgetReader:
source="mock",
configured=True,
catalog=catalog,
current=catalog[0],
current=next((x for x in catalog if x.id == self.mock_selection), None),
status="OK" if self.mock_selection else "NO_WIDGET",
current_verified=True,
)
if not self.rpc.authorized or self._robot_id is None:
+16 -2
View File
@@ -68,13 +68,26 @@ class PhotoSourceContent(StrictModel):
profile: Profile = Profile.color
class ProviderContent(StrictModel):
type: Literal["provider"]
provider_id: str = Field(pattern=r"^[a-z0-9-]{1,60}$")
template: str = Field(default="daily-dashboard", pattern=r"^[a-z0-9-]{1,60}$")
data: dict[str, Any] = Field(default_factory=dict)
profile: Profile = Profile.color
class FutureContent(StrictModel):
type: Literal["html", "markdown"]
data: str = ""
Content = Annotated[
ImageContent | URLContent | TemplateContent | PhotoSourceContent | FutureContent,
ImageContent
| URLContent
| TemplateContent
| PhotoSourceContent
| ProviderContent
| FutureContent,
Field(discriminator="type"),
]
@@ -141,6 +154,7 @@ class ErrorResponse(BaseModel):
class OperationResult(BaseModel):
cover_id: int | None = None
photo_attempts: list[dict[str, str]] = Field(default_factory=list)
image_id: int | None = None
asset_id: str | None = None
@@ -195,7 +209,7 @@ class CurrentContent(BaseModel):
class Device(BaseModel):
id: str
name: str
driver: Literal["kokonna"] = "kokonna"
driver: str = "kokonna"
status: Literal["online", "offline"]
model: str
model_code: str
+227
View File
@@ -0,0 +1,227 @@
"""Read-only OAuth-token drive readers. Credentials never enter catalog metadata."""
import json
import re
from pathlib import Path
from urllib.parse import urlencode, urlsplit
from pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.settings import secret
class CloudDrives:
def __init__(self, photos):
self.photos = photos
self.settings = photos.settings
def credentials(self, source_id, source):
raw = secret(source.credential_env) if source.credential_env else ""
path = self.settings.config_dir / ("source-" + source_id + ".secret")
if not raw and path.exists():
raw = path.read_text()
try:
value = json.loads(raw)
if (
not isinstance(value.get("access_token"), str)
or not value["access_token"]
):
raise ValueError()
for key in ["access_token", "refresh_token", "client_secret"]:
if value.get(key):
self.settings.additional_secrets.append(value[key])
return value
except (ValueError, TypeError, AttributeError):
raise FlowError(
E.CONFIG_REQUIRED,
"Authorize this cloud drive with a valid OAuth access token",
409,
) from None
def fetch(self, url, **kwargs):
# Only fixed provider API hosts and explicit download domains reach the network.
host = urlsplit(url).hostname
trusted = {"www.googleapis.com", "openapi.aliyundrive.com", "pan.baidu.com"}
if host in trusted and host not in self.settings.image_url_hosts:
self.settings.image_url_hosts.append(host)
return self.photos.assets.fetch_url(url, **kwargs)
def api(self, url, token, *, body=None, params=None):
headers = {"Authorization": "Bearer " + token}
if body is not None:
headers["Content-Type"] = "application/json"
try:
raw = self.fetch(
url + ("?" + urlencode(params) if params else ""),
method="POST" if body is not None else "GET",
headers=headers,
body=json.dumps(body).encode() if body is not None else None,
limit=2 * 1024 * 1024,
)
data = json.loads(raw)
if data.get("error") or data.get("code") or data.get("errno", 0):
raise ValueError()
return data
except Exception:
raise FlowError(
E.CLOUD_UNAVAILABLE,
"Cloud drive query failed; check authorization and permissions",
502,
) from None
def catalog(self, source_id, source):
credentials = self.credentials(source_id, source)
token = credentials["access_token"]
items: list[dict] = []
queue = [source.location]
visited = set()
page_count = 0
while queue and len(items) < source.catalog_limit:
folder = queue.pop(0)
if folder in visited:
continue
visited.add(folder)
cursor = None
offset = 0
while len(items) < source.catalog_limit:
page_count += 1
if page_count > 200:
raise FlowError(
E.CONFLICT,
"Drive query exceeds 200 pages; select a smaller folder",
409,
)
if source.kind == "google-drive":
if not re.fullmatch(r"[a-zA-Z0-9_-]{1,200}", folder):
raise FlowError(
E.INVALID_REQUEST, "Invalid Google Drive folder ID"
)
page = self.api(
"https://www.googleapis.com/drive/v3/files",
token,
params={
"q": f"'{folder}' in parents and trashed = false",
"pageSize": 1000,
"fields": "nextPageToken,files(id,name,mimeType)",
**({"pageToken": cursor} if cursor else {}),
},
)
rows = page.get("files", [])
next_page = page.get("nextPageToken")
elif source.kind == "alipan":
drive = credentials.get("drive_id")
if not drive:
raise FlowError(
E.CONFIG_REQUIRED,
"AliPan credentials require drive_id",
409,
)
page = self.api(
"https://openapi.aliyundrive.com/adrive/v1.0/openFile/list",
token,
body={
"drive_id": drive,
"parent_file_id": folder,
"limit": 100,
**({"marker": cursor} if cursor else {}),
},
)
rows = page.get("items", [])
next_page = page.get("next_marker")
else:
page = self.api(
"https://pan.baidu.com/rest/2.0/xpan/file",
token,
params={
"method": "list",
"dir": folder,
"start": offset,
"limit": 1000,
"access_token": token,
},
)
rows = page.get("list", [])
next_page = str(offset + 1000) if len(rows) == 1000 else None
for row in rows:
name = row.get("name", row.get("server_filename", ""))
id = str(row.get("id", row.get("file_id", row.get("fs_id", ""))))
directory = (
row.get("mimeType") == "application/vnd.google-apps.folder"
or row.get("type") == "folder"
or row.get("isdir") == 1
)
if directory:
if source.recursive:
queue.append(row.get("path", id))
continue
if (
not id
or Path(name).suffix.lower() not in self.photos.extensions
):
continue
items.append(
{
"id": id,
"name": name,
"caption": None,
"provider_preview": False,
}
)
if len(items) >= source.catalog_limit:
break
if not next_page:
break
if next_page == cursor:
raise FlowError(
E.CLOUD_UNAVAILABLE, "Drive pagination did not advance", 502
)
cursor = next_page
offset += 1000
return items
def read(self, source_id, source, item):
credentials = self.credentials(source_id, source)
token = credentials["access_token"]
id = item["id"]
if source.kind == "google-drive":
if not re.fullmatch(r"[a-zA-Z0-9_-]{1,200}", id):
raise FlowError(E.INVALID_REQUEST, "Invalid file ID")
return self.fetch(
"https://www.googleapis.com/drive/v3/files/" + id + "?alt=media",
headers={"Authorization": "Bearer " + token},
)
suffixes: tuple[str, ...]
if source.kind == "alipan":
result = self.api(
"https://openapi.aliyundrive.com/adrive/v1.0/openFile/getDownloadUrl",
token,
body={"drive_id": credentials["drive_id"], "file_id": id},
)
url = result.get("url", "")
suffixes = ("aliyuncs.com", "alipan.com", "aliyundrive.com")
else:
result = self.api(
"https://pan.baidu.com/rest/2.0/xpan/multimedia",
token,
params={
"method": "filemetas",
"fsids": json.dumps([int(id)]),
"dlink": 1,
"access_token": token,
},
)
url = result.get("list", [{}])[0].get("dlink", "")
suffixes = ("baidupcs.com", "baidu.com")
host = urlsplit(url).hostname or ""
if not any(
host == suffix or host.endswith("." + suffix) for suffix in suffixes
):
raise FlowError(E.FORBIDDEN, "Unexpected drive download host", 403)
if host not in self.settings.image_url_hosts:
self.settings.image_url_hosts.append(host)
if source.kind == "baidu":
url += ("&" if "?" in url else "?") + urlencode({"access_token": token})
return self.fetch(
url,
headers={"User-Agent": "pan.baidu.com"} if source.kind == "baidu" else {},
)
+196
View File
@@ -0,0 +1,196 @@
"""Optional read-only calendar and state providers, no vendor dependency in templates."""
import json
import re
import time
from datetime import UTC, datetime, timedelta
from urllib.parse import quote, urlsplit
import requests
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.storage.database import encode
class ExternalProviderConfig(StrictModel):
kind: str = Field(pattern=r"^(google-calendar|ics|home-assistant|http-json)$")
name: str = Field(min_length=1, max_length=120)
endpoint: str = Field(max_length=2048)
enabled: bool = True
allow_insecure_lan: bool = False
entities: list[str] = Field(default_factory=list, max_length=100)
class ExternalProviders:
def __init__(self, service):
self.service = service
self.cache = {}
def list(self):
return [
dict(json.loads(row["payload"]), id=row["key"][9:])
for row in self.service.db.all(
"SELECT key,payload FROM app_config WHERE key LIKE 'provider:%'"
)
]
def config(self, id):
row = self.service.db.one(
"SELECT payload FROM app_config WHERE key=?", ("provider:" + id,)
)
if not row:
raise FlowError(E.NOT_FOUND, "Provider not found", 404)
return ExternalProviderConfig.model_validate_json(row["payload"])
def save(self, id, config):
if not re.fullmatch(r"[a-z0-9-]{1,60}", id):
raise FlowError(E.INVALID_REQUEST, "Invalid provider ID")
if config.kind != "google-calendar":
parsed = urlsplit(config.endpoint)
if (
parsed.scheme not in ("http", "https")
or not parsed.hostname
or parsed.username
or parsed.password
or parsed.fragment
):
raise FlowError(
E.INVALID_REQUEST,
"Provider needs an HTTP(S) endpoint without credentials",
)
if not config.allow_insecure_lan and (
parsed.scheme != "https"
or parsed.hostname not in self.service.settings.image_url_hosts
):
raise FlowError(
E.FORBIDDEN,
"Provider host must be allowed; LAN access requires explicit opt-in",
403,
)
self.service.db.execute(
"INSERT OR REPLACE INTO app_config VALUES(?,?)",
("provider:" + id, encode(config.model_dump())),
)
self.cache.pop(id, None)
return dict(config.model_dump(), id=id)
def get(self, id):
config = self.config(id)
if not config.enabled:
raise FlowError(E.CONFLICT, "Provider disabled", 409)
if id in self.cache and time.monotonic() < self.cache[id][0]:
return self.cache[id][1]
secretfile = self.service.settings.config_dir / ("provider-" + id + ".secret")
token = secretfile.read_text().strip() if secretfile.exists() else ""
if token:
self.service.settings.additional_secrets.append(token)
headers = {"Authorization": "Bearer " + token} if token else {}
if config.kind == "google-calendar":
if not token:
raise FlowError(
E.CONFIG_REQUIRED,
"Google Calendar requires an authorized OAuth token",
409,
)
start = datetime.now(UTC)
from urllib.parse import urlencode
url = (
"https://www.googleapis.com/calendar/v3/calendars/"
+ quote(config.endpoint, safe="")
+ "/events?"
+ urlencode(
{
"timeMin": start.isoformat(),
"timeMax": (start + timedelta(days=7)).isoformat(),
"singleEvents": "true",
"orderBy": "startTime",
"maxResults": 100,
}
)
)
if "www.googleapis.com" not in self.service.settings.image_url_hosts:
self.service.settings.image_url_hosts.append("www.googleapis.com")
else:
url = config.endpoint.rstrip("/") + (
"/api/states" if config.kind == "home-assistant" else ""
)
try:
if config.allow_insecure_lan:
# Endpoint is an explicitly administrator-approved, fixed LAN destination.
with requests.get(
url, headers=headers, timeout=10, allow_redirects=False, stream=True
) as response:
response.raise_for_status()
parts = []
total = 0
for part in response.iter_content(65536):
total += len(part)
if total > 2 * 1024 * 1024:
raise ValueError()
parts.append(part)
raw = b"".join(parts)
else:
raw = self.service.assets.fetch_url(
url, headers=headers, limit=2 * 1024 * 1024
)
result: dict
if config.kind == "ics":
events = []
text = (
raw.decode("utf-8-sig").replace("\r\n ", "").replace("\r\n", "\n")
)
for block in text.split("BEGIN:VEVENT")[1:]:
block = block.split("END:VEVENT")[0]
values = {
line.split(":", 1)[0].split(";")[0]: line.split(":", 1)[1]
for line in block.splitlines()
if ":" in line
}
if "RRULE" in values:
raise FlowError(
E.UNSUPPORTED_CAPABILITY,
"Recurring ICS events require a calendar provider with expanded instances",
422,
)
if values.get("SUMMARY"):
events.append(values["SUMMARY"].replace("\\n", " "))
result = {"events": events[:100]}
elif config.kind == "google-calendar":
result = {
"events": [
{
"title": x.get("summary", "Untitled"),
"start": x.get("start", {}).get(
"dateTime", x.get("start", {}).get("date")
),
}
for x in json.loads(raw).get("items", [])
]
}
elif config.kind == "home-assistant":
result = {
"events": [
x["entity_id"] + ": " + str(x["state"])
for x in json.loads(raw)
if not config.entities or x["entity_id"] in config.entities
]
}
else:
result = json.loads(raw)
if not isinstance(result, dict):
raise ValueError()
result = self.service.settings.sanitize(result)
self.cache[id] = (time.monotonic() + 300, result)
return result
except FlowError:
raise
except Exception:
raise FlowError(
E.CLOUD_UNAVAILABLE,
"External provider unavailable; check connection and credentials",
502,
) from None
+30 -3
View File
@@ -27,7 +27,7 @@ from pigway_frame_flow.storage.database import encode
class PhotoSourceInput(StrictModel):
name: str = Field(min_length=1, max_length=120)
kind: Literal["local", "webdav"]
kind: Literal["local", "webdav", "google-drive", "alipan", "baidu"]
location: str = Field(min_length=1, max_length=2048)
recursive: bool = True
enabled: bool = True
@@ -36,6 +36,11 @@ class PhotoSourceInput(StrictModel):
organized_only: bool = False
folder_caption: bool = False
orientation: Literal["auto", "landscape", "portrait"] | None = None
minimum_sharpness: float = Field(default=0, ge=0, le=10000)
minimum_brightness: float = Field(default=0, ge=0, le=255)
maximum_brightness: float = Field(default=255, ge=0, le=255)
avoid_duplicates: bool = False
people_space: Literal["shared", "personal"] = "shared"
people_filter: bool = False
person_ids: list[int] = Field(default_factory=list, max_length=50)
person_match: Literal["any", "all"] = "any"
@@ -102,6 +107,15 @@ class PhotoSources:
return PhotoSourceInput.model_validate_json(row["payload"])
def save(self, source_id, source):
if source.minimum_brightness > source.maximum_brightness:
raise FlowError(E.INVALID_REQUEST, "Brightness range is reversed")
if source.kind != "local" and (
source.organized_only or source.folder_caption or source.synology_previews
):
raise FlowError(
E.UNSUPPORTED_CAPABILITY,
"Folder captions and Synology previews require a local photo source",
)
if source.orientation and source.kind != "local":
raise FlowError(
E.UNSUPPORTED_CAPABILITY,
@@ -126,7 +140,7 @@ class PhotoSources:
raise FlowError(
E.INVALID_REQUEST, "Local sources do not use credentials"
)
else:
elif source.kind == "webdav":
source = source.model_copy(
update={
"recursive": False,
@@ -266,7 +280,12 @@ class PhotoSources:
):
return cached[1]
allowed = (
self.synology.paths(source.person_ids, source.person_match, refresh=refresh)
self.synology.paths(
source.person_ids,
source.person_match,
refresh=refresh,
space=source.people_space,
)
if source.people_filter
else None
)
@@ -387,6 +406,10 @@ class PhotoSources:
break
if len(items) >= source.catalog_limit:
break
elif source.kind in ("google-drive", "alipan", "baidu"):
from pigway_frame_flow.providers.cloud_drives import CloudDrives
items = CloudDrives(self).catalog(source_id, source)
else:
data = self.assets.fetch_url(
source.location,
@@ -503,6 +526,10 @@ class PhotoSources:
os.close(parent_fd)
except OSError:
raise FlowError(E.NOT_FOUND, "Photo is unavailable", 404) from None
elif source.kind in ("google-drive", "alipan", "baidu"):
from pigway_frame_flow.providers.cloud_drives import CloudDrives
data = CloudDrives(self).read(source_id, source, item)
else:
data = self.assets.fetch_url(item["id"], headers=self.headers(source))
if len(data) > self.settings.max_bytes:
+15 -7
View File
@@ -83,6 +83,9 @@ class SynologyPhotos:
("SYNO.API.Auth", "login"),
("SYNO.API.Auth", "logout"),
("SYNO.FotoTeam.Browse.Person", "list"),
("SYNO.Foto.Browse.Person", "list"),
("SYNO.Foto.Browse.Item", "list"),
("SYNO.Foto.Browse.Folder", "get"),
("SYNO.FotoTeam.Browse.Item", "list"),
("SYNO.FotoTeam.Browse.Folder", "get"),
}:
@@ -193,9 +196,10 @@ class SynologyPhotos:
return rows
raise FlowError(E.CONFLICT, "Photos catalog exceeds query limit", 409)
def people(self):
def people(self, space="shared"):
prefix = "SYNO.FotoTeam" if space == "shared" else "SYNO.Foto"
with self.lock:
rows = self.pages("SYNO.FotoTeam.Browse.Person", 3)
rows = self.pages(prefix + ".Browse.Person", 3)
return [
{
"id": r["id"],
@@ -206,13 +210,17 @@ class SynologyPhotos:
for r in rows
]
def paths(self, person_ids, mode, refresh=False):
key = (tuple(sorted(person_ids)), mode)
def paths(self, person_ids, mode, refresh=False, *, space="shared"):
prefix = "SYNO.FotoTeam" if space == "shared" else "SYNO.Foto"
key = (tuple(sorted(person_ids)), mode, space)
with self.lock:
cached = self.cache.get(key)
if cached and not refresh and time.monotonic() - cached[0] < 900:
return cached[1]
known = {p["id"] for p in self.people()}
known = {
p["id"]
for p in (self.people() if space == "shared" else self.people(space))
}
ids = person_ids or sorted(known)
if not set(ids) <= known:
raise FlowError(E.INVALID_REQUEST, "Selected person no longer exists")
@@ -221,7 +229,7 @@ class SynologyPhotos:
for person in ids:
paths = set()
for row in self.pages(
"SYNO.FotoTeam.Browse.Item",
prefix + ".Browse.Item",
7,
person_id=person,
additional=json.dumps(["folder"]),
@@ -241,7 +249,7 @@ class SynologyPhotos:
)
if folder_id not in folders:
folders[folder_id] = self.request(
"SYNO.FotoTeam.Browse.Folder",
prefix + ".Browse.Folder",
"get",
2,
id=folder_id,
+9 -1
View File
@@ -153,7 +153,14 @@ class Assets:
return data
def fetch_url(
self, url, *, method="GET", headers=None, limit=None, accepted_status=(200,)
self,
url,
*,
method="GET",
headers=None,
body=None,
limit=None,
accepted_status=(200,),
):
limit = limit or self.settings.max_bytes
parsed = urlsplit(url)
@@ -197,6 +204,7 @@ class Assets:
method,
(parsed.path or "/") + ("?" + parsed.query if parsed.query else ""),
headers=headers or {},
body=body,
)
response = connection.getresponse()
if response.status not in accepted_status:
+4
View File
@@ -106,6 +106,10 @@ class TemplateRenderer:
return values
def render(self, template_id, data, profile="color"):
if template_id in ("collage", "poster"):
from pigway_frame_flow.extensions.layout import LayoutStudio
return LayoutStudio(self).render(data, profile)
spec = self.info(template_id)
values = self.resolve(template_id, data)
current = datetime.now(ZoneInfo(self.settings.timezone))
+20
View File
@@ -54,6 +54,7 @@ class Scheduler:
self.service, self.db = service, service.db
self.stop_event = threading.Event()
self.thread = None
self.webhook_thread = None
self.tick_lock = threading.Lock()
def jobs(self):
@@ -248,6 +249,21 @@ class Scheduler:
target=self.run, name="frame-flow-worker", daemon=True
)
self.thread.start()
if hasattr(self.service, "webhooks"):
self.webhook_thread = threading.Thread(
target=self.run_webhooks, name="frame-flow-webhooks", daemon=True
)
self.webhook_thread.start()
def run_webhooks(self):
while not self.stop_event.is_set():
try:
self.service.webhooks.tick()
except Exception:
logging.getLogger("frame-flow").error(
"Webhook cycle failed; details suppressed"
)
self.stop_event.wait(60)
def run(self):
while not self.stop_event.is_set():
@@ -255,6 +271,8 @@ class Scheduler:
self.tick()
self.service.expire()
self.service.cloud_library.sweep()
if hasattr(self.service, "local_retention"):
self.service.local_retention.sweep()
processed = self.service.process_one()
self.service.sync()
except Exception:
@@ -268,6 +286,8 @@ class Scheduler:
self.stop_event.set()
if self.thread:
self.thread.join(timeout=45)
if self.webhook_thread:
self.webhook_thread.join(timeout=25)
def healthy(self):
return bool(
+11
View File
@@ -0,0 +1,11 @@
{
"id": "collage",
"name": "Multi-photo collage / 多图拼版",
"width": 1200,
"height": 1600,
"fields": [
"layout"
],
"defaults": {},
"blocks": []
}
+11
View File
@@ -0,0 +1,11 @@
{
"id": "poster",
"name": "Poster / 海报",
"width": 1200,
"height": 1600,
"fields": [
"layout"
],
"defaults": {},
"blocks": []
}
+4 -2
View File
@@ -3,7 +3,7 @@ const $=s=>document.querySelector(s);
const state={lang:localStorage.getItem('ff-language')||'zh',theme:localStorage.getItem('ff-theme')||'system',page:location.hash.slice(1)||'dashboard',token:'',csrf:'',device:'living-room',settings:null,devices:[],templates:[],gallery:[],assets:[],blobs:[],tab:'cloud',preview:null,templatePreview:null,selectedTemplate:null,epoch:0};
function forgetLogin(){localStorage.removeItem('ff-login');state.token='';state.csrf='';state.settings=null;state.devices=[];state.templates=[];}
async function loadConnection(){state.settings=await api('/api/v1/settings');try{state.devices=await api('/api/v1/devices');}catch(error){state.devices=[];toast(error.message);}state.templates=await api('/api/v1/templates');state.device=state.devices[0]?.id||state.device;}
const icons={dashboard:'◫',gallery:'▧','photo-sources':'▣',upload:'↥',templates:'▦',automation:'◷',playlists:'▤',history:'↺',settings:'⚙'};
const icons={studio:'▦',connections:'↔',dashboard:'◫',gallery:'▧','photo-sources':'▣',upload:'↥',templates:'▦',automation:'◷',playlists:'▤',history:'↺',settings:'⚙'};
const t=k=>window.MESSAGES[state.lang][k]||k;
const esc=v=>String(v??'—').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const fmt=v=>v?new Date(v).toLocaleString(state.lang==='zh'?'zh-CN':'en-GB',{timeZone:'Asia/Shanghai'}):'—';
@@ -77,11 +77,13 @@ async function templateEditor(selected){
}
function accountSettings(){const section=$('#logout').parentElement;const form=document.createElement('form');form.id='password-form';form.innerHTML=`<h3>${t('changePassword')}</h3><div class="field"><label for="current-password">${t('currentPassword')}</label><input id="current-password" type="password" required autocomplete="current-password"></div><div class="field"><label for="new-password">${t('newPassword')}</label><input id="new-password" type="password" minlength="8" required autocomplete="new-password"></div><button type="submit">${t('changePassword')}</button>`;section.append(form);form.onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/auth/password',{method:'PUT',body:{current_password:$('#current-password').value,new_password:$('#new-password').value}});forgetLogin();shell();login();toast(t('passwordChanged'));});}
const views={
studio:()=>layoutStudioPage(),
connections:()=>connectionsPage(),
'photo-sources':async()=>{ $('#main').innerHTML='';await photoSourcesPanel(); },
async dashboard(epoch){const d=await api('/api/v1/devices/'+state.device);if(epoch!==state.epoch)return;const s=d.slideshow;$('#main').innerHTML=notice()+`${d.power.battery_percent!==null&&d.power.battery_percent<20?`<div class="notice warning">${t('lowBattery')}</div>`:''}<section class="card hero"><div class="hero-info"><span class="badge ${d.status==='offline'?'bad':''}">● ${t(d.status).toUpperCase()}</span><h2>${esc(d.name)}</h2><p>${esc(d.model)} · ${esc(d.model_code)} · ${d.display.width} × ${d.display.height}</p><div class="actions">${buttons('go-upload','upload',true)}${buttons('restore','restore')}${buttons('refresh-device','refresh')}</div><p class="hint" style="margin-top:28px">${t('refreshInterval')}</p></div><div class="frame-demo"><div class="mini-art"><div class="sun"></div></div><span class="frame-label">${t('testLabel')}</span></div></section><div class="stats">${[['battery',d.power.battery_percent===null?'—':d.power.battery_percent+'%'],['current',d.current_content.id??'—'],['sync',t(d.current_content.synced?'synced':'syncing')],['firmware',d.firmware]].map(([l,v])=>`<div class="card stat"><div class="label">${t(l)}</div><div class="value">${esc(v)}</div></div>`).join('')}</div><div class="columns"><section class="card"><h3>${t('device')}</h3><div class="detail-grid">${detail('heartbeat',fmt(d.last_seen_at))}${detail('rotation',d.display.rotation+'°')}${detail('timezone',d.timezone)}${detail('coverId',d.current_content.cover_id??'—')}${detail('storage',d.storage_used_bytes===null?'—':(d.storage_used_bytes/1024/1024).toFixed(1)+' MiB')}${detail('model',t(d.model_verified?'modelVerified':'modelConfigured'))}</div></section><section class="card"><h3>${t('cloudRotation')}</h3><div class="detail-grid">${detail('mode',s.mode?t(s.mode):'—')}${detail('interval',s.interval_minutes==null?'—':s.interval_minutes+' '+t('minutes'))}</div><p class="hint">${t('rotationNotice')}</p><a href="#playlists" id="go-playlists">${t('playlists')} →</a></section></div>`;bind('go-upload',()=>navigate('upload'));bind('refresh-device',async()=>{await api('/api/v1/devices/'+state.device+'?refresh=true');await navigate();});$('#restore').disabled=!d.can_restore;bind('restore',()=>accepted('/api/v1/devices/'+state.device+'/restore',{}));$('#go-playlists').onclick=e=>{e.preventDefault();navigate('playlists');};},
async gallery(epoch){let items=state.tab==='cloud'?await api('/api/v1/devices/'+state.device+'/images'):await api('/api/v1/assets?limit=200&offset='+(state.assetOffset||0)+'&origin='+encodeURIComponent(state.assetOrigin||'all')+(state.assetJob?'&job_id='+encodeURIComponent(state.assetJob):''));if(epoch!==state.epoch)return;state.gallery=items;$('#main').innerHTML=notice()+`<div class="section-heading"><div class="tabs"><button id="cloud-tab" class="${state.tab==='cloud'?'active':''}">${t('cloud')}</button><button id="local-tab" class="${state.tab==='local'?'active':''}">${t('local')}</button></div><div class="actions">${buttons('gallery-refresh','refresh')}${state.tab==='cloud'?buttons('show-name','displayByName'):''}${buttons('gallery-upload','upload',true)}</div></div>${state.tab==='local'?`<div class="notice">${t('uploadNotice')}</div>`:''}${items.length?`<div class="gallery">${items.map((x,i)=>`<article class="card image-card"><img class="thumb" alt="${esc(x.name)}" data-src="${esc(x.url+(state.tab==='cloud'?'?thumbnail=true':''))}"><h3 title="${esc(x.name)}">${esc(x.name)}</h3><small>${state.tab==='cloud'?'#'+x.image_id:'LOCAL'} · ${esc(fmt(x.created_at))}</small><div class="actions"><button class="primary" data-show="${i}" ${x.current?'disabled':''}>${t(x.current?'current':'display')}</button><button data-original="${i}">${t('original')}</button><button class="danger" data-delete="${i}">${t('delete')}</button></div></article>`).join('')}</div>`:`<div class="card empty"><strong>${t('noImages')}</strong>${t('emptyHint')}</div>`}`;bind('cloud-tab',()=>{state.tab='cloud';return navigate();});bind('local-tab',()=>{state.tab='local';return navigate();});bind('gallery-refresh',()=>navigate());bind('gallery-upload',()=>navigate('upload'));bind('show-name',()=>{showModal(`<h2>${t('displayByName')}</h2><div class="field"><label for="image-name">${t('name')}</label><input id="image-name" maxlength="120"></div><div class="modal-actions">${buttons('modal-cancel','cancel')}${buttons('show-name-confirm','display',true)}</div>`);bind('show-name-confirm',async()=>{const name=$('#image-name').value;$('#modal').close();displayDialog({type:'image',image_name:name});});});hydrateImages();document.querySelectorAll('[data-show]').forEach(b=>b.onclick=busy(()=>{const x=items[b.dataset.show];if(x.current)return;return displayDialog(state.tab==='cloud'?{type:'image',image_id:x.image_id}:{type:'image',asset_id:x.asset_id});}));document.querySelectorAll('[data-original]').forEach(b=>b.onclick=busy(async()=>{const x=items[b.dataset.original];showModal(`<h2>${esc(x.name)}</h2><img id="original-image" class="preview" alt="${esc(x.name)}"><div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);await blobImage($('#original-image'),x.url);}));document.querySelectorAll('[data-delete]').forEach(b=>b.onclick=()=>{const x=items[b.dataset.delete];confirmDialog(x.current?t('deleteCurrent'):t('deleteConfirm'),async()=>{if(state.tab==='local'){await api('/api/v1/assets/'+x.asset_id,{method:'DELETE'});}else{const result=await api(`/api/v1/devices/${state.device}/images/${x.image_id}?confirm_current=${x.current}`,{method:'DELETE',headers:{'Idempotency-Key':'web-'+clientUUID()}});await waitOperation(result.operation_id);}await navigate();});});},
async upload(){state.preview=null;$('#main').innerHTML=notice()+`<div class="notice">${t('uploadNotice')}</div><div class="columns"><section class="card"><h2>${t('upload')}</h2><div class="dropzone"><strong>${t('selectFile')}</strong><p class="hint">${t('uploadHint')}</p><input id="file" type="file" accept="image/jpeg,image/png,image/webp" aria-label="${t('selectFile')}"></div><div class="form-grid" style="margin-top:24px"><div class="field"><label for="fit">${t('mode')}</label><select id="fit">${['contain','cover','smart'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div><div class="field"><label for="profile">${t('profile')}</label><select id="profile">${['color','grayscale'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div></div>${buttons('make-preview','preview',true)}<p class="hint">${t('previewHint')}</p></section><section class="card"><h3>${t('preview')}</h3><div id="preview-box" class="empty">${t('previewHint')}</div><div class="actions" style="margin-top:18px"><button id="save-local" disabled>${t('staging')}</button><button id="display-upload" class="primary" disabled>${t('display')}</button></div></section></div>`;bind('make-preview',async()=>{const file=$('#file').files[0];if(!file)throw new Error(t('selectFile'));const form=new FormData();form.append('file',file);form.append('fit',$('#fit').value);form.append('profile',$('#profile').value);state.preview=await api('/api/v1/preview',{method:'POST',body:form});$('#preview-box').className='';$('#preview-box').innerHTML='<img id="upload-preview" class="preview" alt="Preview">';await blobImage($('#upload-preview'),state.preview.url);$('#save-local').disabled=false;$('#display-upload').disabled=!liveWriteAllowed();});for(const id of ['file','fit','profile'])$('#'+id).onchange=()=>{state.preview=null;$('#save-local').disabled=true;$('#display-upload').disabled=true;};bind('save-local',async()=>{const id=await accepted('/api/v1/devices/'+state.device+'/upload',{type:'image',asset_id:state.preview.asset_id});await waitOperation(id);state.tab='local';return navigate('gallery');});bind('display-upload',()=>displayDialog({type:'image',asset_id:state.preview.asset_id}));},
async templates(){state.templates=await api('/api/v1/templates');const selected=state.templates.find(x=>x.id===state.selectedTemplate)||state.templates[0];state.selectedTemplate=selected.id;if(state.pendingTemplateData?.template===selected.id){selected.defaults=state.pendingTemplateData.data;state.initialProfile=state.pendingTemplateData.profile;state.pendingTemplateData=null;}state.templatePreview=null;$('#main').innerHTML=notice()+`<div class="notice">${t('templateHint')}<p>${t('templateSteps')}</p></div><div class="template-grid">${state.templates.map((x,i)=>`<div class="card template-card ${x.id===selected.id?'selected':''}" role="button" tabindex="0" data-template="${x.id}"><span>${{'daily-poetry':'詩','calendar':'▦','daily-dashboard':'☀','photo-text':'▣','quote':'❝','system-alert':'◒','weather':'☁'}[x.id]||'▦'}</span><div><h3>${esc(x.name.split(' / ')[state.lang==='zh'?1:0]||x.name)}</h3><small>1200 × 1600 · ${esc(x.id)}</small></div></div>`).join('')}</div><div class="columns"><section class="card"><h3>${t('templateData')}</h3><p class="hint">${t('templateGuideTitle')}</p><p class="hint">${t({'photo-text':'guidePhotoText','daily-dashboard':'guideDashboard',weather:'guideWeather',calendar:'guideCalendar',quote:'guideQuote','daily-poetry':'guidePoetry','system-alert':'guideAlert'}[selected.id]||'templateContentHelp')}</p>${['weather','daily-dashboard'].includes(selected.id)?`<p class="hint">${t('templateWeatherHelp')}</p>`:''}<textarea id="template-data" aria-label="${t('templateData')}">${esc(JSON.stringify(selected.defaults,null,2))}</textarea><div class="field" style="margin-top:16px"><label for="template-profile">${t('profile')}</label><select id="template-profile"><option value="color">${t('color')}</option><option value="grayscale">${t('grayscale')}</option></select><p class="hint">${t('helpProfile')}</p></div><p class="hint">${t('templateActionHelp')}</p><p class="hint">${t('templateAutomationHelp')}</p><div class="actions">${buttons('render-template','render',true)}${buttons('save-template','saveTemplate')}</div></section><section class="card"><h3>${t('preview')}</h3><div id="template-preview" class="empty">${t('templatePreviewHint')}</div><button id="display-template" class="primary" style="margin-top:18px" disabled>${t('display')}</button></section></div>`;document.querySelectorAll('[data-template]').forEach(b=>{b.onclick=()=>{state.selectedTemplate=b.dataset.template;navigate();};b.onkeydown=e=>{if(e.key==='Enter')b.click();};});bind('render-template',async()=>{const content={type:'template',template:selected.id,data:JSON.parse($('#template-data').value),profile:$('#template-profile').value};if([...document.querySelectorAll('#template-fields textarea'),$('#template-data')].some(x=>!x.checkValidity())){toast(t('INVALID_REQUEST'));return;}const id=await accepted('/api/v1/render',content);$('#template-preview').textContent=t('previewLoading');const op=await waitOperation(id);state.templatePreview=op.result.asset_id;$('#template-preview').className='';$('#template-preview').innerHTML='<img id="template-image" class="preview" alt="Template preview">';await blobImage($('#template-image'),op.result.url);$('#display-template').disabled=!liveWriteAllowed();});bind('save-template',async()=>{await api('/api/v1/templates/'+selected.id+'/config',{method:'PUT',body:{data:JSON.parse($('#template-data').value)}});toast(t('done'));});$('#template-data').oninput=()=>$('#display-template').disabled=true;$('#template-profile').onchange=()=>$('#display-template').disabled=true;bind('display-template',()=>displayDialog({type:'image',asset_id:state.templatePreview}));await templateEditor(selected);if(state.initialProfile){$('#template-profile').value=state.initialProfile;state.initialProfile=null;}},
async templates(){state.templates=await api('/api/v1/templates');const selected=state.templates.find(x=>x.id===state.selectedTemplate)||state.templates[0];state.selectedTemplate=selected.id;if(state.pendingTemplateData?.template===selected.id){selected.defaults=state.pendingTemplateData.data;state.initialProfile=state.pendingTemplateData.profile;state.pendingTemplateData=null;}state.templatePreview=null;$('#main').innerHTML=notice()+`<div class="notice">${t('templateHint')}<p>${t('templateSteps')}</p></div><div class="template-grid">${state.templates.map((x,i)=>`<div class="card template-card ${x.id===selected.id?'selected':''}" role="button" tabindex="0" data-template="${x.id}"><span>${{'daily-poetry':'詩','calendar':'▦','daily-dashboard':'☀','photo-text':'▣','quote':'❝','system-alert':'◒','weather':'☁'}[x.id]||'▦'}</span><div><h3>${esc(x.name.split(' / ')[state.lang==='zh'?1:0]||x.name)}</h3><small>1200 × 1600 · ${esc(x.id)}</small></div></div>`).join('')}</div><div class="columns"><section class="card"><h3>${t('templateData')}</h3><p class="hint">${t('templateGuideTitle')}</p><p class="hint">${t({'photo-text':'guidePhotoText','daily-dashboard':'guideDashboard',weather:'guideWeather',calendar:'guideCalendar',quote:'guideQuote','daily-poetry':'guidePoetry','system-alert':'guideAlert'}[selected.id]||'templateContentHelp')}</p>${['weather','daily-dashboard'].includes(selected.id)?`<p class="hint">${t('templateWeatherHelp')}</p>`:''}<textarea id="template-data" aria-label="${t('templateData')}">${esc(JSON.stringify(selected.defaults,null,2))}</textarea><div class="field" style="margin-top:16px"><label for="template-profile">${t('profile')}</label><select id="template-profile"><option value="color">${t('color')}</option><option value="grayscale">${t('grayscale')}</option></select><p class="hint">${t('helpProfile')}</p></div><p class="hint">${t('templateActionHelp')}</p><p class="hint">${t('templateAutomationHelp')}</p><div class="actions">${buttons('render-template','render',true)}${buttons('save-template','saveTemplate')}</div></section><section class="card"><h3>${t('preview')}</h3><div id="template-preview" class="empty">${t('templatePreviewHint')}</div><button id="display-template" class="primary" style="margin-top:18px" disabled>${t('display')}</button></section></div>`;document.querySelectorAll('[data-template]').forEach(b=>{b.onclick=()=>{state.selectedTemplate=b.dataset.template;navigate(['collage','poster'].includes(b.dataset.template)?'studio':undefined);};b.onkeydown=e=>{if(e.key==='Enter')b.click();};});bind('render-template',async()=>{const content={type:'template',template:selected.id,data:JSON.parse($('#template-data').value),profile:$('#template-profile').value};if([...document.querySelectorAll('#template-fields textarea'),$('#template-data')].some(x=>!x.checkValidity())){toast(t('INVALID_REQUEST'));return;}const id=await accepted('/api/v1/render',content);$('#template-preview').textContent=t('previewLoading');const op=await waitOperation(id);state.templatePreview=op.result.asset_id;$('#template-preview').className='';$('#template-preview').innerHTML='<img id="template-image" class="preview" alt="Template preview">';await blobImage($('#template-image'),op.result.url);$('#display-template').disabled=!liveWriteAllowed();});bind('save-template',async()=>{await api('/api/v1/templates/'+selected.id+'/config',{method:'PUT',body:{data:JSON.parse($('#template-data').value)}});toast(t('done'));});$('#template-data').oninput=()=>$('#display-template').disabled=true;$('#template-profile').onchange=()=>$('#display-template').disabled=true;bind('display-template',()=>displayDialog({type:'image',asset_id:state.templatePreview}));await templateEditor(selected);if(state.initialProfile){$('#template-profile').value=state.initialProfile;state.initialProfile=null;}},
async automation(epoch){const jobs=await api('/api/v1/jobs');if(epoch!==state.epoch)return;$('#main').innerHTML=notice()+`<div class="section-heading"><p class="hint">Asia/Shanghai · ${t('automation')}</p>${buttons('new-job','newJob',true)}</div><div class="card">${jobs.length?`<div class="table-wrap"><table><thead><tr>${['name','schedule','status','nextRun','lastRun','result','action'].map(x=>`<th>${t(x)}</th>`).join('')}</tr></thead><tbody>${jobs.map((j,i)=>`<tr><td><strong>${esc(j.name)}</strong><br><small>${esc(j.device_id)}</small></td><td>${t(j.schedule.type)}<br>${esc(j.schedule.time||j.schedule.cron||fmt(j.schedule.at))}</td><td><span class="badge">${t(j.enabled?'enabled':'disabled')}</span></td><td>${fmt(j.next_run)}</td><td>${fmt(j.last_run)}</td><td><code>${esc(j.last_result)}</code></td><td><div class="row-actions"><button data-job-edit="${i}">${t('edit')}</button><button data-job-toggle="${i}">${t(j.enabled?'disabled':'enabled')}</button><button class="danger" data-job-delete="${i}">${t('remove')}</button></div></td></tr>`).join('')}</tbody></table></div>`:`<div class="empty"><strong>${t('noTasks')}</strong></div>`}</div>`;bind('new-job',()=>jobEditor());document.querySelectorAll('[data-job-edit]').forEach(b=>b.onclick=()=>jobEditor(jobs[b.dataset.jobEdit]));document.querySelectorAll('[data-job-toggle]').forEach(b=>b.onclick=busy(async()=>{const j=jobs[b.dataset.jobToggle];await api('/api/v1/jobs/'+j.id,{method:'PUT',body:jobPayload(j,{enabled:!j.enabled})});await navigate();}));document.querySelectorAll('[data-job-delete]').forEach(b=>b.onclick=()=>confirmDialog(t('removeConfirm'),async()=>{await api('/api/v1/jobs/'+jobs[b.dataset.jobDelete].id,{method:'DELETE'});await navigate();}));},
async playlists(epoch){const playlists=await api('/api/v1/playlists');if(epoch!==state.epoch)return;$('#main').innerHTML=notice()+`<div class="notice">${t('playlistNotice')}</div><div class="section-heading"><h2>${t('playlists')}</h2>${buttons('new-playlist','newPlaylist',true)}</div><div class="gallery">${playlists.map((p,i)=>`<section class="card"><span class="badge">${t(p.enabled?'enabled':'disabled')}</span><h3 style="margin-top:16px">${esc(p.name)}</h3><p class="hint">${p.image_ids.length} ${t('count')} · ${p.interval_minutes} ${t('minutes')} · ${t(p.mode)}</p><p><code>${esc(p.image_ids.join(' → '))}</code></p><small>${t('lastScheduled')}: ${p.position>=0?esc(p.image_ids[p.position]):'—'}</small><div class="actions" style="margin-top:16px"><button data-pl-edit="${i}">${t('edit')}</button><button class="danger" data-pl-delete="${i}">${t('remove')}</button></div></section>`).join('')||`<div class="card empty"><strong>${t('noTasks')}</strong></div>`}</div>`;bind('new-playlist',()=>playlistEditor());document.querySelectorAll('[data-pl-edit]').forEach(b=>b.onclick=()=>playlistEditor(playlists[b.dataset.plEdit]));document.querySelectorAll('[data-pl-delete]').forEach(b=>b.onclick=()=>confirmDialog(t('removeConfirm'),async()=>{await api('/api/v1/playlists/'+playlists[b.dataset.plDelete].id,{method:'DELETE'});await navigate();}));},
async history(epoch){const ops=await api('/api/v1/operations');if(epoch!==state.epoch)return;$('#main').innerHTML=`<div class="section-heading"><h2>${t('history')}</h2>${buttons('history-refresh','refresh')}</div>${ops.some(x=>x.status==='UNKNOWN')?`<div class="notice warning">${t('unknown')}</div>`:''}<div class="card table-wrap">${ops.length?`<table><thead><tr>${['created','action','status','result','client','requestId'].map(x=>`<th>${t(x)}</th>`).join('')}</tr></thead><tbody>${ops.map((o,i)=>`<tr><td>${fmt(o.created_at)}</td><td>${esc(o.action)}<br><small>${esc(o.device_id)}</small></td><td><span class="badge ${['FAILED','UNKNOWN'].includes(o.status)?'bad':''}">${t(o.status)}</span>${o.status==='UNKNOWN'?`<div><button data-reconcile="${i}">${t('reconcile')}</button></div>`:''}</td><td>${esc(o.result?.image_id||o.result?.asset_id||o.error?.code||'—')}</td><td>${esc(o.client_id)}</td><td><code>${esc(o.request_id)}<br>${esc(o.operation_id)}</code></td></tr>`).join('')}</tbody></table>`:`<div class="empty">${t('noHistory')}</div>`}</div>`;bind('history-refresh',()=>navigate());document.querySelectorAll('[data-reconcile]').forEach(b=>b.onclick=busy(async()=>{const o=ops[b.dataset.reconcile];const r=await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:false}});showModal(`<h2>${t('reconcile')}</h2><p>${t('current')}: ${esc(r.current_image_id)}</p><p>${t('result')}: ${esc(r.expected_image_id)}</p><p class="hint">${t('unknown')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}${r.resolved?'':buttons('acknowledge','acknowledge')}</div>`);bind('acknowledge',async()=>{await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:true}});$('#modal').close();await navigate();});}));},
+52
View File
@@ -0,0 +1,52 @@
'use strict';
const tr=(zh,en)=>state.lang==='zh'?zh:en;
async function layoutStudioPage(){
const assets=await api('/api/v1/assets?source=uploaded&limit=200');let selected=new Set(),plan=null;
$('#main').innerHTML=`<div class="notice">${tr('一次选择 1–12 张图片,生成多图拼版或海报。AI 只接收文字与尺寸,不接收照片。预览不会上传到画框。','Choose 1–12 photos for a collage or poster. AI receives text and dimensions, never photos. Preview does not upload to the frame.')}</div><div class="columns"><section class="card"><h3>${tr('选择照片','Choose photos')}</h3><input id="studio-files" type="file" multiple accept="image/jpeg,image/png,image/webp"><p class="hint">${tr('可以一次上传多张,保留原始比例。或勾选已有本地照片。','Upload several photos at once, preserving aspect ratio, or select local photos.')}</p><div id="studio-assets" class="studio-assets">${assets.map(a=>`<label class="check-row"><input type="checkbox" value="${a.asset_id}">${esc(a.name)}</label>`).join('')}</div><div class="field"><label>${tr('标题','Title')}</label><input id="studio-title" maxlength="100"></div><div class="field"><label>${tr('底部文字','Footer text')}</label><textarea id="studio-message" maxlength="500"></textarea></div><div class="form-grid"><div class="field"><label>${tr('布局','Layout')}</label><select id="studio-style"><option value="grid">${tr('均匀拼版','Grid')}</option><option value="hero">${tr('一张主图+多张附图','Hero with supporting photos')}</option><option value="poster">${tr('海报','Poster')}</option></select></div><div class="field"><label>${tr('方向','Orientation')}</label><select id="studio-orientation"><option value="portrait">${tr('竖向','Portrait')}</option><option value="landscape">${tr('横向','Landscape')}</option></select></div></div><div class="field"><label>${t('profile')}</label><select id="studio-profile"><option value="color">${t('color')}</option><option value="grayscale">${t('grayscale')}</option></select></div><label class="check-row"><input id="studio-ai" type="checkbox">${tr('使用 AI 排版(先在连接中心配置)','Use AI layout (configure in Connections first)')}</label><div class="field"><label>${tr('AI 排版要求','AI layout instructions')}</label><textarea id="studio-prompt" maxlength="1500" placeholder="${tr('例如:第一张作为主图,其余大小错落','Example: feature the first photo with smaller supporting photos')}"></textarea></div><div class="actions"><button id="studio-plan" class="primary">${tr('规划布局','Plan layout')}</button><button id="studio-render" disabled>${t('render')}</button></div><p class="hint">${tr('规划后可拖动照片区域或修改坐标尺寸。区域不能重叠,超出范围的布局会被拒绝。','After planning, drag photo regions or edit coordinates and sizes. Overlapping or out-of-bounds layouts are rejected.')}</p><div id="studio-region-editor"></div></section><section class="card"><h3>${t('preview')}</h3><div id="studio-layout"></div><div id="studio-preview" class="empty">${tr('先规划布局,再生成最终图片','Plan the layout, then render the final image')}</div><div class="actions"><button id="studio-display" disabled>${t('display')}</button><button id="studio-download" disabled>${t('download')}</button></div></section></div>`;
let rendered=null;
const invalidate=()=>{rendered=null;$('#studio-display').disabled=true;$('#studio-download').disabled=true;};
$('#studio-assets').onchange=()=>{selected=new Set([...$('#studio-assets input:checked')].map(x=>x.value));plan=null;$('#studio-render').disabled=true;invalidate();};
$('#studio-files').onchange=busy(async()=>{const files=[...$('#studio-files').files];if(!files.length)return;const body=new FormData();files.forEach(f=>body.append('files',f));const uploaded=await api('/api/v1/layout/images',{method:'POST',body});for(const a of uploaded){const label=document.createElement('label');label.className='check-row';label.innerHTML=`<input type="checkbox" checked value="${a.asset_id}">${esc(a.name)}`;$('#studio-assets').append(label);}$('#studio-assets').onchange();toast(t('done'));});
async function showPlan(){
const box=$('#studio-layout');box.innerHTML='';box.className='studio-canvas';box.style.aspectRatio=plan.width+'/'+plan.height;
$('#studio-region-editor').innerHTML=plan.regions.map((r,i)=>`<div class="field"><label>${tr('图片区域','Photo region')} ${i+1}</label><div class="region-fields">${['x','y','width','height'].map(k=>`<label>${k}<input data-region="${i}" data-key="${k}" type="number" value="${r[k]}" min="0"></label>`).join('')}</div></div>`).join('');
const update=(el,r)=>{el.style.left=(r.x/plan.width*100)+'%';el.style.top=(r.y/plan.height*100)+'%';el.style.width=(r.width/plan.width*100)+'%';el.style.height=(r.height/plan.height*100)+'%';};
for(const [i,r] of plan.regions.entries()){
const img=document.createElement('img');img.alt=tr('拖动图片区域','Drag photo region')+' '+(i+1);img.draggable=false;update(img,r);box.append(img);await blobImage(img,'/api/v1/assets/'+r.asset_id);
img.onpointerdown=e=>{e.preventDefault();img.setPointerCapture(e.pointerId);const start={x:e.clientX,y:e.clientY,rx:r.x,ry:r.y};img.onpointermove=ev=>{r.x=Math.max(0,Math.min(plan.width-r.width,Math.round(start.rx+(ev.clientX-start.x)*plan.width/box.clientWidth)));r.y=Math.max(150,Math.min(plan.height-120-r.height,Math.round(start.ry+(ev.clientY-start.y)*plan.height/box.clientHeight)));update(img,r);for(const el of document.querySelectorAll(`[data-region="${i}"]`))el.value=r[el.dataset.key];invalidate();};img.onpointerup=()=>{img.onpointermove=null;};};
}
document.querySelectorAll('[data-region]').forEach(el=>el.oninput=()=>{const r=plan.regions[Number(el.dataset.region)];r[el.dataset.key]=Number(el.value);update(box.children[Number(el.dataset.region)],r);invalidate();});
}
for(const id of ['studio-title','studio-message','studio-style','studio-orientation','studio-ai','studio-prompt','studio-profile'])$('#'+id).oninput=()=>{plan=null;$('#studio-render').disabled=true;invalidate();};
bind('studio-plan',async()=>{plan=await api('/api/v1/layout/plan',{method:'POST',body:{asset_ids:[...selected],title:$('#studio-title').value,message:$('#studio-message').value,orientation:$('#studio-orientation').value,style:$('#studio-style').value,use_ai:$('#studio-ai').checked,prompt:$('#studio-prompt').value}});invalidate();await showPlan();$('#studio-render').disabled=false;});
bind('studio-render',async()=>{if(!plan)return;const accepted=await api('/api/v1/render',{method:'POST',body:{type:'template',template:$('#studio-style').value==='poster'?'poster':'collage',data:{layout:plan},profile:$('#studio-profile').value}});const op=await waitOperation(accepted.operation_id);rendered=op.result;$('#studio-preview').className='';$('#studio-preview').innerHTML='<img class="preview" id="studio-result" alt="Preview">';await blobImage($('#studio-result'),rendered.url);$('#studio-display').disabled=!liveWriteAllowed();$('#studio-download').disabled=false;});
bind('studio-display',()=>displayDialog({type:'image',asset_id:rendered.asset_id}));bind('studio-download',()=>downloadImage(rendered.url,'frame-flow-layout.jpg'));
}
async function connectionsPage(){
$('#main').innerHTML=`<div class="notice">${tr('可选连接不会影响 NAS 照片和现有设备。账号密钥保存在受限配置文件中,外部客户端密钥仅显示一次。','Optional connections do not affect NAS photos or existing devices. Provider keys use restricted config files; client tokens are shown once.')}</div>`;
await aiSettingsPanel();await storageSettingsPanel();await clientsPanel();await webhooksPanel();await externalProvidersPanel();
}
async function aiSettingsPanel(){
const config=await api('/api/v1/settings/ai-layout');const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>${tr('AI 排版 · OpenAI 兼容','AI layout · OpenAI compatible')}</h3><p class="hint">${tr('只发送文字、尺寸和排版要求,不发送照片。填写完整 chat/completions 地址;使用服务商的模型名称。','Only text, dimensions and instructions are sent; never photos. Enter the full chat/completions URL and provider model name.')}</p><form id="ai-settings-form"><div class="field"><label>Endpoint</label><input id="ai-endpoint" type="url" value="${esc(config.endpoint)}" placeholder="https://…/v1/chat/completions"></div><div class="field"><label>Model</label><input id="ai-model" value="${esc(config.model)}"></div><div class="field"><label>${tr('API Key(留空保留已有密钥)','API key (blank keeps existing)')}</label><input id="ai-key" type="password" autocomplete="off"><p class="hint">${t('keyConfigured')}: ${t(config.key_configured?'yes':'no')}</p></div><button type="submit">${t('save')}</button></form>`;$('#main').append(card);$('#ai-settings-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/settings/ai-layout',{method:'PUT',body:{endpoint:$('#ai-endpoint').value,model:$('#ai-model').value}});const key=$('#ai-key').value;$('#ai-key').value='';if(key)await api('/api/v1/settings/ai-layout/key',{method:'PUT',body:{key}});toast(t('done'));});
}
async function storageSettingsPanel(){
const data=await api('/api/v1/storage');const p=data.policy;const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>${tr('本地图库保留策略','Local storage retention')}</h3><p>${data.asset_count} ${t('count')} · ${(data.total_bytes/1024/1024).toFixed(1)} MiB · ${tr('受保护','Protected')}: ${data.protected_count}</p><p class="hint">${tr('仅清理生成图和预览缓存;手动上传、星标、当前/恢复图片以及任务/模板引用会保留。不会删除 NAS 原照片或云端图片。先查看清理预估,再开启自动清理。','Only generated images and preview cache are cleaned. Manual uploads, stars, current/restore content and task/template references are retained. NAS originals and cloud images are untouched. Preview cleanup before enabling it.')}</p><form id="storage-policy"><label class="check-row"><input id="storage-enabled" type="checkbox" ${p.enabled?'checked':''}>${t('enabled')}</label><div class="form-grid"><div class="field"><label>${tr('生成图保留天数','Generated image retention days')}</label><input id="storage-days" type="number" min="1" value="${p.generated_days}"></div><div class="field"><label>${tr('缓存保留天数','Cache retention days')}</label><input id="storage-cache-days" type="number" min="1" value="${p.cache_days}"></div><div class="field"><label>${tr('容量目标 MiB','Target size MiB')}</label><input id="storage-size" type="number" min="1" value="${p.max_bytes/1024/1024}"></div></div><div class="actions"><button type="submit">${t('save')}</button><button id="storage-preview" type="button">${tr('预估清理','Preview cleanup')}</button></div><p id="storage-estimate"></p></form>`;$('#main').append(card);$('#storage-policy').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/storage/policy',{method:'PUT',body:{enabled:$('#storage-enabled').checked,generated_days:Number($('#storage-days').value),cache_days:Number($('#storage-cache-days').value),max_bytes:Number($('#storage-size').value)*1024*1024}});toast(t('done'));});bind('storage-preview',async()=>{const result=await api('/api/v1/storage/cleanup?dry_run=true',{method:'POST'});$('#storage-estimate').textContent=result.disabled?tr('策略未启用;不会清理','Policy disabled; nothing is removed'):tr('可清理 ','Eligible: ')+result.deleted+' · '+(result.bytes_freed/1024/1024).toFixed(1)+' MiB';});
}
async function clientsPanel(){
const clients=await api('/api/v1/clients');const scopes=['devices:read','images:read','display:write','templates:read','render:write','jobs:read','jobs:write','history:read'];const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>${tr('外部客户端权限','External client permissions')}</h3><p class="hint">${tr('网页继续使用账号登录。这里只为其他程序创建独立凭据,可随时撤销。','The web UI still uses account login. These credentials are for other programs and can be revoked.')}</p>${clients.map(c=>`<p>${esc(c.name)} · ${esc(c.client_id)} · ${t(c.enabled?'enabled':'disabled')} ${c.client_id!=='admin'&&c.enabled?`<button data-revoke="${esc(c.client_id)}">${tr('撤销','Revoke')}</button>`:''}</p>`).join('')}<form id="client-form"><div class="field"><label>Client ID</label><input id="client-id" required pattern="[a-z0-9-]{1,60}"></div><div class="field"><label>${t('name')}</label><input id="client-name" required></div>${scopes.map(s=>`<label class="check-row"><input type="checkbox" name="client-scope" value="${s}" ${['devices:read','display:write','templates:read','render:write'].includes(s)?'checked':''}>${s}</label>`).join('')}<button type="submit">${tr('创建客户端','Create client')}</button><div id="client-secret" class="notice" hidden></div></form>`;$('#main').append(card);document.querySelectorAll('[data-revoke]').forEach(b=>b.onclick=busy(async()=>{await api('/api/v1/clients/'+b.dataset.revoke,{method:'DELETE'});await navigate();}));$('#client-form').onsubmit=busy(async e=>{e.preventDefault();const r=await api('/api/v1/clients',{method:'POST',body:{client_id:$('#client-id').value,name:$('#client-name').value,permissions:[...document.querySelectorAll('[name="client-scope"]:checked')].map(x=>x.value)}});$('#client-secret').hidden=false;$('#client-secret').textContent=tr('仅显示一次,请保存:','Shown once; save it: ')+r.token;});
}
async function webhooksPanel(){
const hooks=await api('/api/v1/webhooks');const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>Webhook</h3><p class="hint">${tr('HTTPS 地址须先加入设置中的允许主机。事件带 HMAC 签名,最多重试三次;接收端应按事件 ID 去重。','Add the HTTPS host to the Settings allowlist first. Events are HMAC signed, with up to three attempts; deduplicate by event ID.')}</p>${hooks.map(h=>`<p>${esc(h.callback_url)} <button data-hook-delete="${h.id}">${t('delete')}</button></p>`).join('')}<form id="hook-form"><div class="field"><label>Callback URL</label><input id="hook-url" type="url" required></div><div class="field"><label>${tr('事件(逗号分隔)','Events (comma separated)')}</label><input id="hook-events" value="operation.completed,operation.failed"></div><button type="submit">${t('save')}</button><p id="hook-secret"></p></form>`;$('#main').append(card);document.querySelectorAll('[data-hook-delete]').forEach(b=>b.onclick=busy(async()=>{await api('/api/v1/webhooks/'+b.dataset.hookDelete,{method:'DELETE'});await navigate();}));$('#hook-form').onsubmit=busy(async e=>{e.preventDefault();const r=await api('/api/v1/webhooks',{method:'POST',body:{callback_url:$('#hook-url').value,event_types:$('#hook-events').value.split(',').map(x=>x.trim()).filter(Boolean)}});$('#hook-secret').textContent=tr('签名密钥仅显示一次:','Signing key shown once: ')+r.signing_secret;});
}
async function externalProvidersPanel(){
const providers=await api('/api/v1/providers/external');const card=document.createElement('section');card.className='card';card.innerHTML=`<h3>${tr('日历、家庭状态与监控','Calendars, home state and monitoring')}</h3><p class="hint">${tr('Google Calendar 使用 OAuth Token;家庭日历可用 ICS。Home Assistant 和 NAS/树莓派/服务器监控可以通过只读 JSON 接口提供数据,不访问外部数据库。LAN 访问必须明确开启。','Google Calendar uses an OAuth token; family calendars can use ICS. Home Assistant and NAS/Pi/server monitoring supply read-only JSON data without database access. LAN access requires explicit opt-in.')}</p>${providers.map(p=>`<p>${esc(p.name)} · ${esc(p.kind)} <button data-provider-read="${p.id}">${tr('读取','Read')}</button><button data-provider-job="${p.id}">${tr('创建展示任务','Create display job')}</button></p>`).join('')}<form id="provider-form"><div class="field"><label>ID</label><input id="provider-id" required pattern="[a-z0-9-]{1,60}"></div><div class="field"><label>${t('name')}</label><input id="provider-name" required></div><div class="field"><label>${tr('类型','Type')}</label><select id="provider-kind"><option value="google-calendar">Google Calendar</option><option value="ics">ICS · ${tr('家庭日历','Family calendar')}</option><option value="home-assistant">Home Assistant</option><option value="http-json">HTTP JSON · NAS / Pi / Server</option></select></div><div class="field"><label>${tr('地址(Google 日历填写 Calendar ID)','URL (Calendar ID for Google)')}</label><input id="provider-url" required></div><div class="field"><label>${tr('Token(可选)','Token (optional)')}</label><input id="provider-key" type="password" autocomplete="off"></div><label class="check-row"><input id="provider-lan" type="checkbox">${tr('允许访问指定 LAN 地址','Allow access to this LAN address')}</label><button type="submit">${t('save')}</button></form><pre id="provider-result"></pre>`;$('#main').append(card);$('#provider-form').onsubmit=busy(async e=>{e.preventDefault();const id=$('#provider-id').value;await api('/api/v1/providers/external/'+id,{method:'PUT',body:{kind:$('#provider-kind').value,name:$('#provider-name').value,endpoint:$('#provider-url').value,allow_insecure_lan:$('#provider-lan').checked}});const key=$('#provider-key').value;$('#provider-key').value='';if(key)await api('/api/v1/providers/external/'+id+'/key',{method:'PUT',body:{key}});await navigate();});document.querySelectorAll('[data-provider-read]').forEach(b=>b.onclick=busy(async()=>{$('#provider-result').textContent=JSON.stringify(await api('/api/v1/providers/external/'+b.dataset.providerRead+'/data'),null,2);}));document.querySelectorAll('[data-provider-job]').forEach(b=>b.onclick=busy(async()=>{await api('/api/v1/jobs',{method:'POST',body:{device_id:state.device,name:tr('外部信息板','External dashboard'),enabled:false,schedule:{type:'daily',time:'07:00'},source:{type:'provider',provider_id:b.dataset.providerJob,template:'daily-dashboard',data:{title:tr('家庭信息','Home information')}},display_duration:0,restore_previous:false,priority:50}});await navigate('automation');}));
}
async function officialWidgetControls(info,data){
const panel=document.createElement('section');panel.className='card';panel.innerHTML=`<h3>${tr('订阅管理','Subscription management')}</h3><p class="hint">${tr('这与本地模板独立。请选择官方目录中的小组件,填写其参数后再确认。取消订阅不会自动恢复之前的照片。','This is separate from local templates. Choose a widget from the official catalog and fill its parameters before confirming. Cancelling does not automatically restore a previous photo.')}</p><div class="field"><label>${tr('官方小组件','Official widget')}</label><select id="official-widget-choice">${data.catalog.map(w=>`<option value="${w.id}">${esc(w.name)}</option>`).join('')}</select></div><div id="official-widget-args"></div><div class="actions"><button id="official-widget-use" class="primary">${tr('确认使用','Apply widget')}</button><button id="official-widget-cancel">${tr('取消订阅','Cancel subscription')}</button></div>`;info.append(panel);const disabled=!data.configured||!liveWriteAllowed();$('#official-widget-use').disabled=disabled;$('#official-widget-cancel').disabled=disabled;
const fields=()=>{const w=data.catalog.find(w=>w.id===Number($('#official-widget-choice').value));$('#official-widget-args').innerHTML=(w?.arg_keys||[]).map(k=>`<div class="field"><label>${esc(k)}</label><input data-widget-arg="${esc(k)}" maxlength="2000"></div>`).join('');};if(data.current)$('#official-widget-choice').value=data.current.id;fields();$('#official-widget-choice').onchange=fields;
const send=cover_id=>confirmDialog(tr('此操作会改变官方订阅和画框内容,是否继续?','This changes the official subscription and frame content. Continue?'),async()=>{const args=cover_id?Object.fromEntries([...document.querySelectorAll('[data-widget-arg]')].filter(x=>x.value).map(x=>[x.dataset.widgetArg,x.value])):{};const r=await api('/api/v1/devices/'+state.device+'/widgets/selection',{method:'POST',body:{cover_id,args},headers:{'Idempotency-Key':'widget-'+clientUUID()}});await waitOperation(r.operation_id);await navigate('templates');});if(!disabled){$('#official-widget-use').onclick=busy(()=>send(Number($('#official-widget-choice').value)));$('#official-widget-cancel').onclick=busy(()=>send(null));}
}
function addPhotoQualityFields(source){
const box=document.createElement('section');box.innerHTML=`<h3>${tr('照片筛选','Photo filtering')}</h3><p class="hint">${tr('按小图清晰度、亮度和相似度筛选,不改动或删除 NAS 原照片;无法保证识别闭眼。建议先用低阈值。个人空间还需要将相应目录只读挂载到 /photos。','Filters thumbnail sharpness, brightness and similarity without modifying or deleting originals. Closed-eye detection is not guaranteed. Start with low thresholds. Personal space also requires mounting its folder read-only at /photos.')}</p><div class="field"><label>${tr('人物空间','People space')}</label><select id="source-people-space"><option value="shared">${tr('共享空间','Shared space')}</option><option value="personal">${tr('个人空间','Personal space')}</option></select></div><div class="form-grid"><div class="field"><label>${tr('最低清晰度(0 不筛选)','Minimum sharpness (0 disables)')}</label><input id="source-sharpness" type="number" min="0" max="10000" value="${source?.minimum_sharpness||0}"></div><div class="field"><label>${tr('最低亮度','Minimum brightness')}</label><input id="source-bright-min" type="number" min="0" max="255" value="${source?.minimum_brightness||0}"></div><div class="field"><label>${tr('最高亮度','Maximum brightness')}</label><input id="source-bright-max" type="number" min="0" max="255" value="${source?.maximum_brightness??255}"></div></div><label class="check-row"><input id="source-dedup" type="checkbox" ${source?.avoid_duplicates?'checked':''}>${tr('避开最近 200 张的相似照片','Avoid photos similar to the last 200 selections')}</label><div class="field"><label>${tr('网盘 OAuth 凭据 JSON(可选;仅原生网盘)','Drive OAuth credentials JSON (optional; native drives only)')}</label><input id="source-oauth" type="password" autocomplete="off" placeholder='{"access_token":"…","drive_id":"…"}'></textarea></div>`;$('#photo-source-form .modal-actions').before(box);$('#source-people-space').value=source?.people_space||'shared';
}
+6 -6
View File
@@ -84,9 +84,9 @@ async function officialContentInfo(){
const data=await api('/api/v1/devices/'+state.device+'/widgets');
const label=data.source==='live'?(zh?'实时目录':'Live catalog'):data.source==='mock'?'Mock':(zh?'历史目录 · '+data.observed_at:'Catalog snapshot · '+data.observed_at);
const currentLabel=data.status==='DEVICE_REQUIRED'?(zh?'官方账号已连接,请先关联画框':'Official account connected; link a frame first'):!data.configured?(zh?'尚未授权官方账号,当前状态不可读取。':'Official account not authorized; current state is unavailable.'):data.status==='NO_WIDGET'?(zh?'未使用官方小组件':'No official widget selected'):data.current_verified&&data.current?((zh?'当前小组件:':'Current widget: ')+data.current.name):(zh?'当前小组件尚未核实':'Current widget is unverified');
info.innerHTML=`<summary>${zh?'官方小组件':'Official widgets'} · ${data.catalog.length}${!data.configured?(zh?' · 需官方账号授权':' · Official account required'):''}</summary><p>${esc(label)}</p><p class="notice">${esc(currentLabel)}</p><p class="notice widget-auth-notice">${zh?'如需使用官方小组件,需要另外授权 KoKonna 官方账号登录信息。现有 API Key 接入继续用于设备状态、图片和图库,不受影响。':'Official widgets require separate KoKonna account authorization. Your existing API Key continues to support device status, images and gallery.'}</p><p>${zh?'此入口只读,不会切换或取消官方订阅。':'Read only. This does not change official subscriptions.'}</p><input type="search" aria-label="${zh?'搜索小组件':'Search widgets'}" placeholder="${zh?'搜索名称或分类':'Search name or category'}"><div class="widget-list"></div>`;
info.innerHTML=`<summary>${zh?'官方小组件':'Official widgets'} · ${data.catalog.length}${!data.configured?(zh?' · 需官方账号授权':' · Official account required'):''}</summary><p>${esc(label)}</p><p class="notice">${esc(currentLabel)}</p><p class="notice widget-auth-notice">${zh?'如需使用官方小组件,需要另外授权 KoKonna 官方账号登录信息。现有 API Key 接入继续用于设备状态、图片和图库,不受影响。':'Official widgets require separate KoKonna account authorization. Your existing API Key continues to support device status, images and gallery.'}</p><p>${zh?'使用或取消订阅会改变画框内容;发送后不自动重试,结果在历史记录中核对。':'Selecting or cancelling changes frame content. Writes are never automatically retried; check History.'}</p><input type="search" aria-label="${zh?'搜索小组件':'Search widgets'}" placeholder="${zh?'搜索名称或分类':'Search name or category'}"><div class="widget-list"></div>`;
const render=()=>{const query=info.querySelector('input').value.toLowerCase();info.querySelector('.widget-list').innerHTML=data.catalog.filter(x=>(x.name+' '+x.tags.join(' ')).toLowerCase().includes(query)).map(x=>`<article class="card"><strong>${esc(x.name)}</strong><p>${esc(x.tags.join(' · '))}</p><small>${esc(x.arg_keys.join(' · '))}</small></article>`).join('');};
info.querySelector('input').oninput=render;render();officialConnectionPanel(info);
info.querySelector('input').oninput=render;render();officialConnectionPanel(info);await officialWidgetControls(info,data);
}catch(e){info.innerHTML=`<summary>${zh?'官方小组件':'Official widgets'}</summary><p class="notice widget-auth-notice">${esc(e.message)}</p>`;officialConnectionPanel(info);}
}
@@ -113,7 +113,7 @@ async function photosPeoplePicker(selected){
target.innerHTML=`<div class="field"><label for="photos-username">${t('username')}</label><input id="photos-username" autocomplete="username"></div><div class="field"><label for="photos-password">${t('password')}</label><input id="photos-password" type="password" autocomplete="current-password"></div><p class="hint">${t('photosSessionHint')}</p><button type="button" id="photos-connect">${t('photosConnect')}</button>`;
$('#photos-connect').onclick=busy(async()=>{const password=$('#photos-password').value;$('#photos-password').value='';await api(path,{method:'POST',body:{username:$('#photos-username').value,password}});await photosPeoplePicker(selected);});return;
}
const people=await api(path+'/people');
const people=await api(path+'/people?space='+encodeURIComponent($('#source-people-space')?.value||'shared'));
target.innerHTML=`<div class="field"><label for="people-search">${t('peopleSearch')}</label><input id="people-search" type="search"></div><label class="check-row"><input id="people-unnamed" type="checkbox">${t('peopleUnnamed')}</label><div id="people-list" style="max-height:260px;overflow:auto"></div><p id="people-selected" class="hint"></p><button type="button" id="photos-disconnect">${t('photosDisconnect')}</button>`;
const render=()=>{const query=$('#people-search').value.toLowerCase();const rows=people.filter(p=>(p.named||selected.has(p.id)||$('#people-unnamed').checked)&&(p.name.toLowerCase().includes(query)||String(p.id).includes(query)));$('#people-list').innerHTML=rows.map(p=>`<label class="check-row"><input type="checkbox" data-person="${p.id}" ${selected.has(p.id)?'checked':''}>${esc(p.name)} · ${p.count}</label>`).join('');$('#people-selected').textContent=t('peopleSelected')+selected.size;$('#people-list').querySelectorAll('[data-person]').forEach(box=>box.onchange=()=>{const id=Number(box.dataset.person);box.checked?selected.add(id):selected.delete(id);$('#people-selected').textContent=t('peopleSelected')+selected.size;});};
$('#people-search').oninput=render;$('#people-unnamed').onchange=render;$('#photos-disconnect').onclick=busy(async()=>{await api(path,{method:'DELETE'});await photosPeoplePicker(selected);});render();
@@ -131,9 +131,9 @@ async function photoSourcesPanel(){
section.querySelectorAll('[data-source-job]').forEach(b=>b.onclick=()=>sourceJobForm(b.dataset.sourceJob));
}
async 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 for="source-orientation">${t('photoOrientation')}</label><select id="source-orientation">${source&&!source.orientation?`<option value="">${t('orientationLegacy')}</option>`:''}<option value="auto">${t('orientationAuto')}</option><option value="landscape">${t('orientationLandscape')}</option><option value="portrait">${t('orientationPortrait')}</option></select><p class="hint">${t('orientationHint')}</p></div><div class="field"><label class="check-row"><input id="source-organized" type="checkbox" ${source?.organized_only?'checked':''}>${t('organizedPhotos')}</label><label class="check-row"><input id="source-caption" type="checkbox" ${source?.folder_caption?'checked':''}>${t('folderCaption')}</label><label class="check-row"><input id="source-synology-previews" type="checkbox" ${source?.synology_previews?'checked':''}>${t('synologyPreviews')}</label><p class="hint">${t('folderCaptionHint')}</p></div><div class="field"><label class="check-row"><input id="source-people-filter" type="checkbox" ${source?.people_filter?'checked':''}>${t('peopleFilter')}</label><p class="hint">${t('peopleHint')}</p><button id="source-people-load" type="button">${t('choosePeople')}</button><div id="source-people-panel"><p class="hint">${t('peopleSelected')}${(source?.person_ids||[]).length}</p></div><label for="source-person-match">${t('personMatch')}</label><select id="source-person-match"><option value="any">${t('personAny')}</option><option value="all">${t('personAll')}</option></select></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-orientation').value=source?(source.orientation||''):'auto';const selectedPeople=new Set(source?.person_ids||[]);$('#source-person-match').value=source?.person_match||'any';$('#source-people-load').onclick=busy(async()=>{await photosPeoplePicker(selectedPeople);});$('#source-kind').value=source?.kind||'local';const update=()=>{const dav=$('#source-kind').value==='webdav';$('#source-orientation').disabled=dav;$('#source-credential').disabled=!dav;$('#source-recursive').disabled=dav;$('#source-synology-previews').disabled=dav;$('#source-people-filter').disabled=dav;$('#source-people-load').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,synology_previews:!dav&&$('#source-synology-previews').checked,catalog_limit:$('#source-organized').checked?100000:(source?.catalog_limit||10000),organized_only:$('#source-organized').checked,folder_caption:$('#source-caption').checked,orientation:dav?null:($('#source-orientation').value||null),people_filter:!dav&&$('#source-people-filter').checked,person_ids:[...selectedPeople],person_match:$('#source-person-match').value,enabled:$('#source-enabled').checked,credential_env:dav?($('#source-credential').value||null):null}});$('#modal').close();await navigate('photo-sources');});
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><option value="google-drive">Google Drive</option><option value="alipan">阿里云盘 / AliPan</option><option value="baidu">百度网盘 / Baidu</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 for="source-orientation">${t('photoOrientation')}</label><select id="source-orientation">${source&&!source.orientation?`<option value="">${t('orientationLegacy')}</option>`:''}<option value="auto">${t('orientationAuto')}</option><option value="landscape">${t('orientationLandscape')}</option><option value="portrait">${t('orientationPortrait')}</option></select><p class="hint">${t('orientationHint')}</p></div><div class="field"><label class="check-row"><input id="source-organized" type="checkbox" ${source?.organized_only?'checked':''}>${t('organizedPhotos')}</label><label class="check-row"><input id="source-caption" type="checkbox" ${source?.folder_caption?'checked':''}>${t('folderCaption')}</label><label class="check-row"><input id="source-synology-previews" type="checkbox" ${source?.synology_previews?'checked':''}>${t('synologyPreviews')}</label><p class="hint">${t('folderCaptionHint')}</p></div><div class="field"><label class="check-row"><input id="source-people-filter" type="checkbox" ${source?.people_filter?'checked':''}>${t('peopleFilter')}</label><p class="hint">${t('peopleHint')}</p><button id="source-people-load" type="button">${t('choosePeople')}</button><div id="source-people-panel"><p class="hint">${t('peopleSelected')}${(source?.person_ids||[]).length}</p></div><label for="source-person-match">${t('personMatch')}</label><select id="source-person-match"><option value="any">${t('personAny')}</option><option value="all">${t('personAll')}</option></select></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-orientation').value=source?(source.orientation||''):'auto';const selectedPeople=new Set(source?.person_ids||[]);$('#source-person-match').value=source?.person_match||'any';$('#source-people-load').onclick=busy(async()=>{await photosPeoplePicker(selectedPeople);});$('#source-kind').value=source?.kind||'local';const update=()=>{const dav=$('#source-kind').value!=='local';$('#source-orientation').disabled=dav;$('#source-credential').disabled=!dav;$('#source-recursive').disabled=$('#source-kind').value==='webdav';$('#source-synology-previews').disabled=dav;$('#source-people-filter').disabled=dav;$('#source-people-load').disabled=dav;};update();$('#source-kind').onchange=update;addPhotoQualityFields(source);$('#source-people-space').onchange=()=>{selectedPeople.clear();$('#source-people-panel').innerHTML='';};
$('#photo-source-form').onsubmit=busy(async e=>{e.preventDefault();const oauth=$('#source-oauth').value;const sourceId=$('#source-id').value;const native=['google-drive','alipan','baidu'].includes($('#source-kind').value);if(oauth&&native)JSON.parse(oauth);const dav=$('#source-kind').value!=='local';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:$('#source-kind').value!=='webdav'&&$('#source-recursive').checked,synology_previews:!dav&&$('#source-synology-previews').checked,catalog_limit:$('#source-organized').checked?100000:(source?.catalog_limit||10000),organized_only:!dav&&$('#source-organized').checked,folder_caption:!dav&&$('#source-caption').checked,orientation:dav?null:($('#source-orientation').value||null),people_filter:!dav&&$('#source-people-filter').checked,person_ids:[...selectedPeople],person_match:$('#source-person-match').value,people_space:$('#source-people-space').value,minimum_sharpness:Number($('#source-sharpness').value),minimum_brightness:Number($('#source-bright-min').value),maximum_brightness:Number($('#source-bright-max').value),avoid_duplicates:$('#source-dedup').checked,enabled:$('#source-enabled').checked,credential_env:dav?($('#source-credential').value||null):null}});if(oauth&&native){$('#source-oauth').value='';await api('/api/v1/photo-sources/'+sourceId+'/credentials',{method:'PUT',body:{key:oauth}});}$('#modal').close();await navigate('photo-sources');});
}
async function browseSource(id,offset=0,refresh=false){
const result=await api('/api/v1/photo-sources/'+id+'/images?device_id='+encodeURIComponent(state.device)+'&offset='+offset+'&limit=50&refresh='+refresh);const box=$('#source-browser');if(!box)return;
+2
View File
@@ -114,3 +114,5 @@ Object.assign(window.MESSAGES.en,{
templatePreviewHint:'Enter content on the left and select Generate preview. Inspect the final image here before selecting Display on frame.',
helpProfile:'Color preserves colors; grayscale creates a grayscale image. This does not change device hardware settings.'
});
Object.assign(window.MESSAGES.zh,{studio:'拼版与海报',connections:'连接中心'});
Object.assign(window.MESSAGES.en,{studio:'Layout Studio',connections:'Connections'});
+1 -1
View File
@@ -1,3 +1,3 @@
<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="light dark"><title>Pigway Frame Flow</title><link rel="stylesheet" href="/ui/style.css"></head>
<body><aside class="sidebar"><a class="brand" href="/"><span class="brand-icon">F<span>↗</span></span><span>Frame Flow<small>Pigway · Smart Frame Automation</small></span></a><nav id="nav"></nav><div class="sidebar-bottom"><small>Version 0.1.0</small></div></aside><div class="workspace"><header><div><span class="eyebrow">PIGWAY FRAME FLOW</span><h1 id="page-title"></h1></div><div class="toolbar"><select id="device" aria-label="Device"></select><button id="lang" class="icon-button" aria-label="Change language">EN</button><button id="theme" class="icon-button" aria-label="Change theme">◐</button></div></header><main id="main"></main><footer id="footer"></footer></div><div id="toast" role="status" aria-live="polite"></div><dialog id="modal"><div id="modal-content"></div></dialog><script src="/ui/i18n.js"></script><script src="/ui/features.js"></script><script src="/ui/app.js"></script></body></html>
<body><aside class="sidebar"><a class="brand" href="/"><span class="brand-icon">F<span>↗</span></span><span>Frame Flow<small>Pigway · Smart Frame Automation</small></span></a><nav id="nav"></nav><div class="sidebar-bottom"><small>Version 0.1.0</small></div></aside><div class="workspace"><header><div><span class="eyebrow">PIGWAY FRAME FLOW</span><h1 id="page-title"></h1></div><div class="toolbar"><select id="device" aria-label="Device"></select><button id="lang" class="icon-button" aria-label="Change language">EN</button><button id="theme" class="icon-button" aria-label="Change theme">◐</button></div></header><main id="main"></main><footer id="footer"></footer></div><div id="toast" role="status" aria-live="polite"></div><dialog id="modal"><div id="modal-content"></div></dialog><script src="/ui/i18n.js"></script><script src="/ui/features.js"></script><script src="/ui/extensions.js"></script><script src="/ui/app.js"></script></body></html>
+1
View File
@@ -74,3 +74,4 @@ dialog.image-viewer h2.image-viewer-name{font-size:16px;min-width:0;flex:1}
@media(max-width:600px){dialog.image-viewer{width:calc(100vw - 16px);height:calc(100dvh - 16px);max-height:calc(100dvh - 16px);padding:12px}}
@media(max-width:600px){.image-viewer-footer{flex-wrap:wrap;gap:8px}.image-viewer-name{flex-basis:100%!important}.image-viewer-actions{width:100%;justify-content:flex-end;gap:6px}.image-viewer-actions button{padding:8px 10px}}
.studio-assets{max-height:240px;overflow:auto;margin:18px 0}.studio-canvas{position:relative;width:100%;background:white;margin-bottom:20px}.studio-canvas img{position:absolute;object-fit:cover;cursor:move;touch-action:none;border:2px solid var(--accent)}.region-fields{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px}.region-fields input{width:100%}#client-secret,#hook-secret{overflow-wrap:anywhere}
+330
View File
@@ -1,4 +1,334 @@
{
"/api/v1/devices/{device_id}/widgets/selection": {
"post": [
"202",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/webhooks": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
],
"post": [
"201",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/webhooks/{id}": {
"delete": [
"204",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/webhooks/{id}/deliveries": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/providers/external": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/providers/external/{id}": {
"put": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/providers/external/{id}/data": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/providers/external/{id}/key": {
"put": [
"204",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/layout/images": {
"post": [
"201",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/layout/plan": {
"post": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/settings/ai-layout": {
"get": [
"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"
]
},
"/api/v1/settings/ai-layout/key": {
"put": [
"204",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/storage": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/storage/policy": {
"put": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/storage/cleanup": {
"post": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/clients": {
"get": [
"200",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
],
"post": [
"201",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/clients/{client_id}": {
"delete": [
"204",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/photo-sources/{source_id}/credentials": {
"put": [
"204",
"400",
"401",
"403",
"404",
"409",
"413",
"422",
"429",
"500",
"502",
"503"
]
},
"/api/v1/auth/login": {
"post": [
"200",
+93 -3
View File
@@ -11,7 +11,6 @@
},
"driver": {
"type": "string",
"const": "kokonna",
"title": "Driver",
"default": "kokonna"
},
@@ -374,6 +373,9 @@
{
"$ref": "#/components/schemas/PhotoSourceContent"
},
{
"$ref": "#/components/schemas/ProviderContent"
},
{
"$ref": "#/components/schemas/FutureContent"
}
@@ -387,6 +389,7 @@
"image_url": "#/components/schemas/URLContent",
"markdown": "#/components/schemas/FutureContent",
"photo_source": "#/components/schemas/PhotoSourceContent",
"provider": "#/components/schemas/ProviderContent",
"template": "#/components/schemas/TemplateContent"
}
}
@@ -673,6 +676,17 @@
},
"OperationResult": {
"properties": {
"cover_id": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"title": "Cover Id"
},
"photo_attempts": {
"items": {
"additionalProperties": {
@@ -841,7 +855,10 @@
"type": "string",
"enum": [
"local",
"webdav"
"webdav",
"google-drive",
"alipan",
"baidu"
],
"title": "Kind"
},
@@ -899,6 +916,41 @@
],
"title": "Orientation"
},
"minimum_sharpness": {
"type": "number",
"maximum": 10000.0,
"minimum": 0.0,
"title": "Minimum Sharpness",
"default": 0
},
"minimum_brightness": {
"type": "number",
"maximum": 255.0,
"minimum": 0.0,
"title": "Minimum Brightness",
"default": 0
},
"maximum_brightness": {
"type": "number",
"maximum": 255.0,
"minimum": 0.0,
"title": "Maximum Brightness",
"default": 255
},
"avoid_duplicates": {
"type": "boolean",
"title": "Avoid Duplicates",
"default": false
},
"people_space": {
"type": "string",
"enum": [
"shared",
"personal"
],
"title": "People Space",
"default": "shared"
},
"people_filter": {
"type": "boolean",
"title": "People Filter",
@@ -955,7 +1007,10 @@
"type": "string",
"enum": [
"local",
"webdav"
"webdav",
"google-drive",
"alipan",
"baidu"
],
"title": "Kind"
},
@@ -1013,6 +1068,41 @@
],
"title": "Orientation"
},
"minimum_sharpness": {
"type": "number",
"maximum": 10000.0,
"minimum": 0.0,
"title": "Minimum Sharpness",
"default": 0
},
"minimum_brightness": {
"type": "number",
"maximum": 255.0,
"minimum": 0.0,
"title": "Minimum Brightness",
"default": 0
},
"maximum_brightness": {
"type": "number",
"maximum": 255.0,
"minimum": 0.0,
"title": "Maximum Brightness",
"default": 255
},
"avoid_duplicates": {
"type": "boolean",
"title": "Avoid Duplicates",
"default": false
},
"people_space": {
"type": "string",
"enum": [
"shared",
"personal"
],
"title": "People Space",
"default": "shared"
},
"people_filter": {
"type": "boolean",
"title": "People Filter",
+1 -1
View File
@@ -21,7 +21,7 @@ def test_health_ready(client):
def test_without_external_project(client):
assert client.get("/").status_code == 200
assert client.get("/api/v1/devices").status_code == 200
assert len(client.get("/api/v1/templates").json()) == 7
assert len(client.get("/api/v1/templates").json()) == 9
assert client.get("/api/v1/jobs").status_code == 200
+375
View File
@@ -0,0 +1,375 @@
from io import BytesIO
import pytest
from PIL import Image
from pydantic import ValidationError
from pigway_frame_flow.extensions.layout import LayoutInput, LayoutPlan, local_plan
from pigway_frame_flow.extensions.secrets import save_secret
@pytest.mark.parametrize("count", range(1, 13))
@pytest.mark.parametrize("orientation", ["portrait", "landscape"])
@pytest.mark.parametrize("style", ["grid", "hero", "poster"])
def test_local_layout_preserves_all_images_inside_canvas(count, orientation, style):
ids = [f"{i:032x}" for i in range(count)]
plan = local_plan(LayoutInput(asset_ids=ids, orientation=orientation, style=style))
assert [r.asset_id for r in plan.regions] == ids
assert LayoutPlan.model_validate(plan.model_dump()) == plan
def test_layout_rejects_overlap():
plan = local_plan(LayoutInput(asset_ids=["a" * 32, "b" * 32])).model_dump()
plan["regions"][1].update(x=plan["regions"][0]["x"], y=plan["regions"][0]["y"])
with pytest.raises(ValidationError):
LayoutPlan.model_validate(plan)
def test_secret_atomic_private_replacement(tmp_path):
path = tmp_path / "key.secret"
save_secret(path, "first")
save_secret(path, "second")
assert path.read_text() == "second"
assert path.stat().st_mode & 0o777 == 0o600
assert list(tmp_path.iterdir()) == [path]
def test_multi_image_import_keeps_aspect_and_never_uploads(client, service):
image = BytesIO()
Image.new("RGB", (320, 200), "red").save(image, "PNG")
response = client.post(
"/api/v1/layout/images",
files=[("files", ("photo.png", image.getvalue(), "image/png"))],
)
assert response.status_code == 201
asset_id = response.json()[0]["asset_id"]
with Image.open(service.assets.path(asset_id)) as imported:
assert imported.size == (320, 200)
assert service.registry.get("living-room").client.uploads == 0
def test_ai_requires_explicit_configuration(client):
image = BytesIO()
Image.new("RGB", (320, 200), "red").save(image, "PNG")
result = client.post(
"/api/v1/layout/images",
files=[("files", ("photo.png", image.getvalue(), "image/png"))],
).json()
response = client.post(
"/api/v1/layout/plan",
json={"asset_ids": [result[0]["asset_id"]], "use_ai": True},
)
assert response.status_code == 409
assert response.json()["error"]["code"] == "CONFIG_REQUIRED"
def test_default_retention_keeps_everything(service):
result = service.local_retention.sweep(force=True)
assert result["deleted"] == 0
assert result["disabled"] is True
def test_ai_secret_never_returned_or_stored_in_database(client, service):
key = "unit-test-private-key"
response = client.put("/api/v1/settings/ai-layout/key", json={"key": key})
assert response.status_code == 204
assert key not in client.get("/api/v1/settings/ai-layout").text
assert key not in str([dict(r) for r in service.db.all("SELECT * FROM app_config")])
@pytest.mark.parametrize(
"kind,reply,location",
[
("google-drive", {"files": [{"id": "photo1", "name": "photo.jpg"}]}, "root"),
(
"alipan",
{"items": [{"file_id": "photo1", "name": "photo.jpg", "type": "file"}]},
"root",
),
(
"baidu",
{"list": [{"fs_id": 123, "server_filename": "photo.jpg", "isdir": 0}]},
"/Photos",
),
],
)
def test_native_drives_metadata_does_not_expose_token(
service, monkeypatch, kind, reply, location
):
from pigway_frame_flow.providers.cloud_drives import CloudDrives
from pigway_frame_flow.providers.photo_sources import PhotoSourceInput
drive = CloudDrives(service.photo_sources)
monkeypatch.setattr(
drive,
"credentials",
lambda *args: {"access_token": "private-token", "drive_id": "drive1"},
)
monkeypatch.setattr(drive, "api", lambda *args, **kwargs: reply)
result = drive.catalog(
"source", PhotoSourceInput(name="Cloud", kind=kind, location=location)
)
assert len(result) == 1
assert result[0]["name"] == "photo.jpg"
assert "private-token" not in str(result)
def test_native_drive_refuses_stalled_pagination(service, monkeypatch):
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.providers.cloud_drives import CloudDrives
from pigway_frame_flow.providers.photo_sources import PhotoSourceInput
drive = CloudDrives(service.photo_sources)
monkeypatch.setattr(
drive, "credentials", lambda *args: {"access_token": "private-token"}
)
monkeypatch.setattr(
drive, "api", lambda *args, **kwargs: {"files": [], "nextPageToken": "same"}
)
with pytest.raises(FlowError):
drive.catalog(
"source",
PhotoSourceInput(name="Cloud", kind="google-drive", location="root"),
)
def test_native_credentials_require_authorization(service):
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.providers.cloud_drives import CloudDrives
from pigway_frame_flow.providers.photo_sources import PhotoSourceInput
with pytest.raises(FlowError) as exc:
CloudDrives(service.photo_sources).credentials(
"source", PhotoSourceInput(name="Cloud", kind="baidu", location="/")
)
assert "CONFIG_REQUIRED" in str(exc.value.code)
def test_client_token_hash_and_revocation(client, service):
response = client.post(
"/api/v1/clients",
json={
"client_id": "calendar",
"name": "Calendar",
"permissions": ["devices:read"],
},
)
assert response.status_code == 201
token = response.json()["token"]
assert token not in client.get("/api/v1/clients").text
assert token not in str([dict(r) for r in service.db.all("SELECT * FROM clients")])
assert (
client.get(
"/api/v1/devices", headers={"Authorization": "Bearer " + token}
).status_code
== 200
)
assert (
client.post(
"/api/v1/display", headers={"Authorization": "Bearer " + token}, json={}
).status_code
== 403
)
assert client.delete("/api/v1/clients/calendar").status_code == 204
assert (
client.get(
"/api/v1/devices", headers={"Authorization": "Bearer " + token}
).status_code
== 401
)
def test_invalid_member_prevents_partial_multi_import(client, service):
image = BytesIO()
Image.new("RGB", (100, 100)).save(image, "PNG")
before = len(service.db.all("SELECT * FROM assets"))
result = client.post(
"/api/v1/layout/images",
files=[
("files", ("good.png", image.getvalue(), "image/png")),
("files", ("bad.jpg", b"bad", "image/jpeg")),
],
)
assert result.status_code == 400
assert len(service.db.all("SELECT * FROM assets")) == before
def test_widget_mutation_is_queued_and_idempotent(client, service):
catalog = service.registry.official["living-room"].widgets().catalog
cover_id = catalog[0].id
headers = {"Idempotency-Key": "widget-test"}
first = client.post(
"/api/v1/devices/living-room/widgets/selection",
json={"cover_id": cover_id},
headers=headers,
)
assert first.status_code == 202
assert (
client.post(
"/api/v1/devices/living-room/widgets/selection",
json={"cover_id": cover_id},
headers=headers,
).json()
== first.json()
)
service.process_one()
assert service.operation(first.json()["operation_id"])["status"] == "COMPLETED"
assert service.registry.get("living-room").client.uploads == 0
def test_widget_post_write_read_failure_blocks_further_writes(
client, service, monkeypatch
):
connection = service.registry.official["living-room"]
cover_id = connection.widgets().catalog[0].id
response = client.post(
"/api/v1/devices/living-room/widgets/selection", json={"cover_id": cover_id}
)
original = connection.widgets
calls = 0
def fail_confirmation():
nonlocal calls
calls += 1
if calls > 1:
raise RuntimeError("network unavailable")
return original()
monkeypatch.setattr(connection, "widgets", fail_confirmation)
service.process_one()
assert service.operation(response.json()["operation_id"])["status"] == "UNKNOWN"
assert service.blocked("living-room")
def test_webhook_signs_exact_body_and_records_delivery(client, service, monkeypatch):
import hashlib
import hmac
import json
service.settings.image_url_hosts.append("example.com")
response = client.post(
"/api/v1/webhooks",
json={
"callback_url": "https://example.com/events",
"event_types": ["display.changed"],
},
)
assert response.status_code == 201
key = response.json()["signing_secret"]
service.db.event("display.changed", {"device_id": "living-room"})
sent = []
monkeypatch.setattr(
service.assets, "fetch_url", lambda url, **kwargs: sent.append(kwargs) or b""
)
service.webhooks.tick()
assert len(sent) == 1
message = sent[0]
assert (
message["headers"]["X-Frame-Flow-Signature"]
== "sha256="
+ hmac.new(key.encode(), message["body"], hashlib.sha256).hexdigest()
)
assert json.loads(message["body"])["type"] == "display.changed"
assert key not in client.get("/api/v1/webhooks").text
deliveries = client.get(
"/api/v1/webhooks/" + response.json()["id"] + "/deliveries"
).json()
assert deliveries[0]["status"] == "DELIVERED"
def test_webhook_requires_explicit_host(client):
response = client.post(
"/api/v1/webhooks",
json={
"callback_url": "http://127.0.0.1/events",
"event_types": ["display.changed"],
},
)
assert response.status_code == 400
def test_external_provider_requires_explicit_lan_approval(client):
payload = {
"kind": "http-json",
"name": "NAS",
"endpoint": "http://192.0.2.10/state",
}
assert client.put("/api/v1/providers/external/nas", json=payload).status_code == 403
payload["allow_insecure_lan"] = True
assert client.put("/api/v1/providers/external/nas", json=payload).status_code == 200
def test_google_calendar_requires_token(client):
assert (
client.put(
"/api/v1/providers/external/calendar",
json={"kind": "google-calendar", "name": "Calendar", "endpoint": "primary"},
).status_code
== 200
)
response = client.get("/api/v1/providers/external/calendar/data")
assert response.status_code == 409
assert response.json()["error"]["code"] == "CONFIG_REQUIRED"
def test_photo_quality_dark_and_duplicate(service):
from pigway_frame_flow.extensions.quality import passes, photo_quality
from pigway_frame_flow.providers.photo_sources import PhotoSourceInput
image = BytesIO()
Image.new("RGB", (100, 100), "black").save(image, "PNG")
result = photo_quality(image.getvalue())
assert result["brightness"] == 0
assert result == photo_quality(image.getvalue())
source = PhotoSourceInput(
name="NAS", kind="local", location=".", minimum_brightness=10
)
assert not passes(result, source)
def test_layout_missing_plan_is_rejected_before_queue(client):
response = client.post(
"/api/v1/render", json={"type": "template", "template": "collage", "data": {}}
)
assert response.status_code == 400
assert response.json()["error"]["code"] == "INVALID_REQUEST"
def test_ai_layout_sends_metadata_only_and_validates_response(
client, service, monkeypatch
):
import json
image = BytesIO()
Image.new("RGB", (100, 200), "red").save(image, "PNG")
asset = client.post(
"/api/v1/layout/images",
files=[("files", ("photo.png", image.getvalue(), "image/png"))],
).json()[0]
client.put(
"/api/v1/settings/ai-layout",
json={
"endpoint": "https://example.com/v1/chat/completions",
"model": "test-model",
},
)
client.put("/api/v1/settings/ai-layout/key", json={"key": "test-ai-secret"})
plan = local_plan(LayoutInput(asset_ids=[asset["asset_id"]]))
calls = []
def respond(url, **kwargs):
calls.append(kwargs)
return json.dumps(
{"choices": [{"message": {"content": plan.model_dump_json()}}]}
).encode()
monkeypatch.setattr(service.assets, "fetch_url", respond)
response = client.post(
"/api/v1/layout/plan", json={"asset_ids": [asset["asset_id"]], "use_ai": True}
)
assert response.status_code == 200
assert response.json()["engine"] == "ai"
assert b"base64" not in calls[0]["body"]
assert b"test-ai-secret" not in calls[0]["body"]
assert image.getvalue() not in calls[0]["body"]
assert calls[0]["headers"]["Authorization"] == "Bearer test-ai-secret"