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