c9270d08e1
Ampel / ampel (push) Successful in 29s
Diagnose auf der VM (Summer-Wars-UHD, MKB v82): - 'Using LibreDrive mode (v06.3)' — der BU40N-Crossflash IST erledigt, das Laufwerk liest UHD. Der Fehlschlag kam von 'The volume key is unknown for this disc': die Disc ist neuer als MakeMKVs Schluessel-Datenbank — mit 1.17.7 UND der aktuellsten 1.18.4 bewiesen (beide Versionen live gegen die Disc getestet). Konsequenzen: - MakeMKV-Pin von 1.17.7 auf 1.18.4 gehoben: der 1.18er Scan-Haenger (Forum t=38128) wird ueberall mit --noscan umgangen (info-Lauf mit 1.18.4 auf der BU40N sauber durchgelaufen), die Flash-Faehigkeit von 1.17.7 wird nicht mehr gebraucht. Aktuelle Version = aktuellste AACS-Key-DB. URL-Base -> /download (dort liegt nur die aktuelle). - run_makemkv reicht KRITISCHE Meldungen (volume key unknown, Key abgelaufen, too old version) in den Fehlertext durch — vorher stand da nur die nichtssagende letzte Zeile 'Failed to open disc'. - UHD-Klartext in tasks.py unterscheidet jetzt die zwei Faelle: volume-key-unknown (MakeMKV/Disc-Alter, AACS-Dump aus /root/.MakeMKV/ im MakeMKV-Forum einreichen) vs. Failed-to-open ohne LibreDrive (Firmware-Hinweis). - SAVEPOINT: Flash-Status korrigiert (war als offen dokumentiert). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
328 lines
11 KiB
Python
328 lines
11 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) -> 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> all <ziel> alle Titel der Disc verlustfrei als MKV
|
|
"""
|
|
return [
|
|
"makemkvcon",
|
|
"-r",
|
|
"--noscan",
|
|
"--progress=-same",
|
|
"mkv",
|
|
f"dev:{device_path}",
|
|
"all",
|
|
output_dir,
|
|
]
|
|
|
|
|
|
def get_progress_from_prgv(line: str) -> int:
|
|
"""Extrahiert Gesamt-Fortschritt (0-100) aus einer PRGV-Zeile.
|
|
|
|
Format laut MakeMKV-Doku: PRGV:current,total,max
|
|
`total` ist der Gesamtfortschritt, `max` die Skala (65536).
|
|
"""
|
|
match = re.match(r"PRGV:(\d+),(\d+),(\d+)", line.strip())
|
|
if not match:
|
|
return -1
|
|
total, maximum = int(match.group(2)), int(match.group(3))
|
|
if maximum <= 0:
|
|
return -1
|
|
return min(100, int(total * 100 / maximum))
|
|
|
|
|
|
def check_handbrake_installed() -> bool:
|
|
"""Prüft, ob HandBrakeCLI installiert ist."""
|
|
return shutil.which("HandBrakeCLI") is not None
|
|
|
|
|
|
DEFAULT_HB_PRESET = "H.265 MKV 1080p30"
|
|
|
|
|
|
def build_handbrake_cmd(input_path: str, output_path: str, preset: str = DEFAULT_HB_PRESET) -> list:
|
|
"""Baut das HandBrake-Kommando für die Kompressions-Stufe (pure Funktion).
|
|
|
|
Arbeitet auf der MKV-DATEI aus dem MakeMKV-Rip — nie auf dem Laufwerk:
|
|
HandBrake kann AACS-verschlüsselte Blu-rays nicht lesen, MakeMKV bleibt
|
|
deshalb zwingend die erste Stufe. --all-audio/--all-subtitles behalten
|
|
alle Sprachen (Preset-Default wäre nur die erste Tonspur).
|
|
"""
|
|
return [
|
|
"HandBrakeCLI",
|
|
"--input", input_path,
|
|
"--output", output_path,
|
|
"--preset", preset,
|
|
"--all-audio",
|
|
"--all-subtitles",
|
|
]
|
|
|
|
|
|
def get_progress_from_line(line: str) -> int:
|
|
"""Extrahiert Fortschritt in Prozent aus HandBrake-Ausgabe.
|
|
|
|
Testfund 22.07.: echtes HandBrake schreibt „45.50 %" MIT Leerzeichen vor
|
|
dem Prozentzeichen — eine Regex ohne \\s* parst NIE echte Ausgabe.
|
|
"""
|
|
match = re.search(r'(\d+\.\d+)\s*%', line)
|
|
if match:
|
|
return int(float(match.group(1)))
|
|
return 0
|
|
|
|
|
|
def run_handbrake(input_path: str, output_path: str, preset: str = DEFAULT_HB_PRESET, progress_cb=None) -> dict:
|
|
"""Komprimiert eine MKV-Datei mit HandBrakeCLI; meldet Fortschritt."""
|
|
if not check_handbrake_installed():
|
|
return {"status": "error", "error": "HandBrakeCLI ist nicht installiert"}
|
|
|
|
try:
|
|
process = subprocess.Popen(
|
|
build_handbrake_cmd(input_path, output_path, preset),
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
text=True,
|
|
bufsize=1
|
|
)
|
|
|
|
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) -> dict:
|
|
"""Rippt eine DVD/Blu-ray verlustfrei mit makemkvcon; meldet Fortschritt."""
|
|
if not check_makemkv_installed():
|
|
return {"status": "error", "error": "makemkvcon ist nicht installiert"}
|
|
|
|
os.makedirs(output_dir, exist_ok=True)
|
|
|
|
try:
|
|
process = subprocess.Popen(
|
|
build_makemkv_cmd(device_path, output_dir),
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
text=True,
|
|
bufsize=1
|
|
)
|
|
|
|
letzte_meldung = ""
|
|
# 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 and progress_cb:
|
|
progress_cb(progress)
|
|
elif line.startswith("MSG:"):
|
|
# MSG:code,flags,count,"message",... — Klartext ist Feld 4
|
|
teile = line.split(",", 4)
|
|
if len(teile) >= 4:
|
|
letzte_meldung = teile[3].strip('"')
|
|
if any(muster in letzte_meldung for muster in KRITISCH):
|
|
kritische_meldungen.append(letzte_meldung)
|
|
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) -> dict:
|
|
"""Rippt eine DVD oder Blu-ray verlustfrei mit MakeMKV.
|
|
|
|
Bewusst KEIN eigener Celery-Task: der einzige Task ist worker.tasks.rip_disc,
|
|
der hier mit seinem eigenen Fortschritts-Callback durchgreift. output_dir
|
|
überschreibt das Ziel (Transcode-Fall: Roh-Rip nach /app/temp).
|
|
"""
|
|
if output_dir is None:
|
|
output_dir = os.path.join(RIP_OUTPUT_DIR, disc_type, disc_id)
|
|
return run_makemkv(device_path, output_dir, progress_cb=progress_cb)
|
|
|
|
|
|
def rip_cd(device_path: str, disc_id: str, progress_cb=None, output_dir: str = None) -> dict:
|
|
"""Rippt eine CD mit abcde (FLAC)."""
|
|
if not check_abcde_installed():
|
|
return {
|
|
"status": "error",
|
|
"error": "abcde ist nicht installiert. Installiere abcde und cdparanoia für CD-Ripping."
|
|
}
|
|
if not check_cdparanoia_installed():
|
|
return {
|
|
"status": "error",
|
|
"error": "cdparanoia ist nicht installiert. Installiere abcde und cdparanoia für CD-Ripping."
|
|
}
|
|
|
|
if output_dir is None:
|
|
output_dir = os.path.join(RIP_OUTPUT_DIR, "cd", disc_id)
|
|
os.makedirs(output_dir, exist_ok=True)
|
|
|
|
def melde(progress: int, message: str = ""):
|
|
if progress_cb:
|
|
progress_cb(progress, message)
|
|
|
|
config_path = write_abcde_config(output_dir)
|
|
|
|
try:
|
|
process = subprocess.Popen(
|
|
build_abcde_cmd(device_path, config_path),
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
text=True,
|
|
bufsize=1
|
|
)
|
|
|
|
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
|