"""FastAPI web frontend: browse jobs, view variants/intermediates, remix.""" from __future__ import annotations import logging import re from pathlib import Path from typing import Any from urllib.parse import quote from fastapi import FastAPI, Form, HTTPException, Request from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from . import config, pipeline, remix logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s [%(name)s] %(message)s") logger = logging.getLogger("livef12.web") app = FastAPI(title=config.SITE_TITLE) BASE_DIR = Path(__file__).resolve().parent templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static") _JOB_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$") def _validate_job_id(job_id: str) -> str: if not _JOB_ID_RE.match(job_id): raise HTTPException(status_code=400, detail="Ungueltige Job-ID") return job_id def _job_root(job_id: str) -> Path: root = (config.JOBS_DIR / _validate_job_id(job_id)).resolve() jobs_dir = config.JOBS_DIR.resolve() if root.parent != jobs_dir or not root.is_dir(): raise HTTPException(status_code=404, detail="Job nicht gefunden") return root def _safe_job_file(job_id: str, rel_path: str) -> Path: root = _job_root(job_id) candidate = (root / rel_path).resolve() if root not in candidate.parents and candidate != root: raise HTTPException(status_code=400, detail="Ungueltiger Pfad") if not candidate.is_file(): raise HTTPException(status_code=404, detail="Datei nicht gefunden") return candidate def _original_suffix(job_root: Path) -> str: for child in job_root.glob("original.*"): return child.suffix return ".jpg" def list_jobs() -> list[dict[str, Any]]: if not config.JOBS_DIR.exists(): return [] jobs = [] for job_dir in config.JOBS_DIR.iterdir(): if not job_dir.is_dir(): continue job_id = job_dir.name status = pipeline.read_status(job_id) or {} manifest = pipeline.read_manifest(job_id) or {} jobs.append( { "job_id": job_id, "status": status.get("status", "unknown"), "created_at": status.get("created_at") or manifest.get("created_at") or "", "variant_count": len(manifest.get("variants", [])), "thumbnail": (manifest.get("variants") or [{}])[-1].get("file") if manifest.get("variants") else None, } ) jobs.sort(key=lambda j: j["job_id"], reverse=True) return jobs @app.get("/", response_class=HTMLResponse) def index(request: Request) -> HTMLResponse: return templates.TemplateResponse( "index.html", {"request": request, "jobs": list_jobs(), "site_title": config.SITE_TITLE}, ) @app.get("/jobs/{job_id}", response_class=HTMLResponse) def job_detail(request: Request, job_id: str) -> HTMLResponse: job_root = _job_root(job_id) status = pipeline.read_status(job_id) or {} manifest = pipeline.read_manifest(job_id) or {"variants": []} original = next(iter(job_root.glob("original.*")), None) rembg_file = job_root / "rembg.png" intermediates = sorted((job_root / "intermediates").glob("*.png")) if (job_root / "intermediates").exists() else [] return templates.TemplateResponse( "job.html", { "request": request, "site_title": config.SITE_TITLE, "job_id": job_id, "status": status, "manifest": manifest, "original_name": original.name if original else None, "has_rembg": rembg_file.exists(), "intermediates": [p.name for p in intermediates], }, ) @app.get("/jobs/{job_id}/files/{rel_path:path}") def job_file(job_id: str, rel_path: str) -> FileResponse: path = _safe_job_file(job_id, rel_path) return FileResponse(path) @app.get("/jobs/{job_id}/remix", response_class=HTMLResponse) def remix_form(request: Request, job_id: str, error: str | None = None) -> HTMLResponse: job_root = _job_root(job_id) if not (job_root / "rembg.png").exists(): raise HTTPException(status_code=409, detail="Job hat noch kein Rembg-Ergebnis, Remix noch nicht moeglich.") assets = pipeline.load_assets() options = remix.build_remix_options(assets) return templates.TemplateResponse( "remix.html", { "request": request, "site_title": config.SITE_TITLE, "job_id": job_id, "options": options, "opacity_choices": config.OPACITY_CHOICES, "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: job_root = _job_root(job_id) suffix = _original_suffix(job_root) 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(job_id, suffix, choice) logger.info("[%s] remix created variant %s", job_id, entry["id"]) except pipeline.PipelineError as exc: logger.warning("[%s] remix failed: %s", job_id, exc) return RedirectResponse(url=f"/jobs/{job_id}/remix?error={quote(str(exc))}", status_code=303) return RedirectResponse(url=f"/jobs/{job_id}", status_code=303)