4195854bf8
Vier neue reine Bausteine, jeder mit Tests. Sie beantworten Fragen, die Rippy bisher geraten oder gar nicht gestellt hat. 1. caps.parse_preset_liste - die Preset-NAMEN, die das HandBrake DIESES Workers wirklich kennt (`--preset-list`, Format im Worker-Image gemessen). Damit endet das Raten: die Namen unterscheiden sich je HandBrake-Version, und ein erfundener Name laesst die Kompression scheitern. Richtigstellung zum SAVEPOINT v3.16: Dort galt es als unmoeglich, die Hardware-Preset-Namen auf der Rippy-VM zu ermitteln, weil dort kein Hardware-Encoder laeuft. Gemessen ist das falsch - die Kategorie `Hardware/` steht vollstaendig in der Liste (VCN, NVENC, QSV, MF). HandBrake trennt zwei Fragen: --preset-list nennt alle Presets, --help nur die nutzbaren Encoder. Zwei Fragen, zwei Quellen. 2. mounts.pfad_map_vorschlag/pfad_map_zeile - der fehlende Anschluss fuer RIPPY_PATH_MAP. Geraten werden muss dafuer nichts: Rippy hat die Freigabe selbst eingehaengt und kennt ihre Quelle (//host/share). Mountpunkt plus Quelle IST das Mapping. NFS gibt bewusst "" - Windows-Schreibweise ist nicht ableitbar (AGENTS Regel D). Der Test fand dabei sofort die dokumentierte Windows-Falle: os.path.join baute `/app/media\rippy` in einen Container-Pfad, das Mapping waere still wirkungslos geblieben. _mountpoint nutzt jetzt posixpath. 3. presets.empfehlung - "immer das Beste" (Commander-Anforderung) ohne Punktesystem: fuer jede Lage eine feste Reihenfolge echter Namen, genommen wird der erste, den der Worker kennt. Hardware nur, wenn die Familie wirklich gemeldet ist (vce -> Preset heisst VCN, sonst liefe eine AMD-Karte unter dem Intel-Namen). vaapi und MF werden nie empfohlen: fuer vaapi gibt es kein Preset, bei MF ist die Nutzbarkeit nicht ablesbar. 4. eta - Restzeit aus dem gemessenen Fortschritt. Messreihe je Phase (Rip und Kompression haben nichts miteinander zu tun), Stillstand verlaengert die Schaetzung, und unter zwei Messwerten oder 60 Sekunden Spanne gibt es ehrlich keine Aussage. Test mit den echten 25.07.-Werten: 1,44 % in 29 min landet bei "noch ca. 1 Tag 23 h" - genau die Angabe, deren Fehlen den 50-Stunden-Lauf unsichtbar machte. Dazu: HandBrakes "Invalid preset <Name>" wird uebersetzt. Vorher stand im UI nur "HandBrake endete mit Code 3" - dass der Preset-NAME das Problem ist, war daraus nicht zu erraten. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
690 lines
27 KiB
Python
690 lines
27 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 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
|