Files
rippy/docker/worker/ripping.py
HitonabiandClaude Opus 5 d3e86d2641 fix(windows): Der Schalter, den es nicht gibt, und vierzehn weitere Funde
Commander: „Kompression fehlgeschlagen bei Spartacus … _t01.mkv: HandBrake
endete mit Code 0" — 16,5 GB fertiger Rohschnitt, und die Kompression war in
derselben Sekunde vorbei, in der sie begann.

Nachgestellt mit genau der Befehlszeile, die Rippy baute:

    unknown option (--audio-codec)
    HandBrake has exited.        $? = 0

Den Schalter `--audio-codec` gibt es bei HandBrakeCLI nicht; er heisst
`-E` / `--aencoder`. Ein unbekannter Schalter ist fuer HandBrake kein Fehler,
der Rueckgabewert ist 0. Der Test dazu forderte den falschen Namen sogar ein.

Daraus wurde ein Rundgang durch den Windows-Pfad. Alles unten ist gemessen,
nichts vermutet (Regel D).

## Die Kompression

1. `--aencoder` statt `--audio-codec`. Am mitgelieferten HandBrakeCLI 1.11.2
   gemessen, mit einem 5-Sekunden-Encode auf der echten Roh-Datei bestaetigt.

2. HandBrakes letzte Zeilen werden aufgehoben (12 gepuffert, 4 in der
   Meldung) und `unknown option (...)` wird als eigener Fall erkannt, VOR
   allen anderen. Vorher wurde jede Zeile weggeworfen, die kein Fortschritt
   war — bei Rueckgabewert 0 blieb damit keine Auskunft uebrig. Die geratene
   Zeile „Meist ist der Zielordner nicht beschreibbar" ist raus; sie war
   falsch und hat die Suche in die falsche Richtung geschickt.

3. Ein `ue`-Umlaut im Pfad toetete die Kompression. HandBrake schreibt zwei
   Kodierungen in denselben Strom (derselbe Pfad einmal UTF-8, einmal CP850).
   In CP850 ist das Byte 0x81, und das ist in cp1252 — was `text=True` auf
   deutschem Windows waehlt — undefiniert:

       UnicodeDecodeError: charmap codec can't decode byte 0x81

   Neu: `rip/handbrake_aufruf.py` mit `HB_LESEN`, benutzt von ripping.py und
   caps.py. Bewusst nicht binaer wie bei makemkvcon: HandBrake trennt
   Fortschrittszeilen mit CR, im Binaermodus waere der Balken weg.

## Die Rohdaten

4. `rohdaten.kandidaten` machte aus dem Arbeitsordner `F:\` ein `F:` und
   verband damit weiter. Das ist unter Windows der aktuelle Ordner auf
   Laufwerk F, nicht dessen Wurzel — 16,5 GB waren unsichtbar, und der
   Wiederholen-Dialog bot nur „Neu rippen" an. Die Falle steht woertlich im
   Kopf von `pfade.verbinden`.

5. Gesucht wurde unter der heutigen Einstellung statt unter der Wahl DIESES
   Rips (`meta["work_dir"]`). Genau dafuer wurde rohdaten.py am 26.07.
   gebaut; repariert wurde damals die Kandidatenliste, nicht der Aufrufer.
   Neu: `_arbeitsverzeichnis_des_jobs`, benutzt an vier Stellen.

6. Zwei Speicher fuer dieselben Ordner: Die Oberflaeche schreibt
   `outputDir`/`workDir` in die Datenbank, `betrieb` liest `storage.*` aus
   der Konfigurationsdatei, und die schreibt niemand. Gemessen: eingestellt
   `E:\Rippy`, angezeigt `C:\Users\...\Videos\Rippy`. Neu:
   `betrieb.mit_einstellungen`.

## Das Laufwerk

7. `device_info` fing den OSError ab und lieferte „unknown" ohne den Grund.
   Nach einem Rip mit Lesefehlern beantwortete das Laufwerk keine
   Medien-Abfragen mehr (Win32-Fehler 1), die Geraete-Auskunft aber schon —
   im UI stand eine volle Laufwerkskarte, kein Rip startbar, und im
   Protokoll das laengst veraltete „Disc erkannt". Neu: `ZUGRIFFS_GRUENDE`,
   ein Feld `grund` im Laufwerks-Eintrag und eine Protokollzeile je Wechsel.
   Eine fehlgeschlagene Disc-Erkennung wird ebenfalls protokolliert.

8. Der Linux-Treiber nannte ein unzugaengliches Laufwerk „empty", waehrend
   Windows richtig „unknown" sagt. Angeglichen, samt Feld-Paritaet.

9. `CreateFileW`, `DeviceIoControl` und `CloseHandle` hatten weder `restype`
   noch `argtypes` — 32-Bit-`c_int` fuer einen 64-Bit-HANDLE, in beide
   Richtungen. Mit `restype` aendert sich der Fehlerwert von -1 auf
   0xFFFFFFFFFFFFFFFF; die Pruefung deckt jetzt beides ab. Am echten
   Laufwerk gegengeprueft, Fehlerpfad eingeschlossen.

10. Der Vor-Scan lief bei JEDER eingelegten Disc ein `makemkvcon info` mit
    120 s Zeitgrenze — 20 bis 120 Sekunden „Disc wird gelesen". Frueher war
    das schnell, weil der Zweig unter Windows nie lief (`shutil.which`,
    repariert am 28.08.). Das Ergebnis landete allein in `toc["tracks"]`,
    das niemand liest: Der Rip-Dialog holt seine Liste ueber
    `/devices/{id}/scan-tracks`, wenn sie gebraucht wird. Entfernt.

## Notbremsen

11. `_frei_bytes` suchte den naechsten vorhandenen Ordner selbst.
    `os.path.dirname("Q:\\")` gibt sich selbst zurueck — ein
    Arbeitsverzeichnis auf einer abgezogenen Platte haette den Job vor dem
    Rip stumm haengen lassen. Benutzt jetzt
    `pfade.naechster_vorhandener`, das den Abbruch seit V2-1 hat.

12. `naechster_vorhandener` haelt Laufwerks- und UNC-Wurzeln jetzt absolut.

13. `aufraeum_skript` baut sein `rmdir /s /q` aus `InstallLocation` in der
    Registry. Waere das eine Laufwerks-Wurzel, loeschte die Deinstallation
    das Laufwerk. Nicht beobachtet, aber nicht wiedergutzumachen — der
    Loeschbefehl bleibt in dem Fall weg.

## Lesefehler

MakeMKV sicherte 1 von 2 Titeln, endete mit 0, und Rippy schrieb „Rip
fertig". Jetzt gibt es eine Warnung, auch wenn der Rip als Erfolg endet, und
die MSG-Nummer steht im Protokoll: MakeMKVs Texte sind uebersetzt, die
Nummern nicht.

## Aus der Gegenprobe am laufenden Rippy

Die erste Fassung dieses Standes war installiert, als der Commander meldete:
„nun oeffnen sich diverse fenster im hintergrund, gehen ganz kurz auf und dann
wieder zu. Das laufwerk hoert auch einfach auf zu lesen." Beides Altlasten,
die erst durch die neue Protokollzeile sichtbar wurden.

14. Prozesserzeugung mitgeschnitten:

        14:54:40  timeout.exe          timeout 4 ls -d C:\Users\...\d7ee6c06-...
        14:54:40  WindowsTerminal.exe

    `rohdaten.pruefen` fragt mit `timeout N ls -d`, ob es ein Verzeichnis
    gibt. Unter Linux ist das richtig (os.path.isdir kann an einem toten
    CIFS-Mount im Kernel haengen, ein Kindprozess laesst sich abbrechen).
    Unter Windows ist es dreifach falsch: timeout.exe gibt es dort, kennt
    aber weder `ls` noch `-d`; sie braucht eine Konsole, und die reisst
    Windows auf; und ihr Rueckgabewert ist nie 0, die Antwort lautete also
    „weg" fuer JEDES Verzeichnis. Rohdaten waren unter Windows
    grundsaetzlich unsichtbar. Neu: `nativ_nachsehen()`. Die zwei
    gleichartigen Aufrufe in mounts.py bekommen dieselbe Absicherung.

15. Der Waechter fragte das Laufwerk alle drei Sekunden ab — auch mitten im
    Rip, also drei CreateFileW plus IOCTLs auf ein Geraet, das makemkvcon
    gerade liest:

        12:49:52  bluray-Rip gestartet
        12:50:09  [watcher] Laufwerk G: beantwortet keine Medien-Abfragen
        12:50:12  MSG 2003 SCSI-Fehler ILLEGAL REQUEST:INVALID FIELD IN CDB
        12:50:12  makemkvcon endete mit Code 11

    `_auto_prescan` haelt sich seit dem 29.08.2026 an die Regel „waehrend
    eines Rips wird nicht gescannt"; die Laufwerksabfrage tat es nicht.
    Jetzt gilt in der Zeit der letzte bekannte Stand.

## Zwei Tests, die gelogen haben

* `assert "--audio-codec" in cmd` schrieb den Fehler fest.
* `lambda: {}` als Doppelgaenger fuer `get_settings(key, bei_fehler_leer)`
  brach, sobald ein Aufrufer einen Parameter benutzte — und zeigte dann auf
  den Code statt auf sich selbst.

977 Tests gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 15:11:00 +02:00

1237 lines
51 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Ripping-Tasks: DVD/Blu-ray verlustfrei via MakeMKV (Etappe 10), CD via abcde.
Etappe 10 (23.07.2026): HandBrake ist aus dem Ripp-Pfad entfernt — das KONZEPT
verlangt verlustfreies Sichern (MakeMKV als Muss-Feature). HandBrake war ohnehin
nie im Worker-Image installiert; jeder Rip endete sofort mit "nicht installiert".
MakeMKV-Aufruf und Fortschritts-Format sind dokumentiert unter
https://www.makemkv.com/developers/usage.txt (Robot-Mode `-r`):
PRGV:current,total,max → Fortschritt gesamt = total/max
MSG:code,flags,... → Meldungen
(AGENTS Regel D: externe Schnittstellen nie aus dem Kopf.)
"""
import os
import re
import shutil
import subprocess
import tempfile
# Auswurf und ioctl-Konstanten leben seit V2-1 im gemeinsamen Treiber
# (rippy/drives/linux.py). Vorher stand derselbe Ablauf ZWEIMAL im Repo —
# hier und in docker/api/devices.py, mit unterschiedlichen Vertraegen.
#
# Die Namen werden hier weiter angeboten, weil tasks.py und die Tests sie so
# kennen; `wirf_disc_aus` ist der Worker-Vertrag (gibt False zurueck, wirft nie).
from rippy.drives.linux import ( # noqa: F401
AUSWURF_WARTEN_SEKUNDEN,
CDROM_DRIVE_STATUS,
CDROM_LOCKDOOR,
CDROMCLOSETRAY,
CDROMEJECT,
CDS_DISC_OK,
CDS_DRIVE_NOT_READY,
CDS_NO_DISC,
CDS_TRAY_OPEN,
_auswurf_geglueckt,
)
from rippy.drives.linux import auswerfen_versuchen as wirf_disc_aus # noqa: F401
from rippy.platform.winlauf import OHNE_FENSTER
from rippy.rip.handbrake_aufruf import HB_LESEN
from rippy.rip.makemkv_aufruf import KRITISCHE_CODES, text_von
from rippy.rip.makemkv_aufruf import quelle as makemkv_quelle
from rippy.tools import katalog as werkzeuge
_NACKTER_NAME = {"makemkv": "makemkvcon", "handbrake": "HandBrakeCLI"}
def werkzeug(name: str) -> str:
"""Der Pfad zu einem Werkzeug — oder sein blosser Name als Rueckfall.
## Warum hier nicht mehr `shutil.which` steht
Bis V2-4 stand ueberall in dieser Datei der nackte Programmname
("makemkvcon", "HandBrakeCLI"). Im Container stimmt das — dort liegen
beide in /usr/local/bin. **Unter Windows findet es NICHTS**, auch wenn
beide installiert sind: Windows-Programme liegen in Program Files und
stehen nicht im PATH. Am 28.08.2026 auf dem Commander-PC nachgesehen —
MakeMKV lag da, und `shutil.which` sah es nicht.
Fuer einen Rippy, der unter Windows eigenstaendig arbeiten soll, ist das
der Unterschied zwischen "laeuft" und "laeuft nicht".
Der Rueckfall auf den blossen Namen ist Absicht: Im Container aendert sich
damit NICHTS — subprocess findet das Programm dort ueber den PATH wie
bisher.
"""
return werkzeuge.finden(name) or _NACKTER_NAME[name]
RIP_OUTPUT_DIR = os.getenv("RIP_OUTPUT_DIR", "/app/media")
class RipAbbruch(Exception):
"""Kooperativer Abbruch: vom Fortschritts-Callback geworfen, wenn der
Nutzer den Job abgebrochen hat (Status 'canceling' in der DB)."""
def check_makemkv_installed() -> bool:
"""Prüft, ob makemkvcon installiert ist."""
return bool(werkzeuge.finden("makemkv"))
def check_abcde_installed() -> bool:
"""Prüft, ob abcde installiert ist."""
return shutil.which("abcde") is not None
def check_cdparanoia_installed() -> bool:
"""Prüft, ob cdparanoia installiert ist."""
return shutil.which("cdparanoia") is not None
#: Sprachunabhaengige Marke fuer „die Disc liess sich stellenweise nicht lesen".
#:
#: Am 30.08.2026 im Protokoll des Commanders abgelesen (Spartacus Disc 2,
#: deutschsprachiges MakeMKV):
#:
#: Encountered 29 errors of type 'Read Error' - see
#: http://www.makemkv.com/errors/read/
#: Das Kopieren wurde abgeschlossen. 1 Titel wurden gesichert, 1 schlugen fehl.
#:
#: Die URL steht auch in der deutschen Fassung englisch da — sie ist damit die
#: einzige Stelle dieser Meldung, auf die Verlass ist. Die MSG-NUMMER waere
#: besser (so macht es KRITISCHE_CODES), aber sie war im Protokoll nicht zu
#: sehen: `melde_makemkv` schrieb sie bis heute nicht mit. Das ist behoben —
#: beim naechsten Lesefehler steht die Nummer im Log, und dann gehoert sie
#: hierher statt dieser Textsuche.
LESEFEHLER_MARKE = "makemkv.com/errors/read"
def build_makemkv_cmd(device_path: str, output_dir: str, titel: str = "all") -> list:
"""Baut das MakeMKV-Kommando (pure Funktion, testbar).
-r Robot-Mode: maschinenlesbare Ausgabe (PRGV/MSG-Zeilen)
--noscan KEIN Scan über alle Geräte — der brachte MakeMKV im
Container zum Hängen (1.18.4) bzw. Segfault (1.17.7):
/sys zeigt dort auch Geräte ohne /dev-Knoten (Befund 23.07.)
--progress=-same Fortschritt in denselben Stream wie die Meldungen
mkv dev:<pfad> <titel> <ziel> 'all' oder eine Titel-Nummer (Hauptfilm)
"""
return [
werkzeug("makemkv"),
"-r",
"--noscan",
"--progress=-same",
"mkv",
makemkv_quelle(device_path),
str(titel),
output_dir,
]
def parse_tinfo_dauern(ausgabe: str) -> dict:
"""TINFO-Zeilen → {titel_nr: dauer_sekunden} (pure Funktion, testbar).
makemkvcon-Robot-Format laut makemkv.com/developers/usage.txt:
TINFO:<titel>,<attr_id>,<code>,"<wert>" — Attribut 9 ist die Laufzeit
als "H:MM:SS" (ap_iaDuration).
"""
dauern = {}
for zeile in ausgabe.splitlines():
treffer = re.match(r'TINFO:(\d+),9,\d+,"(\d+):(\d{2}):(\d{2})"', zeile.strip())
if treffer:
nr, h, m, s = (int(g) for g in treffer.groups())
dauern[nr] = h * 3600 + m * 60 + s
return dauern
def parse_titel_info(ausgabe: str) -> list:
"""TINFO-Zeilen → volle Titel-Liste für die Auswahl-Tabelle (pure Funktion).
Attribut-IDs laut MakeMKV apdefs.h (makemkvgui/inc/lgpl/apdefs.h, LGPL):
8 = ChapterCount, 9 = Duration ("H:MM:SS"), 11 = DiskSizeBytes.
Rückgabe: [{"nr", "dauer_s", "groesse_bytes", "kapitel"}, …] sortiert.
"""
titel: dict = {}
for zeile in ausgabe.splitlines():
treffer = re.match(r'TINFO:(\d+),(\d+),\d+,"(.*)"\s*$', zeile.strip())
if not treffer:
continue
nr, attr, wert = int(treffer.group(1)), int(treffer.group(2)), treffer.group(3)
eintrag = titel.setdefault(nr, {"nr": nr, "dauer_s": 0, "groesse_bytes": 0, "kapitel": 0})
if attr == 9:
teile = wert.split(":")
if len(teile) == 3 and all(t.isdigit() for t in teile):
h, m, s = (int(t) for t in teile)
eintrag["dauer_s"] = h * 3600 + m * 60 + s
elif attr == 11 and wert.isdigit():
eintrag["groesse_bytes"] = int(wert)
elif attr == 8 and wert.isdigit():
eintrag["kapitel"] = int(wert)
return [titel[nr] for nr in sorted(titel)]
# Attribut-IDs der SINFO-Zeilen. Am 26.07.2026 an der Akira-Blu-ray im Laufwerk
# der Rippy-VM abgenommen (AGENTS Regel D):
#
# SINFO:0,1,1,6202,"Audio"
# SINFO:0,1,3,0,"deu"
# SINFO:0,1,4,0,"German"
# SINFO:0,1,6,0,"DTS-HD MA"
# SINFO:0,1,14,0,"2"
# SINFO:0,1,30,0,"DTS-HD MA Stereo German"
#
# ⚠️ Die Sprache steht in 3/4 — NICHT in 28/29. Die tragen auf JEDEM Stream
# „eng"/„English", auch auf dem Videostream einer deutschen Tonspur; das ist
# MakeMKVs eigene Anzeigesprache und nicht die des Tracks. Wer 28 nimmt, hält
# jede Disc für englisch.
SINFO_TYP = 1
SINFO_SPRACHE_CODE = 3
SINFO_SPRACHE_NAME = 4
SINFO_CODEC = 6
SINFO_KANAELE = 14
SINFO_BESCHREIBUNG = 30
SINFO_ZEILE = re.compile(r'SINFO:(\d+),(\d+),(\d+),\d+,"(.*)"\s*$')
def parse_stream_info(ausgabe: str) -> dict:
"""SINFO-Zeilen → Tonspuren und Untertitel je Titel (pure Funktion).
Commander-Anforderung 26.07.2026: *„Es muss verfügbar sein, dass man die
Sprache von Medien auswählen kann … Rippy muss VOR dem Rip fragen: Was genau
willst du haben?"* Die Auskunft liegt längst vor — der Titel-Scan liest sie
mit und hat sie bisher weggeworfen.
Rückgabe: {titel_nr: {"audio": [...], "untertitel": [...]}}, jeder Eintrag
{"nr", "lang", "sprache", "codec", "kanaele", "beschreibung"}.
"""
titel: dict = {}
for zeile in (ausgabe or "").splitlines():
treffer = SINFO_ZEILE.match(zeile.strip())
if not treffer:
continue
t_nr, s_nr, attr = (int(treffer.group(i)) for i in (1, 2, 3))
wert = treffer.group(4)
streams = titel.setdefault(t_nr, {})
eintrag = streams.setdefault(s_nr, {"nr": s_nr, "typ": "", "lang": "",
"sprache": "", "codec": "",
"kanaele": "", "beschreibung": ""})
if attr == SINFO_TYP:
eintrag["typ"] = wert
elif attr == SINFO_SPRACHE_CODE:
eintrag["lang"] = wert
elif attr == SINFO_SPRACHE_NAME:
eintrag["sprache"] = wert
elif attr == SINFO_CODEC:
eintrag["codec"] = wert
elif attr == SINFO_KANAELE:
eintrag["kanaele"] = wert
elif attr == SINFO_BESCHREIBUNG:
eintrag["beschreibung"] = wert
ergebnis = {}
for t_nr, streams in titel.items():
audio, untertitel = [], []
for s_nr in sorted(streams):
s = streams[s_nr]
# MakeMKV schreibt „Subtitles" (englisch, Plural) — gemessen.
if s["typ"] == "Audio":
audio.append(s)
elif s["typ"].startswith("Subtitle"):
untertitel.append(s)
ergebnis[t_nr] = {"audio": audio, "untertitel": untertitel}
return ergebnis
def sprachen_zusammenfassen(streams_je_titel: dict) -> dict:
"""Welche Sprachen hat die DISC insgesamt? (pure Funktion)
Für die Frage vor dem Rip zählt nicht Titel 3 Stream 7, sondern: „Diese Disc
hat Ton auf Deutsch, Englisch und Japanisch, Untertitel auf Deutsch und
Englisch." Deshalb über alle Titel vereinigt, Reihenfolge stabil (so wie sie
auf der Disc stehen).
Rückgabe: {"audio": [{"lang","sprache","spuren"}], "untertitel": [...]}
"""
def sammeln(schluessel):
gefunden = {}
for streams in (streams_je_titel or {}).values():
for s in streams.get(schluessel) or []:
code = s.get("lang") or ""
if not code:
continue
eintrag = gefunden.setdefault(
code, {"lang": code, "sprache": s.get("sprache") or code,
"spuren": 0})
eintrag["spuren"] += 1
return list(gefunden.values())
return {"audio": sammeln("audio"), "untertitel": sammeln("untertitel")}
def lies_titel_info(device_path: str, timeout: int = 300) -> tuple:
"""(Titel-Liste, Streams je Titel) der eingelegten Disc — EIN Info-Lauf.
Beides aus demselben Aufruf: Ein `makemkvcon info` dauert je Disc 20120 s,
und die Sprach-Auskunft steht in derselben Ausgabe. Sie zweimal zu holen
wäre eine Minute Wartezeit für nichts.
"""
ergebnis = subprocess.run(
[werkzeug("makemkv"), "-r", "--noscan", "info", makemkv_quelle(device_path)],
capture_output=True, timeout=timeout, # binaer: siehe text_von
creationflags=OHNE_FENSTER,
)
ausgabe = text_von(ergebnis.stdout)
return parse_titel_info(ausgabe), parse_stream_info(ausgabe)
def rip_titel_auswahl(device_path: str, output_dir: str, titel_liste: list,
progress_cb=None, log_cb=None) -> dict:
"""Rippt GENAU die gewählten Titel (makemkvcon kann pro Aufruf nur einen
Titel oder 'all' — also ein Aufruf je Titel, Fortschritt anteilig)."""
gesamt = len(titel_liste)
alle_dateien = []
# Ein Lesefehler in Titel 1 darf nicht verschwinden, nur weil Titel 2
# sauber durchlief — je Titel laeuft ein eigener makemkvcon.
lesefehler = False
for index, nr in enumerate(titel_liste):
def anteilig(p, _i=index):
if progress_cb:
progress_cb(int((_i * 100 + p) / gesamt))
ergebnis = run_makemkv(device_path, output_dir, progress_cb=anteilig, titel=str(nr),
log_cb=log_cb)
lesefehler = lesefehler or bool(ergebnis.get("lesefehler"))
if ergebnis.get("status") == "cancelled":
return ergebnis
if ergebnis.get("status") != "success":
ergebnis["error"] = f"Titel {nr}: {ergebnis.get('error')}"
ergebnis["lesefehler"] = lesefehler
return ergebnis
alle_dateien = ergebnis.get("files", []) # kumulativ: run_makemkv listet den Ordner
return {"status": "success", "output_dir": output_dir, "files": alle_dateien,
"lesefehler": lesefehler}
def laengster_titel(dauern: dict, meta: dict = None):
"""Titel-Nummer für den Hauptfilm (pure Funktion).
Mit meta['runtime'] wird heuristisch der Titel gesucht, dessen Laufzeit am nächsten
an der TMDB-Laufzeit liegt (vermeidet 'Play All'-Tracks, die länger als der Film sind).
Ohne runtime wird klassisch der längste Titel gewählt.
"""
if not dauern:
return None
ziel_s = (meta.get("runtime") or 0) * 60 if meta else 0
if ziel_s > 0:
bester = min(dauern, key=lambda t: abs(dauern[t] - ziel_s))
if abs(dauern[bester] - ziel_s) <= 20 * 60:
return bester
return max(dauern, key=dauern.get)
def episoden_titel(dauern: dict, meta: dict) -> list:
"""Findet Episoden-Titel anhand der TMDB-Episoden-Laufzeiten.
Toleranz: +/- 10 Minuten (600 Sekunden) zum Durchschnitt.
Gibt eine sortierte Liste der gefundenen Titelnummern zurück.
"""
if not dauern or not meta or not meta.get("episode_runtimes"):
return []
runtimes_s = [r * 60 for r in meta["episode_runtimes"]]
avg_runtime = sum(runtimes_s) / len(runtimes_s)
gefundene_titel = []
for titel, dauer in dauern.items():
if abs(dauer - avg_runtime) <= 10 * 60:
gefundene_titel.append(int(titel))
return sorted(gefundene_titel)
def lies_titel_dauern(device_path: str, timeout: int = 300) -> dict:
"""Fragt die Titel-Laufzeiten der Disc ab (makemkvcon info, Robot-Mode)."""
try:
ergebnis = subprocess.run(
[werkzeug("makemkv"), "-r", "--noscan", "info", makemkv_quelle(device_path)],
capture_output=True, timeout=timeout, # binaer: siehe text_von
creationflags=OHNE_FENSTER,
)
except (OSError, subprocess.TimeoutExpired):
return {}
return parse_tinfo_dauern(text_von(ergebnis.stdout))
def parse_scan_dauer(ausgabe: str) -> int:
"""HandBrake-Scan-Ausgabe → Dauer in Sekunden (pure Funktion, testbar).
HandBrakeCLI --scan schreibt je Titel "+ duration: HH:MM:SS"
(handbrake.fr/docs, Scan-Ausgabe); bei einer MKV-Datei gibt es genau
einen Titel.
"""
treffer = re.search(r"\+ duration: (\d+):(\d{2}):(\d{2})", ausgabe)
if not treffer:
return 0
h, m, s = (int(g) for g in treffer.groups())
return h * 3600 + m * 60 + s
def lies_datei_dauer(pfad: str, timeout: int = 120) -> int:
"""Laufzeit einer MKV-Datei in Sekunden (HandBrakeCLI --scan)."""
if not check_handbrake_installed():
return 0
try:
ergebnis = subprocess.run(
[werkzeug("handbrake"), "--scan", "-i", pfad],
capture_output=True, timeout=timeout, **HB_LESEN,
creationflags=OHNE_FENSTER,
)
except (OSError, subprocess.TimeoutExpired):
return 0
return parse_scan_dauer((ergebnis.stdout or "") + (ergebnis.stderr or ""))
_MSG_RE = re.compile(r'^MSG:(\d+),\d+,\d+,"((?:[^"\\]|\\.)*)"')
def parse_msg(zeile: str):
"""MSG-Zeile -> (code, klartext) oder None (pure Funktion, testbar).
Format laut https://www.makemkv.com/developers/usage.txt:
MSG:code,flags,count,"message","format","param0",... — Feld 4 ist der
fertig zusammengesetzte Klartext.
Bis zum 25.07.2026 stand hier line.split(",", 4)[3]: das schnitt JEDE
Meldung ab, die selbst ein Komma enthält — und MakeMKV schreibt solche
laufend ("Title #1 has length of 12 seconds, which is less than ...").
Deshalb eine Regex, die die Anführungszeichen respektiert.
"""
treffer = _MSG_RE.match(zeile.strip())
if not treffer:
return None
return int(treffer.group(1)), treffer.group(2).replace('\\"', '"')
def get_progress_from_prgv(line: str) -> int:
"""Extrahiert Gesamt-Fortschritt (0-100) aus einer PRGV-Zeile.
Format laut MakeMKV-Doku: PRGV:current,total,max
`total` ist der Gesamtfortschritt, `max` die Skala (65536).
"""
match = re.match(r"PRGV:(\d+),(\d+),(\d+)", line.strip())
if not match:
return -1
total, maximum = int(match.group(2)), int(match.group(3))
if maximum <= 0:
return -1
return min(100, int(total * 100 / maximum))
def check_handbrake_installed() -> bool:
"""Prüft, ob HandBrakeCLI installiert ist."""
return bool(werkzeuge.finden("handbrake"))
DEFAULT_HB_PRESET = "H.265 MKV 1080p30"
# Ein Preset je Disc-Typ (Befund 25.07.2026). Vorher galt EIN Preset für
# alles: eine 4K-UHD wurde damit auf 1080p heruntergerechnet und eine DVD auf
# 1080p hochskaliert — beides Unsinn, und beim ersten UHD-Rip wäre die 4K-
# Auflösung still verlorengegangen. Den Disc-Typ kennt Rippy ohnehin.
PRESET_SCHLUESSEL = {
"dvd": "transcodePresetDvd",
"bluray": "transcodePresetBluray",
"uhd": "transcodePresetUhd",
}
# Reservierter Wert im Preset-Feld eines Disc-Typs: „diesen Typ NICHT
# komprimieren". Kein HandBrake-Preset heißt so (gegengeprüft gegen alle 90 aus
# --preset-list), deshalb kann er nicht mit einem echten Namen kollidieren.
#
# Warum es das gibt (Befund 25.07.2026): Ein 4K-HEVC-Encode brauchte auf der
# Rippy-VM gemessene 28-55 Stunden, weil die CPU kein AVX2 hat. Bis hierher gab
# es nur einen globalen Schalter — entweder ALLES komprimieren oder nichts. Wer
# 4K verlustfrei behalten und DVDs trotzdem schrumpfen wollte, hatte keine
# Möglichkeit. Genau die ist das hier.
PRESET_KEINE = "keine"
def komprimieren_fuer(disc_type: str, einstellungen: dict) -> bool:
"""Soll dieser Disc-Typ nach dem Rip komprimiert werden? (pure, testbar)
Der globale Schalter `transcodeEnabled` schlägt alles. Ist er an, kann ein
einzelner Disc-Typ abgewählt werden, indem sein Preset auf PRESET_KEINE
steht — dann bleibt die verlustfreie Datei aus dem Rip stehen.
"""
einstellungen = einstellungen or {}
if not einstellungen.get("transcodeEnabled", True):
return False
schluessel = PRESET_SCHLUESSEL.get(disc_type or "")
if schluessel and (einstellungen.get(schluessel) or "").strip() == PRESET_KEINE:
return False
return True
def preset_fuer(disc_type: str, einstellungen: dict) -> str:
"""Welches HandBrake-Preset gilt für diesen Disc-Typ? (pure, testbar)
Reihenfolge: Preset des Disc-Typs → allgemeines transcodePreset →
DEFAULT_HB_PRESET. Diese Kette hält Bestandsinstallationen am Leben, in
denen nur `transcodePreset` gesetzt ist: solange der Nutzer die neuen
Felder nicht speichert, ändert sich sein Verhalten nicht.
PRESET_KEINE gilt hier absichtlich als „nicht gesetzt" und wird
übersprungen: Diese Funktion darf NIE den Reservewert als Preset-Namen
zurückgeben, sonst bekäme HandBrake `--preset keine` und scheiterte. Ob
überhaupt komprimiert wird, entscheidet komprimieren_fuer(). Wer über
„Neu komprimieren" ausdrücklich doch komprimieren will, bekommt so ein
brauchbares Preset statt eines Fehlers.
"""
einstellungen = einstellungen or {}
schluessel = PRESET_SCHLUESSEL.get(disc_type or "")
if schluessel:
eigen = (einstellungen.get(schluessel) or "").strip()
if eigen and eigen != PRESET_KEINE:
return eigen
return (einstellungen.get("transcodePreset") or "").strip() or DEFAULT_HB_PRESET
#: Endung -> HandBrake-Container. Am mitgelieferten HandBrake 1.11.2
#: gegengeprueft (`--help`, Abschnitt `-f, --format`).
FORMATE = {".mkv": "av_mkv", ".mp4": "av_mp4", ".m4v": "av_mp4",
".mov": "av_mov", ".webm": "av_webm"}
def build_handbrake_cmd(input_path: str, output_path: str,
preset: str = DEFAULT_HB_PRESET,
audio_sprachen=None, untertitel_sprachen=None) -> list:
"""Baut das HandBrake-Kommando für die Kompressions-Stufe (pure Funktion).
Arbeitet auf der MKV-DATEI aus dem MakeMKV-Rip — nie auf dem Laufwerk:
HandBrake kann AACS-verschlüsselte Blu-rays nicht lesen, MakeMKV bleibt
deshalb zwingend die erste Stufe. --all-audio/--all-subtitles behalten
alle Sprachen (Preset-Default wäre nur die erste Tonspur).
## Die Sprachauswahl greift HIER, nicht beim Rippen
Commander-Anforderung 26.07.2026. Bewusst in der KOMPRESSION und nicht im
Rip, aus drei Gründen:
1. Der Rip bleibt vollständig und verlustfrei — das ist ein Muss-Feature
(KONZEPT). Wer später eine andere Sprachwahl will, komprimiert neu, statt
die Disc wieder einzulegen.
2. MakeMKV kann Tracks nur über eine Auswahl-Profildatei filtern; HandBrake
hat dafür dokumentierte Schalter, die genau die Codes nehmen, die MakeMKV
liefert (ISO 639-2, „deu"/„eng" — beides am 26.07.2026 gegengeprüft).
3. Wird ein Disc-Typ NICHT komprimiert (4K verlustfrei), bleiben ohnehin alle
Sprachen drin — und das ist bei einer verlustfreien Ablage richtig.
`--audio-lang-list` zusammen mit `--first-audio` heißt: HandBrake pickt pro
Sprache genau die erste (beste) Tonspur heraus. `--aencoder copy` reicht
diese dann verlustfrei durch, statt sie auf Stereo herunterzurechnen.
"""
befehl = [
werkzeug("handbrake"),
"--input", input_path,
"--output", output_path,
"--preset", preset,
]
# Den Container zur Endung erzwingen (Befund 29.08.2026).
#
# Commander: „Kompression fehlgeschlagen bei title_t00.mkv: HandBrake
# endete mit Code 0"
#
# Code 0 heisst bei HandBrake ERFOLG — und trotzdem lag am erwarteten Ort
# keine Datei. Der Grund: **HandBrake bestimmt den Container aus dem
# Preset, nicht aus der Endung.** Steht ein MP4-Preset ein, schreibt es
# `title_t00.mp4` neben das verlangte `title_t00.mkv`, meldet „Output
# format changed" — und beendet sich mit 0. Rippy sah an seiner Stelle
# nichts und nannte das „fehlgeschlagen".
#
# `-f/--format` ist der dokumentierte Schalter dafuer (an dem
# mitgelieferten HandBrake 1.11.2 gegengeprueft: av_mp4, av_mov, av_mkv,
# av_webm). Damit sind Endung und Container EINE Entscheidung statt zwei,
# die auseinanderlaufen koennen.
format_name = FORMATE.get(os.path.splitext(output_path)[1].lower())
if format_name:
befehl += ["--format", format_name]
audio = [s for s in (audio_sprachen or []) if s]
if audio:
befehl += ["--audio-lang-list", ",".join(audio)]
befehl.append("--first-audio")
# `--aencoder`, NICHT `--audio-codec` (Befund 30.08.2026).
#
# Den Schalter `--audio-codec` gibt es bei HandBrakeCLI nicht und hat es
# nie gegeben — er heisst `-E` / `--aencoder`. Am mitgelieferten
# HandBrakeCLI 1.11.2 nachgestellt, mit Rippys eigener Befehlszeile:
#
# unknown option (--audio-codec)
# HandBrake has exited. $? = 0
#
# Ein ganzer Blu-ray-Rip (16,5 GB) lief damit ins Leere: HandBrake war
# in derselben Sekunde wieder weg, in der es startete, und meldete das
# als ERFOLG. Der Test darunter forderte den falschen Namen sogar ein
# (`assert "--audio-codec" in cmd`) — ein Test, der einen Fehler
# festschreibt, statt ihn zu finden.
#
# `copy` ist als Wert gueltig (in `--help` gelistet, ebenda geprueft).
befehl += ["--aencoder", "copy", "--audio-fallback", "av_aac"]
untertitel = [s for s in (untertitel_sprachen or []) if s]
if untertitel:
befehl += ["--subtitle-lang-list", ",".join(untertitel)]
befehl.append("--all-subtitles")
return befehl
HB_ENCODE_RE = re.compile(
r'^Encoding:\s*task\s+(\d+)\s+of\s+(\d+),\s*(\d+(?:\.\d+)?)\s*%'
)
def get_progress_from_line(line: str) -> int:
"""Extrahiert den ENCODE-Fortschritt in Prozent aus HandBrake-Ausgabe.
Gibt **-1** zurück, wenn die Zeile kein Encode-Fortschritt ist — genau wie
get_progress_from_prgv. Sonst wäre ein echtes „0 %" nicht von „keine
Angabe" zu unterscheiden, und der Anfang des Encodes bliebe unsichtbar.
Formatstrings aus dem HandBrakeCLI-Binary im Worker-Image gelesen
(AGENTS Regel D — nicht aus dem Kopf):
Scanning title %d of %d, preview %d, %.2f %% → Scan, NICHT Encode
Encoding: task %d of %d, %.2f %% (%.2f fps, avg → Encode
Encoding: task %d of %d, Searching for start time, %.2f → Vorlauf, NICHT Encode
Befund 25.07.2026 (Akira-UHD, live gemessen): Die alte Regex nahm JEDE
Zahl vor einem Prozentzeichen — also auch den Scan-Durchlauf, der VOR dem
Encodieren einmal bis 100 % hochläuft. Die Anzeige stand deshalb auf 99 %,
während der eigentliche Encode bei 1 % war. Zusammen mit dem alten Filter
`progress > 0` im Aufrufer blieb sie dort stundenlang kleben, weil
int(0.87) == 0 verworfen wurde.
`task N of M` wird mitgerechnet: Presets mit zwei Durchläufen zählen die
Prozente je Durchlauf neu, sonst sprang die Anzeige mitten in der Datei
zurück auf 0.
"""
match = HB_ENCODE_RE.match(line.strip())
if not match:
return -1
aktuell, gesamt, prozent = int(match.group(1)), int(match.group(2)), float(match.group(3))
if gesamt < 1 or aktuell < 1:
return -1
return int(((aktuell - 1) * 100 + prozent) / gesamt)
def sprachliste(wert) -> list:
"""„deu, eng , ,DEU" → ["deu", "eng"] (pure Funktion).
Nimmt Komma-Text aus den Einstellungen oder eine fertige Liste. Doppelte und
Leeres fliegen raus, Kleinschreibung wird erzwungen — HandBrake will
ISO-639-2 in Kleinbuchstaben, und ein „DEU" aus einem Eingabefeld soll nicht
still die ganze Auswahl unwirksam machen.
"""
if not wert:
return []
teile = wert.split(",") if isinstance(wert, str) else list(wert)
sauber = []
for t in teile:
code = str(t).strip().lower()
if code and code not in sauber:
sauber.append(code)
return sauber
def _datei_daneben(erwartet: str) -> str:
"""Dieselbe Datei mit anderer Endung im selben Ordner — oder "".
HandBrake waehlt den Container nach dem Preset. Passt er nicht zur
verlangten Endung, liegt das Ergebnis unter demselben Namen mit anderer
Endung daneben (Befund 29.08.2026). Gesucht wird nur in den Endungen, die
HandBrake ueberhaupt schreiben kann.
"""
ordner = os.path.dirname(erwartet)
stamm = os.path.splitext(os.path.basename(erwartet))[0]
for endung in FORMATE:
kandidat = os.path.join(ordner, stamm + endung)
if kandidat != erwartet and os.path.isfile(kandidat):
return kandidat
return ""
def run_handbrake(input_path: str, output_path: str, preset: str = DEFAULT_HB_PRESET,
progress_cb=None, abbruch_cb=None,
audio_sprachen=None, untertitel_sprachen=None) -> dict:
"""Komprimiert eine MKV-Datei mit HandBrakeCLI; meldet Fortschritt.
`abbruch_cb` wird bei JEDER Ausgabezeile aufgerufen und soll RipAbbruch
werfen, wenn der Nutzer abgebrochen hat. Bewusst ein eigener Kanal neben
`progress_cb` — aus demselben Grund wie `log_cb` bei run_makemkv: der
Fortschritts-Callback verwirft Aufrufe mit unveränderter Prozentzahl, und
genau daran hing der Abbruch früher. Bei einem 4K-Encode mit einem Prozent
je halber Stunde sah „Abbrechen" deshalb minutenlang wirkungslos aus
(Befund 25.07.2026). Zusätzlich greift es damit auch während des
Scan-Durchlaufs, der gar keine Encode-Prozente ausgibt.
"""
if not check_handbrake_installed():
return {"status": "error", "error": "HandBrakeCLI ist nicht installiert"}
try:
process = subprocess.Popen(
build_handbrake_cmd(input_path, output_path, preset,
audio_sprachen, untertitel_sprachen),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
bufsize=1,
creationflags=OHNE_FENSTER,
**HB_LESEN,
)
return _handbrake_schleife(process, output_path, abbruch_cb, progress_cb)
except Exception as e:
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 ""
#: Wie viele Ausgabezeilen von HandBrake fuer den Fehlerfall aufgehoben werden.
HB_ZEILEN_PUFFER = 12
#: ... und wie viele davon in die Fehlermeldung wandern. Sie steht in der
#: Jobzeile und im UI; zwoelf Zeilen Muxer-Statistik waeren dort unlesbar.
HB_ZEILEN_MELDUNG = 4
_UNBEKANNTER_SCHALTER = re.compile(r"unknown option \(([^)]*)\)")
def unbekannter_schalter(zeile: str) -> str:
"""Meldet diese Zeile einen Schalter, den DIESES HandBrake nicht kennt?
## Der Befund des Commanders (30.08.2026)
> „Kompression fehlgeschlagen bei Spartacus … _t01.mkv: HandBrake endete
> mit Code 0 — Roh-Datei bleibt erhalten"
16,5 GB Rohschnitt, und die Kompression war in derselben Sekunde vorbei,
in der sie begann — fuer einen Scan-Durchlauf haette das nicht gereicht.
Am mitgelieferten HandBrakeCLI 1.11.2 nachgestellt, mit genau der
Befehlszeile, die Rippy baute:
unknown option (--audio-codec)
HandBrake has exited.
$? = 0
**Der Rueckgabewert ist 0.** HandBrake meldet einen Tippfehler in seiner
eigenen Befehlszeile als ERFOLG. Rippy sah nur „Code 0" und keine Datei —
und riet daraufhin auf „Zielordner nicht beschreibbar". Das war falsch,
und es schickte die Suche in die vollkommen falsche Richtung.
Der Schalter ist repariert (siehe `build_handbrake_cmd`). Diese Pruefung
bleibt trotzdem: Der naechste falsche Schalter soll sich SELBST melden,
statt wieder einen ganzen Rip zu kosten.
"""
treffer = _UNBEKANNTER_SCHALTER.search(zeile or "")
return treffer.group(1).strip() if treffer else ""
def hb_schluss(zeilen) -> str:
"""HandBrakes letzte Worte als Anhang fuer eine Fehlermeldung (pure).
Bis zum 30.08.2026 warf `_handbrake_schleife` jede Zeile weg, die kein
Fortschritt war. Im Fehlerfall blieb damit nur der Rueckgabewert uebrig —
und wenn der 0 ist, sagt er nichts. Der Grund stand die ganze Zeit in der
Ausgabe, nur hoerte niemand zu.
"""
sauber = [z.strip() for z in (zeilen or []) if z and z.strip()]
if not sauber:
return ""
return " — HandBrake sagte zuletzt: " + " | ".join(sauber[-HB_ZEILEN_MELDUNG:])
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 = ""
falscher_schalter = ""
# Die letzten Zeilen aufheben — im Fehlerfall sind sie die einzige
# Auskunft, die es ueberhaupt gibt (siehe `hb_schluss`).
letzte_zeilen = []
try:
for line in process.stdout:
# Zuerst der Abbruch — unabhängig davon, ob die Zeile überhaupt
# einen Fortschritt enthält. Sonst hängt „Abbrechen" daran, dass
# sich die Prozentzahl bewegt (Befund 25.07.2026).
if abbruch_cb:
abbruch_cb()
if line.strip():
letzte_zeilen.append(line)
if len(letzte_zeilen) > HB_ZEILEN_PUFFER:
del letzte_zeilen[0]
if not falsches_preset:
falsches_preset = unbekanntes_preset(line)
if not falscher_schalter:
falscher_schalter = unbekannter_schalter(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
# einem 4K-Encode sind das Stunden ohne sichtbare Bewegung.
if progress >= 0 and progress_cb:
progress_cb(progress)
except RipAbbruch:
process.kill()
process.wait()
return {"status": "cancelled", "error": "Abgebrochen durch Nutzer"}
process.wait()
if process.returncode == 0 and os.path.exists(output_path):
return {"status": "success", "output_path": output_path}
# Ein Schalter, den DIESES HandBrake nicht kennt, ist ein Fehler in
# RIPPY — und er kommt mit Rueckgabewert 0 daher (siehe
# `unbekannter_schalter`). Deshalb steht die Pruefung VOR allen
# anderen: sonst landet der Fall unten bei „HandBrake endete mit Code
# 0", und dort ist er nicht zu erraten. Genau das kostete am
# 30.08.2026 einen fertigen 16,5-GB-Rip.
if falscher_schalter:
return {
"status": "error",
"error": (
'Rippy hat HandBrake den Schalter „%s" übergeben, den '
'diese HandBrake-Fassung nicht kennt. Das ist ein Fehler '
'in Rippy, keine Einstellung — bitte melden.' % falscher_schalter
+ hb_schluss(letzte_zeilen)
),
"return_code": process.returncode,
}
# Code 0, aber die Datei fehlt: HandBrake hat sie woanders hingeschrieben.
#
# Commander 29.08.2026: „Kompression fehlgeschlagen bei title_t00.mkv:
# HandBrake endete mit Code 0". Code 0 heisst ERFOLG — HandBrake meldet
# Fehler notorisch nicht ueber den Rueckgabewert. Der haeufigste Fall ist
# der Container: Er kommt aus dem PRESET, nicht aus der Endung. Ein
# MP4-Preset schreibt `title_t00.mp4` neben das verlangte `.mkv`.
#
# Verhindert wird das jetzt mit `--format` (siehe build_handbrake_cmd).
# Dieser Zweig bleibt trotzdem: Er FINDET die Datei, statt einen
# erfolgreichen Lauf wegzuwerfen — und sagt, was passiert ist.
if process.returncode == 0:
daneben = _datei_daneben(output_path)
if daneben:
return {"status": "success", "output_path": daneben,
"hinweis": "HandBrake hat %s geschrieben statt %s "
"(der Container kommt aus dem Preset)."
% (os.path.basename(daneben),
os.path.basename(output_path))}
# Frueher stand hier geraten „Meist ist der Zielordner nicht
# beschreibbar". Am 30.08.2026 war das falsch (der Ordner war da
# und leer, der Grund ein falscher Schalter) — und die Vermutung
# schickte die Suche in die falsche Richtung. Jetzt wird der
# Ordner GENANNT und HandBrake selbst zitiert.
return {
"status": "error",
"error": ("HandBrake meldet Erfolg, aber es ist keine Datei "
"entstanden (Zielordner: %s)."
% os.path.dirname(output_path)
+ hb_schluss(letzte_zeilen)),
"return_code": 0,
}
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}"
+ hb_schluss(letzte_zeilen)),
"return_code": process.returncode,
}
def build_abcde_cmd(device_path: str, config_path: str, auswerfen: bool = True) -> list:
"""Baut das abcde-Kommando (pure Funktion, testbar).
-o = Ausgabeformat (flac), -N = nicht-interaktiv, -x = Eject am Ende,
-c = Config-Datei (enthält OUTPUTDIR). NIE ein Verzeichnis an -o geben.
Befund 25.07.2026: `-x` stand fest verdrahtet drin. Damit warf eine
Audio-CD IMMER aus, während eine DVD/Blu-ray NIE auswarf — und die
Einstellung „Automatischer Auswurf" regelte keines von beidem, weil sie
nirgends gelesen wurde. Jetzt entscheidet sie hier mit.
"""
cmd = [
"abcde",
"-d", device_path,
"-o", "flac",
"-N",
]
if auswerfen:
cmd.append("-x")
cmd += ["-c", config_path]
return cmd
def write_abcde_config(output_dir: str) -> str:
"""Schreibt eine minimale abcde-Config mit dem Zielverzeichnis, gibt Pfad zurück."""
tmp = tempfile.NamedTemporaryFile(
"w", suffix=".abcde.conf", delete=False, encoding="utf-8"
)
tmp.write(f"OUTPUTDIR='{output_dir}'\nINTERACTIVE=n\n")
tmp.close()
return tmp.name
def disc_fehlt(device_path: str, zustand=None) -> str:
"""Liegt ueberhaupt eine Disc drin? Klartext-Grund oder "" (alles gut).
## Warum vorher nachgesehen wird (Befund 29.08.2026)
Der Commander bekam:
makemkvcon endete mit Code 11 — letzte Meldung:
Das Öffnen der Disk schlug fehl — keine MKV-Datei entstanden
Auffaellig war, was FEHLTE: kein „Ursache:". Waere der Geraetepfad schuld
gewesen, stuende dort MSG 2024. Bleibt: kein Datentraeger im Laufwerk.
Nach einem fertigen Rip wirft Rippy die Disc aus — ein zweiter Versuch
trifft dann ein leeres Laufwerk, laeuft zwei Minuten in makemkvcon hinein
und meldet einen Satz, aus dem niemand das schliessen kann.
Nachsehen kostet Millisekunden und erspart genau diesen Satz.
⚠️ Ein FEHLGESCHLAGENER Blick ist kein Grund, den Rip zu verweigern. Wer
das Laufwerk gerade nicht lesen kann, weiss nicht, dass es leer ist — und
„ich weiss es nicht" darf nie zu „es geht nicht" werden.
"""
if zustand is None:
try:
from rippy import drives as _schicht
zustand = _schicht.treiber().drive_status
except Exception: # noqa: BLE001
return ""
try:
stand = zustand(device_path)
except Exception: # noqa: BLE001
return ""
if stand == CDS_NO_DISC:
return ("Kein Datentraeger im Laufwerk %s. Nach einem fertigen Rip "
"wirft Rippy die Disc aus — fuer einen neuen Rip muss sie "
"wieder hinein." % device_path)
if stand == CDS_TRAY_OPEN:
return "Die Schublade von %s ist offen." % device_path
return ""
def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str = "all",
log_cb=None) -> dict:
"""Rippt eine DVD/Blu-ray verlustfrei mit makemkvcon; meldet Fortschritt.
log_cb(code, text) bekommt JEDE MakeMKV-Meldung. Bewusst ein eigener
Kanal statt progress_cb: der Fortschritts-Callback in tasks.py verwirft
Aufrufe, bei denen sich die Prozentzahl nicht geaendert hat — Meldungen
wären dort also großteils verschwunden. Ohne diesen Kanal war am
25.07.2026 nicht von aussen erkennbar, dass MakeMKV bei der UHD-Disc
nicht einmal versucht, einen Schluessel zu holen (siehe makemkv_daten).
"""
if not check_makemkv_installed():
return {"status": "error", "error": "makemkvcon ist nicht installiert"}
fehlt = disc_fehlt(device_path)
if fehlt:
return {"status": "error", "error": fehlt}
os.makedirs(output_dir, exist_ok=True)
try:
process = subprocess.Popen(
build_makemkv_cmd(device_path, output_dir, titel),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
# BINAER lesen und selbst dekodieren (siehe `text_von`):
# `text=True` nahm die Gebietsschema-Kodierung, makemkvcon
# schrieb aber UTF-8 — daraus wurde beim Commander
# „Das Öffnen der Disk schlug fehl".
#
# bufsize=0 statt 1: Zeilenpufferung gibt es im Binaermodus
# nicht (Python warnt und puffert doch). Ungepuffert kommt
# jede Fortschrittszeile sofort an — bei einem Rip, der
# Stunden laeuft, ist das der Unterschied zwischen einem
# Balken und einem eingefrorenen Fenster.
bufsize=0,
creationflags=OHNE_FENSTER,
)
letzte_meldung = ""
# Lesefehler sind KEIN Abbruchgrund — MakeMKV ueberspringt den
# kaputten Titel und macht mit dem naechsten weiter. Genau deshalb
# muessen sie gesagt werden: Am 30.08.2026 endete ein Rip als
# „erfolgreich", obwohl von zwei Titeln nur einer ankam. Im
# Jobprotokoll stand „Rip fertig" — dass ein Titel fehlt, war nur
# den MakeMKV-Zeilen zu entnehmen, die niemand liest.
lesefehler = False
# Kritische Meldungen einsammeln: die LETZTE Zeile ist fast immer nur
# "Failed to open disc" — die URSACHE ("volume key is unknown", Key
# abgelaufen) steht Zeilen davor und ging im Fehlertext verloren
# (Befund 24.07., Summer-Wars-UHD).
kritische_meldungen = []
# Die Textmuster bleiben als Rueckfall fuer englische Installationen —
# die MELDUNGSNUMMERN sind aber die verlaessliche Quelle. Beim
# Commander meldet MakeMKV deutsch, also traf hier am 29.08.2026 kein
# einziges Muster: Im Fehler stand nur „Das Öffnen der Disk schlug
# fehl", waehrend die Ursache eine Zeile darueber („Der Testzeitraum
# ist abgelaufen", MSG 5051) unter den Tisch fiel.
KRITISCH = (
"volume key is unknown",
"evaluation period has expired",
"registration key",
"Too old version",
)
try:
for rohzeile in process.stdout:
line = text_von(rohzeile)
progress = get_progress_from_prgv(line)
if progress >= 0:
if progress_cb:
progress_cb(progress)
continue
meldung = parse_msg(line)
if meldung is None:
continue
code, letzte_meldung = meldung
if LESEFEHLER_MARKE in letzte_meldung:
lesefehler = True
if code in KRITISCHE_CODES:
kritische_meldungen.append(
"%s (%s)" % (KRITISCHE_CODES[code], letzte_meldung.strip()))
elif any(muster in letzte_meldung for muster in KRITISCH):
kritische_meldungen.append(letzte_meldung)
if log_cb:
try:
log_cb(code, letzte_meldung)
except RipAbbruch:
raise
except Exception:
pass # Protokollieren darf einen laufenden Rip nie beenden
except RipAbbruch:
process.kill()
process.wait()
return {"status": "cancelled", "error": "Abgebrochen durch Nutzer"}
process.wait()
mkv_dateien = [
os.path.join(output_dir, f)
for f in sorted(os.listdir(output_dir))
if f.endswith(".mkv")
]
if process.returncode == 0 and mkv_dateien:
return {
"status": "success",
"output_dir": output_dir,
"files": mkv_dateien,
"return_code": process.returncode,
"lesefehler": lesefehler,
}
return {
"status": "error",
"lesefehler": lesefehler,
"error": (
f"makemkvcon endete mit Code {process.returncode}"
+ (f" — Ursache: {'; '.join(kritische_meldungen)}" if kritische_meldungen else "")
+ (f" — letzte Meldung: {letzte_meldung}" if letzte_meldung else "")
+ ("" if mkv_dateien else " — keine MKV-Datei entstanden")
),
"return_code": process.returncode,
}
except Exception as e:
return {"status": "error", "error": str(e)}
def rip_video(device_path: str, disc_id: str, disc_type: str = "dvd", progress_cb=None,
output_dir: str = None, nur_hauptfilm: bool = False,
titel_liste: list = None, log_cb=None, meta: dict = None) -> dict:
"""Rippt eine DVD oder Blu-ray verlustfrei mit MakeMKV.
Bewusst KEIN eigener Celery-Task: der einzige Task ist worker.tasks.rip_disc,
der hier mit seinem eigenen Fortschritts-Callback durchgreift. output_dir
überschreibt das Ziel (Transcode-Fall: Roh-Rip nach /app/temp).
titel_liste: exakte Titel-Auswahl aus dem UI (schlägt nur_hauptfilm).
nur_hauptfilm: Vorab-Info-Lauf, dann heuristisch den besten Titel rippen.
"""
if output_dir is None:
output_dir = os.path.join(RIP_OUTPUT_DIR, disc_type, disc_id)
if titel_liste:
os.makedirs(output_dir, exist_ok=True)
return rip_titel_auswahl(device_path, output_dir, titel_liste, progress_cb, log_cb)
# Serien-Erkennung: Wenn keine Titel explizit gewählt wurden und es eine Serie ist
if meta and meta.get("series") and meta.get("episode_runtimes"):
dauern = lies_titel_dauern(device_path)
episoden = episoden_titel(dauern, meta)
if episoden:
os.makedirs(output_dir, exist_ok=True)
return rip_titel_auswahl(device_path, output_dir, episoden, progress_cb, log_cb)
titel = "all"
if nur_hauptfilm:
dauern = lies_titel_dauern(device_path)
haupt = laengster_titel(dauern, meta)
if haupt is not None:
titel = str(haupt)
# Kein Titel ermittelbar → ehrlich auf 'all' zurückfallen statt raten
return run_makemkv(device_path, output_dir, progress_cb=progress_cb, titel=titel,
log_cb=log_cb)
def _rip_cd_windows(device_path: str, disc_id: str, progress_cb=None,
output_dir: str = None, auswerfen: bool = True) -> dict:
"""Audio-CD unter Windows: selbst lesen, mit FLAC kodieren.
Die Rechnung steht in `rippy.rip.audio_cd` (ohne Laufwerk pruefbar), das
Lesen im Windows-Treiber (der einzige Ort mit Win32-Aufrufen).
"""
from rippy.drives import windows as win
from rippy.rip import audio_cd
class Leser:
toc = staticmethod(win.audio_toc)
@staticmethod
def spur(geraet, start, sektoren, schreiben, fortschritt=None):
return win.audio_spur_lesen(geraet, start, sektoren, schreiben,
fortschritt)
# Erst die Disc bei MusicBrainz nachschlagen — daraus kommen sowohl der
# Ordnername als auch die Titel. Ohne Treffer heissen die Dateien
# `Track 01.flac`; das ist keine Ausrede, sondern die Wahrheit ueber eine
# Disc, die niemand kennt.
from rippy.rip import musicbrainz_cd
try:
spuren = win.audio_toc(device_path)
antwort = musicbrainz_cd.abfragen(
musicbrainz_cd.kennung_aus_spuren(spuren))
except Exception: # noqa: BLE001
antwort = None
tags = musicbrainz_cd.tags_je_spur(antwort)
ordner = musicbrainz_cd.album_ordner(antwort)
ziel = output_dir or os.path.join(RIP_OUTPUT_DIR, "cd", ordner or disc_id)
ergebnis = audio_cd.rippen(device_path, ziel, Leser(),
werkzeuge.finden("flac"),
fortschritt=progress_cb, tags_je_spur=tags)
if auswerfen and ergebnis.get("status") == "success":
wirf_disc_aus(device_path)
return ergebnis
def rip_cd(device_path: str, disc_id: str, progress_cb=None, output_dir: str = None,
auswerfen: bool = True) -> dict:
"""Rippt eine CD mit abcde (FLAC). `auswerfen` = Einstellung „Automatischer
Auswurf" (abcde macht das selbst per -x)."""
# ── Windows geht einen eigenen Weg (Befund 29.08.2026) ─────────────
#
# `abcde` und `cdparanoia` sind Linux-Werkzeuge; es gibt sie fuer Windows
# nicht, und ein Nachbau ihrer Shell-Logik waere ein zweites Projekt.
# Windows bietet stattdessen zwei Steuercodes an, mit denen sich eine
# Audio-CD direkt lesen laesst — `rippy.rip.audio_cd`, kodiert wird mit
# dem FLAC-Encoder aus dem Werkzeug-Katalog.
#
# Das war die letzte offene Luecke des Windows-Betriebs; sie stand seit
# Wochen so im SAVEPOINT.
if os.name == "nt":
return _rip_cd_windows(device_path, disc_id, progress_cb, output_dir,
auswerfen)
if not check_abcde_installed():
return {
"status": "error",
"error": "abcde ist nicht installiert. Installiere abcde und cdparanoia für CD-Ripping."
}
if not check_cdparanoia_installed():
return {
"status": "error",
"error": "cdparanoia ist nicht installiert. Installiere abcde und cdparanoia für CD-Ripping."
}
if output_dir is None:
output_dir = os.path.join(RIP_OUTPUT_DIR, "cd", disc_id)
os.makedirs(output_dir, exist_ok=True)
def melde(progress: int, message: str = ""):
if progress_cb:
progress_cb(progress, message)
config_path = write_abcde_config(output_dir)
try:
process = subprocess.Popen(
build_abcde_cmd(device_path, config_path, auswerfen=auswerfen),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
creationflags=OHNE_FENSTER,
)
try:
for line in process.stdout:
melde(50, line.strip()[:200])
except RipAbbruch:
process.kill()
process.wait()
return {"status": "cancelled", "error": "Abgebrochen durch Nutzer"}
process.wait()
if process.returncode == 0:
melde(90, "MusicBrainz Lookup abgeschlossen")
return {
"status": "success",
"output_dir": output_dir,
"return_code": process.returncode
}
return {
"status": "error",
"error": f"abcde failed with code {process.returncode}",
"return_code": process.returncode
}
except Exception as e:
return {"status": "error", "error": str(e)}
finally:
try:
os.unlink(config_path)
except OSError:
pass