Files

381 lines
13 KiB
Python
Raw Permalink Normal View History

import hashlib
from fastapi import Depends, File, Query, Request, UploadFile
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.photo_sources import PhotoSourceInput
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 UploadURLInput(StrictModel):
url: str = Field(min_length=1, max_length=2048)
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()
]
def save_upload(source_id, data, name):
if len(data) > service.settings.max_bytes:
raise FlowError(E.INVALID_REQUEST, "Image exceeds size limit")
with service.lock:
if source_id == "manual-uploads" and not db.one(
"SELECT key FROM app_config WHERE key='photo_source:manual-uploads'"
):
from pigway_frame_flow.renderers.images import convert
convert(data, size=(1, 1))
service.photo_sources.save(
source_id,
PhotoSourceInput(name="手动上传", kind="uploads", location="."),
)
if not service.photo_sources.get(source_id).enabled:
raise FlowError(E.CONFLICT, "Photo source is disabled", 409)
return libraries.ingest(
source_id, "upload:" + hashlib.sha256(data).hexdigest(), data, name, {}
)
@app.post(
"/api/v1/source-libraries/{source_id}/upload",
dependencies=[Depends(require("images:write"))],
)
def upload(source_id: str, file: UploadFile = File(...)):
return save_upload(
source_id,
file.file.read(service.settings.max_bytes + 1),
file.filename or "upload.jpg",
)
@app.post(
"/api/v1/source-libraries/{source_id}/upload-url",
dependencies=[Depends(require("images:write"))],
)
def upload_url(source_id: str, payload: UploadURLInput):
return save_upload(
source_id, service.assets.fetch_url(payload.url), "url-upload.jpg"
)
@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("images:write"))],
)
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("images:write"))],
)
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
)
}