Ampel / ampel (push) Successful in 1m24s
Commander: „mach es"
Gemeint war die Lücke, die seit Wochen so im SAVEPOINT stand: **Audio-CDs
laufen unter Windows nicht.** `cdparanoia` und `abcde` sind Linux-Werkzeuge.
## Nicht abcde nachbauen — den Windows-Weg gehen
Eine Audio-CD hat kein Dateisystem. Die `Track01.cda`, die Windows zeigt,
sind 44 Byte grosse Platzhalter; die Musik liegt roh in 2352-Byte-Sektoren.
Windows bietet dafuer zwei Steuercodes an:
IOCTL_CDROM_READ_TOC Inhaltsverzeichnis (MSF je Spur, Audio/Daten)
IOCTL_CDROM_RAW_READ die Sektoren selbst
Gegengeprueft, dass die berechneten Codes den dokumentierten entsprechen:
0x24000 und 0x2403e.
Kodiert wird mit dem FLAC-Encoder vom offiziellen Xiph-Spiegel (1.5.0), den
Rippy beim Einrichten holt — wie MakeMKV. KEIN Pflichtwerkzeug: Ohne ihn
laeuft alles ausser Audio-CDs, und ein Fehlschlag darf das Einrichten nicht
truebe machen.
## Zwei Fallen, beide gemessen
**Die Leseadresse zaehlt in 2048er-Einheiten**, obwohl ein Audio-Sektor 2352
Bytes hat. Das ist dokumentiert und sieht falsch aus; mit 2352 liest man an
der falschen Stelle.
**`flac.exe` braucht `libFLAC.dll` daneben.** Mit nur der exe endete jeder
Aufruf mit 0xC0000135 — „DLL nicht gefunden" — und zwar ohne eine einzige
Zeile Ausgabe. Jetzt wird der ganze Win64-Ordner ausgepackt.
## Was geprueft ist
Ein Laufwerk und eine Audio-CD lassen sich in der Ampel nicht herstellen.
Deshalb steht alles Rechenbare in reinen Funktionen — MSF↔LBA, das Zerlegen
der TOC-Bytes, WAV-Kopf, Blockaufteilung, Dateinamen — und der Ablauf
bekommt Laufwerk und Encoder eingespritzt. 28 Tests dafuer.
Zusaetzlich mit dem ECHTEN Encoder gemessen: zwei Spuren erzeugten Tons
gerippt, und **FLAC bestaetigt seine eigenen Dateien** (`flac -t`, Code 0).
Am echten Laufwerk gegengeprueft, dass das Inhaltsverzeichnis gelesen wird —
die eingelegte Blu-ray meldet sich korrekt als DATEN-Track und wird nicht als
Musik behandelt.
Was noch fehlt: MusicBrainz-Tags. Die Dateien heissen `Track 01.flac`. Der
Weg dafuer steht (`tags_je_spur`), die Disc-Kennung fuer die Abfrage nicht.
915 Tests gruen, ruff sauber.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1036 lines
42 KiB
Python
1036 lines
42 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
|
||
|
||
# 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.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
|
||
|
||
|
||
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 20–120 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 = []
|
||
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, 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, text=True, timeout=timeout,
|
||
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. `--audio-codec 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")
|
||
befehl += ["--audio-codec", "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,
|
||
text=True,
|
||
bufsize=1,
|
||
creationflags=OHNE_FENSTER,
|
||
)
|
||
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}
|
||
|
||
# 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))}
|
||
return {
|
||
"status": "error",
|
||
"error": ("HandBrake meldet Erfolg, aber es ist keine Datei "
|
||
"entstanden. Meist ist der Zielordner nicht "
|
||
"beschreibbar: %s" % os.path.dirname(output_path)),
|
||
"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}",
|
||
"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,
|
||
# 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 = ""
|
||
# 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 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,
|
||
}
|
||
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, 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)
|
||
|
||
ziel = output_dir or os.path.join(RIP_OUTPUT_DIR, "cd", disc_id)
|
||
ergebnis = audio_cd.rippen(device_path, ziel, Leser(),
|
||
werkzeuge.finden("flac"),
|
||
fortschritt=progress_cb)
|
||
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
|