Files
livef12rocks/app/main.py
T
Frank Schwenk c03f17488c
CI / lint-and-test (push) Failing after 1m18s
feat: organizer Android app and token-gated upload API
Replace Syncthing as the product upload path with a share-target app
and POST /api/jobs. Web UI live-polls job progress.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 21:38:50 +02:00

210 lines
7.4 KiB
Python

"""FastAPI web frontend: browse jobs, view variants/intermediates, remix."""
from __future__ import annotations
import logging
from pathlib import Path
from urllib.parse import quote
from fastapi import FastAPI, Form, HTTPException, Query, Request
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from . import api, config, images, jobs, pipeline, remix
from .logging_config import configure_logging
configure_logging()
logger = logging.getLogger("livef12.web")
app = FastAPI(title=config.SITE_TITLE)
app.include_router(api.router)
BASE_DIR = Path(__file__).resolve().parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
templates.env.globals["img_attrs"] = images.img_attrs
templates.env.globals["PAIR_SIZES"] = images.PAIR_SIZES
templates.env.globals["GRID_SIZES"] = images.GRID_SIZES
templates.env.globals["status_label"] = jobs.status_label
templates.env.globals["stage_label"] = jobs.stage_label
templates.env.globals["ACTIVE_STATUSES"] = jobs.ACTIVE_STATUSES
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
def _validate_job_id(job_id: str) -> str:
try:
return jobs.validate_job_id(job_id)
except jobs.JobIdError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
def _job_exists(job_id: str) -> str:
return jobs.require_job_id(job_id)
def _safe_job_file(job_id: str, filename: str) -> Path:
"""Resolve a basename under variants/ or intermediates/ for this stem."""
stem = _validate_job_id(job_id)
name = Path(filename).name
if name != filename or "/" in filename or "\\" in filename or name.startswith("."):
raise HTTPException(status_code=400, detail="Ungültiger Pfad")
if not name.startswith(f"{stem}_"):
raise HTTPException(status_code=400, detail="Ungültiger Pfad")
for directory in (config.VARIANTS_DIR, config.INTERMEDIATES_DIR):
candidate = (directory / name).resolve()
if directory.resolve() not in candidate.parents and candidate != directory.resolve():
continue
if candidate.is_file():
return candidate
raise HTTPException(status_code=404, detail="Datei nicht gefunden")
@app.get("/", response_class=HTMLResponse)
def index(request: Request) -> HTMLResponse:
job_list = jobs.list_jobs()
return templates.TemplateResponse(
request,
"index.html",
{
"jobs": job_list,
"site_title": config.SITE_TITLE,
"has_active": any(j["status"] in jobs.ACTIVE_STATUSES for j in job_list),
},
)
@app.get("/jobs/{job_id}", response_class=HTMLResponse)
def job_detail(request: Request, job_id: str) -> HTMLResponse:
stem = _job_exists(job_id)
paths = pipeline.job_paths(stem)
status = pipeline.read_status(stem) or {}
manifest = pipeline.read_manifest(stem) or {"variants": []}
intermediates = jobs.list_intermediates(stem)
return templates.TemplateResponse(
request,
"job.html",
{
"site_title": config.SITE_TITLE,
"job_id": stem,
"status": status,
"manifest": manifest,
"original_name": paths.original.name if paths.original.exists() else None,
"rembg_name": paths.rembg.name if paths.rembg.exists() else None,
"has_rembg": paths.rembg.exists(),
"intermediates": intermediates,
"live": (status.get("status") or "") in jobs.ACTIVE_STATUSES,
},
)
@app.get("/jobs/{job_id}/files/{filename:path}")
def job_file(
job_id: str,
filename: str,
w: int | None = Query(None, description="Longest-edge width for srcset WebP"),
) -> FileResponse:
path = _safe_job_file(job_id, filename)
if w is None:
return FileResponse(path)
if w not in images.SRCSET_WIDTHS:
raise HTTPException(
status_code=400,
detail=f"Ungültige Breite (erlaubt: {', '.join(map(str, images.SRCSET_WIDTHS))})",
)
try:
cached = images.get_or_create_resized(path, w)
except OSError as exc:
logger.warning("resize failed for %s w=%s: %s", path.name, w, exc)
raise HTTPException(status_code=500, detail="Bild konnte nicht skaliert werden") from exc
return FileResponse(cached, media_type="image/webp", filename=cached.name)
@app.get("/jobs/{job_id}/remix", response_class=HTMLResponse)
def remix_form(
request: Request,
job_id: str,
error: str | None = None,
from_variant: str | None = Query(None, alias="from"),
) -> HTMLResponse:
stem = _job_exists(job_id)
paths = pipeline.job_paths(stem)
if not paths.rembg.exists():
raise HTTPException(
status_code=409,
detail="Job hat noch kein Rembg-Ergebnis, Remix noch nicht möglich.",
)
assets = pipeline.load_assets()
options = remix.build_remix_options(assets)
prefill: dict[str, str] = {
"bg_filter": "",
"bg_blend": "",
"fg_filter": "",
"fg_blend": "",
"opacity": config.BLEND_OPACITY if config.BLEND_OPACITY in config.OPACITY_CHOICES else "30%",
}
if from_variant:
manifest = pipeline.read_manifest(stem) or {}
match = next((v for v in manifest.get("variants", []) if v.get("id") == from_variant), None)
if match:
opacity = match.get("blend_opacity") or prefill["opacity"]
if opacity not in config.OPACITY_CHOICES:
try:
pct = int(str(opacity).rstrip("%"))
nearest = min(
config.OPACITY_CHOICES,
key=lambda c: abs(int(c.rstrip("%")) - pct),
)
opacity = nearest
except ValueError:
opacity = prefill["opacity"]
prefill = {
"bg_filter": match.get("background_filter") or "",
"bg_blend": match.get("background_blend") or "",
"fg_filter": match.get("foreground_filter") or "",
"fg_blend": match.get("foreground_blend") or "",
"opacity": opacity,
}
return templates.TemplateResponse(
request,
"remix.html",
{
"site_title": config.SITE_TITLE,
"job_id": stem,
"options": options,
"opacity_choices": config.OPACITY_CHOICES,
"prefill": prefill,
"from_variant": from_variant,
"error": error,
},
)
@app.post("/jobs/{job_id}/remix")
def remix_submit(
job_id: str,
bg_filter: str = Form(...),
bg_blend: str = Form(...),
fg_filter: str = Form(...),
fg_blend: str = Form(...),
opacity: str = Form(...),
) -> RedirectResponse:
stem = _job_exists(job_id)
choice = remix.RemixChoice(
bg_filter=bg_filter,
bg_blend=bg_blend,
fg_filter=fg_filter,
fg_blend=fg_blend,
opacity=opacity,
)
try:
entry = remix.create_remix_variant(stem, choice)
logger.info("[%s] remix created variant %s", stem, entry["id"])
except pipeline.PipelineError as exc:
logger.warning("[%s] remix failed: %s", stem, exc)
return RedirectResponse(url=f"/jobs/{stem}/remix?error={quote(str(exc))}", status_code=303)
return RedirectResponse(url=f"/jobs/{stem}", status_code=303)