"""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 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).""" # include/uapi/linux/cdrom.h — dieselben ioctls wie in api/devices.py CDROMEJECT = 0x5309 CDROM_LOCKDOOR = 0x5329 # 1 = Tür verriegeln, 0 = entriegeln CDROM_DRIVE_STATUS = 0x5326 CDROMCLOSETRAY = 0x5319 # Antworten von CDROM_DRIVE_STATUS (cdrom.h) CDS_NO_DISC = 1 CDS_TRAY_OPEN = 2 CDS_DRIVE_NOT_READY = 3 CDS_DISC_OK = 4 # Wie lange auf die Schublade gewartet wird. Ein Laufwerk braucht dafür ein # bis zwei Sekunden; fünf sind reichlich und blockieren nichts Wichtiges. AUSWURF_WARTEN_SEKUNDEN = 5 def _auswurf_geglueckt(status: int) -> bool: """Ist die Disc nach dem Auswurf wirklich draußen? (pure Funktion) Sowohl „Schublade offen" als auch „kein Datenträger" zählen: Ein Slot-Laufwerk hat keine Schublade und meldet nach dem Auswerfen CDS_NO_DISC. """ return status in (CDS_TRAY_OPEN, CDS_NO_DISC) def wirf_disc_aus(device_path: str, ioctl_fn=None, oeffnen=None, schliessen=None, warten=None) -> bool: """Wirft die Disc aus und PRÜFT, ob sie draußen ist. Wirft NIE. ## Warum `CDROMEJECT` allein nicht genügt (Befund 26.07.2026, gemessen) Der Commander meldete: „Der Button gibt es in den Settings, aber es passiert nicht, das Laufwerk geht nicht auf." Am laufenden System nachgestellt: wirf_disc_aus("/dev/sr0") → True CDROM_DRIVE_STATUS danach → 4 (Disc drin) Das ioctl wird also **angenommen und tut nichts**. Ursache: MakeMKV verriegelt während des Rips die Laufwerkstür (`CDROM_LOCKDOOR 1`) und entriegelt sie nicht wieder. Ein verriegeltes Laufwerk quittiert den Auswurf trotzdem mit Erfolg. Deshalb macht das Werkzeug `eject` immer beides: erst entriegeln, dann auswerfen. Gegenprobe an derselben Disc: CDROM_LOCKDOOR 0 + CDROMEJECT → Status 2 (SCHUBLADE OFFEN) ## Und deshalb wird das Ergebnis geprüft, nicht geglaubt Genau diese Sorte Fehler ist zweimal durchgerutscht: In v3.14 stand hier „Auswurf tat nichts, jetzt entscheidet die Einstellung" — die Einstellung wurde danach wirklich gelesen, nur ausgeworfen wurde weiterhin nicht, und im Log stand „Disc ausgeworfen". Ein Rückgabewert eines ioctls beweist nichts; gefragt wird jetzt das Laufwerk. Bewusst hier und nicht in detection.py: das Modul ist ein byteweiser Zwilling der API-Kopie. `fcntl` gibt es nur unter Linux — der native Windows-Worker lädt ripping.py ebenfalls, rippt dort aber nie. Die drei Parameter sind nur zum Testen einspritzbar (kein echtes Laufwerk). """ if ioctl_fn is None: try: from fcntl import ioctl except ImportError: # Windows — dieser Worker rippt nie return False ioctl_fn = ioctl if warten is None: import time warten = time.sleep oeffnen = oeffnen or (lambda p: os.open(p, os.O_RDONLY | os.O_NONBLOCK)) schliessen = schliessen or os.close try: fd = oeffnen(device_path) except OSError: return False try: # Entriegeln ist der entscheidende Schritt. Scheitert er, wird der # Auswurf trotzdem versucht — bei einem nicht verriegelten Laufwerk # (oder einem, das das ioctl nicht kennt) klappt er ohnehin. try: ioctl_fn(fd, CDROM_LOCKDOOR, 0) except OSError: pass try: ioctl_fn(fd, CDROMEJECT, 0) except OSError: return False # Nachsehen statt hoffen: Die Schublade braucht ein bis zwei Sekunden. for _ in range(AUSWURF_WARTEN_SEKUNDEN): try: if _auswurf_geglueckt(ioctl_fn(fd, CDROM_DRIVE_STATUS, 0)): return True except OSError: return False warten(1) return False finally: try: schliessen(fd) except OSError: pass def check_makemkv_installed() -> bool: """Prüft, ob makemkvcon installiert ist.""" return shutil.which("makemkvcon") is not None 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 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: 'all' oder eine Titel-Nummer (Hauptfilm) """ return [ "makemkvcon", "-r", "--noscan", "--progress=-same", "mkv", f"dev:{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:,,,"" — 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)] def lies_titel_info(device_path: str, timeout: int = 300) -> list: """Volle Titel-Liste der eingelegten Disc (makemkvcon info, Robot-Mode).""" ergebnis = subprocess.run( ["makemkvcon", "-r", "--noscan", "info", f"dev:{device_path}"], capture_output=True, text=True, timeout=timeout, ) return parse_titel_info(ergebnis.stdout or "") 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 = [] 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) if ergebnis.get("status") == "cancelled": return ergebnis if ergebnis.get("status") != "success": ergebnis["error"] = f"Titel {nr}: {ergebnis.get('error')}" return ergebnis alle_dateien = ergebnis.get("files", []) # kumulativ: run_makemkv listet den Ordner return {"status": "success", "output_dir": output_dir, "files": alle_dateien} def laengster_titel(dauern: dict): """Titel-Nummer mit der längsten Laufzeit — der Hauptfilm (pure Funktion).""" if not dauern: return None return max(dauern, key=dauern.get) 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( ["makemkvcon", "-r", "--noscan", "info", f"dev:{device_path}"], capture_output=True, text=True, timeout=timeout, ) except (OSError, subprocess.TimeoutExpired): return {} return parse_tinfo_dauern(ergebnis.stdout or "") 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( ["HandBrakeCLI", "--scan", "-i", pfad], capture_output=True, text=True, timeout=timeout, ) 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 shutil.which("HandBrakeCLI") is not None 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 def build_handbrake_cmd(input_path: str, output_path: str, preset: str = DEFAULT_HB_PRESET) -> 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). """ return [ "HandBrakeCLI", "--input", input_path, "--output", output_path, "--preset", preset, "--all-audio", "--all-subtitles", ] 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 run_handbrake(input_path: str, output_path: str, preset: str = DEFAULT_HB_PRESET, progress_cb=None, abbruch_cb=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), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1 ) 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 "" 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 # einen Fortschritt enthält. Sonst hängt „Abbrechen" daran, dass # 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 # 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} 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}", "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 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"} 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, text=True, bufsize=1 ) letzte_meldung = "" # 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 = [] KRITISCH = ( "volume key is unknown", "evaluation period has expired", "registration key", "Too old version", ) try: for line in process.stdout: 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 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, } return { "status": "error", "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) -> 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 nur den LÄNGSTEN Titel rippen — das löst das eingestellte (vorher wirkungslose) mainFeatureOnly ein. """ 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) titel = "all" if nur_hauptfilm: dauern = lies_titel_dauern(device_path) haupt = laengster_titel(dauern) 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(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).""" 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 ) 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