Files
pigway-frame-flow/pigway_frame_flow/api/app.py
T

1312 lines
44 KiB
Python
Raw Normal View History

import logging
import os
import re
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any, Literal
from uuid import uuid4
from fastapi import Depends, FastAPI, File, Form, Header, Query, Request, UploadFile
from fastapi.concurrency import run_in_threadpool
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.openapi.docs import get_swagger_ui_html
from fastapi.responses import FileResponse, JSONResponse, Response
from starlette.exceptions import HTTPException
from pigway_frame_flow.core.accounts import install_accounts, seed_accounts
from pigway_frame_flow.core.auth import require, seed_client
from pigway_frame_flow.core.registry import DeviceRegistry
from pigway_frame_flow.display.service import DisplayService
from pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.integrations.kokonna_official.connection import (
ConnectionState,
LoginRequest,
SelectRequest,
)
from pigway_frame_flow.models import (
Accepted,
Asset,
AssetBatch,
AutoCollageContent,
CurrentDisplay,
DeleteRequest,
Device,
DeviceIdentity,
DisplayRequest,
ErrorResponse,
Fit,
GalleryDestination,
GalleryImage,
Health,
History,
ImageContent,
ImageStar,
ImageURLConfig,
Job,
JobInput,
Operation,
Options,
PhotoSourceContent,
Playlist,
PlaylistInput,
Profile,
Readiness,
ReconcileRequest,
Reconciliation,
SettingsView,
TemplateConfig,
TemplateContent,
TemplateInfo,
URLContent,
Version,
WeatherConfig,
WeatherView,
)
from pigway_frame_flow.providers.photo_sources import (
PhotoCatalog,
PhotoSourceInput,
PhotoSourceView,
)
from pigway_frame_flow.providers.synology_photos import (
PhotosConnection,
PhotosLogin,
PhotosPerson,
)
from pigway_frame_flow.providers.weather import ConfigurableWeatherProvider
from pigway_frame_flow.renderers.frame_geometry import frame_canvas
from pigway_frame_flow.renderers.images import Assets, convert
from pigway_frame_flow.renderers.templates import TemplateRenderer
from pigway_frame_flow.scheduler.engine import Scheduler
from pigway_frame_flow.settings import Settings
from pigway_frame_flow.storage.database import Database, encode
def create_app(settings=None, start_worker=True):
settings = settings or Settings()
for kind in ["images", "generated", "cache"]:
(settings.data_dir / kind).mkdir(parents=True, exist_ok=True)
settings.config_dir.mkdir(parents=True, exist_ok=True)
db = Database(settings.data_dir / "frameflow.db")
import json
image_config = db.one("SELECT payload FROM app_config WHERE key='image_url_hosts'")
if image_config:
settings.image_url_hosts = ImageURLConfig.model_validate(
json.loads(image_config["payload"])
).allowed_hosts
registry = DeviceRegistry(settings)
assets = Assets(db, settings)
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)
for name in ["urllib3", "requests", "httpx", "httpcore"]:
logging.getLogger(name).setLevel(logging.CRITICAL)
@asynccontextmanager
async def lifespan(app):
if start_worker:
scheduler.start()
app.state.mqtt.start()
yield
app.state.mqtt.stop()
scheduler.stop()
db.close()
error_responses: dict[int | str, dict[str, Any]] = {
code: {"model": ErrorResponse}
for code in [400, 401, 403, 404, 409, 413, 422, 429, 500, 502, 503]
}
app = FastAPI(
title="Pigway Frame Flow API",
docs_url=None,
version="0.1.8",
lifespan=lifespan,
responses=error_responses,
)
install_extensions(app, service)
install_accounts(app, db)
app.state.service = service
app.state.worker_enabled = start_worker
app.state.scheduler = scheduler
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_methods=["GET", "POST", "PUT", "DELETE"],
allow_headers=[
"Authorization",
"Content-Type",
"Idempotency-Key",
"X-Request-ID",
"X-CSRF-Token",
],
)
def error_response(code, message, status, request):
return JSONResponse(
status_code=status,
content={
"error": {
"code": code,
"message": message,
"request_id": getattr(request.state, "request_id", uuid4().hex),
"details": {},
}
},
)
@app.middleware("http")
async def request_context(request, call_next):
supplied = request.headers.get("X-Request-ID", "")
request.state.request_id = (
supplied
if re.fullmatch(r"[A-Za-z0-9_.:-]{1,120}", supplied)
else uuid4().hex
)
if any(
s and s in request.state.request_id
for s in [
settings.api_key,
settings.api_token,
*registry.keys,
*settings.additional_secrets,
]
):
request.state.request_id = uuid4().hex
if request.method in ["POST", "PUT", "DELETE"]:
try:
chunks = []
total = 0
async for chunk in request.stream():
total += len(chunk)
if total > 29_000_000:
return error_response(
E.INVALID_REQUEST,
"Request exceeds size limit",
413,
request,
)
chunks.append(chunk)
request._body = b"".join(chunks)
except Exception:
return error_response(
E.INVALID_REQUEST, "Invalid request body", 400, request
)
try:
response = await call_next(request)
except Exception:
response = error_response(
E.INTERNAL_ERROR, "Internal service error", 500, request
)
response.headers["X-Request-ID"] = request.state.request_id
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Referrer-Policy"] = "no-referrer"
response.headers["Cache-Control"] = "no-store"
response.headers["Content-Security-Policy"] = (
"default-src 'self'; img-src 'self' blob: data:; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; connect-src 'self'; frame-ancestors 'none'"
)
return response
@app.exception_handler(FlowError)
async def flow_error(request, exc):
return error_response(exc.code, exc.message, exc.status, request)
@app.exception_handler(RequestValidationError)
async def validation_error(request, exc):
return error_response(
E.INVALID_REQUEST, "Request validation failed", 422, request
)
@app.exception_handler(HTTPException)
async def http_error(request, exc):
return error_response(
E.NOT_FOUND if exc.status_code == 404 else E.INVALID_REQUEST,
"Resource not found" if exc.status_code == 404 else "HTTP request rejected",
exc.status_code,
request,
)
def accepted(id):
return Accepted(operation_id=id, job_id=id)
def submit(request, action, payload, device, key):
if key and not re.fullmatch(r"[A-Za-z0-9_.:-]{1,200}", key):
raise FlowError(E.INVALID_REQUEST, "Invalid Idempotency-Key")
return accepted(
service.submit(
action,
payload,
device,
request.state.client_id,
request.state.request_id,
key,
)
)
def write_guard(device):
registry.get(device)
if not settings.mock and not settings.write_enabled:
raise FlowError(E.WRITE_DISABLED, "Real device writes are disabled", 403)
@app.get("/health", response_model=Health, tags=["Health"])
def health():
return Health(status="ok")
@app.get(
"/ready",
response_model=Readiness,
tags=["Health"],
responses={503: {"model": Readiness}},
)
def ready():
checks = {
"sqlite": bool(db.one("SELECT 1 AS ok")),
"device_config": bool(registry.drivers)
and (settings.mock or all(registry.keys)),
"scheduler": scheduler.healthy() if start_worker else True,
"authentication": bool(
db.one("SELECT client_id FROM clients WHERE enabled=1")
or db.one("SELECT username FROM users WHERE enabled=1")
),
}
good = all(checks.values())
return JSONResponse(
status_code=200 if good else 503,
content={"status": "ready" if good else "not_ready", "checks": checks},
)
@app.get("/api/v1/version", response_model=Version, tags=["System"])
def version():
return Version(
commit=os.getenv("BUILD_COMMIT", "development"),
build_time=os.getenv("BUILD_TIME", "unknown"),
)
@app.get(
"/api/v1/devices",
response_model=list[Device],
tags=["Devices"],
dependencies=[Depends(require("devices:read"))],
)
def devices():
return [device(id) for id in registry.drivers]
@app.get(
"/api/v1/devices/{device_id}",
response_model=Device,
tags=["Devices"],
dependencies=[Depends(require("devices:read"))],
)
def device(device_id: str, refresh: bool = False):
d = registry.get(device_id).device(refresh)
state = db.one("SELECT * FROM device_state WHERE device_id=?", (device_id,))
possible = bool(
state
and (
state["previous_image_id"] is not None
or state["base_image_id"] is not None
)
) and not service.blocked(device_id)
return d.model_copy(update={"can_restore": possible})
@app.get(
"/api/v1/devices/{device_id}/images",
response_model=list[GalleryImage],
tags=["Images"],
dependencies=[Depends(require("images:read"))],
)
def images(device_id: str):
result = []
for image in registry.get(device_id).images():
source = db.one(
"SELECT asset_id FROM image_sources WHERE device_id=? AND image_id=?",
(device_id, image.image_id),
)
meta = (
db.one(
"SELECT starred FROM asset_metadata WHERE asset_id=?",
(source["asset_id"],),
)
if source
else None
)
result.append(
image.model_copy(update={"starred": bool(meta and meta["starred"])})
)
return result
@app.put(
"/api/v1/devices/{device_id}/images/{image_id}/star",
tags=["Images"],
dependencies=[Depends(require("images:write"))],
)
def star_image(device_id: str, image_id: int, payload: ImageStar):
registry.get(device_id)
row = db.one(
"SELECT asset_id FROM image_sources WHERE device_id=? AND image_id=?",
(device_id, image_id),
)
if not row or not row["asset_id"]:
raise FlowError(
E.UNSUPPORTED_CAPABILITY, "Star images in the local library", 409
)
return star_asset(row["asset_id"], payload)
@app.post(
"/api/v1/devices/{device_id}/images/clear",
tags=["Images"],
dependencies=[Depends(require("admin"))],
)
def clear_cloud(device_id: str, payload: DeleteRequest, request: Request):
if not payload.confirm_current:
raise FlowError(
E.INVALID_REQUEST,
"Explicit current-image deletion confirmation required",
)
write_guard(device_id)
ids = [image.image_id for image in registry.get(device_id).images()]
return {
"operations": [
submit(
request,
"delete",
{"image_id": id, "confirm_current": True, "purge": True},
device_id,
request.state.request_id + "-" + str(id),
)
for id in ids
]
}
@app.get(
"/api/v1/devices/{device_id}/images/{image_id}/file",
tags=["Images"],
dependencies=[Depends(require("images:read"))],
responses={
200: {
"content": {
"image/jpeg": {"schema": {"type": "string", "format": "binary"}}
}
}
},
)
def image_file(device_id: str, image_id: int, thumbnail: bool = False):
if image_id < 1:
raise FlowError(E.INVALID_REQUEST, "Image ID must be positive")
driver = registry.get(device_id)
cached = settings.data_dir / "cache" / f"{device_id}-{image_id}.original"
if not cached.exists():
data = driver.download(image_id)
# Validate and strip EXIF without changing original geometry.
from io import BytesIO
from PIL import Image
try:
with Image.open(BytesIO(data)) as source:
if source.width * source.height > 25_000_000:
raise ValueError()
if source.format not in {"JPEG", "PNG", "WEBP"}:
raise ValueError()
source.verify()
cached.write_bytes(data)
except Exception:
raise FlowError(E.INVALID_REQUEST, "Invalid cloud image") from None
if thumbnail:
thumb = cached.with_name(cached.stem + "-thumb.jpg")
if not thumb.exists():
from PIL import Image
with Image.open(cached) as img:
img.thumbnail((300, 400))
img.save(thumb, "JPEG")
return FileResponse(thumb, media_type="image/jpeg")
from PIL import Image
with Image.open(cached) as original:
media_type = Image.MIME.get(
original.format or "", "application/octet-stream"
)
return FileResponse(cached, media_type=media_type)
@app.post(
"/api/v1/display",
response_model=Accepted,
status_code=202,
tags=["Display"],
dependencies=[Depends(require("display:write"))],
)
@app.post(
"/api/display",
response_model=Accepted,
status_code=202,
deprecated=True,
dependencies=[Depends(require("display:write"))],
)
def display(
payload: DisplayRequest,
request: Request,
idempotency_key: str | None = Header(default=None),
):
service.validate(payload)
write_guard(payload.device_id)
return submit(
request,
"display",
payload.model_dump(mode="json"),
payload.device_id,
idempotency_key,
)
@app.post(
"/api/v1/devices/{device_id}/display",
response_model=Accepted,
status_code=202,
tags=["Display"],
dependencies=[Depends(require("display:write"))],
)
def device_display(
device_id: str,
payload: DisplayRequest,
request: Request,
idempotency_key: str | None = Header(default=None),
):
payload = payload.model_copy(update={"device_id": device_id})
return display(payload, request, idempotency_key)
@app.post(
"/api/v1/render",
response_model=Accepted,
status_code=202,
tags=["Render"],
dependencies=[Depends(require("render:write"))],
)
def render(
payload: TemplateContent | AutoCollageContent,
request: Request,
idempotency_key: str | None = Header(default=None),
):
service.validate(
DisplayRequest(device_id=settings.device_id, content=payload), writing=False
)
return submit(
request,
"render",
payload.model_dump(mode="json"),
settings.device_id,
idempotency_key,
)
@app.post(
"/api/v1/devices/{device_id}/upload",
response_model=Accepted,
status_code=202,
tags=["Images"],
dependencies=[Depends(require("display:write"))],
)
def upload(
device_id: str,
payload: ImageContent | URLContent | PhotoSourceContent,
request: Request,
display_now: bool = False,
idempotency_key: str | None = Header(default=None),
):
registry.get(device_id)
if isinstance(payload, ImageContent) and (
payload.image_id or payload.image_name
):
raise FlowError(E.INVALID_REQUEST, "Upload needs image bytes or an asset")
if display_now:
return display(
DisplayRequest(device_id=device_id, content=payload),
request,
idempotency_key,
)
return submit(
request,
"local-upload",
payload.model_dump(mode="json"),
device_id,
idempotency_key,
)
@app.post(
"/api/v1/preview",
response_model=Asset,
tags=["Render"],
dependencies=[Depends(require("render:write"))],
)
async def preview(
request: Request,
file: UploadFile = File(),
fit: Fit = Form(default=Fit.contain),
profile: Profile = Form(default=Profile.color),
):
data = await file.read(settings.max_bytes + 1)
if len(data) > settings.max_bytes:
raise FlowError(E.INVALID_REQUEST, "Image exceeds 20 MiB")
name = Path(file.filename or "image").name[:120]
asset = assets.put(
await run_in_threadpool(convert, data, fit, profile),
settings.sanitize(name),
kind="cache",
)
db.execute(
"INSERT INTO asset_metadata(asset_id,origin) VALUES(?,?)",
(asset["asset_id"], "manual"),
)
return assets.info(asset["asset_id"])
@app.get(
"/api/v1/assets",
response_model=list[Asset],
tags=["Images"],
dependencies=[Depends(require("images:read"))],
)
def list_assets(
limit: int = Query(default=100, ge=1, le=200),
source: Literal["all", "uploaded", "generated"] = "all",
origin: Literal[
"all", "manual", "task", "external", "template", "unknown"
] = "all",
job_id: str | None = None,
offset: int = Query(default=0, ge=0),
):
return [
assets.info(r["id"])
for r in db.all(
"SELECT assets.id FROM assets LEFT JOIN asset_metadata ON asset_metadata.asset_id=assets.id WHERE kind!='cache' AND "
"(?='all' OR (?='uploaded' AND kind='images') OR (?='generated' AND kind='generated')) "
"AND (?='all' OR COALESCE(origin,'unknown')=?) AND (? IS NULL OR job_id=?) "
"ORDER BY created_at DESC LIMIT ? OFFSET ?",
(source, source, source, origin, origin, job_id, job_id, limit, offset),
)
]
@app.delete(
"/api/v1/assets/{asset_id}",
status_code=204,
tags=["Images"],
dependencies=[Depends(require("images:delete"))],
)
def delete_asset(asset_id: str):
path = assets.path(asset_id)
reference = "%" + asset_id + "%"
if (
db.one(
"SELECT id FROM operations WHERE payload LIKE ? AND status IN ('QUEUED','RENDERING','UPLOADING','SYNCING','UNKNOWN')",
(reference,),
)
or db.one("SELECT id FROM jobs WHERE payload LIKE ?", (reference,))
or db.one(
"SELECT template_id FROM template_config WHERE payload LIKE ?",
(reference,),
)
):
raise FlowError(
E.CONFLICT,
"Asset is referenced by a task, template or pending operation",
409,
)
path.unlink(missing_ok=True)
db.execute("DELETE FROM asset_metadata WHERE asset_id=?", (asset_id,))
db.execute("DELETE FROM asset_sources WHERE asset_id=?", (asset_id,))
db.execute("DELETE FROM assets WHERE id=?", (asset_id,))
return Response(status_code=204)
@app.put(
"/api/v1/assets/{asset_id}/star",
response_model=Asset,
tags=["Images"],
dependencies=[Depends(require("images:write"))],
)
def star_asset(asset_id: str, payload: ImageStar):
with service.lock:
assets.info(asset_id)
db.execute(
"INSERT INTO asset_metadata(asset_id,starred) VALUES(?,?) ON CONFLICT(asset_id) DO UPDATE SET starred=excluded.starred",
(asset_id, int(payload.starred)),
)
db.execute(
"UPDATE image_retention SET starred=? WHERE (device_id,image_id) IN (SELECT device_id,image_id FROM image_sources WHERE asset_id=?)",
(int(payload.starred), asset_id),
)
return assets.info(asset_id)
@app.post(
"/api/v1/assets/batch",
tags=["Images"],
dependencies=[Depends(require("images:delete"))],
)
def batch_assets(payload: AssetBatch):
results = []
with service.lock:
for id in dict.fromkeys(payload.asset_ids):
try:
if payload.action == "delete":
delete_asset(id)
else:
star_asset(id, ImageStar(starred=payload.action == "star"))
results.append({"asset_id": id, "status": "completed"})
except FlowError as exc:
results.append(
{"asset_id": id, "status": "failed", "code": exc.code}
)
return {"results": results}
@app.get(
"/api/v1/assets/{asset_id}",
tags=["Images"],
dependencies=[Depends(require("images:read"))],
responses={
200: {
"content": {
"image/jpeg": {"schema": {"type": "string", "format": "binary"}}
}
}
},
)
def asset_file(asset_id: str):
return FileResponse(assets.path(asset_id), media_type="image/jpeg")
@app.get(
"/api/v1/integrations/synology-photos",
response_model=PhotosConnection,
tags=["Photo sources"],
dependencies=[Depends(require("admin"))],
)
def photos_connection():
return service.photo_sources.synology.status()
@app.post(
"/api/v1/integrations/synology-photos",
response_model=PhotosConnection,
tags=["Photo sources"],
dependencies=[Depends(require("admin"))],
)
def connect_photos(payload: PhotosLogin):
result = service.photo_sources.synology.connect(payload)
service.photo_sources.cache.clear()
return result
@app.delete(
"/api/v1/integrations/synology-photos",
response_model=PhotosConnection,
tags=["Photo sources"],
dependencies=[Depends(require("admin"))],
)
def disconnect_photos():
result = service.photo_sources.synology.disconnect()
service.photo_sources.cache.clear()
return result
@app.get(
"/api/v1/integrations/synology-photos/people",
response_model=list[PhotosPerson],
tags=["Photo sources"],
dependencies=[Depends(require("admin"))],
)
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",
response_model=list[PhotoSourceView],
tags=["Photo sources"],
dependencies=[Depends(require("images:read"))],
)
def photo_sources():
return service.photo_sources.list()
@app.put(
"/api/v1/photo-sources/{source_id}",
response_model=PhotoSourceView,
tags=["Photo sources"],
dependencies=[Depends(require("admin"))],
)
def save_photo_source(source_id: str, payload: PhotoSourceInput):
if not re.fullmatch(r"[a-z0-9-]{1,60}", source_id):
raise FlowError(E.INVALID_REQUEST, "Invalid source ID")
return service.photo_sources.save(source_id, payload)
@app.delete(
"/api/v1/photo-sources/{source_id}",
status_code=204,
tags=["Photo sources"],
dependencies=[Depends(require("admin"))],
)
def delete_photo_source(source_id: str):
service.photo_sources.delete(source_id)
return Response(status_code=204)
@app.get(
"/api/v1/photo-sources/{source_id}/images",
response_model=PhotoCatalog,
tags=["Photo sources"],
dependencies=[Depends(require("images:read"))],
)
def source_images(
source_id: str,
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
refresh: bool = False,
device_id: str | None = None,
):
source = service.photo_sources.get(source_id)
size = (1200, 1600)
if source.orientation == "auto":
size = frame_canvas(
registry.get(device_id or settings.device_id).device().display
)
items = service.photo_sources.catalog(
source_id, refresh=refresh, target_size=size
)
return {
"items": items[offset : offset + limit],
"total": len(items),
"offset": offset,
"limit": limit,
"catalog_limit": service.photo_sources.get(source_id).catalog_limit,
}
@app.delete(
"/api/v1/devices/{device_id}/images/{image_id}",
response_model=Accepted,
status_code=202,
tags=["Images"],
dependencies=[Depends(require("images:delete"))],
)
def delete_image(
device_id: str,
image_id: int,
request: Request,
confirm_current: bool = False,
idempotency_key: str | None = Header(default=None),
):
write_guard(device_id)
if image_id < 1:
raise FlowError(E.INVALID_REQUEST, "Image ID must be positive")
if (
registry.get(device_id).get_current_image() == image_id
and not confirm_current
):
raise FlowError(E.CONFLICT, "Confirm deleting the current image", 409)
return submit(
request,
"delete",
{"image_id": image_id, "confirm_current": confirm_current},
device_id,
idempotency_key,
)
@app.post(
"/api/v1/devices/{device_id}/restore",
response_model=Accepted,
status_code=202,
tags=["Display"],
dependencies=[Depends(require("display:write"))],
)
def restore(
device_id: str,
request: Request,
idempotency_key: str | None = Header(default=None),
):
write_guard(device_id)
return submit(request, "restore", {}, device_id, idempotency_key)
@app.get(
"/api/v1/operations",
response_model=list[Operation],
tags=["Operations"],
dependencies=[Depends(require("history:read"))],
)
def operations(request: Request, limit: int = Query(default=50, ge=1, le=200)):
return [
service.operation(x["id"])
for x in db.all(
"SELECT id FROM operations WHERE (? OR client_id=?) ORDER BY created_at DESC LIMIT ?",
("admin" in request.state.permissions, request.state.client_id, limit),
)
]
@app.get(
"/api/v1/operations/{operation_id}",
response_model=Operation,
tags=["Operations"],
dependencies=[Depends(require("devices:read"))],
)
def operation(operation_id: str, request: Request):
op = service.operation(operation_id)
if (
"admin" not in request.state.permissions
and request.state.client_id != op["client_id"]
):
raise FlowError(E.FORBIDDEN, "Operation belongs to another client", 403)
return op
@app.post(
"/api/v1/operations/{operation_id}/reconcile",
response_model=Reconciliation,
tags=["Operations"],
dependencies=[Depends(require("admin"))],
)
def reconcile(operation_id: str, payload: ReconcileRequest):
return service.reconcile(operation_id, payload.acknowledge)
@app.get(
"/api/v1/templates",
response_model=list[TemplateInfo],
tags=["Templates"],
dependencies=[Depends(require("templates:read"))],
)
def templates():
return renderer.list()
@app.get(
"/api/v1/templates/{template_id}",
response_model=TemplateInfo,
tags=["Templates"],
dependencies=[Depends(require("templates:read"))],
)
def template(template_id: str):
return renderer.info(template_id)
@app.put(
"/api/v1/templates/{template_id}/config",
response_model=TemplateConfig,
tags=["Templates"],
dependencies=[Depends(require("settings:write"))],
)
def template_config(template_id: str, payload: TemplateConfig):
renderer.info(template_id)
db.execute(
"INSERT INTO template_config VALUES(?,?) ON CONFLICT(template_id) DO UPDATE SET payload=excluded.payload",
(template_id, encode(settings.sanitize(payload.data))),
)
return TemplateConfig(data=settings.sanitize(payload.data))
@app.delete(
"/api/v1/templates/{template_id}/config",
status_code=204,
tags=["Templates"],
dependencies=[Depends(require("settings:write"))],
)
def reset_template_config(template_id: str):
renderer.info(template_id)
db.execute("DELETE FROM template_config WHERE template_id=?", (template_id,))
@app.get(
"/api/v1/jobs",
response_model=list[Job],
tags=["Automation"],
dependencies=[Depends(require("jobs:read"))],
)
def jobs():
return scheduler.jobs()
@app.post(
"/api/v1/jobs",
response_model=Job,
status_code=201,
tags=["Automation"],
dependencies=[Depends(require("jobs:write"))],
)
def create_job(payload: JobInput):
return scheduler.save_job(payload)
@app.put(
"/api/v1/jobs/{job_id}",
response_model=Job,
tags=["Automation"],
dependencies=[Depends(require("jobs:write"))],
)
def update_job(job_id: str, payload: JobInput):
if not db.one("SELECT id FROM jobs WHERE id=?", (job_id,)):
raise FlowError(E.NOT_FOUND, "Job not found", 404)
return scheduler.save_job(payload, job_id)
@app.delete(
"/api/v1/jobs/{job_id}",
status_code=204,
tags=["Automation"],
dependencies=[Depends(require("jobs:write"))],
)
def delete_job(job_id: str):
db.execute("DELETE FROM jobs WHERE id=?", (job_id,))
return Response(status_code=204)
@app.get(
"/api/v1/playlists",
response_model=list[Playlist],
tags=["Playlists"],
dependencies=[Depends(require("jobs:read"))],
)
def playlists():
return scheduler.playlists()
@app.post(
"/api/v1/playlists",
response_model=Playlist,
status_code=201,
tags=["Playlists"],
dependencies=[Depends(require("jobs:write"))],
)
def create_playlist(payload: PlaylistInput):
return scheduler.save_playlist(payload)
@app.put(
"/api/v1/playlists/{playlist_id}",
response_model=Playlist,
tags=["Playlists"],
dependencies=[Depends(require("jobs:write"))],
)
def update_playlist(playlist_id: str, payload: PlaylistInput):
if not db.one("SELECT id FROM playlists WHERE id=?", (playlist_id,)):
raise FlowError(E.NOT_FOUND, "Playlist not found", 404)
return scheduler.save_playlist(payload, playlist_id)
@app.delete(
"/api/v1/playlists/{playlist_id}",
status_code=204,
tags=["Playlists"],
dependencies=[Depends(require("jobs:write"))],
)
def delete_playlist(playlist_id: str):
db.execute("DELETE FROM playlists WHERE id=?", (playlist_id,))
return Response(status_code=204)
@app.get(
"/api/v1/history",
response_model=list[History],
tags=["History"],
dependencies=[Depends(require("history:read"))],
)
def history(limit: int = Query(default=100, ge=1, le=200)):
return db.all("SELECT * FROM history ORDER BY id DESC LIMIT ?", (limit,))
@app.get(
"/api/v1/settings",
response_model=SettingsView,
tags=["Settings"],
dependencies=[Depends(require("admin"))],
)
def settings_view():
return SettingsView(
storage_name=settings.storage_name,
# Page navigation needs device identity, not a fresh cloud status request.
device_catalog=[
DeviceIdentity(
id=id,
name=getattr(getattr(driver, "cached", None), "name", id),
)
for id, driver in registry.drivers.items()
],
gallery_destinations=[
GalleryDestination(
id="cloud",
adapter="kokonna",
name="KoKonna",
storage="cloud",
supports_upload=True,
connected=settings.mock
or bool(settings.api_key)
or any(registry.keys),
),
GalleryDestination(
id="local",
adapter="local-assets",
name=settings.storage_name,
storage="local",
connected=True,
supports_upload=True,
),
],
version="0.1.8",
mock=settings.mock,
write_enabled=settings.write_enabled,
api_key_configured=bool(settings.api_key)
or all(registry.keys)
and bool(registry.keys),
api_token_configured=bool(settings.api_token),
timezone=settings.timezone,
device_id=settings.device_id,
weather_provider=weather.config().provider,
)
def official_connection(device_id):
registry.get(device_id)
return registry.official[device_id]
@app.get(
"/api/v1/devices/{device_id}/official/connection",
response_model=ConnectionState,
tags=["Official"],
dependencies=[Depends(require("admin"))],
)
def connection_state(device_id: str, discover: bool = False):
return official_connection(device_id).state(discover)
@app.post(
"/api/v1/devices/{device_id}/official/connection",
response_model=ConnectionState,
tags=["Official"],
dependencies=[Depends(require("admin"))],
)
def connection_login(device_id: str, body: LoginRequest):
return official_connection(device_id).login(
body.username, body.password.get_secret_value()
)
@app.put(
"/api/v1/devices/{device_id}/official/connection",
response_model=ConnectionState,
tags=["Official"],
dependencies=[Depends(require("admin"))],
)
def connection_select(device_id: str, body: SelectRequest):
return official_connection(device_id).select(body.robot_id)
@app.delete(
"/api/v1/devices/{device_id}/official/connection",
response_model=ConnectionState,
tags=["Official"],
dependencies=[Depends(require("admin"))],
)
def connection_disconnect(device_id: str):
return official_connection(device_id).disconnect()
@app.get(
"/api/v1/devices/{device_id}/widgets",
response_model=__import__(
"pigway_frame_flow.models", fromlist=["WidgetState"]
).WidgetState,
tags=["Devices"],
dependencies=[Depends(require("devices:read"))],
)
def official_widgets(device_id: str):
return registry.official_widgets(device_id)
@app.get(
"/api/v1/devices/{device_id}/current-content",
response_model=CurrentDisplay,
tags=["Devices"],
dependencies=[Depends(require("devices:read"))],
)
def current_content(device_id: str):
return service.current_content(device_id)
@app.get(
"/api/v1/providers/weather",
response_model=WeatherView,
tags=["Providers"],
dependencies=[Depends(require("templates:read"))],
)
def weather_view():
values = weather.fetch()
return WeatherView(config=weather.config(), **values)
@app.put(
"/api/v1/settings/weather",
response_model=WeatherConfig,
tags=["Settings"],
dependencies=[Depends(require("settings:write"))],
)
def weather_config(payload: WeatherConfig):
return weather.configure(payload)
@app.get(
"/api/v1/assets/{asset_id}/source",
response_model=dict[str, Any],
tags=["Templates"],
dependencies=[Depends(require("templates:read"))],
)
def asset_source(asset_id: str):
assets.info(asset_id)
row = db.one("SELECT payload FROM asset_sources WHERE asset_id=?", (asset_id,))
import json
return {
"source_known": bool(row),
"source": json.loads(row["payload"]) if row else None,
}
@app.get(
"/api/v1/jobs/{job_id}",
response_model=Job,
tags=["Automation"],
dependencies=[Depends(require("jobs:read"))],
)
def get_job(job_id: str):
job = next((j for j in scheduler.jobs() if j["id"] == job_id), None)
if not job:
raise FlowError(E.NOT_FOUND, "Job not found", 404)
return job
@app.post(
"/api/v1/jobs/{job_id}/run",
response_model=Accepted,
status_code=202,
tags=["Automation"],
dependencies=[Depends(require("jobs:write"))],
)
def run_job(
job_id: str,
request: Request,
idempotency_key: str | None = Header(default=None),
):
job = JobInput.model_validate(
{k: v for k, v in get_job(job_id).items() if k in JobInput.model_fields}
)
write_guard(job.device_id)
if job.source.type == "restore":
return submit(request, "restore", {}, job.device_id, idempotency_key)
payload = DisplayRequest(
device_id=job.device_id,
content=job.source,
options=Options(
duration=job.display_duration,
restore_previous=job.restore_previous,
priority=job.priority,
cleanup_unstarred=job.cleanup_unstarred,
),
)
service.validate(payload)
accepted = submit(
request,
"display",
payload.model_dump(mode="json"),
job.device_id,
idempotency_key,
)
db.execute(
"INSERT OR IGNORE INTO job_runs VALUES(?,?,?)",
(job_id, "manual-" + accepted.operation_id, accepted.operation_id),
)
return accepted
@app.post(
"/api/v1/jobs/{job_id}/preview",
response_model=Accepted,
status_code=202,
tags=["Automation"],
dependencies=[Depends(require("render:write"))],
)
def preview_job(
job_id: str,
request: Request,
idempotency_key: str | None = Header(default=None),
):
source = get_job(job_id)["source"]
if source["type"] not in ("template", "auto_collage"):
raise FlowError(
E.UNSUPPORTED_CAPABILITY,
"Job preview requires template or auto-collage content",
422,
)
return submit(
request, "render", source, get_job(job_id)["device_id"], idempotency_key
)
@app.get(
"/api/v1/settings/image-url",
response_model=ImageURLConfig,
tags=["Settings"],
dependencies=[Depends(require("admin"))],
)
def image_url_config():
return ImageURLConfig(allowed_hosts=settings.image_url_hosts)
@app.put(
"/api/v1/settings/image-url",
response_model=ImageURLConfig,
tags=["Settings"],
dependencies=[Depends(require("settings:write"))],
)
def set_image_url_config(payload: ImageURLConfig):
settings.image_url_hosts = payload.allowed_hosts
db.execute(
"INSERT OR REPLACE INTO app_config VALUES('image_url_hosts',?)",
(encode(payload.model_dump()),),
)
return payload
@app.get(
"/api/v1/providers/poetry",
response_model=dict[str, Any],
tags=["Providers"],
dependencies=[Depends(require("templates:read"))],
)
def daily_poetry(date: str | None = None, poem_id: str | None = None):
from pigway_frame_flow.providers.poetry import catalog, get_poem
try:
return {"current": get_poem(date, poem_id), "catalog": catalog()}
except ValueError:
raise FlowError(
E.INVALID_REQUEST, "Invalid poetry date or poem selection"
) from None
@app.get("/docs", include_in_schema=False)
def api_docs():
response = get_swagger_ui_html(
openapi_url="/openapi.json",
title="Pigway Frame Flow · API Docs",
swagger_js_url="/ui/swagger-ui-bundle.js",
swagger_css_url="/ui/swagger-ui.css",
swagger_favicon_url='data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg"/>',
swagger_ui_parameters={
"persistAuthorization": False,
"displayRequestDuration": True,
},
)
html = (
bytes(response.body)
.decode()
.replace(
"SwaggerUIBundle({",
"""SwaggerUIBundle({
requestInterceptor: async (req) => {
if (!['GET','HEAD'].includes(req.method.toUpperCase())) {
const session = await fetch('/api/v1/auth/me');
if (session.ok) req.headers['X-CSRF-Token'] = (await session.json()).csrf_token;
}
return req;
},""",
)
)
from fastapi.responses import HTMLResponse
return HTMLResponse(html)
root = Path(__file__).resolve().parent.parent / "web"
@app.get("/", include_in_schema=False)
def index():
return FileResponse(root / "index.html", media_type="text/html")
@app.get("/ui/{filename}", include_in_schema=False)
def static(filename: str):
if filename not in [
"app.js",
"features.js",
"extensions.js",
"style.css",
"i18n.js",
"swagger-ui-bundle.js",
"swagger-ui.css",
]:
raise FlowError(E.NOT_FOUND, "Resource not found", 404)
return FileResponse(root / filename)
return app