c03f17488c
CI / lint-and-test (push) Failing after 1m18s
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>
181 lines
5.8 KiB
Python
181 lines
5.8 KiB
Python
"""Job listing, labels, and JSON payloads shared by the HTML UI and /api."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import uuid
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from fastapi import HTTPException
|
|
|
|
from . import config, images, pipeline
|
|
|
|
_JOB_ID_RE = re.compile(r"^[A-Za-z0-9._-]+$")
|
|
|
|
STATUS_LABELS: dict[str, str] = {
|
|
"queued": "Warteschlange",
|
|
"processing": "Läuft",
|
|
"done": "Fertig",
|
|
"error": "Fehler",
|
|
"unknown": "unbekannt",
|
|
}
|
|
|
|
ACTIVE_STATUSES = frozenset({"queued", "processing", "pending"})
|
|
|
|
|
|
class JobIdError(ValueError):
|
|
"""Invalid or missing job id."""
|
|
|
|
|
|
def validate_job_id(job_id: str) -> str:
|
|
raw = job_id or ""
|
|
if not raw.strip() or "/" in raw or "\\" in raw or ".." in raw:
|
|
raise JobIdError("Ungültige Job-ID")
|
|
stem = pipeline.sanitize_stem(raw)
|
|
if not _JOB_ID_RE.match(stem):
|
|
raise JobIdError("Ungültige Job-ID")
|
|
return stem
|
|
|
|
|
|
def require_job_id(job_id: str) -> str:
|
|
try:
|
|
stem = validate_job_id(job_id)
|
|
except JobIdError as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
if pipeline.read_status(stem) is None and not pipeline.job_paths(stem).original.exists():
|
|
raise HTTPException(status_code=404, detail="Job nicht gefunden")
|
|
return stem
|
|
|
|
|
|
def status_label(status: str | None) -> str:
|
|
key = (status or "unknown").strip() or "unknown"
|
|
return STATUS_LABELS.get(key, key)
|
|
|
|
|
|
def stage_label(status: str | None, stage: str | None, progress: dict[str, Any] | None) -> str:
|
|
if status == "queued":
|
|
return "Wartet auf Worker"
|
|
if status == "done":
|
|
return "Fertig"
|
|
if status == "error":
|
|
return "Fehler"
|
|
if stage == "preprocess":
|
|
return "Vorverarbeitung"
|
|
if stage == "rembg":
|
|
return "Freistellen"
|
|
if stage == "variant":
|
|
current = (progress or {}).get("current")
|
|
total = (progress or {}).get("total")
|
|
if current and total:
|
|
return f"Variante {current}/{total}"
|
|
return "Varianten"
|
|
if status == "processing":
|
|
return "Läuft"
|
|
return status_label(status)
|
|
|
|
|
|
def file_url(job_id: str, filename: str | None) -> str | None:
|
|
if not filename:
|
|
return None
|
|
return images.file_url(job_id, filename)
|
|
|
|
|
|
def list_intermediates(stem: str) -> list[str]:
|
|
paths = pipeline.job_paths(stem)
|
|
if not paths.intermediates.exists():
|
|
return []
|
|
return sorted(p.name for p in paths.intermediates.glob(f"{stem}_*.png") if p.is_file())
|
|
|
|
|
|
def _progress(data: dict[str, Any]) -> dict[str, int] | None:
|
|
raw = data.get("progress")
|
|
if not isinstance(raw, dict):
|
|
return None
|
|
try:
|
|
current = int(raw.get("current", 0))
|
|
total = int(raw.get("total", 0))
|
|
except (TypeError, ValueError):
|
|
return None
|
|
if total <= 0:
|
|
return None
|
|
return {"current": current, "total": total}
|
|
|
|
|
|
def _file_entry(job_id: str, filename: str | None) -> dict[str, str] | None:
|
|
if not filename:
|
|
return None
|
|
return {"file": filename, "url": images.file_url(job_id, filename)}
|
|
|
|
|
|
def summarize_job(job_id: str, data: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
meta = data if data is not None else (pipeline.read_meta(job_id) or {})
|
|
variants = meta.get("variants") or []
|
|
status = meta.get("status", "unknown")
|
|
progress = _progress(meta)
|
|
stage = meta.get("stage")
|
|
thumb = variants[-1].get("file") if variants else None
|
|
return {
|
|
"job_id": job_id,
|
|
"status": status,
|
|
"status_label": status_label(status),
|
|
"stage": stage,
|
|
"stage_label": stage_label(status, stage if isinstance(stage, str) else None, progress),
|
|
"progress": progress,
|
|
"created_at": meta.get("created_at") or "",
|
|
"updated_at": meta.get("updated_at") or "",
|
|
"variant_count": len(variants),
|
|
"thumbnail": thumb,
|
|
"thumbnail_url": file_url(job_id, thumb),
|
|
"error": meta.get("error"),
|
|
}
|
|
|
|
|
|
def list_jobs() -> list[dict[str, Any]]:
|
|
jobs = [summarize_job(job_id) for job_id in pipeline.list_job_ids()]
|
|
jobs.sort(key=lambda j: j["created_at"] or j["job_id"], reverse=True)
|
|
return jobs
|
|
|
|
|
|
def job_detail_payload(job_id: str) -> dict[str, Any]:
|
|
stem = require_job_id(job_id)
|
|
meta = pipeline.read_meta(stem) or {}
|
|
paths = pipeline.job_paths(stem)
|
|
summary = summarize_job(stem, meta)
|
|
variants = []
|
|
for item in meta.get("variants") or []:
|
|
if not isinstance(item, dict) or not item.get("file"):
|
|
continue
|
|
entry = dict(item)
|
|
entry["url"] = images.file_url(stem, item["file"])
|
|
variants.append(entry)
|
|
intermediates = [_file_entry(stem, name) for name in list_intermediates(stem)]
|
|
original_name = paths.original.name if paths.original.exists() else meta.get("original_file")
|
|
rembg_name = paths.rembg.name if paths.rembg.exists() else meta.get("rembg_file")
|
|
summary.update(
|
|
{
|
|
"original": _file_entry(stem, original_name if original_name else None),
|
|
"rembg": _file_entry(stem, rembg_name if rembg_name else None),
|
|
"has_rembg": paths.rembg.exists(),
|
|
"variants": variants,
|
|
"intermediates": [item for item in intermediates if item],
|
|
"variant_errors": meta.get("variant_errors") or [],
|
|
"source_file": meta.get("source_file"),
|
|
}
|
|
)
|
|
return summary
|
|
|
|
|
|
def make_inbox_filename(original_name: str) -> str:
|
|
"""Unique inbox name so two party.jpg uploads don't clobber each other."""
|
|
stem = pipeline.sanitize_stem(original_name)
|
|
suffix = Path(original_name).suffix.lower()
|
|
if suffix not in config.SUPPORTED_EXTENSIONS:
|
|
suffix = ".jpg"
|
|
if suffix == ".jpeg":
|
|
suffix = ".jpg"
|
|
stamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S")
|
|
short = uuid.uuid4().hex[:6]
|
|
return f"{stamp}_{short}_{stem}{suffix}"
|