Files
livef12rocks/app/jobs.py
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

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}"