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 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-26 12:58:06 +02:00
parent 4195854bf8
commit 8b7bdfe798
+164 -6
View File
@@ -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/<job>`). 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/<name>=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.