From 8b7bdfe7980cbb24be9bd81488e943e342b080de Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Sun, 26 Jul 2026 12:58:06 +0200 Subject: [PATCH] feat(api): drei Endpunkte, die das Raten beenden - plus Restzeit in /jobs GET /worker-setup/pfad-map - was ein externer Worker fuer RIPPY_PATH_MAP eintragen muss, abgeleitet aus Rippys eigenen Mounts. DAS war der stille Blocker: /app/media/... sind Container-Pfade, ein externer Worker sieht sie nur uebersetzt, und dieses Mapping setzte NIEMAND. Externes Encoden konnte deshalb nie funktionieren, obwohl das Celery-Routing einwandfrei arbeitete (Job 95afdc89: angenommen, 182 ms spaeter abgelehnt). Hat Rippy keine Freigabe, sagt der Endpunkt das im Klartext samt Abhilfe - statt ein leeres Mapping zu liefern. GET /presets - Preset-Namen der Worker plus Empfehlung MIT Begruendung. Die Namen standen bisher fest verdrahtet an vier Stellen im UI. GET /jobs/{id}/rohdaten + DELETE /jobs/{id}?rohdaten=true - was liegen bleibt, wenn ein Job aus der Liste fliegt. Grund (v3.14): Entfernen loescht bewusst keine Dateien, aber Job und Rohdaten haengen nur an der Job-ID - der Rohschnitt ist danach UNERREICHBAR. Damals verwaisten so 75 GB unsichtbar, gefunden erst per SSH. /jobs liefert jetzt eta_sekunden + eta_text. Die Schaetzung entsteht in der API und nicht im Browser, aus drei Gruenden: ein Seitenwechsel setzte die Messreihe zurueck, zwei offene Tabs zeigten verschiedene Zahlen, und fuer einen externen Encoder-Worker gaebe es gar keine - genau dort wollte der Commander sie sehen. Co-Authored-By: Claude Opus 5 --- docker/api/main.py | 170 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 164 insertions(+), 6 deletions(-) diff --git a/docker/api/main.py b/docker/api/main.py index 185e74a..7edbcd6 100644 --- a/docker/api/main.py +++ b/docker/api/main.py @@ -12,15 +12,17 @@ import uuid import db import devices as device_discovery +import eta import makemkv_daten import makemkv_key import mounts as mount_verwaltung import notify +import presets as preset_auswahl from celery_client import celery_client, start_rip from detection import CDS_DISC_OK, CDS_NO_DISC, CDS_TRAY_OPEN, drive_status from config_validation import validate_config, ConfigValidationError -from cache import init_cache +from cache import get as cache_get, init_cache, set as cache_set from ratelimit import check_rate_limit, get_rate_limit_remaining from prescan import PreScan @@ -253,6 +255,10 @@ class Job(BaseModel): error: Optional[str] = None can_retry: bool = False # Rohdaten vorhanden → „Neu komprimieren" sinnvoll meta: Optional[Dict] = None # Disc-Metadaten (Poster/Jahr/Plot) — fürs Thumbnail in der Jobliste + aktivem Rip-Header + # Restzeit-Schätzung (siehe eta.py). -1/"" heißt „noch keine Aussage" — + # bewusst ehrlich statt einer erfundenen Minutenzahl. + eta_sekunden: int = -1 + eta_text: str = "" class Device(BaseModel): id: str @@ -314,30 +320,119 @@ def _kann_neu_komprimieren(job: dict, work_dir: str) -> bool: @app.get("/jobs", response_model=List[Job]) async def get_jobs(): - """Holt alle Jobs aus der Datenbank (neueste zuerst).""" + """Holt alle Jobs aus der Datenbank (neueste zuerst) — inkl. Restzeit. + + Die ETA entsteht HIER, weil hier der Fortschritt vorbeikommt: jeder Aufruf + schreibt die Messreihe im Cache fort (eta.py). Im Browser zu rechnen wäre + einfacher gewesen und dreifach schlechter — ein Seitenwechsel setzte die + Reihe zurück, zwei Tabs zeigten verschiedene Zahlen, und für einen externen + Encoder-Worker gäbe es gar keine (genau dort wollte der Commander sie). + """ def sammle(): work_dir = os.path.normpath((db.get_settings().get("workDir") or "").strip() or "/") + jetzt = time.monotonic() modelle = [] for z in db.list_jobs(): modell = _job_row_to_model(z) modell.can_retry = _kann_neu_komprimieren(z, work_dir) + schaetzung = eta.aktualisiere_und_schaetze( + z["id"], z.get("status") or "", z.get("progress") or 0, + jetzt, cache_get, cache_set, + ) + modell.eta_sekunden = schaetzung["sekunden"] + modell.eta_text = schaetzung["text"] modelle.append(modell) return modelle return await asyncio.to_thread(sammle) +def _rohdaten_verzeichnisse(job_id: str, work_dir: str) -> list: + """Wo könnten Roh-MKVs dieses Jobs liegen? (beide möglichen Orte)""" + kandidaten = [os.path.join("/app/temp/raw", job_id)] + if unter_wurzel(work_dir, MEDIA_ROOT): + kandidaten.append(os.path.join(work_dir, job_id)) + return [p for p in kandidaten if os.path.isdir(p)] + + +def _rohdaten_groesse(pfade: list) -> tuple: + """(Bytes, Dateizahl) der Roh-MKVs — nur die Dateien, nicht rekursiv tief.""" + bytes_gesamt, dateien = 0, 0 + for pfad in pfade: + try: + for name in os.listdir(pfad): + voll = os.path.join(pfad, name) + if os.path.isfile(voll): + bytes_gesamt += os.path.getsize(voll) + dateien += 1 + except OSError: + continue + return bytes_gesamt, dateien + + +@app.get("/jobs/{job_id}/rohdaten") +async def job_rohdaten(job_id: str): + """Was bleibt liegen, wenn dieser Job aus der Liste fliegt? + + ⚠️ Der Grund für diesen Endpunkt (Vorfall 25.07.2026, v3.14): „Job aus der + Liste entfernen" löscht bewusst keine Dateien — richtig, aber der Rohschnitt + ist danach UNERREICHBAR, weil Job und Dateien nur über die Job-ID verbunden + sind. Ein 75-GB-Rohschnitt verwaiste so unsichtbar auf der Platte: kein + Eintrag zeigte mehr darauf, „Neu komprimieren" war unmöglich, und im UI war + nichts davon zu sehen. Erst ein Blick per SSH brachte es zutage. + + Jetzt fragt das UI vor dem Entfernen hier nach und sagt die Zahl. + """ + job = await asyncio.to_thread(db.get_job, job_id) + if not job: + raise HTTPException(status_code=404, detail="Job nicht gefunden") + + def sammle(): + work_dir = os.path.normpath((db.get_settings().get("workDir") or "").strip() or "/") + pfade = _rohdaten_verzeichnisse(job_id, work_dir) + bytes_gesamt, dateien = _rohdaten_groesse(pfade) + return { + "pfade": pfade, + "dateien": dateien, + "bytes": bytes_gesamt, + "gb": round(bytes_gesamt / 1024**3, 1), + } + + return await asyncio.to_thread(sammle) + + @app.delete("/jobs/{job_id}") -async def delete_job(job_id: str): - """Entfernt einen erledigten Job aus der Liste (Dateien bleiben liegen).""" +async def delete_job(job_id: str, rohdaten: bool = False): + """Entfernt einen erledigten Job aus der Liste. + + `rohdaten=true` löscht zusätzlich die Roh-MKVs. Ohne den Schalter bleiben + sie liegen (Standard wie bisher) — das UI nennt vorher die Größe, damit die + Entscheidung bewusst fällt und kein Rohschnitt unsichtbar verwaist. + """ job = await asyncio.to_thread(db.get_job, job_id) if not job: raise HTTPException(status_code=404, detail="Job nicht gefunden") if job["status"] not in ("completed", "failed"): raise HTTPException(status_code=409, detail="Job läuft noch — erst abbrechen") + + geloescht_gb = 0.0 + if rohdaten: + def raeume(): + work_dir = os.path.normpath((db.get_settings().get("workDir") or "").strip() or "/") + pfade = _rohdaten_verzeichnisse(job_id, work_dir) + bytes_gesamt, _ = _rohdaten_groesse(pfade) + for pfad in pfade: + shutil.rmtree(pfad, ignore_errors=True) + return round(bytes_gesamt / 1024**3, 1) + + geloescht_gb = await asyncio.to_thread(raeume) + await asyncio.to_thread(db.delete_job, job_id) - await asyncio.to_thread(db.add_log, "info", "api", f"Job {job_id} aus der Liste entfernt") - return {"status": "deleted"} + vermerk = f" (inkl. {geloescht_gb} GB Rohdaten gelöscht)" if rohdaten else "" + await asyncio.to_thread( + db.add_log, "info", "api", f"Job {job_id} aus der Liste entfernt{vermerk}" + ) + return {"status": "deleted", "rohdaten_geloescht_gb": geloescht_gb} @app.delete("/jobs") @@ -768,6 +863,24 @@ async def capabilities(): return {"workers": await asyncio.to_thread(sammle)} +@app.get("/presets") +async def preset_uebersicht(): + """Welche HandBrake-Presets es WIRKLICH gibt — und welches das beste ist. + + Die Namen kommen von den Workern selbst (`HandBrakeCLI --preset-list`, siehe + worker/caps.py), die Staffelung aus presets.py. Damit endet das Raten: + vorher standen die Namen fest verdrahtet im UI, und ein Name, den das + jeweilige HandBrake nicht kennt, ließ die Kompression scheitern. + + Commander-Anforderung 26.07.2026: „Bei den Presets soll IMMER das Beste + ausgewählt werden" — die Begründung steht bei jeder Empfehlung mit dabei. + """ + def sammle(): + return preset_auswahl.uebersicht(db.list_workers()) + + return await asyncio.to_thread(sammle) + + @app.delete("/workers/{name}") async def delete_worker(name: str): """Verwaisten Worker-Eintrag entfernen (alte Container-IDs nach Rebuilds). @@ -1201,6 +1314,51 @@ async def worker_setup_windows_exe(): ) +@app.get("/worker-setup/pfad-map") +async def worker_setup_pfad_map(): + """Was ein externer Worker für `RIPPY_PATH_MAP` eintragen muss. + + DER fehlende Anschluss (Befund 26.07.2026): Ein Windows-Worker bekommt von + Rippy Container-Pfade (`/app/media/rippy/`). Ohne Übersetzung auf eine + Freigabe sieht er sie nicht — und das Mapping setzte niemand. Externes + Encoden konnte deshalb nie funktionieren, obwohl das Celery-Routing + einwandfrei arbeitete (Job 95afdc89: angenommen, 182 ms später abgelehnt). + + Geraten wird hier nichts: Rippy hat die Freigabe selbst eingehängt und + kennt ihre Quelle. Der Installer holt den Vorschlag von hier und schreibt + ihn in start-tray.bat — der Nutzer muss nichts über Container-Pfade wissen. + """ + def sammle(): + vorschlaege = mount_verwaltung.pfad_map_vorschlag(db.list_mounts()) + mapping = mount_verwaltung.pfad_map_zeile(vorschlaege) + if mapping: + hinweis = ( + "Diese Freigabe(n) hängt Rippy selbst ein — der Worker erreicht " + "sie unter demselben Namen im Netzwerk. Wichtig: Arbeits" + "verzeichnis UND Ablage müssen darunter liegen, sonst kann der " + "Worker lesen, aber nicht schreiben (oder umgekehrt)." + ) + elif vorschlaege: + hinweis = ( + 'Es sind nur NFS-Ziele eingehängt. Windows kann NFS zwar über ' + 'die Funktion „Client für NFS" einbinden, die Pfad-Schreibweise ' + 'lässt sich aber nicht zuverlässig ableiten — bitte selbst ' + 'eintragen, Format: /app/media/=Z:\\ (oder UNC-Pfad).' + ) + else: + hinweis = ( + "Rippy hat keine Netzwerk-Freigabe eingehängt. Ein externer " + "Worker kann dann NICHTS komprimieren: Rohdaten und Ziel liegen " + "auf der Platte der Rippy-Maschine, und dorthin gibt es keinen " + "Netzwerk-Zugang. Abhilfe: unter Einstellungen → Speicherziele " + "eine Freigabe (NAS oder PC) einhängen und sie als " + "Arbeitsverzeichnis UND Ablage wählen." + ) + return {"mapping": mapping, "eintraege": vorschlaege, "hinweis": hinweis} + + return await asyncio.to_thread(sammle) + + @app.get("/worker-setup/paket") async def worker_setup_paket(): """Worker-Quellcode als Zip — der Windows-Installer lädt ihn von hier.