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 \
gcc \
makepkg \
syslinux-utils \
build-essential \
pkg-config \
ca-certificates \
curl \
libssl-dev \
libexpat1-dev \
libavcodec-dev \
zlib1g-dev \
&& 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 .
RUN pip install --no-cache-dir -r requirements.txt
COPY docker/worker/ .
RUN chmod +x /app/entrypoint.sh
ENTRYPOINT ["/app/entrypoint.sh"]
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:
- 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:
+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 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")
def rip_disc(self, device_path: str, disc_id: str = None):
"""Rippt eine Disc basierend auf ihrem Typ."""
def rip_disc(self, device_path: str, job_id: str):
"""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)
if not disc_id:
disc_id = device_path.replace("/", "_")
if disc_type in ("no_disc", "unknown"):
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":
return rip_cd(device_path, disc_id)
elif disc_type == "dvd":
return rip_dvd(device_path, disc_id)
elif disc_type == "bluray":
return rip_bluray(device_path, disc_id)
ergebnis = rip_cd(device_path, job_id, progress_cb=fortschritt)
else:
return {
"status": "error",
"message": f"Unbekannter Disc-Typ: {disc_type}",
"disc_type": disc_type
}
ergebnis = rip_video(device_path, job_id, disc_type, progress_cb=fortschritt)
if ergebnis.get("status") == "success":
db.update_job(
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
gefunden wurden (abcde-Flags, Celery-API) — damit so etwas nie wieder still liegt.
Deckt genau die Stellen ab, an denen Reviews erfundene Schnittstellen fanden
(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 ripping
from ripping import (
build_abcde_cmd,
build_handbrake_cmd,
detect_disc_type,
get_progress_from_line,
build_makemkv_cmd,
get_progress_from_prgv,
write_abcde_config,
)
def test_progress_parsing():
assert get_progress_from_line("Encoding: task 1 of 1, 45.50 %") == 45
assert get_progress_from_line("Encoding: task 1 of 1, 100.00 %") == 100
assert get_progress_from_line("kein Fortschritt hier") == 0
def test_makemkv_cmd_vollstaendig():
cmd = build_makemkv_cmd("/dev/sr0", "/app/media/dvd/x")
assert cmd[0] == "makemkvcon"
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():
@@ -34,46 +52,11 @@ def test_abcde_cmd_hat_genau_ein_ausgabeformat():
def test_abcde_config_enthaelt_zielverzeichnis():
pfad = write_abcde_config("/output/cd/test123")
pfad = write_abcde_config("/app/media/cd/test123")
try:
with open(pfad, encoding="utf-8") as f:
inhalt = f.read()
assert "OUTPUTDIR='/output/cd/test123'" in inhalt
assert "OUTPUTDIR='/app/media/cd/test123'" in inhalt
assert "INTERACTIVE=n" in inhalt
finally:
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"