Files
pigway-frame-flow/pigway_frame_flow/api/libraries.py
T
渡口浪人 ed3b61bc58
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Failing after 7m11s
fix: resolve gallery labels from current plans and preserve render snapshots
2026-10-23 23:10:50 +08:00

334 lines
12 KiB
Python

from fastapi import Depends, Query, Request
from fastapi.responses import Response
from pydantic import Field
from pigway_frame_flow.core.auth import require
from pigway_frame_flow.core.photo_preferences import set_flags
from pigway_frame_flow.display.libraries import GenerationPlan
from pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.models import ImageExclusion, ImageStar, StrictModel
from pigway_frame_flow.providers.source_browser import BrowseFilters
class BindingInput(StrictModel):
source_id: str = Field(min_length=1, max_length=120)
plan_id: str = Field(pattern=r"^[a-f0-9]{32}$")
class ImportInput(StrictModel):
item_id: str = Field(min_length=1, max_length=2048)
class RunInput(StrictModel):
item_id: str | None = Field(default=None, min_length=1, max_length=2048)
deliver: bool = False
class LibraryModeInput(StrictModel):
enabled: bool
class GenerateInput(StrictModel):
plan_id: str | None = Field(default=None, pattern=r"^[a-f0-9]{32}$")
def install_libraries(app, service):
libraries, db = service.libraries, service.db
@app.put(
"/api/v1/jobs/{job_id}/library-mode",
dependencies=[Depends(require("jobs:write"))],
)
def set_mode(job_id: str, payload: LibraryModeInput):
return app.state.scheduler.set_library_mode(job_id, payload.enabled)
@app.get("/api/v1/source-libraries", dependencies=[Depends(require("images:read"))])
def sources():
return [
{
**source,
"image_count": db.one(
"SELECT COUNT(*) AS n FROM source_images WHERE source_id=?",
(source["id"],),
)["n"],
}
for source in service.photo_sources.list()
]
@app.get(
"/api/v1/source-libraries/{source_id}/images",
dependencies=[Depends(require("images:read"))],
)
def pictures(
source_id: str,
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
):
service.photo_sources.get(source_id)
rows = db.all(
"SELECT id FROM source_images WHERE source_id=? ORDER BY created_at DESC,id DESC LIMIT ? OFFSET ?",
(source_id, limit, offset),
)
return {
"items": [libraries.picture(row["id"]) for row in rows],
"offset": offset,
"limit": limit,
"total": db.one(
"SELECT COUNT(*) AS n FROM source_images WHERE source_id=?",
(source_id,),
)["n"],
}
@app.get(
"/api/v1/source-libraries/{source_id}/browse",
dependencies=[Depends(require("images:read"))],
)
def browse(
source_id: str,
filters: BrowseFilters = Query(),
):
return service.photo_sources.browser.page(
source_id, filters.offset, filters.limit, filters
)
@app.get(
"/api/v1/source-libraries/{source_id}/browse/{item_id}/preview",
dependencies=[Depends(require("images:read"))],
)
def browse_preview(source_id: str, item_id: str):
data = service.photo_sources.browser.preview(source_id, item_id)
return Response(
data,
media_type="image/jpeg",
headers={"Cache-Control": "private, no-store"},
)
@app.post(
"/api/v1/source-libraries/{source_id}/import",
dependencies=[Depends(require("admin"))],
)
def import_picture(source_id: str, payload: ImportInput):
return libraries.import_picture(source_id, payload.item_id)
@app.post(
"/api/v1/source-libraries/{source_id}/run",
dependencies=[Depends(require("admin"))],
)
def run(source_id: str, payload: RunInput, request: Request):
with service.lock:
return libraries.pipeline(
source_id,
payload.item_id,
deliver=payload.deliver,
caller=request.state.client_id,
)
@app.get("/api/v1/generation-plans", dependencies=[Depends(require("images:read"))])
def plans(source_id: str):
return libraries.plans(source_id)
@app.post("/api/v1/generation-plans", dependencies=[Depends(require("admin"))])
def create_plan(payload: GenerationPlan):
return libraries.save_plan(payload)
@app.put(
"/api/v1/generation-plans/{plan_id}", dependencies=[Depends(require("admin"))]
)
def update_plan(plan_id: str, payload: GenerationPlan):
return libraries.save_plan(payload, plan_id)
@app.delete(
"/api/v1/generation-plans/{plan_id}",
status_code=204,
dependencies=[Depends(require("admin"))],
)
def delete_plan(plan_id: str):
libraries.archive(plan_id)
@app.post(
"/api/v1/source-images/{picture_id}/generate",
dependencies=[Depends(require("admin"))],
)
def generate(picture_id: str, payload: GenerateInput):
with service.lock:
picture = libraries.picture(picture_id)
ids = (
[payload.plan_id]
if payload.plan_id
else [
p["id"]
for p in libraries.plans(picture["source_id"])
if not p["archived"]
]
)
return [libraries.generate(picture_id, plan_id) for plan_id in ids]
@app.post(
"/api/v1/source-images/{picture_id}/preview",
dependencies=[Depends(require("images:read"))],
)
def preview(picture_id: str, payload: GenerationPlan):
picture = libraries.picture(picture_id)
if picture["source_id"] != payload.source_id:
raise FlowError(
E.CONFLICT, "Picture and plan must share a source library", 409
)
data = libraries.render(
service.assets.path(picture_id).read_bytes(), picture["caption"], payload
)
return Response(
data, media_type="image/jpeg", headers={"Cache-Control": "no-store"}
)
@app.put(
"/api/v1/source-images/{picture_id}/star",
dependencies=[Depends(require("admin"))],
)
def star(picture_id: str, payload: ImageStar):
picture = libraries.picture(picture_id)
return set_flags(
db, picture["source_id"], picture["item_id"], starred=payload.starred
)
@app.put(
"/api/v1/source-images/{picture_id}/exclusion",
dependencies=[Depends(require("admin"))],
)
def exclude(picture_id: str, payload: ImageExclusion):
picture = libraries.picture(picture_id)
return set_flags(
db, picture["source_id"], picture["item_id"], excluded=payload.excluded
)
@app.delete(
"/api/v1/source-images/{picture_id}",
status_code=204,
dependencies=[Depends(require("images:delete"))],
)
def delete_picture(picture_id: str):
libraries.delete_picture(picture_id)
@app.delete(
"/api/v1/library-versions/{asset_id}",
status_code=204,
dependencies=[Depends(require("images:delete"))],
)
def delete_version(asset_id: str):
with service.lock:
if not db.one(
"SELECT asset_id FROM library_versions WHERE asset_id=?", (asset_id,)
):
raise FlowError(E.NOT_FOUND, "Generated image not found", 404)
libraries.check_asset_references(asset_id)
libraries.remove_asset(asset_id)
@app.get(
"/api/v1/library-terminals", dependencies=[Depends(require("images:read"))]
)
def terminals():
return libraries.terminals()
@app.put(
"/api/v1/library-terminals/{terminal_id}/binding",
dependencies=[Depends(require("admin"))],
)
def bind(terminal_id: str, payload: BindingInput):
return libraries.bind(terminal_id, payload.source_id, payload.plan_id)
@app.delete(
"/api/v1/library-terminals/{terminal_id}/binding",
status_code=204,
dependencies=[Depends(require("admin"))],
)
def unbind(terminal_id: str):
with service.lock:
db.execute(
"DELETE FROM terminal_bindings WHERE terminal_id=?", (terminal_id,)
)
@app.get(
"/api/v1/library-terminals/{terminal_id}/images",
dependencies=[Depends(require("images:read"))],
)
def terminal_images(
terminal_id: str,
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
):
terminal = next(
(t for t in libraries.terminals() if t["id"] == terminal_id), None
)
if terminal is None:
raise FlowError(E.NOT_FOUND, "Terminal not found", 404)
binding = libraries.binding(terminal_id)
if not binding:
return {"items": [], "total": 0, "offset": offset, "limit": limit}
where = "FROM library_versions v JOIN source_images s ON s.id=v.source_image_id WHERE v.plan_id=? AND s.source_id=?"
args = (binding["plan_id"], binding["source_id"])
rows = db.all(
"SELECT v.asset_id,v.source_image_id "
+ where
+ " ORDER BY v.created_at DESC,v.asset_id LIMIT ? OFFSET ?",
(*args, limit, offset),
)
current_asset = None
if rows and terminal["transport_supported"]:
try:
device = service.registry.get(terminal_id).device()
if device.status == "online":
current = service.current_content(terminal_id, device=device)
if current["synced"] and current["source_known"]:
current_asset = current["asset_id"]
except FlowError:
# Offline devices must not hide locally generated versions.
pass
items = []
versions_by_picture = {}
for row in rows:
picture_id = row["source_image_id"]
if picture_id not in versions_by_picture:
versions_by_picture[picture_id] = {
v["asset_id"]: v for v in libraries.versions(picture_id)
}
version = versions_by_picture[picture_id][row["asset_id"]]
delivery = db.one(
"SELECT operation_id FROM terminal_deliveries WHERE terminal_id=? AND asset_id=? ORDER BY created_at DESC,operation_id DESC LIMIT 1",
(terminal_id, row["asset_id"]),
)
operation = (
db.one(
"SELECT id AS operation_id,status,created_at,updated_at FROM operations WHERE id=?",
(delivery["operation_id"],),
)
if delivery and delivery["operation_id"]
else None
)
items.append(
{
**version,
"operation": operation,
"current": row["asset_id"] == current_asset,
"delivery_status": operation["status"]
if operation
else "GENERATED",
}
)
return {
"items": items,
"total": db.one("SELECT COUNT(*) AS n " + where, args)["n"],
"offset": offset,
"limit": limit,
}
@app.post(
"/api/v1/library-terminals/{terminal_id}/images/{asset_id}/send",
dependencies=[Depends(require("display:write"))],
)
def send(terminal_id: str, asset_id: str, request: Request):
return {
"operation_id": libraries.send(
terminal_id, asset_id, client_id=request.state.client_id
)
}