2026-10-02 00:03:48 +08:00
import logging
import os
import re
from contextlib import asynccontextmanager
from pathlib import Path
2026-10-05 00:31:56 +08:00
from typing import Any , Literal
2026-10-02 00:03:48 +08:00
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
2026-10-20 16:53:27 +08:00
from pigway_frame_flow.api.history import install_history
2026-10-02 08:43:07 +08:00
from pigway_frame_flow.core.accounts import install_accounts , seed_accounts
2026-10-02 00:03:48 +08:00
from pigway_frame_flow.core.auth import require , seed_client
2026-10-20 19:19:30 +08:00
from pigway_frame_flow.core.photo_preferences import (
annotate ,
ensure_compatible ,
set_flags ,
)
2026-10-02 00:03:48 +08:00
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
2026-10-04 00:20:43 +08:00
from pigway_frame_flow.integrations.kokonna_official.connection import (
ConnectionState ,
LoginRequest ,
SelectRequest ,
)
2026-10-02 00:03:48 +08:00
from pigway_frame_flow.models import (
Accepted ,
Asset ,
2026-10-06 09:31:09 +08:00
AssetBatch ,
2026-10-10 09:28:37 +08:00
AutoCollageContent ,
2026-10-02 09:19:25 +08:00
CurrentDisplay ,
2026-10-06 09:31:09 +08:00
DeleteRequest ,
2026-10-02 00:03:48 +08:00
Device ,
2026-10-16 19:21:20 +08:00
DeviceIdentity ,
2026-10-02 00:03:48 +08:00
DisplayRequest ,
ErrorResponse ,
Fit ,
2026-10-16 18:58:01 +08:00
GalleryDestination ,
2026-10-02 00:03:48 +08:00
GalleryImage ,
Health ,
History ,
ImageContent ,
2026-10-20 18:50:43 +08:00
ImageExclusion ,
2026-10-06 09:10:36 +08:00
ImageStar ,
2026-10-02 09:19:25 +08:00
ImageURLConfig ,
2026-10-02 00:03:48 +08:00
Job ,
JobInput ,
Operation ,
2026-10-02 09:19:25 +08:00
Options ,
2026-10-20 18:50:43 +08:00
PhotoPreference ,
2026-10-05 00:52:39 +08:00
PhotoSourceContent ,
2026-10-02 00:03:48 +08:00
Playlist ,
PlaylistInput ,
Profile ,
Readiness ,
ReconcileRequest ,
Reconciliation ,
SettingsView ,
TemplateConfig ,
TemplateContent ,
TemplateInfo ,
URLContent ,
Version ,
2026-10-02 09:19:25 +08:00
WeatherConfig ,
WeatherView ,
2026-10-02 00:03:48 +08:00
)
2026-10-05 00:52:39 +08:00
from pigway_frame_flow.providers.photo_sources import (
PhotoCatalog ,
PhotoSourceInput ,
PhotoSourceView ,
)
2026-10-05 08:43:07 +08:00
from pigway_frame_flow.providers.synology_photos import (
PhotosConnection ,
PhotosLogin ,
PhotosPerson ,
)
2026-10-02 09:19:25 +08:00
from pigway_frame_flow.providers.weather import ConfigurableWeatherProvider
2026-10-05 22:44:41 +08:00
from pigway_frame_flow.renderers.frame_geometry import frame_canvas
2026-10-02 00:03:48 +08:00
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" )
2026-10-02 09:19:25 +08:00
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
2026-10-02 00:03:48 +08:00
registry = DeviceRegistry ( settings )
assets = Assets ( db , settings )
2026-10-02 09:19:25 +08:00
weather = ConfigurableWeatherProvider ( db )
renderer = TemplateRenderer ( db , settings , weather , assets )
2026-10-02 00:03:48 +08:00
service = DisplayService ( db , settings , registry , assets , renderer )
2026-10-08 23:18:11 +08:00
from pigway_frame_flow.extensions.api import install_extensions
2026-10-02 00:03:48 +08:00
scheduler = Scheduler ( service )
seed_client ( db , settings . api_token )
2026-10-02 08:43:07 +08:00
seed_accounts ( db , settings . config_dir )
2026-10-02 00:03:48 +08:00
for name in [ "urllib3" , "requests" , "httpx" , "httpcore" ]:
logging . getLogger ( name ) . setLevel ( logging . CRITICAL )
@asynccontextmanager
async def lifespan ( app ):
if start_worker :
scheduler . start ()
2026-10-08 23:44:07 +08:00
app . state . mqtt . start ()
2026-10-02 00:03:48 +08:00
yield
2026-10-08 23:44:07 +08:00
app . state . mqtt . stop ()
2026-10-02 00:03:48 +08:00
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 (
2026-10-18 13:35:10 +08:00
title = "PIGWay Frame Flow API" ,
2026-10-02 00:03:48 +08:00
docs_url = None ,
2026-10-20 21:02:11 +08:00
version = "0.1.29" ,
2026-10-02 00:03:48 +08:00
lifespan = lifespan ,
responses = error_responses ,
)
2026-10-08 23:18:11 +08:00
install_extensions ( app , service )
2026-10-02 08:43:07 +08:00
install_accounts ( app , db )
2026-10-20 16:53:27 +08:00
install_history ( app , service )
2026-10-23 14:47:27 +08:00
from pigway_frame_flow.api.thumbnails import install_thumbnails
install_thumbnails ( app , service )
2026-10-23 17:45:30 +08:00
from pigway_frame_flow.api.libraries import install_libraries
install_libraries ( app , service )
2026-10-02 00:03:48 +08:00
app . state . service = service
2026-10-08 23:44:07 +08:00
app . state . worker_enabled = start_worker
2026-10-02 00:03:48 +08:00
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" ,
2026-10-02 08:43:07 +08:00
"X-CSRF-Token" ,
2026-10-02 00:03:48 +08:00
],
)
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
2026-10-05 00:21:17 +08:00
for s in [
settings . api_key ,
settings . api_token ,
* registry . keys ,
* settings . additional_secrets ,
]
2026-10-02 00:03:48 +08:00
):
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" )
2026-10-02 08:43:07 +08:00
or db . one ( "SELECT username FROM users WHERE enabled=1" )
2026-10-02 00:03:48 +08:00
),
}
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 ():
2026-10-02 00:11:37 +08:00
return [ device ( id ) for id in registry . drivers ]
2026-10-02 00:03:48 +08:00
@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 ):
2026-10-02 00:11:37 +08:00
d = registry . get ( device_id ) . device ( refresh )
2026-10-17 23:44:40 +08:00
service . clear_self_restore_reference ( device_id , d )
2026-10-02 00:11:37 +08:00
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 })
2026-10-02 00:03:48 +08:00
@app.get (
"/api/v1/devices/ {device_id} /images" ,
response_model = list [ GalleryImage ],
tags = [ "Images" ],
dependencies = [ Depends ( require ( "images:read" ))],
)
def images ( device_id : str ):
2026-10-06 09:31:09 +08:00
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
2026-10-06 09:10:36 +08:00
@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 )
2026-10-06 09:31:09 +08:00
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
]
}
2026-10-02 00:03:48 +08:00
@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" )
2026-10-18 15:17:55 +08:00
from PIL import Image
2026-10-02 00:03:48 +08:00
2026-10-18 15:17:55 +08:00
with Image . open ( cached ) as img :
# The 430px cards need up to 860px on 2x displays. Replace the
# existing cache in place so old 300px previews are upgraded.
expected_edge = min ( 860 , max ( img . size ))
regenerate = not thumb . exists ()
if not regenerate :
with Image . open ( thumb ) as existing :
regenerate = max ( existing . size ) != expected_edge
if regenerate :
img . thumbnail (( 860 , 860 ), Image . Resampling . LANCZOS )
img . convert ( "RGB" ) . save ( thumb , "JPEG" , quality = 88 , optimize = True )
2026-10-02 00:03:48 +08:00
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 (
2026-10-10 09:28:37 +08:00
payload : TemplateContent | AutoCollageContent ,
2026-10-02 00:03:48 +08:00
request : Request ,
idempotency_key : str | None = Header ( default = None ),
):
2026-10-08 23:18:11 +08:00
service . validate (
DisplayRequest ( device_id = settings . device_id , content = payload ), writing = False
)
2026-10-02 00:03:48 +08:00
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 ,
2026-10-05 00:52:39 +08:00
payload : ImageContent | URLContent | PhotoSourceContent ,
2026-10-02 00:03:48 +08:00
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 ),
2026-10-02 00:11:37 +08:00
kind = "cache" ,
2026-10-02 00:03:48 +08:00
)
2026-10-06 09:31:09 +08:00
db . execute (
"INSERT INTO asset_metadata(asset_id,origin) VALUES(?,?)" ,
( asset [ "asset_id" ], "manual" ),
)
return assets . info ( asset [ "asset_id" ])
2026-10-02 00:03:48 +08:00
@app.get (
"/api/v1/assets" ,
response_model = list [ Asset ],
tags = [ "Images" ],
dependencies = [ Depends ( require ( "images:read" ))],
)
2026-10-05 00:31:56 +08:00
def list_assets (
limit : int = Query ( default = 100 , ge = 1 , le = 200 ),
source : Literal [ "all" , "uploaded" , "generated" ] = "all" ,
2026-10-06 09:31:09 +08:00
origin : Literal [
"all" , "manual" , "task" , "external" , "template" , "unknown"
] = "all" ,
job_id : str | None = None ,
offset : int = Query ( default = 0 , ge = 0 ),
2026-10-05 00:31:56 +08:00
):
2026-10-02 00:03:48 +08:00
return [
assets . info ( r [ "id" ])
for r in db . all (
2026-10-23 17:45:30 +08:00
"SELECT assets.id FROM assets LEFT JOIN asset_metadata ON asset_metadata.asset_id=assets.id WHERE kind NOT IN ('cache','source-images','renditions') "
2026-10-23 15:00:29 +08:00
"AND NOT EXISTS (SELECT 1 FROM asset_sources s WHERE s.asset_id=assets.id AND json_extract(s.payload,'$.type')='thumbnail') AND "
2026-10-05 00:31:56 +08:00
"(?='all' OR (?='uploaded' AND kind='images') OR (?='generated' AND kind='generated')) "
2026-10-06 09:31:09 +08:00
"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 ),
2026-10-02 00:03:48 +08:00
)
]
@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 )
2026-10-23 17:45:30 +08:00
if db . one (
"SELECT id FROM assets WHERE id=? AND kind IN ('source-images','renditions')" ,
( asset_id ,),
):
raise FlowError (
E . CONFLICT , "Delete library images through their source library" , 409
)
2026-10-02 00:03:48 +08:00
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 )
2026-10-20 18:50:43 +08:00
db . execute (
"DELETE FROM photo_preferences WHERE source_id='@assets' AND item_id=?" ,
( asset_id ,),
)
2026-10-06 09:31:09 +08:00
db . execute ( "DELETE FROM asset_metadata WHERE asset_id=?" , ( asset_id ,))
db . execute ( "DELETE FROM asset_sources WHERE asset_id=?" , ( asset_id ,))
2026-10-02 00:03:48 +08:00
db . execute ( "DELETE FROM assets WHERE id=?" , ( asset_id ,))
2026-10-23 15:00:29 +08:00
for row in db . all (
"SELECT key,payload FROM app_config WHERE key LIKE 'wrist-thumbnail:%'"
):
import json
if json . loads ( row [ "payload" ]) . get ( "asset_id" ) == asset_id :
db . execute (
"DELETE FROM app_config WHERE key=? AND payload=?" ,
( row [ "key" ], row [ "payload" ]),
)
2026-10-02 00:03:48 +08:00
return Response ( status_code = 204 )
2026-10-06 09:31:09 +08:00
@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 :
2026-10-20 19:19:30 +08:00
with db . transaction () as connection :
current = assets . info ( asset_id )
ensure_compatible ( payload . starred , current [ "excluded" ])
connection . execute (
"INSERT INTO asset_metadata(asset_id,starred) VALUES(?,?) ON CONFLICT(asset_id) DO UPDATE SET starred=excluded.starred" ,
( asset_id , int ( payload . starred )),
)
connection . 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 ),
)
2026-10-06 09:31:09 +08:00
return assets . info ( asset_id )
2026-10-20 18:50:43 +08:00
@app.put (
"/api/v1/assets/ {asset_id} /exclusion" ,
response_model = Asset ,
tags = [ "Images" ],
dependencies = [ Depends ( require ( "images:write" ))],
)
def exclude_asset ( asset_id : str , payload : ImageExclusion ):
with service . lock :
assets . info ( asset_id )
set_flags ( db , "@assets" , asset_id , excluded = payload . excluded )
return assets . info ( asset_id )
2026-10-06 09:31:09 +08:00
@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 )
2026-10-20 18:50:43 +08:00
elif payload . action in { "exclude" , "include" }:
exclude_asset (
id , ImageExclusion ( excluded = payload . action == "exclude" )
)
2026-10-06 09:31:09 +08:00
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 }
2026-10-02 00:03:48 +08:00
@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" )
2026-10-05 08:43:07 +08:00
@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" ))],
)
2026-10-08 23:18:11 +08:00
def photos_people ( space : Literal [ "shared" , "personal" ] = "shared" ):
return (
service . photo_sources . synology . people ()
if space == "shared"
else service . photo_sources . synology . people ( space )
)
2026-10-05 08:43:07 +08:00
2026-10-05 00:52:39 +08:00
@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 ):
2026-10-23 17:45:30 +08:00
with service . lock :
service . libraries . check_source_delete ( source_id )
service . photo_sources . delete ( source_id )
2026-10-05 00:52:39 +08:00
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 ,
2026-10-05 22:44:41 +08:00
device_id : str | None = None ,
2026-10-05 00:52:39 +08:00
):
2026-10-05 22:44:41 +08:00
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
)
2026-10-05 00:52:39 +08:00
return {
2026-10-20 18:50:43 +08:00
"items" : annotate ( db , source_id , items [ offset : offset + limit ]),
2026-10-05 00:52:39 +08:00
"total" : len ( items ),
"offset" : offset ,
"limit" : limit ,
2026-10-05 01:36:05 +08:00
"catalog_limit" : service . photo_sources . get ( source_id ) . catalog_limit ,
2026-10-05 00:52:39 +08:00
}
2026-10-20 18:50:43 +08:00
@app.put (
"/api/v1/photo-sources/ {source_id} /preferences" ,
tags = [ "Photo sources" ],
dependencies = [ Depends ( require ( "admin" ))],
)
def source_preference (
source_id : str , payload : PhotoPreference , 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 , target_size = size )
if not any ( item [ "id" ] == payload . item_id for item in items ):
raise FlowError ( E . NOT_FOUND , "Photo is not in this source" , 404 )
return set_flags (
db ,
source_id ,
payload . item_id ,
starred = payload . starred ,
excluded = payload . excluded ,
)
2026-10-02 00:03:48 +08:00
@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" ))],
)
2026-10-06 22:27:26 +08:00
def operations ( request : Request , limit : int = Query ( default = 50 , ge = 1 , le = 200 )):
2026-10-02 00:03:48 +08:00
return [
service . operation ( x [ "id" ])
for x in db . all (
2026-10-20 16:53:27 +08:00
"SELECT id FROM operations WHERE (? OR client_id=?) AND NOT EXISTS (SELECT 1 FROM hidden_history h WHERE h.operation_id=operations.id) ORDER BY created_at DESC,rowid DESC LIMIT ?" ,
2026-10-06 22:27:26 +08:00
( "admin" in request . state . permissions , request . state . client_id , limit ),
2026-10-02 00:03:48 +08:00
)
]
@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 )
2026-10-06 22:27:26 +08:00
if (
"admin" not in request . state . permissions
and request . state . client_id != op [ "client_id" ]
):
2026-10-02 00:03:48 +08:00
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 ))
2026-10-02 08:43:07 +08:00
@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 ,))
2026-10-02 00:03:48 +08:00
@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 ):
2026-10-23 14:47:27 +08:00
with service . lock :
return scheduler . save_job ( payload )
2026-10-02 00:03:48 +08:00
@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 )
2026-10-23 14:47:27 +08:00
with service . lock :
return scheduler . save_job ( payload , job_id )
2026-10-02 00:03:48 +08:00
@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 ,))
2026-10-18 08:42:05 +08:00
db . execute ( "DELETE FROM app_config WHERE key=?" , ( "upload-retry:" + job_id ,))
2026-10-02 00:03:48 +08:00
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 )):
2026-10-20 16:53:27 +08:00
return db . all (
"SELECT * FROM history WHERE NOT EXISTS (SELECT 1 FROM hidden_history h WHERE h.operation_id=history.operation_id) ORDER BY id DESC LIMIT ?" ,
( limit ,),
)
2026-10-02 00:03:48 +08:00
@app.get (
"/api/v1/settings" ,
response_model = SettingsView ,
tags = [ "Settings" ],
dependencies = [ Depends ( require ( "admin" ))],
)
def settings_view ():
return SettingsView (
2026-10-16 18:58:01 +08:00
storage_name = settings . storage_name ,
2026-10-16 19:21:20 +08:00
# 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 ()
],
2026-10-16 18:58:01 +08:00
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 ),
),
2026-10-23 15:00:29 +08:00
GalleryDestination (
id = "watch" ,
adapter = "watch-thumbnails" ,
name = "Watch" ,
storage = "local" ,
connected = True ,
supports_upload = True ,
),
2026-10-16 18:58:01 +08:00
GalleryDestination (
id = "local" ,
adapter = "local-assets" ,
name = settings . storage_name ,
storage = "local" ,
connected = True ,
supports_upload = True ,
),
],
2026-10-20 21:02:11 +08:00
version = "0.1.29" ,
2026-10-02 00:03:48 +08:00
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 ,
2026-10-02 09:19:25 +08:00
weather_provider = weather . config () . provider ,
2026-10-02 00:03:48 +08:00
)
2026-10-04 00:20:43 +08:00
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 ()
2026-10-02 16:20:54 +08:00
@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 ):
2026-10-03 07:03:35 +08:00
return registry . official_widgets ( device_id )
2026-10-02 16:20:54 +08:00
2026-10-02 09:19:25 +08:00
@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 ,
2026-10-06 09:10:36 +08:00
cleanup_unstarred = job . cleanup_unstarred ,
2026-10-02 09:19:25 +08:00
),
)
service . validate ( payload )
2026-10-06 09:31:09 +08:00
accepted = submit (
2026-10-02 09:19:25 +08:00
request ,
"display" ,
payload . model_dump ( mode = "json" ),
job . device_id ,
idempotency_key ,
)
2026-10-06 09:31:09 +08:00
db . execute (
"INSERT OR IGNORE INTO job_runs VALUES(?,?,?)" ,
( job_id , "manual-" + accepted . operation_id , accepted . operation_id ),
)
return accepted
2026-10-02 09:19:25 +08:00
@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" ]
2026-10-10 09:28:37 +08:00
if source [ "type" ] not in ( "template" , "auto_collage" ):
2026-10-02 09:19:25 +08:00
raise FlowError (
2026-10-10 09:28:37 +08:00
E . UNSUPPORTED_CAPABILITY ,
"Job preview requires template or auto-collage content" ,
422 ,
2026-10-02 09:19:25 +08:00
)
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
2026-10-02 09:57:42 +08:00
@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
2026-10-02 00:03:48 +08:00
@app.get ( "/docs" , include_in_schema = False )
def api_docs ():
2026-10-02 09:27:44 +08:00
response = get_swagger_ui_html (
2026-10-02 00:03:48 +08:00
openapi_url = "/openapi.json" ,
2026-10-18 13:35:10 +08:00
title = "PIGWay Frame Flow · API Docs" ,
2026-10-02 00:03:48 +08:00
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 ,
},
)
2026-10-02 09:27:44 +08:00
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 )
2026-10-02 00:03:48 +08:00
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" ,
2026-10-23 14:47:27 +08:00
"thumbnails.js" ,
2026-10-23 17:45:30 +08:00
"libraries.js" ,
2026-10-23 19:59:02 +08:00
"source-browser.js" ,
2026-10-02 09:19:25 +08:00
"features.js" ,
2026-10-08 23:44:07 +08:00
"extensions.js" ,
2026-10-02 00:03:48 +08:00
"style.css" ,
"i18n.js" ,
"swagger-ui-bundle.js" ,
"swagger-ui.css" ,
2026-10-19 19:03:08 +08:00
"favicon.svg" ,
"favicon.ico" ,
"apple-touch-icon.png" ,
"logo-symbol.svg" ,
2026-10-02 00:03:48 +08:00
]:
raise FlowError ( E . NOT_FOUND , "Resource not found" , 404 )
return FileResponse ( root / filename )
return app