diff --git a/docker/api/eta.py b/docker/api/eta.py new file mode 100644 index 0000000..83a7292 --- /dev/null +++ b/docker/api/eta.py @@ -0,0 +1,136 @@ +"""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)} diff --git a/docker/api/mounts.py b/docker/api/mounts.py index de943db..9a9703f 100644 --- a/docker/api/mounts.py +++ b/docker/api/mounts.py @@ -11,6 +11,7 @@ sondern über eine temporäre credentials-Datei). """ import os +import posixpath import re import subprocess import tempfile @@ -22,7 +23,16 @@ NAME_MUSTER = re.compile(r"^[a-z0-9][a-z0-9-]{1,30}$") def _mountpoint(name: str) -> str: - return os.path.join(MEDIA_ROOT, name) + """Mountpunkt eines Speicherziels — IMMER ein Container-Pfad. + + posixpath statt os.path (Befund 26.07.2026): Innerhalb des api-Containers + ist das dasselbe, aber `pfad_map_vorschlag()` liefert diesen Pfad an einen + WINDOWS-Worker aus. Mit os.path.join entstand beim Test unter Windows + `/app/media\\rippy`, und `pfad_lokal()` fand dann kein Präfix — das Mapping + wäre still wirkungslos geblieben. Dieselbe Falle wie bei `_zielbasis()` im + Worker (v3.14), diesmal vom Test gefunden statt live. + """ + return posixpath.join(MEDIA_ROOT, name) def validiere_name(name: str) -> bool: @@ -161,6 +171,65 @@ def liste_smb_freigaben(host: str, username: str = "", passwort: str = "") -> li return freigaben +def unc_aus_quelle(typ: str, quelle: str) -> str: + """`//host/freigabe` → `\\\\host\\freigabe` (pure Funktion, testbar). + + Nur für CIFS/SMB. NFS gibt "" zurück: Windows kann NFS zwar einhängen + (optionale Funktion „Client für NFS"), die Schreibweise ist aber eine + andere und der Export-Pfad lässt sich nicht zuverlässig übersetzen — + raten verstößt gegen AGENTS Regel D. + """ + if typ != "cifs" or not quelle: + return "" + rest = quelle.strip() + if not rest.startswith("//"): + return "" + return "\\\\" + rest[2:].replace("/", "\\") + + +def pfad_map_vorschlag(eintraege: list) -> list: + """Welcher Container-Pfad entspricht welcher Windows-Freigabe? + + Das ist der fehlende Anschluss für `RIPPY_PATH_MAP` (Befund 26.07.2026): + Ein externer Worker bekommt von Rippy Pfade wie `/app/media/rippy/` + — das sind Pfade INNERHALB des Containers. Er sieht sie nur, wenn sie auf + eine Netzwerk-Freigabe übersetzt werden, und dieses Mapping wurde bis + dahin von niemandem gesetzt. Externes Encoden konnte also nie laufen. + + Geraten werden muss dafür nichts: Rippy hat die Freigabe selbst + eingehängt und kennt ihre Quelle (`//192.168.178.62/rippy`). Der + Mountpunkt ist `/app/media/`, und beides zusammen IST das Mapping. + """ + vorschlaege = [] + for eintrag in eintraege or []: + name = (eintrag.get("name") or "").strip() + if not name: + continue + typ = (eintrag.get("typ") or "").strip() + unc = unc_aus_quelle(typ, eintrag.get("quelle") or "") + vorschlaege.append({ + "name": name, + "typ": typ, + "quelle": eintrag.get("quelle") or "", + "container": _mountpoint(name), + "unc": unc, + "gemountet": ist_gemountet(name), + }) + return vorschlaege + + +def pfad_map_zeile(vorschlaege: list) -> str: + """Die Vorschläge als fertiger RIPPY_PATH_MAP-Wert (pure Funktion). + + Format wie `worker/tasks.pfad_lokal()` es liest: Paare `container=ziel`, + getrennt durch `;`. Einträge ohne übersetzbaren UNC-Pfad (NFS) fallen + weg — ein halbes Mapping wäre schlimmer als keines, weil `pfad_lokal` + beim ersten passenden Präfix aufhört. + """ + paare = [f"{v['container']}={v['unc']}" for v in vorschlaege or [] if v.get("unc")] + return ";".join(paare) + + def mounten(name: str, typ: str, quelle: str, optionen: str = "", username: str = "", passwort: str = "") -> bool: """Hängt ein NFS/CIFS-Ziel unter /app/media/ ein. diff --git a/docker/api/presets.py b/docker/api/presets.py new file mode 100644 index 0000000..cb3c96f --- /dev/null +++ b/docker/api/presets.py @@ -0,0 +1,245 @@ +"""Welches HandBrake-Preset passt zu welcher Disc — und was ist das BESTE. + +Commander-Anforderung (26.07.2026): *„Bei den Presets soll IMMER das Beste +ausgewählt werden"* und *„wenn der Worker AV1 oder noch besseres kann, immer +diesem empfehlen"*. + +## Warum das hier liegt und nicht im UI + +Die Preset-Namen standen bis dahin fest verdrahtet im UI (drei Listen in +Settings.tsx, eine vierte im Wizard). Zwei Probleme: die Namen unterscheiden +sich zwischen HandBrake-Versionen, und ein erfundener Name lässt die Kompression +scheitern — das Projekt hat genau das zweimal teuer bezahlt (AGENTS Regel D). +Jetzt meldet jeder Worker seine echte Liste (`worker/caps.py`, +`HandBrakeCLI --preset-list`), und die Auswahl entsteht hier: als reine +Funktionen, die die CI-Ampel prüft. Das UI zeigt nur noch an. + +## Es wird nicht bewertet, sondern gestaffelt + +Kein Punktesystem. Für jede Lage gibt es eine feste Reihenfolge von Namen, und +genommen wird der erste, den der Worker WIRKLICH kennt. Jeder Name unten ist am +26.07.2026 aus `--preset-list` des Worker-Images abgenommen (HandBrake 1.6.1). +Ein Punktesystem hätte über Namen geurteilt, die es vielleicht nicht gibt. + +## Zwei Quellen, zwei Fragen + +- `--preset-list` sagt, welche Presets es GIBT. Die Kategorie `Hardware/` steht + dort auch auf Maschinen ohne Hardware-Encoder. +- `--help` sagt, welche Encoder LAUFEN. + +Deshalb wird ein Hardware-Preset nur empfohlen, wenn ein Worker die passende +Familie wirklich meldet. (Richtigstellung zum SAVEPOINT v3.16: dort galt es als +unmöglich, die Hardware-Preset-Namen auf der VM zu ermitteln — die Messung sagt +das Gegenteil, siehe caps.parse_preset_liste.) +""" + +# Reservewert: „diesen Disc-Typ NICHT komprimieren". Gleichlautend in +# worker/ripping.PRESET_KEINE und ui/src/lib/encoder.ts — es gibt kein +# geteiltes Paket zwischen den drei Seiten. +PRESET_KEINE = "keine" + +# Auflösungs-Token je Disc-Typ, so wie HandBrake sie in die Preset-Namen +# schreibt. Damit bietet die 4K-Auswahl keine 1080p-Presets als Normalfall an — +# genau dieser Griff hat in v3.12 eine 4K-UHD auf 1080p heruntergerechnet. +AUFLOESUNG_JE_TYP = { + "uhd": ("2160p",), + "bluray": ("1080p",), + "dvd": ("576p", "480p"), +} + +# Hardware-Familie im ENCODER-Namen (aus `--help`) → Kürzel im PRESET-Namen. +# Gemessen: die Presets heißen „H.265 VCN 2160p 4K", der Encoder dazu aber +# `vce_h265`. Ohne diese Zuordnung bekäme eine AMD-Karte kein AMD-Preset. +# Reihenfolge = Vorzug, wenn eine Maschine mehrere Familien meldet. +HW_PRESET_KUERZEL = ( + ("qsv", "QSV"), # Intel QuickSync — in 1.6.1 die einzige mit AV1-Preset + ("nvenc", "NVENC"), # NVIDIA + ("vce", "VCN"), # AMD (Encoder heißt vce, Preset heißt VCN) +) + +# Absichtlich NICHT dabei: +# - `vaapi`: HandBrake 1.6.1 liefert kein VAAPI-Preset mit (Kategorie +# `Hardware/` kennt nur QSV/NVENC/VCN/MF). Ein VAAPI-Worker fällt daher auf +# Software zurück, statt einen Namen zu bekommen, den es nicht gibt. +# - `MF` (Media Foundation): die Presets existieren, aber ob diese Maschine sie +# nutzen kann, ist aus der Encoder-Liste nicht ablesbar. Empfohlen wird MF +# deshalb nie; in der Auswahlliste steht es trotzdem. + +# Vektorbefehls-Stufen, mit denen Software-Encoding brauchbar schnell ist. +# Gleichlautend in ui/src/lib/encoder.ts (SIMD_SCHNELL). +SIMD_SCHNELL = ("avx512f", "avx2") + +# Rückfall, wenn KEIN Worker eine Preset-Liste meldet (alter Worker-Stand, oder +# gar kein Worker online). Alle vier Namen sind im Worker-Image gegengeprüft. +RUECKFALL_PRESETS = ( + "H.265 MKV 2160p60 4K", + "H.265 MKV 1080p30", + "H.265 MKV 576p25", + "H.265 MKV 480p30", + "HQ 2160p60 4K HEVC Surround", + "HQ 1080p30 Surround", + "HQ 576p25 Surround", + "Super HQ 2160p60 4K HEVC Surround", + "Super HQ 1080p30 Surround", +) + + +def verfuegbare_presets(workers) -> list: + """Alle Preset-Namen, die die gemeldeten Worker kennen (Vereinigung). + + Vereinigung und nicht Schnittmenge: Die Kompression kann gezielt an EINEN + Worker geroutet werden (worker_direct), es muss also nicht jeder alles + können. Ob der gewählte Worker das Preset kennt, entscheidet er selbst — + und meldet es als Klartext-Fehler. + """ + gefunden = [] + for w in workers or []: + for name in ((w.get("info") or {}).get("presets") or []): + if name and name not in gefunden: + gefunden.append(name) + return gefunden or list(RUECKFALL_PRESETS) + + +def hardware_kuerzel(workers) -> list: + """Preset-Kürzel der Hardware-Encoder, die WIRKLICH gemeldet sind.""" + vorhanden = set() + for w in workers or []: + for encoder in (w.get("encoders") or []): + vorhanden.add(str(encoder).lower()) + kuerzel = [] + for familie, kurz in HW_PRESET_KUERZEL: + if any(e == familie or e.startswith(familie + "-") for e in vorhanden): + kuerzel.append(kurz) + return kuerzel + + +def simd_schnell(workers) -> bool: + """Kann mindestens ein Worker Software-Encoding brauchbar schnell? + + Bei unbekannter Stufe wird NICHT geraten (ein Windows-Worker ohne die + Erkennung aus v3.16 meldet `unbekannt`) — dann zählt er hier nicht mit, + bremst aber auch niemanden aus. + """ + for w in workers or []: + if ((w.get("info") or {}).get("cpu_simd") or "") in SIMD_SCHNELL: + return True + return False + + +def _kandidaten(disc_type: str, kuerzel: list, schnell: bool) -> list: + """Namens-Staffel für diese Lage — bester Kandidat zuerst. + + Die Software-Zweige sind bewusst dieselben, die der Wizard seit v3.15 + vorschlägt (dort schon geprüft): schwache CPU → 4K verlustfrei behalten und + H.264 für den Rest, starke CPU → H.265 durchgehend. Neu ist nur, dass + Hardware-Presets davor kommen. + """ + liste = [] + # 1. Hardware zuerst — sie ist um Größenordnungen schneller. Auf der + # Rippy-VM brauchte Software-4K gemessene 28-55 Stunden pro Film. + for kurz in kuerzel: + if disc_type == "uhd": + liste += [f"AV1 {kurz} 2160p 4K", f"H.265 {kurz} 2160p 4K"] + elif disc_type == "bluray": + liste += [f"H.265 {kurz} 1080p"] + # Für DVD-Auflösungen liefert HandBrake 1.6.1 keine Hardware-Presets — + # eine DVD ist auch in Software in Minuten fertig. + + # 2. Software. + if disc_type == "uhd": + liste += ["H.265 MKV 2160p60 4K"] if schnell else [PRESET_KEINE] + elif disc_type == "bluray": + liste += ["H.265 MKV 1080p30"] if schnell else ["HQ 1080p30 Surround"] + elif disc_type == "dvd": + liste += ["H.265 MKV 576p25"] if schnell else ["HQ 576p25 Surround"] + return liste + + +def _grund(preset: str, disc_type: str, kuerzel: list, schnell: bool) -> str: + """Ein Satz, WARUM das die Empfehlung ist — der Commander liest das.""" + if preset == PRESET_KEINE: + return ( + "Keiner der gemeldeten Worker hat AVX2 oder einen Hardware-Encoder. " + "4K in Software dauert auf so einer Maschine gemessene 28-55 Stunden " + "pro Film — die verlustfreie Datei zu behalten ist hier die ehrliche " + "Wahl (kostet 20-100 GB)." + ) + if any(f" {k} " in f" {preset} " for k in kuerzel): + return ( + f"Nutzt den gemeldeten Hardware-Encoder ({', '.join(kuerzel)}) — " + "um Größenordnungen schneller als die CPU. Software-x265 wäre bei " + "gleicher Dateigröße etwas sauberer, dauert aber Stunden bis Tage." + ) + if schnell: + return ( + "H.265 in Software: kleinste Dateien bei sehr guter Qualität. Ein " + "Worker mit AVX2 ist dafür schnell genug (gemessen)." + ) + return ( + "H.264 statt H.265: ohne AVX2 ist H.265 sehr langsam. Die Datei wird " + "etwas größer, der Encode dafür um ein Mehrfaches schneller." + ) + + +def empfehlung(disc_type: str, workers) -> dict: + """Bestes Preset für diesen Disc-Typ — nur Namen, die es wirklich gibt. + + Rückgabe: {"preset": str, "grund": str, "gefunden": bool}. `gefunden` ist + False, wenn keiner der bekannten Namen in der Liste des Workers steht (z. B. + eine HandBrake-Version mit anderer Benennung) — dann muss der Nutzer selbst + wählen, und das UI sagt es ihm, statt still etwas Falsches einzustellen. + """ + vorhanden = verfuegbare_presets(workers) + kuerzel = hardware_kuerzel(workers) + schnell = simd_schnell(workers) + for kandidat in _kandidaten(disc_type, kuerzel, schnell): + if kandidat == PRESET_KEINE or kandidat in vorhanden: + return { + "preset": kandidat, + "grund": _grund(kandidat, disc_type, kuerzel, schnell), + "gefunden": True, + } + return { + "preset": "", + "grund": ( + "Dieses HandBrake kennt keines der Presets, die Rippy vorschlagen " + "kann — die Namen unterscheiden sich zwischen HandBrake-Versionen. " + "Bitte unten selbst eines aus der Liste des Workers wählen." + ), + "gefunden": False, + } + + +def auswahl(disc_type: str, workers) -> list: + """Die Presets, die für diesen Disc-Typ zur Wahl stehen — sortiert. + + Gefiltert auf die passende Auflösung: Bei 4K sollen keine 1080p-Presets als + Normalfall in der Liste stehen, sonst passiert wieder, was in v3.12 passiert + ist (4K-Rip auf 1080p heruntergerechnet, weil ein globales Preset galt). + Das bewusste Verkleinern bleibt möglich — es steht am Ende, mit Hinweis. + """ + vorhanden = verfuegbare_presets(workers) + tokens = AUFLOESUNG_JE_TYP.get(disc_type, ()) + passend = [p for p in vorhanden if any(t in p for t in tokens)] + liste = [{"name": p, "verkleinert": False} for p in sorted(passend)] + if disc_type == "uhd": + # Bewusstes Verkleinern auf 1080p — eigene Gruppe, damit niemand aus + # Versehen dort landet. + kleiner = [p for p in vorhanden if "1080p" in p] + liste += [{"name": p, "verkleinert": True} for p in sorted(kleiner)] + return liste + + +def uebersicht(workers) -> dict: + """Alles, was das UI für die Preset-Auswahl braucht — in einem Aufruf.""" + typen = ("uhd", "bluray", "dvd") + return { + "quelle": "worker" if verfuegbare_presets(workers) != list(RUECKFALL_PRESETS) + else "rueckfall", + "hardware": hardware_kuerzel(workers), + "simd_schnell": simd_schnell(workers), + "typen": { + t: {"empfehlung": empfehlung(t, workers), "auswahl": auswahl(t, workers)} + for t in typen + }, + } diff --git a/docker/api/test_eta.py b/docker/api/test_eta.py new file mode 100644 index 0000000..af016c8 --- /dev/null +++ b/docker/api/test_eta.py @@ -0,0 +1,159 @@ +"""Tests der Restzeit-Schätzung — reine Funktionen, keine Infrastruktur. + +Die Zahlen in den Szenarien sind die echten Messwerte vom 25.07.2026: Der +4K-Encode von Akira kam in 29 Minuten von 0 auf 1,44 % (an der Leseposition im +Quellstrom gemessen, `/proc//fdinfo/`) — hochgerechnet 28-55 Stunden. Genau +dieser Fall muss eine ehrliche Antwort geben, statt „gleich fertig" zu behaupten. +""" + +import eta + + +def test_ohne_genug_punkte_keine_aussage(): + assert eta.restzeit_sekunden(None, 100.0) == -1 + assert eta.restzeit_sekunden({"status": "transcoding", "punkte": []}, 100.0) == -1 + einer = {"status": "transcoding", "punkte": [[0.0, 5]]} + assert eta.restzeit_sekunden(einer, 100.0) == -1 + + +def test_zu_kurze_spanne_keine_aussage(): + """Zwei Punkte 10 s auseinander sagen nichts über einen Encode, der Stunden + läuft — MINDEST_SPANNE_SEKUNDEN verhindert die Hochrechnung.""" + reihe = {"status": "transcoding", "punkte": [[0.0, 1], [10.0, 2]]} + assert eta.restzeit_sekunden(reihe, 10.0) == -1 + + +def test_kein_fortschritt_keine_aussage(): + """Eine Stunde ohne einen einzigen Prozentpunkt: die Rate ist unbekannt, + nicht null. Ohne diese Sperre käme eine Division durch 0.""" + reihe = {"status": "transcoding", "punkte": [[0.0, 3]]} + reihe = eta.beobachtung_hinzufuegen(reihe, "transcoding", 3, 3600.0) + assert eta.restzeit_sekunden(reihe, 3600.0) == -1 + + +def test_einfache_hochrechnung(): + """10 % in 10 Minuten → 90 % brauchen 90 Minuten.""" + reihe = {"status": "ripping", "punkte": [[0.0, 0], [600.0, 10]]} + assert eta.restzeit_sekunden(reihe, 600.0) == 90 * 60 + + +def test_der_echte_4k_fall_landet_in_der_groessenordnung_tage(): + """Gemessen: 1,44 % in 29 Minuten. Die Schätzung muss in der Größenordnung + TAGE landen — das ist die Angabe, deren Fehlen den 50-Stunden-Lauf am + 25.07.2026 unsichtbar machte. Die genaue Stundenzahl ist Nebensache; wer + „1 Tag 23 h" liest, bricht ab, wer nichts liest, wartet.""" + reihe = {"status": "transcoding", "punkte": [[0.0, 0], [29 * 60.0, 1]]} + sekunden = eta.restzeit_sekunden(reihe, 29 * 60.0) + stunden = sekunden / 3600 + assert 45 < stunden < 50 # 99 % × 29 min ≈ 47,85 h + assert eta.formatiere_restzeit(sekunden) == "noch ca. 1 Tag 23 h" + # Etwas langsamer, und es heißt nur noch „mehr als 2 Tage" — bei der + # Größenordnung wäre jede Stundenangabe erfundene Genauigkeit. + langsamer = {"status": "transcoding", "punkte": [[0.0, 0], [45 * 60.0, 1]]} + assert eta.formatiere_restzeit( + eta.restzeit_sekunden(langsamer, 45 * 60.0)) == "mehr als 2 Tage" + + +def test_stillstand_verlaengert_die_schaetzung(): + """Kernpunkt: Bleibt der Fortschritt stehen, MUSS die Restzeit steigen — + sonst zeigt ein hängender Job stundenlang „noch 5 Minuten".""" + reihe = {"status": "transcoding", "punkte": [[0.0, 0], [600.0, 50]]} + frisch = eta.restzeit_sekunden(reihe, 600.0) + # ... eine Stunde später steht der Fortschritt immer noch bei 50 % + reihe = eta.beobachtung_hinzufuegen(reihe, "transcoding", 50, 4200.0) + spaeter = eta.restzeit_sekunden(reihe, 4200.0) + assert spaeter > frisch * 5 + + +def test_statuswechsel_verwirft_die_reihe(): + """Rip (eine Stunde) und Kompression (Tage) haben nichts miteinander zu tun. + Ohne diesen Schnitt entstünde beim Phasenwechsel eine Phantasiezahl.""" + reihe = {"status": "ripping", "punkte": [[0.0, 0], [600.0, 50]]} + neu = eta.beobachtung_hinzufuegen(reihe, "transcoding", 2, 610.0) + assert neu["status"] == "transcoding" + assert neu["punkte"] == [[610.0, 2]] + assert eta.restzeit_sekunden(neu, 610.0) == -1 + + +def test_nur_das_fenster_zaehlt(): + """Alte Punkte fliegen raus — HandBrake wird bei komplexen Szenen langsamer, + und dann lügt der Anfang der Messreihe.""" + reihe = None + for i in range(20): + reihe = eta.beobachtung_hinzufuegen(reihe, "transcoding", i, float(i * 60)) + assert len(reihe["punkte"]) == eta.FENSTER + assert reihe["punkte"][0][1] == 10 # die ersten zehn sind weg + + +def test_gleicher_fortschritt_haengt_keinen_punkt_an(): + reihe = eta.beobachtung_hinzufuegen(None, "transcoding", 7, 0.0) + reihe = eta.beobachtung_hinzufuegen(reihe, "transcoding", 7, 30.0) + reihe = eta.beobachtung_hinzufuegen(reihe, "transcoding", 7, 60.0) + assert reihe["punkte"] == [[0.0, 7]] + + +def test_fertig_ist_null(): + reihe = {"status": "transcoding", "punkte": [[0.0, 50], [600.0, 100]]} + assert eta.restzeit_sekunden(reihe, 600.0) == 0 + assert eta.formatiere_restzeit(0) == "unter einer Minute" + + +# --- Textform --------------------------------------------------------------- + + +def test_textform_deckt_alle_groessenordnungen(): + assert eta.formatiere_restzeit(-1) == "" + assert eta.formatiere_restzeit(None) == "" + assert eta.formatiere_restzeit(30) == "unter einer Minute" + assert eta.formatiere_restzeit(90) == "noch ca. 1 min" + assert eta.formatiere_restzeit(45 * 60) == "noch ca. 45 min" + assert eta.formatiere_restzeit(3 * 3600 + 7 * 60) == "noch ca. 3 h 07 min" + assert eta.formatiere_restzeit(30 * 3600) == "noch ca. 1 Tag 6 h" + assert eta.formatiere_restzeit(60 * 3600) == "mehr als 2 Tage" + + +# --- Der Weg über den Cache ------------------------------------------------- + + +def test_aktualisiere_und_schaetze_ueber_zwei_aufrufe(): + """So läuft es live: /jobs wird alle vier Sekunden abgefragt, jeder Aufruf + schreibt die Reihe fort.""" + speicher = {} + + def hole(k): + return speicher.get(k) + + def lege(k, wert, expire=None): + speicher[k] = wert + + erst = eta.aktualisiere_und_schaetze( + "job1", "transcoding", 10, 0.0, hole, lege) + assert erst == {"sekunden": -1, "text": ""} # ein Punkt sagt nichts + + dann = eta.aktualisiere_und_schaetze( + "job1", "transcoding", 20, 600.0, hole, lege) + assert dann["sekunden"] == 80 * 60 + assert dann["text"] == "noch ca. 1 h 20 min" + + +def test_kaputter_cache_bringt_nichts_zum_absturz(): + """Redis weg → keine ETA, aber die Job-Liste muss weiter funktionieren.""" + def kaputt_holen(k): + raise RuntimeError("Redis weg") + + def kaputt_legen(k, wert, expire=None): + raise RuntimeError("Redis weg") + + ergebnis = eta.aktualisiere_und_schaetze( + "job1", "transcoding", 10, 0.0, kaputt_holen, kaputt_legen) + assert ergebnis == {"sekunden": -1, "text": ""} + + +def test_fertige_und_wartende_jobs_bekommen_keine_eta(): + speicher = {} + for status, progress in (("completed", 100), ("failed", 42), ("pending", 0)): + ergebnis = eta.aktualisiere_und_schaetze( + "x", status, progress, 0.0, + speicher.get, lambda k, v, expire=None: speicher.__setitem__(k, v)) + assert ergebnis == {"sekunden": -1, "text": ""} + assert speicher == {} # nichts geschrieben diff --git a/docker/api/test_mounts_helpers.py b/docker/api/test_mounts_helpers.py index f6e516e..5d9c894 100644 --- a/docker/api/test_mounts_helpers.py +++ b/docker/api/test_mounts_helpers.py @@ -1,6 +1,12 @@ """Tests für die SMB-Fehlerübersetzung (Speicherziele → Freigaben auflisten).""" -from mounts import uebersetze_smb_fehler, validiere_name +from mounts import ( + pfad_map_vorschlag, + pfad_map_zeile, + uebersetze_smb_fehler, + unc_aus_quelle, + validiere_name, +) def test_access_denied_ohne_credentials_erklaert_gastproblem(): @@ -69,3 +75,91 @@ def test_mounten_loest_stale_vor_dem_mount(): quelle = inspect.getsource(mounts.mounten) assert "_stale_mounts_loesen(ziel)" in quelle + + +# --- RIPPY_PATH_MAP: der Anschluss für externe Worker (Befund 26.07.2026) ---- + + +def test_unc_aus_quelle_uebersetzt_cifs(): + assert unc_aus_quelle("cifs", "//192.168.178.62/rippy") == "\\\\192.168.178.62\\rippy" + assert unc_aus_quelle("cifs", "//nas/medien/filme") == "\\\\nas\\medien\\filme" + + +def test_unc_aus_quelle_raet_bei_nfs_nicht(): + """NFS gibt "" — Windows-Schreibweise ist nicht ableitbar (AGENTS Regel D).""" + assert unc_aus_quelle("nfs", "192.168.178.62:/volume1/rippy") == "" + assert unc_aus_quelle("cifs", "") == "" + assert unc_aus_quelle("cifs", "kein-unc-pfad") == "" + + +def test_pfad_map_zeile_baut_was_pfad_lokal_liest(monkeypatch): + """Der erzeugte Wert muss vom Worker gelesen werden können — genau dieses + Format erwartet worker/tasks.pfad_lokal(): Paare, getrennt durch ';'.""" + import mounts + + monkeypatch.setattr(mounts, "ist_gemountet", lambda name: True) + vorschlaege = pfad_map_vorschlag([ + {"name": "rippy", "typ": "cifs", "quelle": "//192.168.178.62/rippy"}, + ]) + assert pfad_map_zeile(vorschlaege) == "/app/media/rippy=\\\\192.168.178.62\\rippy" + + +def test_pfad_map_zeile_laesst_nfs_weg(monkeypatch): + """Ein halbes Mapping wäre schlimmer als keines: pfad_lokal() hört beim + ersten passenden Präfix auf, ein NFS-Eintrag ohne Ziel würde also einen + Pfad 'übersetzen', den der Worker danach trotzdem nicht sieht.""" + import mounts + + monkeypatch.setattr(mounts, "ist_gemountet", lambda name: True) + vorschlaege = pfad_map_vorschlag([ + {"name": "nfs-ziel", "typ": "nfs", "quelle": "10.0.0.9:/export"}, + {"name": "rippy", "typ": "cifs", "quelle": "//nas/rippy"}, + ]) + assert pfad_map_zeile(vorschlaege) == "/app/media/rippy=\\\\nas\\rippy" + + +def test_pfad_map_zeile_ohne_mounts_ist_leer(): + assert pfad_map_zeile([]) == "" + assert pfad_map_zeile(None) == "" + + +def test_erzeugtes_mapping_uebersetzt_den_echten_fehlerfall(monkeypatch): + """Gegenprobe mit dem Pfad, an dem es am 26.07.2026 live scheiterte. + + Der Rohschnitt lag auf `/app/media/rippy/95afdc89-…/title_t00.mkv`; der + Windows-Worker sah dort nichts. Mit dem hier erzeugten Mapping muss + genau dieser Pfad auf die Freigabe zeigen. `pfad_lokal` ist eine reine + Funktion im Worker — hier nachgebaut aufzurufen wäre wertlos, deshalb + wird sie über den Pfad importiert. + """ + import importlib.util + import os + + import mounts + + monkeypatch.setattr(mounts, "ist_gemountet", lambda name: True) + mapping = pfad_map_zeile(pfad_map_vorschlag([ + {"name": "rippy", "typ": "cifs", "quelle": "//192.168.178.62/rippy"}, + ])) + + # Der Worker liegt neben der API im Repo; kein geteiltes Paket zwischen + # den Containern, deshalb per Pfad laden statt importieren. + worker_tasks = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "worker", "tasks.py" + ) + spec = importlib.util.spec_from_file_location("_worker_tasks_pfad", worker_tasks) + quelltext = open(worker_tasks, encoding="utf-8").read() + assert "def pfad_lokal" in quelltext and spec is not None + + # pfad_lokal ist bewusst rein und ohne Modul-Zustand — die Funktion aus dem + # Quelltext zu holen, ohne tasks.py komplett zu laden (das braucht celery, + # db, requests …), geht am ehrlichsten über exec des Funktionsblocks. + anfang = quelltext.index("def pfad_lokal") + ende = quelltext.index("\nRAW_DIR", anfang) + umgebung = {"os": os} + exec(compile(quelltext[anfang:ende], worker_tasks, "exec"), umgebung) # noqa: S102 + pfad_lokal = umgebung["pfad_lokal"] + + assert pfad_lokal("/app/media/rippy/95afdc89/title_t00.mkv", mapping) == ( + "\\\\192.168.178.62\\rippy\\95afdc89\\title_t00.mkv" + ) diff --git a/docker/api/test_presets.py b/docker/api/test_presets.py new file mode 100644 index 0000000..9ee3281 --- /dev/null +++ b/docker/api/test_presets.py @@ -0,0 +1,212 @@ +"""Tests der Preset-Auswahl — reine Funktionen, keine Infrastruktur. + +Die Preset-Namen in den Testdaten sind ECHT: `HandBrakeCLI --preset-list` im +Worker-Image der Rippy-VM, 26.07.2026, HandBrake 1.6.1 (AGENTS Regel D). +Erfundene Namen hätten hier keinen Wert — genau daran ist das Projekt zweimal +gescheitert. +""" + +import presets + +# Ausschnitt der echten Liste: die Kategorien, aus denen Rippy wählt. +PRESETS_ECHT = [ + # General/ + "Very Fast 2160p60 4K AV1", "Very Fast 1080p30", + "HQ 2160p60 4K HEVC Surround", "HQ 1080p30 Surround", "HQ 576p25 Surround", + "Super HQ 2160p60 4K HEVC Surround", "Super HQ 1080p30 Surround", + # Matroska/ + "AV1 MKV 2160p60 4K", + "H.265 MKV 2160p60 4K", "H.265 MKV 1080p30", "H.265 MKV 576p25", + "H.265 MKV 480p30", "H.264 MKV 1080p30", + # Hardware/ — steht auch auf Maschinen OHNE Hardware-Encoder in der Liste + "AV1 QSV 2160p 4K", + "H.265 NVENC 2160p 4K", "H.265 NVENC 1080p", + "H.265 QSV 2160p 4K", "H.265 QSV 1080p", + "H.265 VCN 2160p 4K", "H.265 VCN 1080p", + "H.265 MF 2160p 4K", "H.265 MF 1080p", +] + + +def worker(name="w", encoders=None, simd="", presets_liste=None): + return { + "name": name, + "encoders": encoders if encoders is not None else ["cpu-x264", "cpu-x265"], + "info": { + "cpu_simd": simd, + "presets": presets_liste if presets_liste is not None else PRESETS_ECHT, + }, + } + + +# --- Die Rippy-VM: sse4_2, 4 Kerne, kein Hardware-Encoder -------------------- + + +def test_vm_ohne_avx2_bekommt_4k_verlustfrei(): + """Gemessen: 4K-HEVC in Software = 28-55 h auf dieser Maschine. Die + Empfehlung muss deshalb „nicht komprimieren" lauten, nicht ein 4K-Preset.""" + w = [worker(simd="sse4_2")] + e = presets.empfehlung("uhd", w) + assert e["preset"] == presets.PRESET_KEINE + assert "28-55 Stunden" in e["grund"] + + +def test_vm_ohne_avx2_bekommt_h264_fuer_bluray_und_dvd(): + w = [worker(simd="sse4_2")] + assert presets.empfehlung("bluray", w)["preset"] == "HQ 1080p30 Surround" + assert presets.empfehlung("dvd", w)["preset"] == "HQ 576p25 Surround" + + +# --- Der PC des Commanders: avx512f, 16 Kerne, RX 9070 XT ------------------- + + +def test_hardware_av1_wird_bevorzugt_wenn_die_familie_gemeldet_ist(): + """Commander-Vorgabe: „wenn der Worker AV1 oder noch besseres kann, immer + diesem empfehlen". QSV meldet AV1, also muss das AV1-Preset kommen.""" + w = [worker(encoders=["cpu-x265", "qsv", "qsv-av1"], simd="avx512f")] + assert presets.empfehlung("uhd", w)["preset"] == "AV1 QSV 2160p 4K" + + +def test_amd_bekommt_das_amd_preset_nicht_das_intel(): + """Der Encoder heißt `vce`, das Preset heißt `VCN` — ohne diese Zuordnung + liefe die RX 9070 XT des Commanders unter einem Intel-Namen.""" + w = [worker(encoders=["cpu-x265", "vce"], simd="avx512f")] + assert presets.empfehlung("uhd", w)["preset"] == "H.265 VCN 2160p 4K" + assert presets.empfehlung("bluray", w)["preset"] == "H.265 VCN 1080p" + + +def test_hardware_grund_nennt_den_nachteil_auch(): + w = [worker(encoders=["vce"], simd="avx512f")] + grund = presets.empfehlung("uhd", w)["grund"] + assert "VCN" in grund + assert "sauberer" in grund # ehrlich: Software hätte etwas mehr Qualität + + +def test_starke_cpu_ohne_hardware_bekommt_h265(): + w = [worker(encoders=["cpu-x264", "cpu-x265"], simd="avx2")] + assert presets.empfehlung("uhd", w)["preset"] == "H.265 MKV 2160p60 4K" + assert presets.empfehlung("bluray", w)["preset"] == "H.265 MKV 1080p30" + assert presets.empfehlung("dvd", w)["preset"] == "H.265 MKV 576p25" + + +def test_dvd_bekommt_nie_ein_hardware_preset(): + """HandBrake 1.6.1 hat keine Hardware-Presets in DVD-Auflösung — ein + erfundenes „H.265 VCN 576p" würde die Kompression scheitern lassen.""" + w = [worker(encoders=["vce", "qsv", "nvenc"], simd="avx512f")] + assert presets.empfehlung("dvd", w)["preset"] == "H.265 MKV 576p25" + + +# --- Hardware, die KEIN passendes Preset hat -------------------------------- + + +def test_vaapi_faellt_auf_software_zurueck_statt_zu_raten(): + """HandBrake 1.6.1 liefert kein VAAPI-Preset mit. Ein VAAPI-Worker darf + deshalb keinen Hardware-Namen bekommen (AGENTS Regel D).""" + w = [worker(encoders=["cpu-x265", "vaapi"], simd="avx2")] + assert presets.empfehlung("uhd", w)["preset"] == "H.265 MKV 2160p60 4K" + + +def test_mf_wird_nie_empfohlen_steht_aber_zur_wahl(): + """Media-Foundation-Presets existieren, aber ob die Maschine sie nutzen + kann, ist aus der Encoder-Liste nicht ablesbar — nicht empfehlen, nicht + verstecken.""" + w = [worker(encoders=["cpu-x265"], simd="avx2")] + assert "MF" not in presets.empfehlung("uhd", w)["preset"] + namen = [a["name"] for a in presets.auswahl("uhd", w)] + assert "H.265 MF 2160p 4K" in namen + + +# --- Nur echte Namen, nie geratene ------------------------------------------ + + +def test_unbekannte_handbrake_version_bekommt_keine_erfundene_empfehlung(): + """Meldet ein Worker nur Presets, die Rippy nicht kennt, wird ehrlich + „selbst wählen" gesagt — statt einen Namen einzustellen, den dieses + HandBrake ablehnt.""" + w = [worker(simd="avx2", presets_liste=["Irgendwas 4711p", "Noch was"])] + e = presets.empfehlung("uhd", w) + assert e["gefunden"] is False + assert e["preset"] == "" + assert "selbst" in e["grund"] + + +def test_ohne_worker_greift_der_rueckfall(): + """Kein Worker online (oder alter Worker-Stand ohne Preset-Meldung): die + Auswahl darf nicht leer sein, sonst ist die Seite unbenutzbar.""" + assert presets.verfuegbare_presets([]) == list(presets.RUECKFALL_PRESETS) + assert presets.uebersicht([])["quelle"] == "rueckfall" + # ... und der Rückfall muss selbst brauchbar sein: + e = presets.empfehlung("bluray", []) + assert e["gefunden"] is True + assert e["preset"] in presets.RUECKFALL_PRESETS + + +def test_quelle_ist_worker_wenn_einer_meldet(): + assert presets.uebersicht([worker(simd="avx2")])["quelle"] == "worker" + + +def test_verfuegbare_presets_vereinigt_ohne_doppelte(): + a = worker("a", presets_liste=["H.265 MKV 1080p30", "HQ 1080p30 Surround"]) + b = worker("b", presets_liste=["H.265 MKV 1080p30", "H.265 NVENC 1080p"]) + assert presets.verfuegbare_presets([a, b]) == [ + "H.265 MKV 1080p30", "HQ 1080p30 Surround", "H.265 NVENC 1080p", + ] + + +# --- Auswahl-Listen: die 4K-Falle aus v3.12 darf nicht wiederkommen --------- + + +def test_4k_auswahl_zeigt_2160p_presets_zuerst(): + w = [worker(simd="avx2")] + liste = presets.auswahl("uhd", w) + zuerst = [a["name"] for a in liste if not a["verkleinert"]] + assert all("2160p" in n for n in zuerst) + assert "H.265 MKV 2160p60 4K" in zuerst + + +def test_4k_auswahl_kennzeichnet_das_verkleinern(): + """Ein 1080p-Preset für eine 4K-Disc ist eine legitime Wahl, aber eine + BEWUSSTE — in v3.12 passierte es aus Versehen und die 4K-Auflösung war weg.""" + w = [worker(simd="avx2")] + verkleinert = [a["name"] for a in presets.auswahl("uhd", w) if a["verkleinert"]] + assert "H.265 MKV 1080p30" in verkleinert + assert all("1080p" in n for n in verkleinert) + + +def test_dvd_auswahl_nimmt_beide_pal_und_ntsc_aufloesungen(): + w = [worker(simd="avx2")] + namen = [a["name"] for a in presets.auswahl("dvd", w)] + assert "H.265 MKV 576p25" in namen # PAL + assert "H.265 MKV 480p30" in namen # NTSC + assert "H.265 MKV 1080p30" not in namen + + +def test_bluray_auswahl_enthaelt_kein_4k(): + w = [worker(simd="avx2")] + namen = [a["name"] for a in presets.auswahl("bluray", w)] + assert namen + assert not any("2160p" in n for n in namen) + + +# --- Erkennung der Maschinen-Stärke ---------------------------------------- + + +def test_unbekannte_simd_stufe_gilt_nicht_als_schnell_und_nicht_als_langsam(): + """Ein Windows-Worker ohne die Erkennung aus v3.16 meldet `unbekannt`. Er + darf nichts behaupten — aber auch keinen schnellen Worker ausbremsen.""" + assert presets.simd_schnell([worker(simd="unbekannt")]) is False + assert presets.simd_schnell([worker(simd="unbekannt"), worker(simd="avx2")]) is True + + +def test_hardware_kuerzel_nur_bei_echter_meldung(): + assert presets.hardware_kuerzel([worker(encoders=["cpu-x265"])]) == [] + assert presets.hardware_kuerzel([worker(encoders=["nvenc", "nvenc-av1"])]) == ["NVENC"] + # Reihenfolge ist die Vorzugsreihenfolge, nicht die Meldereihenfolge + assert presets.hardware_kuerzel([worker(encoders=["vce", "qsv"])]) == ["QSV", "VCN"] + + +def test_uebersicht_liefert_alle_drei_disc_typen(): + u = presets.uebersicht([worker(simd="avx2")]) + assert set(u["typen"]) == {"uhd", "bluray", "dvd"} + for eintrag in u["typen"].values(): + assert eintrag["empfehlung"]["preset"] + assert eintrag["auswahl"] diff --git a/docker/worker/caps.py b/docker/worker/caps.py index f405718..9a50095 100644 --- a/docker/worker/caps.py +++ b/docker/worker/caps.py @@ -106,6 +106,58 @@ def leite_backends_ab(hb_encoder) -> list: return gefunden +def parse_preset_liste(text: str) -> list: + """Preset-NAMEN, die DIESES HandBrake wirklich kennt. + + Quelle: `HandBrakeCLI --preset-list`, am 26.07.2026 im Worker-Image + gemessen (HandBrake 1.6.1) — AGENTS Regel D, nicht aus dem Kopf: + + General/ + Very Fast 2160p60 4K AV1 + AV1 video (up to 2160p60) and AAC stereo audio, in an MP4 + container. + Hardware/ + H.265 VCN 2160p 4K + + Die Struktur ist die Einrückung: Kategorien ohne Einrückung (mit „/" am + Ende), Preset-Namen mit GENAU vier Leerzeichen, Beschreibungen mit acht. + HandBrakes Logzeilen („[10:26:05] qsv: not available on this system") und + Meldungen wie „Cannot load libnvidia-encode.so.1" haben keine Einrückung + und fallen damit von selbst weg. + + ⚠️ Wichtige Richtigstellung zum SAVEPOINT v3.16: Dort steht, die Namen der + HARDWARE-Presets seien auf der Rippy-VM „nicht ermittelbar", weil deren + HandBrake keinen Hardware-Encoder hat. Gemessen ist das falsch — die + Kategorie `Hardware/` steht vollständig in der Liste (VCN, NVENC, QSV, MF). + HandBrake trennt beides: `--preset-list` nennt alle mitgelieferten Presets, + `--help` nur die Encoder, die diese Maschine wirklich benutzen kann. Für + „welches Preset gibt es" ist die Preset-Liste die Quelle, für „läuft das + hier" die Encoder-Liste. Deshalb werden beide gemeldet. + """ + presets = [] + for zeile in (text or "").splitlines(): + if not zeile.strip(): + continue + einrueckung = len(zeile) - len(zeile.lstrip(" ")) + if einrueckung == 4: + presets.append(zeile.strip()) + return presets + + +def hole_handbrake_presets() -> list: + """`HandBrakeCLI --preset-list` einmal abrufen (leer, wenn nicht installiert).""" + if not shutil.which("HandBrakeCLI"): + return [] + try: + aus = subprocess.run( + ["HandBrakeCLI", "--preset-list"], + capture_output=True, text=True, timeout=30, + ) + return parse_preset_liste((aus.stdout or "") + (aus.stderr or "")) + except (OSError, subprocess.TimeoutExpired): + return [] + + def beste_simd(flags_text: str) -> str: """Höchste Vektor-Befehlsstufe der CPU — entscheidend fürs Software-Encodieren. @@ -285,6 +337,14 @@ def werkzeug_versionen() -> dict: # Die ungefilterte Auskunft von HandBrake — damit im UI nachprüfbar ist, # woher die Backend-Liste kommt. info["handbrake_encoder"] = ",".join(hb_encoder) + # Die Preset-NAMEN dieses HandBrake. Damit hört das Raten auf: Das UI bietet + # genau an, was der jeweilige Worker kennt — die Namen unterscheiden sich + # zwischen HandBrake-Versionen, und ein erfundener Name lässt die Kompression + # scheitern (Commander-Anforderung „immer das Beste auswählen" braucht eine + # echte Liste, nicht eine geratene). + presets = hole_handbrake_presets() + if presets: + info["presets"] = presets if shutil.which("makemkvcon"): info["makemkv"] = os.getenv("MAKEMKV_VERSION") or "installiert" if shutil.which("HandBrakeCLI"): diff --git a/docker/worker/ripping.py b/docker/worker/ripping.py index 1f7b11b..cc90f85 100644 --- a/docker/worker/ripping.py +++ b/docker/worker/ripping.py @@ -403,9 +403,30 @@ def run_handbrake(input_path: str, output_path: str, preset: str = DEFAULT_HB_PR return {"status": "error", "error": str(e)} +def unbekanntes_preset(zeile: str) -> str: + """Meldet HandBrake diese Zeile ein UNBEKANNTES Preset? Name oder "". + + Wortlaut aus dem echten Lauf im Worker-Image (26.07.2026, AGENTS Regel D): + + $ HandBrakeCLI --preset 'Gibt Es Nicht 999p' … + Invalid preset Gibt Es Nicht 999p + Valid presets are: + + Ohne diese Übersetzung stand im UI nur „HandBrake endete mit Code 3" — + und die eigentliche Ursache (ein Preset-Name, den DIESES HandBrake nicht + kennt) war daraus nicht zu erraten. Genau dieser Fall wird häufiger, weil + die Namen je HandBrake-Version verschieden sind und ein Remote-Worker eine + andere Version haben kann als die Rippy-VM. + """ + text = (zeile or "").strip() + kopf = "Invalid preset " + return text[len(kopf):].strip() if text.startswith(kopf) else "" + + def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb=None) -> dict: """Liest HandBrakes Ausgabe und wertet sie aus. Eigene Funktion, damit die Reihenfolge (Abbruch VOR Fortschritt) ohne echtes HandBrake testbar ist.""" + falsches_preset = "" try: for line in process.stdout: # Zuerst der Abbruch — unabhängig davon, ob die Zeile überhaupt @@ -413,6 +434,8 @@ def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb= # sich die Prozentzahl bewegt (Befund 25.07.2026). if abbruch_cb: abbruch_cb() + if not falsches_preset: + falsches_preset = unbekanntes_preset(line) progress = get_progress_from_line(line) # >= 0: ein echtes 0 % ist eine Angabe und muss durch. Der alte # Filter `> 0` verwarf den gesamten ersten Prozentpunkt — bei @@ -428,6 +451,18 @@ def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb= if process.returncode == 0 and os.path.exists(output_path): return {"status": "success", "output_path": output_path} + if falsches_preset: + return { + "status": "error", + "error": ( + f'Das HandBrake dieses Workers kennt das Preset „{falsches_preset}" ' + 'nicht. Preset-Namen unterscheiden sich zwischen ' + 'HandBrake-Versionen. Abhilfe: Einstellungen → Verarbeitung, dort ' + 'auf „Bestes wählen" klicken — die Liste dort kommt vom Worker ' + 'selbst und enthält nur Namen, die es wirklich gibt.' + ), + "return_code": process.returncode, + } return { "status": "error", "error": f"HandBrake endete mit Code {process.returncode}", diff --git a/docker/worker/test_caps.py b/docker/worker/test_caps.py index 9efabc9..5280998 100644 --- a/docker/worker/test_caps.py +++ b/docker/worker/test_caps.py @@ -138,3 +138,79 @@ def test_simd_stufe_der_rippy_vm_ist_sse4_2(): "pni cx16 sse4_1 sse4_2 x2apic popcnt aes hypervisor lahf_lm" ) assert caps.beste_simd(flags_vm) == "sse4_2" + + +# --- Preset-Liste: die Namen, die HandBrake wirklich kennt ------------------- + +# Wörtlich aus `HandBrakeCLI --preset-list` im Worker-Image (26.07.2026, +# HandBrake 1.6.1) — gekürzt auf je zwei Presets pro Kategorie, Struktur und +# Einrückung unverändert. Die zwei Logzeilen oben stehen dort wirklich mit +# drin (HandBrake schreibt sie in denselben Strom) und müssen wegfallen. +HB_PRESETS_ECHT = """[10:26:05] Compile-time hardening features are enabled +[10:26:05] qsv: not available on this system +Cannot load libnvidia-encode.so.1 +General/ + Very Fast 2160p60 4K AV1 + AV1 video (up to 2160p60) and AAC stereo audio, in an MP4 + container. + Super HQ 1080p30 Surround + High quality H.264 video (up to 1080p30) and high bit rate AAC + surround audio, in an MP4 container. +Matroska/ + H.265 MKV 2160p60 4K + H.265 video (up to 2160p60) and surround audio, in an MKV + container. + H.265 MKV 1080p30 + H.265 video (up to 1080p30) and surround audio, in an MKV + container. +Hardware/ + AV1 QSV 2160p 4K + AV1 video (up to 2160p) and surround audio, in an MP4 container. + H.265 VCN 2160p 4K + H.265 video (up to 2160p) and surround audio, in an MP4 container. +CLI Defaults/ + CLI Default +""" + + +def test_preset_liste_kommt_aus_der_einrueckung(): + presets = caps.parse_preset_liste(HB_PRESETS_ECHT) + assert presets == [ + "Very Fast 2160p60 4K AV1", + "Super HQ 1080p30 Surround", + "H.265 MKV 2160p60 4K", + "H.265 MKV 1080p30", + "AV1 QSV 2160p 4K", + "H.265 VCN 2160p 4K", + "CLI Default", + ] + + +def test_preset_liste_nimmt_keine_beschreibung_und_kein_log(): + """Beschreibungen (8 Leerzeichen), Kategorien (0) und HandBrakes eigene + Logzeilen (0) dürfen nicht als Preset-Namen durchgehen.""" + presets = caps.parse_preset_liste(HB_PRESETS_ECHT) + assert not any(p.endswith("/") for p in presets) + assert not any("container" in p for p in presets) + assert not any(p.startswith("[") for p in presets) + assert "Cannot load libnvidia-encode.so.1" not in presets + + +def test_preset_liste_leer_bleibt_leer(): + assert caps.parse_preset_liste("") == [] + assert caps.parse_preset_liste(None) == [] + + +def test_hardware_presets_stehen_auch_ohne_hardware_in_der_liste(): + """Richtigstellung zum SAVEPOINT v3.16: Dort galt es als unmöglich, die + Hardware-Preset-Namen auf der Rippy-VM zu ermitteln, weil dort kein + Hardware-Encoder läuft. Die Messung sagt das Gegenteil — `--preset-list` + nennt sie, `--help` nennt nur die nutzbaren Encoder. Zwei Fragen, zwei + Quellen.""" + presets = caps.parse_preset_liste(HB_PRESETS_ECHT) + assert "H.265 VCN 2160p 4K" in presets # AMD, aus der VM-Liste + assert "AV1 QSV 2160p 4K" in presets # Intel, aus der VM-Liste + # ... und dieselbe Maschine meldet trotzdem KEINEN Hardware-Encoder: + assert caps.leite_backends_ab(caps.parse_encoder_liste(HB_HILFE_ECHT)) == [ + "cpu-x264", "cpu-x265", "cpu-av1", + ] diff --git a/docker/worker/test_ripping_helpers.py b/docker/worker/test_ripping_helpers.py index 1a06dd0..7c3c539 100644 --- a/docker/worker/test_ripping_helpers.py +++ b/docker/worker/test_ripping_helpers.py @@ -317,3 +317,66 @@ def test_reservewert_kollidiert_mit_keinem_echten_preset(): assert PRESET_KEINE == "keine" assert " " not in PRESET_KEINE # echte Preset-Namen haben durchweg Leerzeichen + + +# --- Unbekanntes Preset: aus „Code 3" wird eine handelbare Meldung ----------- + + +def test_unbekanntes_preset_wird_erkannt(): + """Wortlaut echt gemessen (26.07.2026, Worker-Image): HandBrake antwortet + „Invalid preset " und listet danach die gültigen auf.""" + from ripping import unbekanntes_preset + + assert unbekanntes_preset("Invalid preset Gibt Es Nicht 999p") == "Gibt Es Nicht 999p" + assert unbekanntes_preset(" Invalid preset H.265 XYZ 4K ") == "H.265 XYZ 4K" + # Alles andere ist kein Preset-Fehler + assert unbekanntes_preset("Valid presets are:") == "" + assert unbekanntes_preset("Encoding: task 1 of 1, 5.00 %") == "" + assert unbekanntes_preset("") == "" + assert unbekanntes_preset(None) == "" + + +def test_falsches_preset_erklaert_den_fehlschlag_statt_nur_den_code(): + """Vorher stand im UI nur „HandBrake endete mit Code 3" — daraus war nicht + zu erraten, dass der Preset-NAME das Problem ist. Der Fall wird häufiger, + weil ein Remote-Worker eine andere HandBrake-Version haben kann.""" + import ripping + + class FakeProcess: + def __init__(self): + self.stdout = iter([ + "Invalid preset H.265 VCN 2160p 4K\n", + "Valid presets are:\n", + ]) + self.returncode = 3 + + def kill(self): + pass + + def wait(self): + return 3 + + ergebnis = ripping._handbrake_schleife(FakeProcess(), "/gibt-es-nicht.mkv") + + assert ergebnis["status"] == "error" + assert "H.265 VCN 2160p 4K" in ergebnis["error"] + assert "Bestes wählen" in ergebnis["error"] + assert ergebnis["return_code"] == 3 + + +def test_fehler_ohne_preset_problem_bleibt_der_alte(): + import ripping + + class FakeProcess: + def __init__(self): + self.stdout = iter(["irgendwas ganz anderes\n"]) + self.returncode = 1 + + def kill(self): + pass + + def wait(self): + return 1 + + ergebnis = ripping._handbrake_schleife(FakeProcess(), "/gibt-es-nicht.mkv") + assert ergebnis["error"] == "HandBrake endete mit Code 1"