From 69a08418680c1c5eb5527e1e7169a98b19b7dad5 Mon Sep 17 00:00:00 2001 From: Pigway Date: Thu, 8 Oct 2026 23:18:11 +0800 Subject: [PATCH] Add optional photo providers, layout studio and scoped integrations --- README.md | 13 +- docs/EXTENSIONS.md | 55 +++ pigway_frame_flow/api/app.py | 15 +- pigway_frame_flow/core/registry.py | 13 +- pigway_frame_flow/display/service.py | 86 ++++ pigway_frame_flow/drivers/generic_http.py | 110 +++++ pigway_frame_flow/extensions/__init__.py | 0 pigway_frame_flow/extensions/api.py | 292 ++++++++++++++ pigway_frame_flow/extensions/layout.py | 227 +++++++++++ pigway_frame_flow/extensions/official.py | 171 ++++++++ pigway_frame_flow/extensions/providers.py | 51 +++ pigway_frame_flow/extensions/quality.py | 39 ++ pigway_frame_flow/extensions/secrets.py | 18 + pigway_frame_flow/extensions/storage.py | 135 +++++++ pigway_frame_flow/extensions/webhooks.py | 196 +++++++++ .../kokonna_official/capabilities.py | 7 +- .../integrations/kokonna_official/rpc.py | 22 +- .../integrations/kokonna_official/widgets.py | 4 +- pigway_frame_flow/models.py | 18 +- pigway_frame_flow/providers/cloud_drives.py | 227 +++++++++++ pigway_frame_flow/providers/external.py | 196 +++++++++ pigway_frame_flow/providers/photo_sources.py | 33 +- .../providers/synology_photos.py | 22 +- pigway_frame_flow/renderers/images.py | 10 +- pigway_frame_flow/renderers/templates.py | 4 + pigway_frame_flow/scheduler/engine.py | 20 + pigway_frame_flow/templates/collage.json | 11 + pigway_frame_flow/templates/poster.json | 11 + pigway_frame_flow/web/app.js | 6 +- pigway_frame_flow/web/extensions.js | 52 +++ pigway_frame_flow/web/features.js | 12 +- pigway_frame_flow/web/i18n.js | 2 + pigway_frame_flow/web/index.html | 2 +- pigway_frame_flow/web/style.css | 1 + tests/contract.json | 330 +++++++++++++++ tests/schema-contract.json | 96 ++++- tests/test_api.py | 2 +- tests/test_extensions.py | 375 ++++++++++++++++++ 38 files changed, 2843 insertions(+), 41 deletions(-) create mode 100644 docs/EXTENSIONS.md create mode 100644 pigway_frame_flow/drivers/generic_http.py create mode 100644 pigway_frame_flow/extensions/__init__.py create mode 100644 pigway_frame_flow/extensions/api.py create mode 100644 pigway_frame_flow/extensions/layout.py create mode 100644 pigway_frame_flow/extensions/official.py create mode 100644 pigway_frame_flow/extensions/providers.py create mode 100644 pigway_frame_flow/extensions/quality.py create mode 100644 pigway_frame_flow/extensions/secrets.py create mode 100644 pigway_frame_flow/extensions/storage.py create mode 100644 pigway_frame_flow/extensions/webhooks.py create mode 100644 pigway_frame_flow/providers/cloud_drives.py create mode 100644 pigway_frame_flow/providers/external.py create mode 100644 pigway_frame_flow/templates/collage.json create mode 100644 pigway_frame_flow/templates/poster.json create mode 100644 pigway_frame_flow/web/extensions.js create mode 100644 tests/test_extensions.py diff --git a/README.md b/README.md index 375df09..9ec1db9 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/EXTENSIONS.md b/docs/EXTENSIONS.md new file mode 100644 index 0000000..4fe26b7 --- /dev/null +++ b/docs/EXTENSIONS.md @@ -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-.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=` 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). diff --git a/pigway_frame_flow/api/app.py b/pigway_frame_flow/api/app.py index 9a988de..cf8eff2 100644 --- a/pigway_frame_flow/api/app.py +++ b/pigway_frame_flow/api/app.py @@ -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", diff --git a/pigway_frame_flow/core/registry.py b/pigway_frame_flow/core/registry.py index fa04c5b..68ab2b4 100644 --- a/pigway_frame_flow/core/registry.py +++ b/pigway_frame_flow/core/registry.py @@ -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() diff --git a/pigway_frame_flow/display/service.py b/pigway_frame_flow/display/service.py index d0f3337..219a7b6 100644 --- a/pigway_frame_flow/display/service.py +++ b/pigway_frame_flow/display/service.py @@ -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() diff --git a/pigway_frame_flow/drivers/generic_http.py b/pigway_frame_flow/drivers/generic_http.py new file mode 100644 index 0000000..e208c6c --- /dev/null +++ b/pigway_frame_flow/drivers/generic_http.py @@ -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) diff --git a/pigway_frame_flow/extensions/__init__.py b/pigway_frame_flow/extensions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pigway_frame_flow/extensions/api.py b/pigway_frame_flow/extensions/api.py new file mode 100644 index 0000000..bf82fa7 --- /dev/null +++ b/pigway_frame_flow/extensions/api.py @@ -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) diff --git a/pigway_frame_flow/extensions/layout.py b/pigway_frame_flow/extensions/layout.py new file mode 100644 index 0000000..e16820e --- /dev/null +++ b/pigway_frame_flow/extensions/layout.py @@ -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() diff --git a/pigway_frame_flow/extensions/official.py b/pigway_frame_flow/extensions/official.py new file mode 100644 index 0000000..9486789 --- /dev/null +++ b/pigway_frame_flow/extensions/official.py @@ -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 diff --git a/pigway_frame_flow/extensions/providers.py b/pigway_frame_flow/extensions/providers.py new file mode 100644 index 0000000..45b05ac --- /dev/null +++ b/pigway_frame_flow/extensions/providers.py @@ -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) diff --git a/pigway_frame_flow/extensions/quality.py b/pigway_frame_flow/extensions/quality.py new file mode 100644 index 0000000..1a2b5df --- /dev/null +++ b/pigway_frame_flow/extensions/quality.py @@ -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 + ) diff --git a/pigway_frame_flow/extensions/secrets.py b/pigway_frame_flow/extensions/secrets.py new file mode 100644 index 0000000..aac908c --- /dev/null +++ b/pigway_frame_flow/extensions/secrets.py @@ -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) diff --git a/pigway_frame_flow/extensions/storage.py b/pigway_frame_flow/extensions/storage.py new file mode 100644 index 0000000..b56ae9a --- /dev/null +++ b/pigway_frame_flow/extensions/storage.py @@ -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 diff --git a/pigway_frame_flow/extensions/webhooks.py b/pigway_frame_flow/extensions/webhooks.py new file mode 100644 index 0000000..4c6936f --- /dev/null +++ b/pigway_frame_flow/extensions/webhooks.py @@ -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,), + ) + ] diff --git a/pigway_frame_flow/integrations/kokonna_official/capabilities.py b/pigway_frame_flow/integrations/kokonna_official/capabilities.py index db877cd..2addab4 100644 --- a/pigway_frame_flow/integrations/kokonna_official/capabilities.py +++ b/pigway_frame_flow/integrations/kokonna_official/capabilities.py @@ -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 diff --git a/pigway_frame_flow/integrations/kokonna_official/rpc.py b/pigway_frame_flow/integrations/kokonna_official/rpc.py index 6db156f..c12c4b6 100644 --- a/pigway_frame_flow/integrations/kokonna_official/rpc.py +++ b/pigway_frame_flow/integrations/kokonna_official/rpc.py @@ -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, + ) diff --git a/pigway_frame_flow/integrations/kokonna_official/widgets.py b/pigway_frame_flow/integrations/kokonna_official/widgets.py index 3ccf33d..fabd2bc 100644 --- a/pigway_frame_flow/integrations/kokonna_official/widgets.py +++ b/pigway_frame_flow/integrations/kokonna_official/widgets.py @@ -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: diff --git a/pigway_frame_flow/models.py b/pigway_frame_flow/models.py index 23fc44a..1816459 100644 --- a/pigway_frame_flow/models.py +++ b/pigway_frame_flow/models.py @@ -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 diff --git a/pigway_frame_flow/providers/cloud_drives.py b/pigway_frame_flow/providers/cloud_drives.py new file mode 100644 index 0000000..2e101aa --- /dev/null +++ b/pigway_frame_flow/providers/cloud_drives.py @@ -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 {}, + ) diff --git a/pigway_frame_flow/providers/external.py b/pigway_frame_flow/providers/external.py new file mode 100644 index 0000000..a9ebdd4 --- /dev/null +++ b/pigway_frame_flow/providers/external.py @@ -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 diff --git a/pigway_frame_flow/providers/photo_sources.py b/pigway_frame_flow/providers/photo_sources.py index 6334f32..0f2bc89 100644 --- a/pigway_frame_flow/providers/photo_sources.py +++ b/pigway_frame_flow/providers/photo_sources.py @@ -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: diff --git a/pigway_frame_flow/providers/synology_photos.py b/pigway_frame_flow/providers/synology_photos.py index 95bfab6..de64df0 100644 --- a/pigway_frame_flow/providers/synology_photos.py +++ b/pigway_frame_flow/providers/synology_photos.py @@ -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, diff --git a/pigway_frame_flow/renderers/images.py b/pigway_frame_flow/renderers/images.py index 917d2c0..82e3175 100644 --- a/pigway_frame_flow/renderers/images.py +++ b/pigway_frame_flow/renderers/images.py @@ -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: diff --git a/pigway_frame_flow/renderers/templates.py b/pigway_frame_flow/renderers/templates.py index c5d17ef..2a7c426 100644 --- a/pigway_frame_flow/renderers/templates.py +++ b/pigway_frame_flow/renderers/templates.py @@ -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)) diff --git a/pigway_frame_flow/scheduler/engine.py b/pigway_frame_flow/scheduler/engine.py index 650f834..2c598dd 100644 --- a/pigway_frame_flow/scheduler/engine.py +++ b/pigway_frame_flow/scheduler/engine.py @@ -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( diff --git a/pigway_frame_flow/templates/collage.json b/pigway_frame_flow/templates/collage.json new file mode 100644 index 0000000..0f5c8f5 --- /dev/null +++ b/pigway_frame_flow/templates/collage.json @@ -0,0 +1,11 @@ +{ + "id": "collage", + "name": "Multi-photo collage / 多图拼版", + "width": 1200, + "height": 1600, + "fields": [ + "layout" + ], + "defaults": {}, + "blocks": [] +} diff --git a/pigway_frame_flow/templates/poster.json b/pigway_frame_flow/templates/poster.json new file mode 100644 index 0000000..9d53ef0 --- /dev/null +++ b/pigway_frame_flow/templates/poster.json @@ -0,0 +1,11 @@ +{ + "id": "poster", + "name": "Poster / 海报", + "width": 1200, + "height": 1600, + "fields": [ + "layout" + ], + "defaults": {}, + "blocks": [] +} diff --git a/pigway_frame_flow/web/app.js b/pigway_frame_flow/web/app.js index b689a3d..dec928a 100644 --- a/pigway_frame_flow/web/app.js +++ b/pigway_frame_flow/web/app.js @@ -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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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=`

${t('changePassword')}

`;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?`
${t('lowBattery')}
`:''}
● ${t(d.status).toUpperCase()}

${esc(d.name)}

${esc(d.model)} · ${esc(d.model_code)} · ${d.display.width} × ${d.display.height}

${buttons('go-upload','upload',true)}${buttons('restore','restore')}${buttons('refresh-device','refresh')}

${t('refreshInterval')}

${t('testLabel')}
${[['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])=>`
${t(l)}
${esc(v)}
`).join('')}

${t('device')}

${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'))}

${t('cloudRotation')}

${detail('mode',s.mode?t(s.mode):'—')}${detail('interval',s.interval_minutes==null?'—':s.interval_minutes+' '+t('minutes'))}

${t('rotationNotice')}

${t('playlists')} →
`;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()+`
${buttons('gallery-refresh','refresh')}${state.tab==='cloud'?buttons('show-name','displayByName'):''}${buttons('gallery-upload','upload',true)}
${state.tab==='local'?`
${t('uploadNotice')}
`:''}${items.length?``:`
${t('noImages')}${t('emptyHint')}
`}`;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(`

${t('displayByName')}

`);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(`

${esc(x.name)}

${esc(x.name)}`);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()+`
${t('uploadNotice')}

${t('upload')}

${t('selectFile')}

${t('uploadHint')}

${buttons('make-preview','preview',true)}

${t('previewHint')}

${t('preview')}

${t('previewHint')}
`;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='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()+`
${t('templateHint')}

${t('templateSteps')}

${state.templates.map((x,i)=>`
${{'daily-poetry':'詩','calendar':'▦','daily-dashboard':'☀','photo-text':'▣','quote':'❝','system-alert':'◒','weather':'☁'}[x.id]||'▦'}

${esc(x.name.split(' / ')[state.lang==='zh'?1:0]||x.name)}

1200 × 1600 · ${esc(x.id)}
`).join('')}

${t('templateData')}

${t('templateGuideTitle')}

${t({'photo-text':'guidePhotoText','daily-dashboard':'guideDashboard',weather:'guideWeather',calendar:'guideCalendar',quote:'guideQuote','daily-poetry':'guidePoetry','system-alert':'guideAlert'}[selected.id]||'templateContentHelp')}

${['weather','daily-dashboard'].includes(selected.id)?`

${t('templateWeatherHelp')}

`:''}

${t('helpProfile')}

${t('templateActionHelp')}

${t('templateAutomationHelp')}

${buttons('render-template','render',true)}${buttons('save-template','saveTemplate')}

${t('preview')}

${t('templatePreviewHint')}
`;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='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()+`
${t('templateHint')}

${t('templateSteps')}

${state.templates.map((x,i)=>`
${{'daily-poetry':'詩','calendar':'▦','daily-dashboard':'☀','photo-text':'▣','quote':'❝','system-alert':'◒','weather':'☁'}[x.id]||'▦'}

${esc(x.name.split(' / ')[state.lang==='zh'?1:0]||x.name)}

1200 × 1600 · ${esc(x.id)}
`).join('')}

${t('templateData')}

${t('templateGuideTitle')}

${t({'photo-text':'guidePhotoText','daily-dashboard':'guideDashboard',weather:'guideWeather',calendar:'guideCalendar',quote:'guideQuote','daily-poetry':'guidePoetry','system-alert':'guideAlert'}[selected.id]||'templateContentHelp')}

${['weather','daily-dashboard'].includes(selected.id)?`

${t('templateWeatherHelp')}

`:''}

${t('helpProfile')}

${t('templateActionHelp')}

${t('templateAutomationHelp')}

${buttons('render-template','render',true)}${buttons('save-template','saveTemplate')}

${t('preview')}

${t('templatePreviewHint')}
`;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='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()+`

Asia/Shanghai · ${t('automation')}

${buttons('new-job','newJob',true)}
${jobs.length?`
${['name','schedule','status','nextRun','lastRun','result','action'].map(x=>``).join('')}${jobs.map((j,i)=>``).join('')}
${t(x)}
${esc(j.name)}
${esc(j.device_id)}
${t(j.schedule.type)}
${esc(j.schedule.time||j.schedule.cron||fmt(j.schedule.at))}
${t(j.enabled?'enabled':'disabled')}${fmt(j.next_run)}${fmt(j.last_run)}${esc(j.last_result)}
`:`
${t('noTasks')}
`}
`;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()+`
${t('playlistNotice')}

${t('playlists')}

${buttons('new-playlist','newPlaylist',true)}
`;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=`

${t('history')}

${buttons('history-refresh','refresh')}
${ops.some(x=>x.status==='UNKNOWN')?`
${t('unknown')}
`:''}
${ops.length?`${['created','action','status','result','client','requestId'].map(x=>``).join('')}${ops.map((o,i)=>``).join('')}
${t(x)}
${fmt(o.created_at)}${esc(o.action)}
${esc(o.device_id)}
${t(o.status)}${o.status==='UNKNOWN'?`
`:''}
${esc(o.result?.image_id||o.result?.asset_id||o.error?.code||'—')}${esc(o.client_id)}${esc(o.request_id)}
${esc(o.operation_id)}
`:`
${t('noHistory')}
`}
`;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(`

${t('reconcile')}

${t('current')}: ${esc(r.current_image_id)}

${t('result')}: ${esc(r.expected_image_id)}

${t('unknown')}

`);bind('acknowledge',async()=>{await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:true}});$('#modal').close();await navigate();});}));}, diff --git a/pigway_frame_flow/web/extensions.js b/pigway_frame_flow/web/extensions.js new file mode 100644 index 0000000..6b1ae30 --- /dev/null +++ b/pigway_frame_flow/web/extensions.js @@ -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=`
${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.')}

${tr('选择照片','Choose photos')}

${tr('可以一次上传多张,保留原始比例。或勾选已有本地照片。','Upload several photos at once, preserving aspect ratio, or select local photos.')}

${assets.map(a=>``).join('')}

${tr('规划后可拖动照片区域或修改坐标尺寸。区域不能重叠,超出范围的布局会被拒绝。','After planning, drag photo regions or edit coordinates and sizes. Overlapping or out-of-bounds layouts are rejected.')}

${t('preview')}

${tr('先规划布局,再生成最终图片','Plan the layout, then render the final image')}
`; + 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=`${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)=>`
${['x','y','width','height'].map(k=>``).join('')}
`).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='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=`
${tr('可选连接不会影响 NAS 照片和现有设备。账号密钥保存在受限配置文件中,外部客户端密钥仅显示一次。','Optional connections do not affect NAS photos or existing devices. Provider keys use restricted config files; client tokens are shown once.')}
`; + 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=`

${tr('AI 排版 · OpenAI 兼容','AI layout · OpenAI compatible')}

${tr('只发送文字、尺寸和排版要求,不发送照片。填写完整 chat/completions 地址;使用服务商的模型名称。','Only text, dimensions and instructions are sent; never photos. Enter the full chat/completions URL and provider model name.')}

${t('keyConfigured')}: ${t(config.key_configured?'yes':'no')}

`;$('#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=`

${tr('本地图库保留策略','Local storage retention')}

${data.asset_count} ${t('count')} · ${(data.total_bytes/1024/1024).toFixed(1)} MiB · ${tr('受保护','Protected')}: ${data.protected_count}

${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.')}

`;$('#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=`

${tr('外部客户端权限','External client permissions')}

${tr('网页继续使用账号登录。这里只为其他程序创建独立凭据,可随时撤销。','The web UI still uses account login. These credentials are for other programs and can be revoked.')}

${clients.map(c=>`

${esc(c.name)} · ${esc(c.client_id)} · ${t(c.enabled?'enabled':'disabled')} ${c.client_id!=='admin'&&c.enabled?``:''}

`).join('')}
${scopes.map(s=>``).join('')}
`;$('#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=`

Webhook

${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.')}

${hooks.map(h=>`

${esc(h.callback_url)}

`).join('')}

`;$('#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=`

${tr('日历、家庭状态与监控','Calendars, home state and monitoring')}

${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.')}

${providers.map(p=>`

${esc(p.name)} · ${esc(p.kind)}

`).join('')}
`;$('#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=`

${tr('订阅管理','Subscription management')}

${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.')}

`;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=>`
`).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=`

${tr('照片筛选','Photo filtering')}

${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.')}

`;$('#photo-source-form .modal-actions').before(box);$('#source-people-space').value=source?.people_space||'shared'; + +} diff --git a/pigway_frame_flow/web/features.js b/pigway_frame_flow/web/features.js index 2147013..e648a3d 100644 --- a/pigway_frame_flow/web/features.js +++ b/pigway_frame_flow/web/features.js @@ -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=`${zh?'官方小组件':'Official widgets'} · ${data.catalog.length}${!data.configured?(zh?' · 需官方账号授权':' · Official account required'):''}

${esc(label)}

${esc(currentLabel)}

${zh?'如需使用官方小组件,需要另外授权 KoKonna 官方账号登录信息。现有 API Key 接入继续用于设备状态、图片和图库,不受影响。':'Official widgets require separate KoKonna account authorization. Your existing API Key continues to support device status, images and gallery.'}

${zh?'此入口只读,不会切换或取消官方订阅。':'Read only. This does not change official subscriptions.'}

`; + info.innerHTML=`${zh?'官方小组件':'Official widgets'} · ${data.catalog.length}${!data.configured?(zh?' · 需官方账号授权':' · Official account required'):''}

${esc(label)}

${esc(currentLabel)}

${zh?'如需使用官方小组件,需要另外授权 KoKonna 官方账号登录信息。现有 API Key 接入继续用于设备状态、图片和图库,不受影响。':'Official widgets require separate KoKonna account authorization. Your existing API Key continues to support device status, images and gallery.'}

${zh?'使用或取消订阅会改变画框内容;发送后不自动重试,结果在历史记录中核对。':'Selecting or cancelling changes frame content. Writes are never automatically retried; check History.'}

`; 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=>`
${esc(x.name)}

${esc(x.tags.join(' · '))}

${esc(x.arg_keys.join(' · '))}
`).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=`${zh?'官方小组件':'Official widgets'}

${esc(e.message)}

`;officialConnectionPanel(info);} } @@ -113,7 +113,7 @@ async function photosPeoplePicker(selected){ target.innerHTML=`

${t('photosSessionHint')}

`; $('#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=`

`; 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=>``).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(`

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

${t('sourceLocationHint')}

${t('sourceCredentialHint')}

${t('orientationHint')}

${t('folderCaptionHint')}

${t('peopleHint')}

${t('peopleSelected')}${(source?.person_ids||[]).length}

`); - $('#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(`

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

${t('sourceLocationHint')}

${t('sourceCredentialHint')}

${t('orientationHint')}

${t('folderCaptionHint')}

${t('peopleHint')}

${t('peopleSelected')}${(source?.person_ids||[]).length}

`); + $('#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; diff --git a/pigway_frame_flow/web/i18n.js b/pigway_frame_flow/web/i18n.js index b70f2ce..62b0654 100644 --- a/pigway_frame_flow/web/i18n.js +++ b/pigway_frame_flow/web/i18n.js @@ -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'}); diff --git a/pigway_frame_flow/web/index.html b/pigway_frame_flow/web/index.html index a699c56..16aaa82 100644 --- a/pigway_frame_flow/web/index.html +++ b/pigway_frame_flow/web/index.html @@ -1,3 +1,3 @@ Pigway Frame Flow -
PIGWAY FRAME FLOW

+
PIGWAY FRAME FLOW

diff --git a/pigway_frame_flow/web/style.css b/pigway_frame_flow/web/style.css index a0e0a7c..a753313 100644 --- a/pigway_frame_flow/web/style.css +++ b/pigway_frame_flow/web/style.css @@ -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} diff --git a/tests/contract.json b/tests/contract.json index 3979031..e34ccd5 100644 --- a/tests/contract.json +++ b/tests/contract.json @@ -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", diff --git a/tests/schema-contract.json b/tests/schema-contract.json index 4082c5d..50fa320 100644 --- a/tests/schema-contract.json +++ b/tests/schema-contract.json @@ -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", diff --git a/tests/test_api.py b/tests/test_api.py index faab80d..5201055 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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 diff --git a/tests/test_extensions.py b/tests/test_extensions.py new file mode 100644 index 0000000..0113e88 --- /dev/null +++ b/tests/test_extensions.py @@ -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"