"""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") 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) -> 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 alle Titel der Disc verlustfrei als MKV """ return [ "makemkvcon", "-r", "--noscan", "--progress=-same", "mkv", f"dev:{device_path}", "all", output_dir, ] 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" 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", ] def get_progress_from_line(line: str) -> int: """Extrahiert Fortschritt in Prozent aus HandBrake-Ausgabe. Testfund 22.07.: echtes HandBrake schreibt „45.50 %" MIT Leerzeichen vor dem Prozentzeichen — eine Regex ohne \\s* parst NIE echte Ausgabe. """ match = re.search(r'(\d+\.\d+)\s*%', line) if match: return int(float(match.group(1))) return 0 def run_handbrake(input_path: str, output_path: str, preset: str = DEFAULT_HB_PRESET, progress_cb=None) -> dict: """Komprimiert eine MKV-Datei mit HandBrakeCLI; meldet Fortschritt.""" 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 ) for line in process.stdout: progress = get_progress_from_line(line) if progress > 0 and progress_cb: progress_cb(progress) process.wait() if process.returncode == 0 and os.path.exists(output_path): return {"status": "success", "output_path": output_path} return { "status": "error", "error": f"HandBrake endete mit Code {process.returncode}", "return_code": process.returncode, } except Exception as e: return {"status": "error", "error": str(e)} def build_abcde_cmd(device_path: str, config_path: str) -> 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. """ return [ "abcde", "-d", device_path, "-o", "flac", "-N", "-x", "-c", config_path ] 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) -> dict: """Rippt eine DVD/Blu-ray verlustfrei mit makemkvcon; meldet Fortschritt.""" 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), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1 ) letzte_meldung = "" for line in process.stdout: progress = get_progress_from_prgv(line) if progress >= 0 and progress_cb: progress_cb(progress) elif line.startswith("MSG:"): # MSG:code,flags,count,"message",... — Klartext ist Feld 4 teile = line.split(",", 4) if len(teile) >= 4: letzte_meldung = teile[3].strip('"') 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" — 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) -> 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). """ if output_dir is None: output_dir = os.path.join(RIP_OUTPUT_DIR, disc_type, disc_id) return run_makemkv(device_path, output_dir, progress_cb=progress_cb) def rip_cd(device_path: str, disc_id: str, progress_cb=None, output_dir: str = None) -> dict: """Rippt eine CD mit abcde (FLAC).""" 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), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1 ) for line in process.stdout: melde(50, line.strip()[:200]) 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