Files
rippy/docker/worker/ripping.py
T
Hitonabi 0ea5f10b31
Ampel / ampel (push) Failing after 12m31s
Fix-Runde nach Review: alle Befunde behoben + echte Tests
- Metadaten-Preview WIEDERHERGESTELLT (Stub ueberschattete echte
  prescan-Implementierung), tote Altmodule geloescht
- Celery update_state statt Phantom-Task/erfundener API
- abcde-Kommando korrigiert (CD-Ripping war nie funktionsfaehig)
- JWT: fester Schluessel Pflicht, echtes Logout, Cleanup nur Abgelaufene
- main.py: crashende Endpoints (Path/secrets/api_keys), year-Bug,
  Admin-Login aus .env
- Ruff gruen (29 Funde), Tests: auth/cache_keys/ripping_helpers,
  Placebo-test_health raus
- SAVEPOINT: offene MakeMKV-Entscheidung SICHTBAR gemacht (Regel B)
2026-07-22 19:31:48 +02:00

222 lines
6.8 KiB
Python

"""Ripping-Tasks: DVD/Blu-ray via HandBrake, 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.
"""
import os
import re
import shutil
import subprocess
import tempfile
from celery_app import celery_app
HANDBRAKE_PRESET = "Fast 1080p30"
def check_handbrake_installed() -> bool:
"""Prüft, ob HandBrakeCLI installiert ist."""
return shutil.which("HandBrakeCLI") 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 get_progress_from_line(line: str) -> int:
"""Extrahiert Fortschritt in Prozent aus HandBrake-Ausgabe."""
match = re.search(r'(\d+\.\d+)%', 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
]
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_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"}
try:
process = subprocess.Popen(
build_handbrake_cmd(device_path, output_path),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1
)
for line in process.stdout:
progress = get_progress_from_line(line)
if progress > 0 and progress_cb:
progress_cb(progress)
process.wait()
if process.returncode == 0:
return {
"status": "success",
"output_path": output_path,
"return_code": process.returncode
}
return {
"status": "error",
"error": f"HandBrake failed with code {process.returncode}",
"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 _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:
"""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."
}
output_dir = f"/output/cd/{disc_id}"
os.makedirs(output_dir, exist_ok=True)
melde = _progress_melder(self)
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
)
for line in process.stdout:
melde(50, line.strip()[:200])
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