4195854bf8
Vier neue reine Bausteine, jeder mit Tests. Sie beantworten Fragen, die Rippy bisher geraten oder gar nicht gestellt hat. 1. caps.parse_preset_liste - die Preset-NAMEN, die das HandBrake DIESES Workers wirklich kennt (`--preset-list`, Format im Worker-Image gemessen). Damit endet das Raten: die Namen unterscheiden sich je HandBrake-Version, und ein erfundener Name laesst die Kompression scheitern. Richtigstellung zum SAVEPOINT v3.16: Dort galt es als unmoeglich, die Hardware-Preset-Namen auf der Rippy-VM zu ermitteln, weil dort kein Hardware-Encoder laeuft. Gemessen ist das falsch - die Kategorie `Hardware/` steht vollstaendig in der Liste (VCN, NVENC, QSV, MF). HandBrake trennt zwei Fragen: --preset-list nennt alle Presets, --help nur die nutzbaren Encoder. Zwei Fragen, zwei Quellen. 2. mounts.pfad_map_vorschlag/pfad_map_zeile - der fehlende Anschluss fuer RIPPY_PATH_MAP. Geraten werden muss dafuer nichts: Rippy hat die Freigabe selbst eingehaengt und kennt ihre Quelle (//host/share). Mountpunkt plus Quelle IST das Mapping. NFS gibt bewusst "" - Windows-Schreibweise ist nicht ableitbar (AGENTS Regel D). Der Test fand dabei sofort die dokumentierte Windows-Falle: os.path.join baute `/app/media\rippy` in einen Container-Pfad, das Mapping waere still wirkungslos geblieben. _mountpoint nutzt jetzt posixpath. 3. presets.empfehlung - "immer das Beste" (Commander-Anforderung) ohne Punktesystem: fuer jede Lage eine feste Reihenfolge echter Namen, genommen wird der erste, den der Worker kennt. Hardware nur, wenn die Familie wirklich gemeldet ist (vce -> Preset heisst VCN, sonst liefe eine AMD-Karte unter dem Intel-Namen). vaapi und MF werden nie empfohlen: fuer vaapi gibt es kein Preset, bei MF ist die Nutzbarkeit nicht ablesbar. 4. eta - Restzeit aus dem gemessenen Fortschritt. Messreihe je Phase (Rip und Kompression haben nichts miteinander zu tun), Stillstand verlaengert die Schaetzung, und unter zwei Messwerten oder 60 Sekunden Spanne gibt es ehrlich keine Aussage. Test mit den echten 25.07.-Werten: 1,44 % in 29 min landet bei "noch ca. 1 Tag 23 h" - genau die Angabe, deren Fehlen den 50-Stunden-Lauf unsichtbar machte. Dazu: HandBrakes "Invalid preset <Name>" wird uebersetzt. Vorher stand im UI nur "HandBrake endete mit Code 3" - dass der Preset-NAME das Problem ist, war daraus nicht zu erraten. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
137 lines
5.5 KiB
Python
137 lines
5.5 KiB
Python
"""Restzeit-Schätzung für laufende Jobs — aus dem gemessenen Fortschritt.
|
|
|
|
Commander-Anforderung 26.07.2026: *„Der Server Status muss dringend überarbeitet
|
|
werden, das Dashboard soll ja quasi alles auf einen Blick zeigen"* — dazu eine
|
|
**ETA** im Dashboard und beim externen Worker.
|
|
|
|
## Warum hier und nicht im Browser
|
|
|
|
Der naheliegende Weg wäre, den Fortschritt im UI mitzuschreiben. Drei Gründe
|
|
dagegen: Ein Seitenwechsel setzt die Messreihe zurück; zwei offene Browser
|
|
zeigten verschiedene Zahlen; und die Kompression läuft womöglich auf einer
|
|
ANDEREN Maschine (genau dort wollte der Commander die ETA sehen). Die Reihe
|
|
gehört also dorthin, wo der Fortschritt ankommt.
|
|
|
|
## Warum nicht einfach „vergangene Zeit / Prozent"
|
|
|
|
Weil ein Job zwei völlig verschiedene Phasen hat: Der Rip dauert rund eine
|
|
Stunde, die Kompression Stunden bis Tage (auf der Rippy-VM gemessene 28-55 h je
|
|
4K-Film). Aus dem Gesamt-Mittel entstünde bei jedem Phasenwechsel eine
|
|
haarsträubende Zahl. Deshalb wird die Messreihe bei JEDEM Statuswechsel
|
|
verworfen und die Rate nur innerhalb der laufenden Phase bestimmt.
|
|
|
|
## Ehrlichkeit vor Zahl
|
|
|
|
Lieber „wird noch geschätzt" als eine erfundene Minute:
|
|
|
|
- Unter MINDEST_PUNKTE Messwerten gibt es keine Schätzung.
|
|
- Die Reihe muss MINDEST_SPANNE_SEKUNDEN abdecken UND MINDEST_FORTSCHRITT
|
|
Prozentpunkte gestiegen sein — bei 4K bewegt sich eine halbe Stunde lang
|
|
nichts, daraus ließe sich sonst „fertig in 3 Minuten" ableiten.
|
|
- Nur die letzten FENSTER Punkte zählen: HandBrake wird bei komplexen Szenen
|
|
langsamer, die frühen Werte lügen dann.
|
|
- Über OBERGRENZE_SEKUNDEN wird nicht mehr aufs Detail gerechnet, sondern
|
|
„mehr als 2 Tage" gesagt. Eine Zahl wie „51:23 h" wirkt genau, ist es aber
|
|
nicht.
|
|
"""
|
|
|
|
MINDEST_PUNKTE = 2
|
|
MINDEST_SPANNE_SEKUNDEN = 60
|
|
MINDEST_FORTSCHRITT = 1
|
|
FENSTER = 10
|
|
OBERGRENZE_SEKUNDEN = 48 * 3600
|
|
|
|
# Status, für die eine Restzeit überhaupt Sinn hat.
|
|
LAUFENDE_STATUS = ("running", "processing", "ripping", "transcoding")
|
|
|
|
|
|
def beobachtung_hinzufuegen(reihe, status: str, progress: int, jetzt: float) -> dict:
|
|
"""Neuen Messpunkt anfügen (pure Funktion) → die aktualisierte Reihe.
|
|
|
|
`reihe` ist {"status": str, "punkte": [[zeit, prozent], ...]} oder None.
|
|
Bei Statuswechsel beginnt die Reihe neu — siehe Modul-Doku (Rip und
|
|
Kompression haben nichts miteinander zu tun).
|
|
|
|
Ein unveränderter Fortschritt wird NICHT als neuer Punkt angefügt, aber der
|
|
letzte Punkt behält seine ursprüngliche Zeit. Das ist wichtig: Ein Stillstand
|
|
verlängert damit automatisch die gemessene Spanne und macht die Schätzung
|
|
langsamer — genau richtig, denn er heißt ja, dass es langsam vorangeht.
|
|
"""
|
|
alt = reihe or {}
|
|
punkte = list(alt.get("punkte") or []) if alt.get("status") == status else []
|
|
if not punkte or punkte[-1][1] != progress:
|
|
punkte.append([jetzt, progress])
|
|
return {"status": status, "punkte": punkte[-FENSTER:]}
|
|
|
|
|
|
def restzeit_sekunden(reihe, jetzt: float) -> int:
|
|
"""Geschätzte Restzeit in Sekunden — oder -1 für „noch keine Aussage".
|
|
|
|
-1 statt None, damit der Wert unverändert durch JSON und das
|
|
Antwort-Modell passt (dasselbe Muster wie get_progress_from_line im
|
|
Worker, wo -1 „keine Angabe" heißt).
|
|
"""
|
|
punkte = (reihe or {}).get("punkte") or []
|
|
if len(punkte) < MINDEST_PUNKTE:
|
|
return -1
|
|
erste_zeit, erster_prozent = punkte[0]
|
|
letzte_zeit, letzter_prozent = punkte[-1]
|
|
# Die Spanne bis JETZT, nicht bis zum letzten Punkt: sonst zeigt ein
|
|
# stehender Job dauerhaft die Rate von vor drei Stunden.
|
|
spanne = max(jetzt, letzte_zeit) - erste_zeit
|
|
gewachsen = letzter_prozent - erster_prozent
|
|
if spanne < MINDEST_SPANNE_SEKUNDEN or gewachsen < MINDEST_FORTSCHRITT:
|
|
return -1
|
|
rest_prozent = 100 - letzter_prozent
|
|
if rest_prozent <= 0:
|
|
return 0
|
|
pro_prozent = spanne / gewachsen
|
|
return int(rest_prozent * pro_prozent)
|
|
|
|
|
|
def formatiere_restzeit(sekunden: int) -> str:
|
|
"""Restzeit als Text fürs UI. Leer, wenn es keine Aussage gibt."""
|
|
if sekunden is None or sekunden < 0:
|
|
return ""
|
|
if sekunden > OBERGRENZE_SEKUNDEN:
|
|
return "mehr als 2 Tage"
|
|
if sekunden < 60:
|
|
return "unter einer Minute"
|
|
minuten = sekunden // 60
|
|
if minuten < 60:
|
|
return f"noch ca. {minuten} min"
|
|
stunden, rest_minuten = divmod(minuten, 60)
|
|
if stunden < 24:
|
|
return f"noch ca. {stunden} h {rest_minuten:02d} min"
|
|
tage, rest_stunden = divmod(stunden, 24)
|
|
return f"noch ca. {tage} Tag{'e' if tage > 1 else ''} {rest_stunden} h"
|
|
|
|
|
|
def schluessel(job_id: str) -> str:
|
|
return f"eta:{job_id}"
|
|
|
|
|
|
def aktualisiere_und_schaetze(job_id: str, status: str, progress: int,
|
|
jetzt: float, cache_get, cache_set) -> dict:
|
|
"""Messreihe im Cache fortschreiben und die Restzeit zurückgeben.
|
|
|
|
Der Cache (Redis) ist schon da und überlebt einen Neustart des UI. Fällt er
|
|
aus, kommt bei jedem Aufruf eine leere Reihe zurück — dann gibt es eben
|
|
keine ETA, aber nichts scheitert.
|
|
"""
|
|
if status not in LAUFENDE_STATUS or progress <= 0:
|
|
return {"sekunden": -1, "text": ""}
|
|
k = schluessel(job_id)
|
|
try:
|
|
reihe = cache_get(k)
|
|
except Exception:
|
|
reihe = None
|
|
reihe = beobachtung_hinzufuegen(reihe, status, progress, jetzt)
|
|
try:
|
|
# Eine Reihe ohne Fortschritt ist nach einem Tag wertlos.
|
|
cache_set(k, reihe, expire=86400)
|
|
except Exception:
|
|
pass
|
|
sekunden = restzeit_sekunden(reihe, jetzt)
|
|
return {"sekunden": sekunden, "text": formatiere_restzeit(sekunden)}
|