From 780d114fe43989cde0bc27ab6af58ff46fefb9e8 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Thu, 23 Jul 2026 15:02:13 +0200 Subject: [PATCH] =?UTF-8?q?Etappe=2010:=20Worker=20rippt=20wirklich=20?= =?UTF-8?q?=E2=80=94=20MakeMKV=201.18.4=20+=20ioctl-Disc-Erkennung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitattributes | 3 + docker/worker/Dockerfile | 66 ++++++++- docker/worker/db.py | 73 ++++++++++ docker/worker/detection.py | 91 +++++++++++++ docker/worker/entrypoint.sh | 11 ++ docker/worker/ripping.py | 184 ++++++++++++-------------- docker/worker/tasks.py | 79 ++++++++--- docker/worker/test_detection.py | 39 ++++++ docker/worker/test_ripping_helpers.py | 79 +++++------ 9 files changed, 458 insertions(+), 167 deletions(-) create mode 100644 .gitattributes create mode 100644 docker/worker/db.py create mode 100644 docker/worker/detection.py create mode 100644 docker/worker/entrypoint.sh create mode 100644 docker/worker/test_detection.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5aeb5d5 --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/docker/worker/Dockerfile b/docker/worker/Dockerfile index 2eca483..bfb048b 100644 --- a/docker/worker/Dockerfile +++ b/docker/worker/Dockerfile @@ -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"] diff --git a/docker/worker/db.py b/docker/worker/db.py new file mode 100644 index 0000000..2d760f4 --- /dev/null +++ b/docker/worker/db.py @@ -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) + ) diff --git a/docker/worker/detection.py b/docker/worker/detection.py new file mode 100644 index 0000000..287d37e --- /dev/null +++ b/docker/worker/detection.py @@ -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" diff --git a/docker/worker/entrypoint.sh b/docker/worker/entrypoint.sh new file mode 100644 index 0000000..c76d54d --- /dev/null +++ b/docker/worker/entrypoint.sh @@ -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 "$@" diff --git a/docker/worker/ripping.py b/docker/worker/ripping.py index c8fe353..6f037db 100644 --- a/docker/worker/ripping.py +++ b/docker/worker/ripping.py @@ -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: all 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: diff --git a/docker/worker/tasks.py b/docker/worker/tasks.py index d0d41cc..fa531cc 100644 --- a/docker/worker/tasks.py +++ b/docker/worker/tasks.py @@ -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 diff --git a/docker/worker/test_detection.py b/docker/worker/test_detection.py new file mode 100644 index 0000000..bbabd63 --- /dev/null +++ b/docker/worker/test_detection.py @@ -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" diff --git a/docker/worker/test_ripping_helpers.py b/docker/worker/test_ripping_helpers.py index 8209fdf..b60ba5b 100644 --- a/docker/worker/test_ripping_helpers.py +++ b/docker/worker/test_ripping_helpers.py @@ -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"