feat(bausteine): Preset-Liste, Pfad-Vorschlag und Restzeit - alles gemessen

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>
This commit is contained in:
Hitonabi
2026-07-26 12:57:51 +02:00
parent a468e4b6a4
commit 4195854bf8
10 changed files with 1151 additions and 2 deletions
+60
View File
@@ -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"):
+35
View File
@@ -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}",
+76
View File
@@ -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",
]
+63
View File
@@ -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 <Name>" 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"