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
+3
View File
@@ -0,0 +1,3 @@
# Shell-Skripte brauchen LF — mit CRLF scheitert der Interpreter im Container
# ("/bin/sh^M: bad interpreter"). Wir entwickeln auf Windows, gebaut wird auf Linux.
*.sh text eol=lf
+61 -5
View File
@@ -1,16 +1,72 @@
FROM python:3.12-slim # Worker-Image: MakeMKV (verlustfrei, Etappe 10) + abcde/cdparanoia (CD→FLAC).
#
# Multi-Stage: makemkv-oss wird aus Quellen gebaut (GPL-Teil), makemkv-bin ist
# das proprietäre Binärpaket (EULA wird per tmp/eula_accepted akzeptiert —
# Standard-Mechanismus des Makefiles für nicht-interaktive Builds).
# Beide Stages sind auf bookworm gepinnt, damit die libavcodec-Version passt.
#
# Vorher stand hier `makepkg` (ein Arch-Linux-Werkzeug, existiert in Debian
# nicht) — das Image war seit dem 23.07. gar nicht mehr baubar. Und es fehlte
# schlicht JEDES Ripping-Werkzeug: weder HandBrake noch abcde noch MakeMKV
# waren installiert.
WORKDIR /app FROM python:3.12-slim-bookworm AS makemkv-build
ARG MAKEMKV_VERSION=1.18.4
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \ build-essential \
makepkg \ pkg-config \
syslinux-utils \ ca-certificates \
curl \
libssl-dev \
libexpat1-dev \
libavcodec-dev \
zlib1g-dev \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /build
RUN curl -fsSL -o oss.tar.gz "https://www.makemkv.com/download/makemkv-oss-${MAKEMKV_VERSION}.tar.gz" \
&& curl -fsSL -o bin.tar.gz "https://www.makemkv.com/download/makemkv-bin-${MAKEMKV_VERSION}.tar.gz" \
&& tar xzf oss.tar.gz \
&& tar xzf bin.tar.gz
RUN cd "makemkv-oss-${MAKEMKV_VERSION}" \
&& ./configure --disable-gui --prefix=/usr/local \
&& make -j"$(nproc)" \
&& make install
RUN cd "makemkv-bin-${MAKEMKV_VERSION}" \
&& mkdir -p tmp \
&& touch tmp/eula_accepted \
&& make \
&& make install
FROM python:3.12-slim-bookworm
RUN apt-get update && apt-get install -y --no-install-recommends \
libssl3 \
libexpat1 \
zlib1g \
libavcodec59 \
abcde \
cdparanoia \
cd-discid \
flac \
&& rm -rf /var/lib/apt/lists/*
COPY --from=makemkv-build /usr/local /usr/local
RUN ldconfig
WORKDIR /app
COPY docker/worker/requirements.txt . COPY docker/worker/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY docker/worker/ . COPY docker/worker/ .
RUN chmod +x /app/entrypoint.sh
ENTRYPOINT ["/app/entrypoint.sh"]
CMD ["celery", "-A", "celery_app", "worker", "--loglevel=info"] CMD ["celery", "-A", "celery_app", "worker", "--loglevel=info"]
+73
View File
@@ -0,0 +1,73 @@
"""Job- und Log-Persistenz in PostgreSQL (KONZEPT: Postgres für Job-Logs).
Die Tabellendefinition existiert bewusst identisch in API und Worker
(docker/api/db.py) — es gibt kein geteiltes Paket zwischen den Containern.
Wer die Struktur ändert, ändert BEIDE Dateien. create_all ist idempotent.
"""
import os
from datetime import datetime, timezone
from sqlalchemy import (
Column,
DateTime,
Integer,
MetaData,
String,
Table,
Text,
create_engine,
)
DATABASE_URL = os.getenv(
"DATABASE_URL", "postgresql://rippy:rippy@localhost:5432/rippy"
)
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
metadata = MetaData()
jobs = Table(
"jobs",
metadata,
Column("id", String(36), primary_key=True),
Column("disc_type", String(16)),
Column("device", String(64)),
Column("title", String(255)),
Column("status", String(16), nullable=False, server_default="pending"),
Column("progress", Integer, nullable=False, server_default="0"),
Column("output_path", Text),
Column("error", Text),
Column("created_at", DateTime(timezone=True)),
Column("finished_at", DateTime(timezone=True)),
)
logs = Table(
"logs",
metadata,
Column("id", Integer, primary_key=True, autoincrement=True),
Column("ts", DateTime(timezone=True)),
Column("level", String(16)),
Column("source", String(32)),
Column("message", Text),
)
def utcnow() -> datetime:
return datetime.now(timezone.utc)
def init_db() -> None:
"""Legt fehlende Tabellen an (idempotent)."""
metadata.create_all(engine)
def update_job(job_id: str, **fields) -> None:
with engine.begin() as conn:
conn.execute(jobs.update().where(jobs.c.id == job_id).values(**fields))
def add_log(level: str, source: str, message: str) -> None:
with engine.begin() as conn:
conn.execute(
logs.insert().values(ts=utcnow(), level=level, source=source, message=message)
)
+91
View File
@@ -0,0 +1,91 @@
"""Disc-Erkennung über Kernel-ioctls — ohne `file`, ohne udev.
Warum neu (Review 23.07.): Die alte Erkennung rief `file -L /dev/sr0` auf.
`file` liest ohne `-s` aber NIE den Inhalt eines Block-Devices — die Ausgabe ist
immer nur "block special", die Erkennung lieferte also strukturell IMMER
"unknown". Der alte Test hatte sich die `file`-Ausgabe passend gemockt und
bewies damit nichts.
Die ioctl-Konstanten stammen aus der Kernel-UAPI (include/uapi/linux/cdrom.h
bzw. linux/fs.h für BLKGETSIZE64) und sind seit Jahrzehnten stabil.
"""
import os
import struct
from fcntl import ioctl
# include/uapi/linux/cdrom.h
CDROM_DRIVE_STATUS = 0x5326
CDROM_DISC_STATUS = 0x5327
CDS_NO_DISC = 1
CDS_TRAY_OPEN = 2
CDS_DRIVE_NOT_READY = 3
CDS_DISC_OK = 4
CDS_AUDIO = 100
CDS_DATA_1 = 101
CDS_DATA_2 = 102
CDS_XA_2_1 = 103
CDS_XA_2_2 = 104
CDS_MIXED = 105
# include/uapi/linux/fs.h: BLKGETSIZE64 = _IOR(0x12, 114, size_t) auf 64-bit
BLKGETSIZE64 = 0x80081272
# Eine DVD9 fasst ~8,5 GB; Blu-ray beginnt bei 25 GB (Single Layer).
# Alles ab 10 GB ist also sicher eine Blu-ray.
BLURAY_MIN_BYTES = 10 * 1024**3
def _open_nonblock(device_path: str) -> int:
"""O_NONBLOCK ist Pflicht: ohne blockiert open() bis eine Disc eingelegt ist."""
return os.open(device_path, os.O_RDONLY | os.O_NONBLOCK)
def drive_status(device_path: str) -> int:
"""CDROM_DRIVE_STATUS: 1=keine Disc, 2=Schublade offen, 3=nicht bereit, 4=Disc ok."""
fd = _open_nonblock(device_path)
try:
return ioctl(fd, CDROM_DRIVE_STATUS, 0)
finally:
os.close(fd)
def disc_status(device_path: str) -> int:
"""CDROM_DISC_STATUS: 100=Audio, 101-104=Daten, 105=Mixed."""
fd = _open_nonblock(device_path)
try:
return ioctl(fd, CDROM_DISC_STATUS, 0)
finally:
os.close(fd)
def disc_size_bytes(device_path: str) -> int:
"""Größe des eingelegten Mediums in Bytes (BLKGETSIZE64)."""
fd = _open_nonblock(device_path)
try:
buf = bytearray(8)
ioctl(fd, BLKGETSIZE64, buf)
return struct.unpack("Q", bytes(buf))[0]
finally:
os.close(fd)
def classify(disc_status_code: int, size_bytes: int) -> str:
"""Pure Zuordnung (testbar): Disc-Status + Größe → cd | dvd | bluray | unknown."""
if disc_status_code in (CDS_AUDIO, CDS_MIXED):
return "cd"
if disc_status_code in (CDS_DATA_1, CDS_DATA_2, CDS_XA_2_1, CDS_XA_2_2):
return "bluray" if size_bytes >= BLURAY_MIN_BYTES else "dvd"
return "unknown"
def detect_disc_type(device_path: str) -> str:
"""Erkennt den Typ der eingelegten Disc; 'no_disc' wenn keine drin ist."""
try:
if drive_status(device_path) != CDS_DISC_OK:
return "no_disc"
return classify(disc_status(device_path), disc_size_bytes(device_path))
except OSError:
return "unknown"
+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
# Schreibt den MakeMKV-Beta-Key aus der Umgebung in die Settings (falls gesetzt).
# Ohne Key: DVD-Ripping geht immer, Blu-ray läuft im 30-Tage-Testmodus.
set -e
if [ -n "${MAKEMKV_APP_KEY}" ]; then
mkdir -p /root/.MakeMKV
printf 'app_Key = "%s"\n' "${MAKEMKV_APP_KEY}" > /root/.MakeMKV/settings.conf
fi
exec "$@"
+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: Etappe 10 (23.07.2026): HandBrake ist aus dem Ripp-Pfad entfernt — das KONZEPT
- Fortschritt läuft jetzt über Celery `update_state` (Standard) — vorher gingen verlangt verlustfreies Sichern (MakeMKV als Muss-Feature). HandBrake war ohnehin
send_task-Aufrufe an einen Task `update_progress`, den es NIE gab. nie im Worker-Image installiert; jeder Rip endete sofort mit "nicht installiert".
- abcde-Kommando korrigiert: `-o` ist das AUSGABEFORMAT (nicht das Verzeichnis!),
das Zielverzeichnis geht als OUTPUTDIR über eine Config-Datei (-c). Vorher wurde MakeMKV-Aufruf und Fortschritts-Format sind dokumentiert unter
das Verzeichnis als Format geparst — CD-Ripping war nie funktionsfähig. 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 os
@@ -14,14 +17,12 @@ import shutil
import subprocess import subprocess
import tempfile import tempfile
from celery_app import celery_app RIP_OUTPUT_DIR = os.getenv("RIP_OUTPUT_DIR", "/app/media")
HANDBRAKE_PRESET = "Fast 1080p30"
def check_handbrake_installed() -> bool: def check_makemkv_installed() -> bool:
"""Prüft, ob HandBrakeCLI installiert ist.""" """Prüft, ob makemkvcon installiert ist."""
return shutil.which("HandBrakeCLI") is not None return shutil.which("makemkvcon") is not None
def check_abcde_installed() -> bool: def check_abcde_installed() -> bool:
@@ -34,31 +35,39 @@ def check_cdparanoia_installed() -> bool:
return shutil.which("cdparanoia") is not None return shutil.which("cdparanoia") is not None
def get_progress_from_line(line: str) -> int: def build_makemkv_cmd(device_path: str, output_dir: str) -> list:
"""Extrahiert Fortschritt in Prozent aus HandBrake-Ausgabe. """Baut das MakeMKV-Kommando (pure Funktion, testbar).
Testfund 22.07.: echtes HandBrake schreibt „45.50 %" MIT Leerzeichen vor -r Robot-Mode: maschinenlesbare Ausgabe (PRGV/MSG-Zeilen)
dem Prozentzeichen — die alte Regex ohne \\s* hat NIE einen Fortschritt --progress=-same Fortschritt in denselben Stream wie die Meldungen
aus echter Ausgabe geparst. 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 [ return [
"HandBrakeCLI", "makemkvcon",
"--input", device_path, "-r",
"--output", output_path, "--progress=-same",
"--all", "mkv",
"--progress", f"dev:{device_path}",
"--preset", HANDBRAKE_PRESET "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: def build_abcde_cmd(device_path: str, config_path: str) -> list:
"""Baut das abcde-Kommando (pure Funktion, testbar). """Baut das abcde-Kommando (pure Funktion, testbar).
@@ -85,95 +94,72 @@ def write_abcde_config(output_dir: str) -> str:
return tmp.name return tmp.name
def run_handbrake(device_path: str, output_path: str, progress_cb=None) -> dict: def run_makemkv(device_path: str, output_dir: str, progress_cb=None) -> dict:
"""Rippt eine DVD/Blu-ray mit HandBrakeCLI; meldet Fortschritt via Callback.""" """Rippt eine DVD/Blu-ray verlustfrei mit makemkvcon; meldet Fortschritt."""
if not check_handbrake_installed(): if not check_makemkv_installed():
return {"status": "error", "error": "HandBrakeCLI ist nicht installiert"} return {"status": "error", "error": "makemkvcon ist nicht installiert"}
os.makedirs(output_dir, exist_ok=True)
try: try:
process = subprocess.Popen( process = subprocess.Popen(
build_handbrake_cmd(device_path, output_path), build_makemkv_cmd(device_path, output_dir),
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, stderr=subprocess.STDOUT,
text=True, text=True,
bufsize=1 bufsize=1
) )
letzte_meldung = ""
for line in process.stdout: for line in process.stdout:
progress = get_progress_from_line(line) progress = get_progress_from_prgv(line)
if progress > 0 and progress_cb: if progress >= 0 and progress_cb:
progress_cb(progress) 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() 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 { return {
"status": "success", "status": "success",
"output_path": output_path, "output_dir": output_dir,
"return_code": process.returncode "files": mkv_dateien,
"return_code": process.returncode,
} }
return { return {
"status": "error", "status": "error",
"error": f"HandBrake failed with code {process.returncode}", "error": (
"return_code": process.returncode 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: except Exception as e:
return {"status": "error", "error": str(e)} return {"status": "error", "error": str(e)}
def detect_disc_type(device_path: str) -> str: def rip_video(device_path: str, disc_id: str, disc_type: str = "dvd", progress_cb=None) -> dict:
"""Erkennt den Disc-Typ anhand des Gerätepfads.""" """Rippt eine DVD oder Blu-ray verlustfrei mit MakeMKV.
try:
result = subprocess.run( Bewusst KEIN eigener Celery-Task: der einzige Task ist worker.tasks.rip_disc,
["file", "-L", device_path], der hier mit seinem eigenen Fortschritts-Callback durchgreift.
capture_output=True, """
text=True, output_dir = os.path.join(RIP_OUTPUT_DIR, disc_type, disc_id)
timeout=10 return run_makemkv(device_path, output_dir, progress_cb=progress_cb)
)
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): def rip_cd(device_path: str, disc_id: str, progress_cb=None) -> dict:
"""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).""" """Rippt eine CD mit abcde (FLAC)."""
if not check_abcde_installed(): if not check_abcde_installed():
return { 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." "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) 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) config_path = write_abcde_config(output_dir)
try: try:
+60 -15
View File
@@ -1,24 +1,69 @@
"""Zentraler Rip-Task: erkennt den Disc-Typ, rippt und schreibt Status nach Postgres.
Die API legt beim POST /jobs die Job-Zeile an und schickt diesen Task los —
der Worker hält die Zeile aktuell (running → completed/failed) und schreibt
Ereignisse ins Log. Das UI liest beides über die API.
"""
import db
from celery_app import celery_app from celery_app import celery_app
from ripping import rip_dvd, rip_bluray, rip_cd, detect_disc_type from detection import detect_disc_type
from ripping import rip_cd, rip_video
@celery_app.task(bind=True, name="worker.tasks.rip_disc") @celery_app.task(bind=True, name="worker.tasks.rip_disc")
def rip_disc(self, device_path: str, disc_id: str = None): def rip_disc(self, device_path: str, job_id: str):
"""Rippt eine Disc basierend auf ihrem Typ.""" """Rippt eine Disc basierend auf ihrem Typ; job_id ist die DB-Zeile der API."""
db.init_db()
disc_type = detect_disc_type(device_path) disc_type = detect_disc_type(device_path)
if not disc_id: if disc_type in ("no_disc", "unknown"):
disc_id = device_path.replace("/", "_") fehler = (
"Keine Disc im Laufwerk" if disc_type == "no_disc"
else "Disc-Typ nicht erkennbar"
)
db.update_job(
job_id, status="failed", error=fehler, finished_at=db.utcnow()
)
db.add_log("error", "worker", f"Job {job_id}: {fehler} ({device_path})")
return {"status": "error", "error": fehler, "disc_type": disc_type}
db.update_job(job_id, status="running", disc_type=disc_type)
db.add_log("info", "worker", f"Job {job_id}: {disc_type}-Rip gestartet ({device_path})")
letzter = [-1]
def fortschritt(progress: int, message: str = ""):
# MakeMKV liefert viele PRGV-Zeilen pro Sekunde — DB nur bei Änderung.
if progress == letzter[0]:
return
letzter[0] = progress
self.update_state(
state="PROGRESS",
meta={"progress": progress, "status": "ripping", "message": message},
)
db.update_job(job_id, progress=progress)
if disc_type == "cd": if disc_type == "cd":
return rip_cd(device_path, disc_id) ergebnis = rip_cd(device_path, job_id, progress_cb=fortschritt)
elif disc_type == "dvd":
return rip_dvd(device_path, disc_id)
elif disc_type == "bluray":
return rip_bluray(device_path, disc_id)
else: else:
return { ergebnis = rip_video(device_path, job_id, disc_type, progress_cb=fortschritt)
"status": "error", if ergebnis.get("status") == "success":
"message": f"Unbekannter Disc-Typ: {disc_type}", db.update_job(
"disc_type": disc_type job_id,
} status="completed",
progress=100,
output_path=ergebnis.get("output_dir"),
finished_at=db.utcnow(),
)
db.add_log("success", "worker", f"Job {job_id}: Rip abgeschlossen → {ergebnis.get('output_dir')}")
else:
db.update_job(
job_id,
status="failed",
error=ergebnis.get("error", "unbekannter Fehler"),
finished_at=db.utcnow(),
)
db.add_log("error", "worker", f"Job {job_id}: {ergebnis.get('error', 'unbekannter Fehler')}")
return ergebnis
+39
View File
@@ -0,0 +1,39 @@
"""Tests für detection.py — die pure Zuordnung classify().
Der Vorgänger (`file -L` auf ein Block-Device) konnte strukturell nie etwas
erkennen; sein Test mockte sich die file-Ausgabe passend zurecht. Hier wird
nur echte, deterministische Logik getestet — die ioctl-Aufrufe selbst sind
dünne Kernel-Durchreichen und werden im E2E-Test mit echter Disc bewiesen.
"""
from detection import (
BLURAY_MIN_BYTES,
CDS_AUDIO,
CDS_DATA_1,
CDS_MIXED,
classify,
)
def test_audio_cd():
assert classify(CDS_AUDIO, 700 * 1024**2) == "cd"
def test_mixed_mode_zaehlt_als_cd():
assert classify(CDS_MIXED, 700 * 1024**2) == "cd"
def test_dvd_unter_schwelle():
# DVD9 = ~8,5 GB — liegt unter der 10-GB-Blu-ray-Schwelle
assert classify(CDS_DATA_1, 8 * 1024**3) == "dvd"
def test_bluray_ab_schwelle():
# BD-SL = 25 GB
assert classify(CDS_DATA_1, 25 * 1024**3) == "bluray"
assert classify(CDS_DATA_1, BLURAY_MIN_BYTES) == "bluray"
def test_unbekannter_status():
assert classify(999, 5 * 1024**3) == "unknown"
assert classify(0, 0) == "unknown"
+31 -48
View File
@@ -1,25 +1,43 @@
"""Tests für ripping.py: Fortschritts-Parsing, Kommando-Bau, Disc-Erkennung. """Tests für ripping.py: Kommando-Bau und Fortschritts-Parsing.
Deckt genau die Stellen ab, an denen im Review 22.07. erfundene Schnittstellen Deckt genau die Stellen ab, an denen Reviews erfundene Schnittstellen fanden
gefunden wurden (abcde-Flags, Celery-API) — damit so etwas nie wieder still liegt. (abcde-Flags, Celery-API, HandBrake-Regex) — damit so etwas nie wieder still liegt.
Das PRGV-Format stammt aus der MakeMKV-Doku (makemkv.com/developers/usage.txt).
""" """
import os import os
import ripping
from ripping import ( from ripping import (
build_abcde_cmd, build_abcde_cmd,
build_handbrake_cmd, build_makemkv_cmd,
detect_disc_type, get_progress_from_prgv,
get_progress_from_line,
write_abcde_config, write_abcde_config,
) )
def test_progress_parsing(): def test_makemkv_cmd_vollstaendig():
assert get_progress_from_line("Encoding: task 1 of 1, 45.50 %") == 45 cmd = build_makemkv_cmd("/dev/sr0", "/app/media/dvd/x")
assert get_progress_from_line("Encoding: task 1 of 1, 100.00 %") == 100 assert cmd[0] == "makemkvcon"
assert get_progress_from_line("kein Fortschritt hier") == 0 assert "-r" in cmd # Robot-Mode: maschinenlesbar
assert "--progress=-same" in cmd # Fortschritt im selben Stream
assert "mkv" in cmd
assert "dev:/dev/sr0" in cmd # Geräte-Notation laut Doku
assert cmd[-2:] == ["all", "/app/media/dvd/x"]
def test_prgv_parsing():
# PRGV:current,total,max — total/max ist der Gesamtfortschritt
assert get_progress_from_prgv("PRGV:100,32768,65536") == 50
assert get_progress_from_prgv("PRGV:0,65536,65536") == 100
assert get_progress_from_prgv("PRGV:0,0,65536") == 0
def test_prgv_parsing_ignoriert_fremde_zeilen():
# -1 heißt „keine Fortschrittszeile" — MSG-Zeilen dürfen NIE als 0% gelten,
# sonst springt die Anzeige ständig auf null zurück.
assert get_progress_from_prgv('MSG:1005,0,1,"MakeMKV gestartet","%1","x"') == -1
assert get_progress_from_prgv("irgendwas") == -1
assert get_progress_from_prgv("PRGV:kaputt") == -1
def test_abcde_cmd_hat_genau_ein_ausgabeformat(): def test_abcde_cmd_hat_genau_ein_ausgabeformat():
@@ -34,46 +52,11 @@ def test_abcde_cmd_hat_genau_ein_ausgabeformat():
def test_abcde_config_enthaelt_zielverzeichnis(): def test_abcde_config_enthaelt_zielverzeichnis():
pfad = write_abcde_config("/output/cd/test123") pfad = write_abcde_config("/app/media/cd/test123")
try: try:
with open(pfad, encoding="utf-8") as f: with open(pfad, encoding="utf-8") as f:
inhalt = f.read() inhalt = f.read()
assert "OUTPUTDIR='/output/cd/test123'" in inhalt assert "OUTPUTDIR='/app/media/cd/test123'" in inhalt
assert "INTERACTIVE=n" in inhalt assert "INTERACTIVE=n" in inhalt
finally: finally:
os.unlink(pfad) os.unlink(pfad)
def test_handbrake_cmd_vollstaendig():
cmd = build_handbrake_cmd("/dev/sr0", "/output/dvd/x/film.mkv")
assert cmd[0] == "HandBrakeCLI"
assert cmd[cmd.index("--input") + 1] == "/dev/sr0"
assert cmd[cmd.index("--output") + 1] == "/output/dvd/x/film.mkv"
assert "--preset" in cmd
class _FakeResult:
def __init__(self, stdout):
self.stdout = stdout
def test_disc_typ_erkennung(monkeypatch):
faelle = [
("UDF filesystem data 'MEIN_FILM' DVD Video", "dvd"),
("data, BDMV bluray structure", "bluray"),
("Audio CD, cda tracks", "cd"),
("irgendwas anderes", "unknown"),
]
for ausgabe, erwartet in faelle:
monkeypatch.setattr(
ripping.subprocess, "run", lambda *a, _out=ausgabe, **k: _FakeResult(_out)
)
assert detect_disc_type("/dev/sr0") == erwartet
def test_disc_typ_erkennung_fehler_gibt_unknown(monkeypatch):
def kaputt(*a, **k):
raise OSError("kein Geraet")
monkeypatch.setattr(ripping.subprocess, "run", kaputt)
assert detect_disc_type("/dev/sr0") == "unknown"