Etappe 10: Worker rippt wirklich — MakeMKV 1.18.4 + ioctl-Disc-Erkennung

Vorher: Dockerfile unbaubar (makepkg ist ein Arch-Paket, existiert in Debian
nicht) und KEIN einziges Ripping-Tool im Image — jeder Rip endete sofort.
Disc-Erkennung via `file -L` konnte auf Block-Devices strukturell nie etwas
erkennen; ihr Test mockte sich die Ausgabe passend.

- makemkv-oss/bin 1.18.4 multi-stage (bookworm-gepinnt), EULA via
  tmp/eula_accepted, Beta-Key aus MAKEMKV_APP_KEY (entrypoint.sh)
- makemkvcon-Aufruf + PRGV-Parsing laut makemkv.com/developers/usage.txt
  (AGENTS Regel D), HandBrake raus aus dem Ripp-Pfad (KONZEPT: lossless=Muss)
- detection.py: CDROM_DISC_STATUS + BLKGETSIZE64 (cd/dvd/bluray), pure
  classify() mit ehrlichen Tests
- tasks.py: rip_disc als einziger Celery-Task, schreibt Status/Fortschritt
  nach Postgres (db.py), Ausgabe auf /app/media (Volume) statt totem /output

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-23 15:02:13 +02:00
parent b80b7681c5
commit 780d114fe4
9 changed files with 458 additions and 167 deletions
+87 -97
View File
@@ -1,11 +1,14 @@
"""Ripping-Tasks: DVD/Blu-ray via HandBrake, CD via abcde.
"""Ripping-Tasks: DVD/Blu-ray verlustfrei via MakeMKV (Etappe 10), CD via abcde.
Review-Fixes 22.07.2026:
- Fortschritt läuft jetzt über Celery `update_state` (Standard) — vorher gingen
send_task-Aufrufe an einen Task `update_progress`, den es NIE gab.
- abcde-Kommando korrigiert: `-o` ist das AUSGABEFORMAT (nicht das Verzeichnis!),
das Zielverzeichnis geht als OUTPUTDIR über eine Config-Datei (-c). Vorher wurde
das Verzeichnis als Format geparst — CD-Ripping war nie funktionsfähig.
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
@@ -14,14 +17,12 @@ import shutil
import subprocess
import tempfile
from celery_app import celery_app
HANDBRAKE_PRESET = "Fast 1080p30"
RIP_OUTPUT_DIR = os.getenv("RIP_OUTPUT_DIR", "/app/media")
def check_handbrake_installed() -> bool:
"""Prüft, ob HandBrakeCLI installiert ist."""
return shutil.which("HandBrakeCLI") is not None
def check_makemkv_installed() -> bool:
"""Prüft, ob makemkvcon installiert ist."""
return shutil.which("makemkvcon") is not None
def check_abcde_installed() -> bool:
@@ -34,31 +35,39 @@ def check_cdparanoia_installed() -> bool:
return shutil.which("cdparanoia") is not None
def get_progress_from_line(line: str) -> int:
"""Extrahiert Fortschritt in Prozent aus HandBrake-Ausgabe.
def build_makemkv_cmd(device_path: str, output_dir: str) -> list:
"""Baut das MakeMKV-Kommando (pure Funktion, testbar).
Testfund 22.07.: echtes HandBrake schreibt „45.50 %" MIT Leerzeichen vor
dem Prozentzeichen — die alte Regex ohne \\s* hat NIE einen Fortschritt
aus echter Ausgabe geparst.
-r Robot-Mode: maschinenlesbare Ausgabe (PRGV/MSG-Zeilen)
--progress=-same Fortschritt in denselben Stream wie die Meldungen
mkv dev:<pfad> all <ziel> alle Titel der Disc verlustfrei als MKV
"""
match = re.search(r'(\d+\.\d+)\s*%', line)
if match:
return int(float(match.group(1)))
return 0
def build_handbrake_cmd(device_path: str, output_path: str) -> list:
"""Baut das HandBrake-Kommando (pure Funktion, testbar)."""
return [
"HandBrakeCLI",
"--input", device_path,
"--output", output_path,
"--all",
"--progress",
"--preset", HANDBRAKE_PRESET
"makemkvcon",
"-r",
"--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 build_abcde_cmd(device_path: str, config_path: str) -> list:
"""Baut das abcde-Kommando (pure Funktion, testbar).
@@ -85,95 +94,72 @@ def write_abcde_config(output_dir: str) -> str:
return tmp.name
def run_handbrake(device_path: str, output_path: str, progress_cb=None) -> dict:
"""Rippt eine DVD/Blu-ray mit HandBrakeCLI; meldet Fortschritt via Callback."""
if not check_handbrake_installed():
return {"status": "error", "error": "HandBrakeCLI ist nicht installiert"}
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_handbrake_cmd(device_path, output_path),
build_makemkv_cmd(device_path, output_dir),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1
)
letzte_meldung = ""
for line in process.stdout:
progress = get_progress_from_line(line)
if progress > 0 and progress_cb:
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('"')
process.wait()
if process.returncode == 0:
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_path": output_path,
"return_code": process.returncode
"output_dir": output_dir,
"files": mkv_dateien,
"return_code": process.returncode,
}
return {
"status": "error",
"error": f"HandBrake failed with code {process.returncode}",
"return_code": process.returncode
"error": (
f"makemkvcon endete mit Code {process.returncode}"
+ (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 detect_disc_type(device_path: str) -> str:
"""Erkennt den Disc-Typ anhand des Gerätepfads."""
try:
result = subprocess.run(
["file", "-L", device_path],
capture_output=True,
text=True,
timeout=10
)
output = result.stdout.lower()
if "dvd" in output or "video_ts" in output:
return "dvd"
elif "bluray" in output or "bdmv" in output:
return "bluray"
elif "audio" in output or "cda" in output:
return "cd"
return "unknown"
except Exception:
return "unknown"
def rip_video(device_path: str, disc_id: str, disc_type: str = "dvd", progress_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 = os.path.join(RIP_OUTPUT_DIR, disc_type, disc_id)
return run_makemkv(device_path, output_dir, progress_cb=progress_cb)
def _progress_melder(task):
"""Baut einen Fortschritts-Callback, der Celery-Standard update_state nutzt."""
def melde(progress: int, message: str = ""):
task.update_state(
state="PROGRESS",
meta={"progress": progress, "status": "ripping", "message": message}
)
return melde
@celery_app.task(bind=True, name="worker.ripping.rip_dvd")
def rip_dvd(self, device_path: str, disc_id: str) -> dict:
"""Rippt eine DVD mit HandBrake."""
output_dir = f"/output/dvd/{disc_id}"
os.makedirs(output_dir, exist_ok=True)
output_path = f"{output_dir}/dvd_{disc_id}.mkv"
melde = _progress_melder(self)
return run_handbrake(device_path, output_path, progress_cb=melde)
@celery_app.task(bind=True, name="worker.ripping.rip_bluray")
def rip_bluray(self, device_path: str, disc_id: str) -> dict:
"""Rippt eine Blu-ray mit HandBrake."""
output_dir = f"/output/bluray/{disc_id}"
os.makedirs(output_dir, exist_ok=True)
output_path = f"{output_dir}/bluray_{disc_id}.mkv"
melde = _progress_melder(self)
return run_handbrake(device_path, output_path, progress_cb=melde)
@celery_app.task(bind=True, name="worker.ripping.rip_cd")
def rip_cd(self, device_path: str, disc_id: str) -> dict:
def rip_cd(device_path: str, disc_id: str, progress_cb=None) -> dict:
"""Rippt eine CD mit abcde (FLAC)."""
if not check_abcde_installed():
return {
@@ -186,9 +172,13 @@ def rip_cd(self, device_path: str, disc_id: str) -> dict:
"error": "cdparanoia ist nicht installiert. Installiere abcde und cdparanoia für CD-Ripping."
}
output_dir = f"/output/cd/{disc_id}"
output_dir = os.path.join(RIP_OUTPUT_DIR, "cd", disc_id)
os.makedirs(output_dir, exist_ok=True)
melde = _progress_melder(self)
def melde(progress: int, message: str = ""):
if progress_cb:
progress_cb(progress, message)
config_path = write_abcde_config(output_dir)
try: