e84afc1718
Ampel / ampel (push) Successful in 29s
Rueckfrage des Commanders beim ersten echten UHD-Rip: "merkt Rippy, dass es
eine UHD ist, und nimmt direkt das 4K-Preset?" Antwort war nein. Die
Kompression fragte den Disc-Typ ueberhaupt nicht:
preset = einstellungen.get("transcodePreset") or DEFAULT_HB_PRESET
Live eingestellt war "HQ 1080p30 Surround". Der gerade laufende Akira-Rip
waere also verlustfrei in 4K gerippt und danach auf 1080p heruntergerechnet
worden - und mit keepOriginal=False waere der 4K-Rohschnitt danach geloescht
worden. Umgekehrt wurde eine DVD auf 1080p hochskaliert, was nichts bringt.
- preset_fuer(disc_type, einstellungen) in ripping.py, pure und getestet.
Reihenfolge: Preset des Disc-Typs -> allgemeines transcodePreset ->
DEFAULT_HB_PRESET. Bestandsinstallationen aendern ihr Verhalten NICHT,
solange die neuen Felder nicht gespeichert sind.
- Drei Einstellungen: transcodePresetDvd / transcodePresetBluray /
transcodePresetUhd. transcodePreset bleibt als Rueckfall bestehen.
- transcode_files holt den Disc-Typ aus dem Job-Datensatz und schreibt ihn
mit ins Log ("Disc-Typ 'uhd', Preset '...'").
- UI: drei Auswahlfelder statt einem, mit Klartext dazu, warum eine 4K-UHD
auf ein 2160p-Preset gehoert.
Preset-Namen stammen aus "HandBrakeCLI --preset-list" im Worker-Image
(HandBrake 1.6.1) - nicht aus dem Kopf (AGENTS Regel D):
H.265 MKV 2160p60 4K, HQ 2160p60 4K HEVC Surround,
Super HQ 2160p60 4K HEVC Surround, H.265 MKV 1080p30, HQ 1080p30 Surround,
Super HQ 1080p30 Surround, H.265 MKV 576p25, H.265 MKV 480p30,
HQ 576p25 Surround.
Sofortmassnahme am laufenden Job (auf Ansage des Commanders): keepOriginal
auf True gesetzt - nur dieses eine Feld, gegengeprueft dass kein anderer
Schluessel veraendert wurde. Damit ueberlebt der 4K-Rohschnitt die
Kompression.
ACHTUNG - Deploy bewusst NICHT ausgefuehrt: docker compose up -d --build
wuerde den Worker-Container neu erstellen und den laufenden Akira-Rip
abbrechen. Erst nach Abschluss des Jobs deployen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
531 lines
19 KiB
Python
531 lines
19 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)."""
|
|
|
|
|
|
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 enthaelt — und MakeMKV schreibt solche
|
|
laufend ("Title #1 has length of 12 seconds, which is less than ...").
|
|
Deshalb eine Regex, die die Anfuehrungszeichen 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",
|
|
}
|
|
|
|
|
|
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.
|
|
"""
|
|
einstellungen = einstellungen or {}
|
|
schluessel = PRESET_SCHLUESSEL.get(disc_type or "")
|
|
if schluessel:
|
|
eigen = (einstellungen.get(schluessel) or "").strip()
|
|
if eigen:
|
|
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",
|
|
]
|
|
|
|
|
|
def get_progress_from_line(line: str) -> int:
|
|
"""Extrahiert Fortschritt in Prozent aus HandBrake-Ausgabe.
|
|
|
|
Testfund 22.07.: echtes HandBrake schreibt „45.50 %" MIT Leerzeichen vor
|
|
dem Prozentzeichen — eine Regex ohne \\s* parst NIE echte Ausgabe.
|
|
"""
|
|
match = re.search(r'(\d+\.\d+)\s*%', line)
|
|
if match:
|
|
return int(float(match.group(1)))
|
|
return 0
|
|
|
|
|
|
def run_handbrake(input_path: str, output_path: str, preset: str = DEFAULT_HB_PRESET, progress_cb=None) -> dict:
|
|
"""Komprimiert eine MKV-Datei mit HandBrakeCLI; meldet Fortschritt."""
|
|
if not check_handbrake_installed():
|
|
return {"status": "error", "error": "HandBrakeCLI ist nicht installiert"}
|
|
|
|
try:
|
|
process = subprocess.Popen(
|
|
build_handbrake_cmd(input_path, output_path, preset),
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
text=True,
|
|
bufsize=1
|
|
)
|
|
|
|
try:
|
|
for line in process.stdout:
|
|
progress = get_progress_from_line(line)
|
|
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,
|
|
}
|
|
except Exception as e:
|
|
return {"status": "error", "error": str(e)}
|
|
|
|
|
|
def build_abcde_cmd(device_path: str, config_path: str) -> list:
|
|
"""Baut das abcde-Kommando (pure Funktion, testbar).
|
|
|
|
-o = Ausgabeformat (flac), -N = nicht-interaktiv, -x = Eject am Ende,
|
|
-c = Config-Datei (enthält OUTPUTDIR). NIE ein Verzeichnis an -o geben.
|
|
"""
|
|
return [
|
|
"abcde",
|
|
"-d", device_path,
|
|
"-o", "flac",
|
|
"-N",
|
|
"-x",
|
|
"-c", config_path
|
|
]
|
|
|
|
|
|
def write_abcde_config(output_dir: str) -> str:
|
|
"""Schreibt eine minimale abcde-Config mit dem Zielverzeichnis, gibt Pfad zurück."""
|
|
tmp = tempfile.NamedTemporaryFile(
|
|
"w", suffix=".abcde.conf", delete=False, encoding="utf-8"
|
|
)
|
|
tmp.write(f"OUTPUTDIR='{output_dir}'\nINTERACTIVE=n\n")
|
|
tmp.close()
|
|
return tmp.name
|
|
|
|
|
|
def run_makemkv(device_path: str, output_dir: str, progress_cb=None, 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
|
|
waeren dort also grossteils 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) -> dict:
|
|
"""Rippt eine CD mit abcde (FLAC)."""
|
|
if not check_abcde_installed():
|
|
return {
|
|
"status": "error",
|
|
"error": "abcde ist nicht installiert. Installiere abcde und cdparanoia für CD-Ripping."
|
|
}
|
|
if not check_cdparanoia_installed():
|
|
return {
|
|
"status": "error",
|
|
"error": "cdparanoia ist nicht installiert. Installiere abcde und cdparanoia für CD-Ripping."
|
|
}
|
|
|
|
if output_dir is None:
|
|
output_dir = os.path.join(RIP_OUTPUT_DIR, "cd", disc_id)
|
|
os.makedirs(output_dir, exist_ok=True)
|
|
|
|
def melde(progress: int, message: str = ""):
|
|
if progress_cb:
|
|
progress_cb(progress, message)
|
|
|
|
config_path = write_abcde_config(output_dir)
|
|
|
|
try:
|
|
process = subprocess.Popen(
|
|
build_abcde_cmd(device_path, config_path),
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
text=True,
|
|
bufsize=1
|
|
)
|
|
|
|
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
|