feat: organizer Android app and token-gated upload API
CI / lint-and-test (push) Failing after 1m18s
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>
This commit is contained in:
+180
@@ -0,0 +1,180 @@
|
||||
"""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}"
|
||||
Reference in New Issue
Block a user