Files
rippy/docker/worker/ripping.py
T
Hitonabi 35cfcbcb07
Ampel / ampel (push) Successful in 27s
style: echte Umlaute im ganzen Projekt + Installer im Rippy-Look
Commander-Rueckmeldung: Umlaute fehlen. Ausloeser war sichtbar der Installer -
"Diese Maschine uebernimmt die Video-Kompression fuer Rippy" stand woertlich im
Screenshot.

## Die .ps1-Falle war loesbar, nicht unumgehbar

Bisher galt: ausgelieferte .ps1 MUESSEN ASCII sein, weil PowerShell 5.1 sie
ohne BOM als ANSI liest. Das ist nur die halbe Wahrheit - gemessen mit echtem
powershell.exe 5.1:

    ohne BOM:  $s = "Größe: äöü"   + Unerwartetes Token -> Skript kaputt
    mit  BOM:  Groesse: aeoeue         + laeuft, Length 16 korrekt

Alle drei Skripte sind jetzt UTF-8 MIT BOM und tragen echte Umlaute; unter 5.1
gegengeprueft (BOM vorhanden, Parser fehlerfrei, Text korrekt gelesen). Der
Kopfkommentar sagt das jetzt richtig statt "ASCII-only".

## Umstellung: Text ja, Bezeichner nein

Umlaute gehoeren in Kommentare und Anzeigetexte, nicht in Funktionsnamen oder
Datenschluessel. Deshalb je Sprache das passende Werkzeug:

- Python: ueber den TOKENIZER - angefasst wurden ausschliesslich COMMENT- und
  STRING-Tokens. 156 Stellen. Code ist damit garantiert unberuehrt.
- TypeScript: nur // und /* */ Kommentare sowie JSX-Text (kann per Definition
  kein Bezeichner sein). 24 Stellen. `const waehlen`, `let laeuft`, `plaetze`,
  `GeraetInfo` sind nachweislich unversehrt.
- install.sh: Anzeigetext, aber die Shell-Funktionen (gruen/rot/gelb/titel) und
  der Schalter --nur-pruefen bleiben ASCII - das sind Schnittstellen.
- Markdown: 0 Aenderungen, die Doku hatte schon Umlaute.

ZWEI FEHLER MEINES KONVERTERS, beide von Werkzeugen gefangen:

1. In f-Strings steht in {...} CODE, kein Text. Aus f"{groesse}" wurde
   f"{größe}", waehrend die Variable groesse hiess - Ruff meldete F821
   "Undefined name". Der Konverter lagert Einsetzungen jetzt aus.
2. Ein Dict-Schluessel wurde umbenannt: die Wortliste enthaelt das PRAEFIX
   "uebersprung", der Tabu-Schutz prueft aber ganze Woerter. bericht[...] ist
   wieder ASCII - Umlaute in Datenschluesseln brechen JSON-Runden und DB-Felder.

## Installer im Rippy-Look

Statt hellgrau jetzt dieselben Toene wie das Web-UI (aus lib/design.ts
uebernommen): slate-900 Flaeche, dunkle Eingabefelder, Amber-Hauptknopf wie
"Los geht's" im Wizard. Oben ein Kopfbereich mit dem Farbverlauf
amber -> indigo -> purple und dem Disc-Symbol - in WinForms per Paint-Ereignis
gezeichnet, weil es dort keine Verlaeufe von der Stange gibt.

Geprueft, nicht gehofft: Das Fenster wurde headless in ein PNG gerendert
(DrawToBitmap) und angesehen - Verlauf, Symbol, Umlaute und Farben sitzen.
RippyWorkerSetup.exe neu gebaut (57344 -> 60416 Bytes); Umlaute und das
requireAdministrator-Manifest sind in der .exe verifiziert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 22:37:23 +02:00

655 lines
25 KiB
Python

"""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 — dasselbe ioctl wie in api/devices.py
CDROMEJECT = 0x5309
def wirf_disc_aus(device_path: str) -> bool:
"""Wirft die Disc aus; gibt zurück, ob es geklappt hat. Wirft NIE.
Befund 25.07.2026: Die Einstellung „Automatischer Auswurf" (Standard: ein,
Beschreibung „Disc nach erfolgreichem Ripping automatisch auswerfen") wurde
von niemandem gelesen — bei DVD/Blu-ray warf Rippy nie aus.
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.
"""
try:
from fcntl import ioctl
except ImportError: # Windows
return False
try:
fd = os.open(device_path, os.O_RDONLY | os.O_NONBLOCK)
try:
ioctl(fd, CDROMEJECT, 0)
finally:
os.close(fd)
return True
except OSError:
return False
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:<pfad> <titel> <ziel> '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:<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)]
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 _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."""
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()
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}
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