From 780d114fe43989cde0bc27ab6af58ff46fefb9e8 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Thu, 23 Jul 2026 15:02:13 +0200 Subject: [PATCH 1/6] =?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" From 28a12b2e06d77c3760348670b796c4e2a73fe1b1 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Thu, 23 Jul 2026 15:02:13 +0200 Subject: [PATCH 2/6] =?UTF-8?q?API:=20Die=20Job-Kette=20existiert=20jetzt?= =?UTF-8?q?=20=E2=80=94=20POST=20/jobs,=20Watcher,=20Postgres,=20/logs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vorher gab es KEINEN Code-Pfad, der je einen Rip ausgelöst hat: kein POST /jobs, kein udev-Daemon (udev_daemon.py existierte nirgends), GET /jobs gab hart [] zurück, Postgres lag komplett brach, der SSE-Stream konnte strukturell nie senden (sse_connections wurde nie befüllt), udevadm lieferte ohne udevd nichts. - POST /jobs: legt Job-Zeile an, schickt worker.tasks.rip_disc via Celery - GET /jobs aus Postgres (running→processing fürs UI) - Disc-Watcher: 3s-ioctl-Poll statt udev, protokolliert Einwurf/Auswurf - /devices über /sys (vendor/model) + ioctl-Status — ehrlich statt leer - /logs + /settings (Settings-Seite sprach vorher gegen 404) - SSE-Fix, udev aus dem API-Image entfernt, Import-Smoke-Test Co-Authored-By: Claude Fable 5 --- docker/api/Dockerfile | 5 +- docker/api/celery_client.py | 20 ++++ docker/api/db.py | 140 ++++++++++++++++++++++ docker/api/detection.py | 91 +++++++++++++++ docker/api/devices.py | 63 ++++++++++ docker/api/main.py | 221 +++++++++++++++++++++-------------- docker/api/test_api_smoke.py | 32 +++++ 7 files changed, 485 insertions(+), 87 deletions(-) create mode 100644 docker/api/celery_client.py create mode 100644 docker/api/db.py create mode 100644 docker/api/detection.py create mode 100644 docker/api/devices.py create mode 100644 docker/api/test_api_smoke.py diff --git a/docker/api/Dockerfile b/docker/api/Dockerfile index 26c3144..98045c9 100644 --- a/docker/api/Dockerfile +++ b/docker/api/Dockerfile @@ -1,8 +1,9 @@ -FROM python:3.12-slim +FROM python:3.12-slim-bookworm WORKDIR /app -RUN apt-get update && apt-get install -y --no-install-recommends gcc udev && rm -rf /var/lib/apt/lists/* +# udev ist raus (23.07.): udevadm lieferte im Container nie Daten (kein udevd) — +# die Geräte-Erkennung läuft jetzt über /sys + ioctls, ganz ohne Systempakete. COPY docker/api/requirements.txt . RUN pip install --no-cache-dir -r requirements.txt diff --git a/docker/api/celery_client.py b/docker/api/celery_client.py new file mode 100644 index 0000000..7bad835 --- /dev/null +++ b/docker/api/celery_client.py @@ -0,0 +1,20 @@ +"""Dünner Celery-Client: die API SCHICKT Tasks an den Worker, führt sie nie aus. + +Bis 23.07. gab es überhaupt keinen Code-Pfad, der je einen Rip auslöste — +kein POST /jobs, kein udev-Daemon. Dieser Client schließt die Lücke. +""" + +import os + +from celery import Celery + +REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0") + +celery_client = Celery("rippy_api", broker=REDIS_URL, backend=REDIS_URL) + + +def start_rip(device_path: str, job_id: str): + """Schickt den Rip-Task an den Worker (Task-Name aus worker/tasks.py).""" + return celery_client.send_task( + "worker.tasks.rip_disc", args=[device_path, job_id] + ) diff --git a/docker/api/db.py b/docker/api/db.py new file mode 100644 index 0000000..04406e3 --- /dev/null +++ b/docker/api/db.py @@ -0,0 +1,140 @@ +"""Job-, Log- und Settings-Persistenz in PostgreSQL (KONZEPT: Postgres für Job-Logs). + +Die jobs/logs-Tabellendefinition existiert bewusst identisch im Worker +(docker/worker/db.py) — es gibt kein geteiltes Paket zwischen den Containern. +Wer die Struktur ändert, ändert BEIDE Dateien. create_all ist idempotent. + +Bis 23.07. lag Postgres komplett brach: GET /jobs gab hart [] zurück, nichts +schrieb je eine Zeile — der Job-Verlauf im UI war ein Placebo. +""" + +import json +import os +from datetime import datetime, timezone + +from sqlalchemy import ( + Column, + DateTime, + Integer, + MetaData, + String, + Table, + Text, + create_engine, + select, +) + +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), +) + +settings_table = Table( + "settings", + metadata, + Column("key", String(64), primary_key=True), + Column("value", Text), +) + + +def utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def init_db() -> None: + """Legt fehlende Tabellen an (idempotent).""" + metadata.create_all(engine) + + +def insert_job(job_id: str, device: str, disc_type: str = None, title: str = None) -> None: + with engine.begin() as conn: + conn.execute( + jobs.insert().values( + id=job_id, + device=device, + disc_type=disc_type, + title=title, + status="pending", + progress=0, + created_at=utcnow(), + ) + ) + + +def list_jobs(limit: int = 100) -> list: + with engine.connect() as conn: + zeilen = conn.execute( + select(jobs).order_by(jobs.c.created_at.desc()).limit(limit) + ).mappings().all() + return [dict(z) for z in zeilen] + + +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) + ) + + +def list_logs(limit: int = 200) -> list: + with engine.connect() as conn: + zeilen = conn.execute( + select(logs).order_by(logs.c.id.desc()).limit(limit) + ).mappings().all() + return [dict(z) for z in zeilen] + + +def get_settings(key: str = "ui") -> dict: + with engine.connect() as conn: + zeile = conn.execute( + select(settings_table.c.value).where(settings_table.c.key == key) + ).first() + if not zeile or not zeile[0]: + return {} + try: + return json.loads(zeile[0]) + except ValueError: + return {} + + +def save_settings(werte: dict, key: str = "ui") -> None: + payload = json.dumps(werte) + with engine.begin() as conn: + vorhanden = conn.execute( + select(settings_table.c.key).where(settings_table.c.key == key) + ).first() + if vorhanden: + conn.execute( + settings_table.update() + .where(settings_table.c.key == key) + .values(value=payload) + ) + else: + conn.execute(settings_table.insert().values(key=key, value=payload)) diff --git a/docker/api/detection.py b/docker/api/detection.py new file mode 100644 index 0000000..287d37e --- /dev/null +++ b/docker/api/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/api/devices.py b/docker/api/devices.py new file mode 100644 index 0000000..553aa0b --- /dev/null +++ b/docker/api/devices.py @@ -0,0 +1,63 @@ +"""Laufwerks-Discovery über /sys und ioctls — ehrlich, ohne udev. + +Warum kein udevadm mehr: Im Container läuft kein udevd, die udev-Datenbank ist +leer — `udevadm info` lieferte schlicht nichts (der alte Weg zeigte deshalb nie +ein Laufwerk an). /sys/class/block//device/{vendor,model} kommt dagegen +direkt vom Kernel und funktioniert überall. +""" + +import glob +import os + +from detection import ( + CDS_DISC_OK, + classify, + disc_size_bytes, + disc_status, + drive_status, +) + + +def list_optical_devices() -> list: + """Alle optischen Laufwerke, die der Container sieht (devices: in compose).""" + return sorted(glob.glob("/dev/sr[0-9]*")) + + +def read_sys_attr(device_name: str, attr: str) -> str: + pfad = f"/sys/class/block/{device_name}/device/{attr}" + try: + with open(pfad, encoding="ascii", errors="replace") as f: + return f.read().strip() + except OSError: + return "" + + +def device_info(device_path: str) -> dict: + """Baut den Geräte-Eintrag fürs UI: Name aus /sys, Disc-Status per ioctl.""" + name = os.path.basename(device_path) + vendor = read_sys_attr(name, "vendor") + model = read_sys_attr(name, "model") + + try: + status_code = drive_status(device_path) + except OSError: + status_code = -1 + + disc_type = "unknown" + status = "empty" + if status_code == CDS_DISC_OK: + status = "ready" + try: + disc_type = classify(disc_status(device_path), disc_size_bytes(device_path)) + except OSError: + disc_type = "unknown" + + return { + "id": name, + "name": " ".join(teil for teil in (vendor, model) if teil) or f"Laufwerk {name}", + "type": disc_type, + "path": device_path, + "status": status, + "model": model, + "serial": read_sys_attr(name, "wwid"), + } diff --git a/docker/api/main.py b/docker/api/main.py index 21322d0..d181ac3 100644 --- a/docker/api/main.py +++ b/docker/api/main.py @@ -4,9 +4,14 @@ from fastapi.responses import StreamingResponse from pydantic import BaseModel from typing import List, Optional, Dict from pathlib import Path -import subprocess import asyncio import json +import uuid + +import db +import devices as device_discovery +from celery_client import start_rip +from detection import CDS_DISC_OK, CDS_NO_DISC, CDS_TRAY_OPEN, drive_status from fastapi.security import OAuth2PasswordBearer @@ -41,20 +46,47 @@ app = FastAPI( # OAuth2 Scheme oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") -# SSE-Connections -sse_connections: List = [] - @app.on_event("startup") async def startup_event(): - """Initialisiere Cache beim Start und validiere Konfiguration.""" + """Initialisiere Cache + Datenbank, validiere Konfiguration, starte Disc-Wache.""" init_cache() - + db.init_db() + try: validate_config() except ConfigValidationError as e: print(f"⚠️ Konfigurations-Warnung: {e}") + asyncio.create_task(disc_watcher()) + + +async def disc_watcher(): + """Disc-Wache: pollt die Laufwerke und protokolliert Einwurf/Auswurf. + + Ersetzt den nie gebauten udev-Daemon aus dem KONZEPT: udev funktioniert im + Container nicht sinnvoll (kein udevd) — ein 3-Sekunden-Poll per ioctl ist + für den Heim-Use-Case gleichwertig und läuft überall. + """ + bekannt: Dict[str, int] = {} + while True: + try: + for pfad in device_discovery.list_optical_devices(): + try: + status = await asyncio.to_thread(drive_status, pfad) + except OSError: + continue + vorher = bekannt.get(pfad) + if vorher is not None and status != vorher: + if status == CDS_DISC_OK: + db.add_log("info", "watcher", f"Disc eingelegt: {pfad}") + elif status in (CDS_NO_DISC, CDS_TRAY_OPEN) and vorher == CDS_DISC_OK: + db.add_log("info", "watcher", f"Disc entfernt: {pfad}") + bekannt[pfad] = status + except Exception as e: + print(f"Disc-Wache: {e}") + await asyncio.sleep(3) + # Middleware für Rate-Limiting @app.middleware("http") @@ -102,6 +134,8 @@ class Job(BaseModel): startTime: str endTime: Optional[str] = None progress: int = 0 + title: Optional[str] = None + error: Optional[str] = None class Device(BaseModel): id: str @@ -109,6 +143,24 @@ class Device(BaseModel): type: str path: str status: str + model: Optional[str] = None + serial: Optional[str] = None + + +def _job_row_to_model(zeile: dict) -> Job: + """DB-Zeile → UI-Form (Worker-Status 'running' heißt im UI 'processing').""" + status_map = {"running": "processing"} + return Job( + id=zeile["id"], + type=zeile.get("disc_type") or "unknown", + status=status_map.get(zeile["status"], zeile["status"]), + device=zeile.get("device") or "", + startTime=zeile["created_at"].isoformat() if zeile.get("created_at") else "", + endTime=zeile["finished_at"].isoformat() if zeile.get("finished_at") else None, + progress=zeile.get("progress") or 0, + title=zeile.get("title"), + error=zeile.get("error"), + ) @app.get("/health") async def health_check(): @@ -126,96 +178,95 @@ async def root(): @app.get("/jobs", response_model=List[Job]) async def get_jobs(): - """Holt alle Jobs.""" - return [] + """Holt alle Jobs aus der Datenbank (neueste zuerst).""" + zeilen = await asyncio.to_thread(db.list_jobs) + return [_job_row_to_model(z) for z in zeilen] + + +class JobCreateRequest(BaseModel): + device_path: Optional[str] = None + device: Optional[str] = None # Alias, so schickt es das UI + title: Optional[str] = None + + +@app.post("/jobs", status_code=201) +async def create_job(request: JobCreateRequest): + """Legt einen Rip-Job an und schickt ihn an den Worker. + + Das war DIE fehlende Stelle: bis 23.07. gab es keinerlei Code-Pfad, + der je einen Rip ausgelöst hat. + """ + device_path = request.device_path or request.device + if not device_path: + raise HTTPException(status_code=422, detail="device_path fehlt") + if device_path not in device_discovery.list_optical_devices(): + raise HTTPException(status_code=404, detail=f"Laufwerk {device_path} nicht gefunden") + + job_id = str(uuid.uuid4()) + await asyncio.to_thread(db.insert_job, job_id, device_path, None, request.title) + await asyncio.to_thread(db.add_log, "info", "api", f"Job {job_id} angelegt für {device_path}") + start_rip(device_path, job_id) + return {"id": job_id, "status": "pending", "device": device_path} + + +@app.get("/logs") +async def get_logs(limit: int = 200): + """Echte Ereignisse aus der Datenbank (Watcher, API, Worker).""" + zeilen = await asyncio.to_thread(db.list_logs, min(limit, 1000)) + return [ + { + "id": str(z["id"]), + "timestamp": z["ts"].isoformat() if z.get("ts") else "", + "level": z.get("level") or "info", + "source": z.get("source") or "system", + "message": z.get("message") or "", + } + for z in zeilen + ] + + +@app.get("/settings") +async def get_settings(): + """UI-Einstellungen aus der Datenbank (leeres Objekt = Defaults im UI).""" + return await asyncio.to_thread(db.get_settings) + + +@app.post("/settings") +async def save_settings(werte: Dict): + """Speichert die UI-Einstellungen als JSON in der Datenbank.""" + await asyncio.to_thread(db.save_settings, werte) + return {"status": "saved"} @app.get("/devices", response_model=List[Device]) async def get_devices(): - """Holt alle Geräte.""" - devices = [] - - try: - # 1. Zuerst Symlinks in /dev/disc/ prüfen - result = subprocess.run( - ["ls", "-la", "/dev/disc/"], - capture_output=True, - text=True, - timeout=5 - ) - - for line in result.stdout.strip().split('\n')[1:]: - if line and 'total' not in line: - parts = line.split() - if len(parts) >= 9: - name = parts[-1] - devices.append(Device( - id=name, - name=f"Laufwerk {name}", - type="dvd", - path=f"/dev/disc/{name}", - status="ready" - )) - - # 2. Direkte Geräte /dev/sr0, /dev/cdrom prüfen - import os - direct_devices = ['/dev/sr0', '/dev/cdrom', '/dev/dvd'] - for device_path in direct_devices: - if os.path.exists(device_path): - # Prüfen ob es ein block device ist - import stat - mode = os.stat(device_path).st_mode - if stat.S_ISBLK(mode): - # Geräte-Info aus udevadm holen - result = subprocess.run( - ["udevadm", "info", "-q", "property", "-n", device_path], - capture_output=True, - text=True, - timeout=5 - ) - info = {} - for line in result.stdout.strip().split('\n'): - if '=' in line: - key, value = line.split('=', 1) - info[key] = value - - device_type = "dvd" - if info.get("ID_CDROM_BD") == "1": - device_type = "bluray" - elif info.get("ID_CDROM_CD") == "1": - device_type = "cd" - - model = info.get("ID_MODEL", "Laufwerk") - serial = info.get("ID_SERIAL", "unknown") - - devices.append(Device( - id=f"{model}_{serial}".replace(" ", "_"), - name=model, - type=device_type, - path=device_path, - status="ready", - serial=serial, - model=model - )) - except Exception as e: - print(f"Fehler beim Lesen der Geräte: {e}") - pass - - return devices + """Alle optischen Laufwerke mit ehrlichem Status (leer/bereit + Disc-Typ). + + Der alte Weg (udevadm + /dev/disc-Symlinks) lieferte im Container + prinzipbedingt nichts: kein udevd, keine udev-Datenbank, kein Daemon, + der Symlinks anlegt. Jetzt: /sys fürs Modell, ioctl für den Disc-Status. + """ + geraete = [] + for pfad in device_discovery.list_optical_devices(): + info = await asyncio.to_thread(device_discovery.device_info, pfad) + geraete.append(Device(**info)) + return geraete # SSE-Stream für Echtzeit-Updates @app.get("/stream/jobs") async def job_stream(): - """SSE-Stream für Job-Updates.""" + """SSE-Stream für Job-Updates. + + Fix 23.07.: Der alte Generator sendete nur, wenn `sse_connections` gefüllt + war — aber NICHTS hat diese Liste je befüllt. Der Stream war ein Placebo. + """ async def event_generator(): while True: - if sse_connections: - # Job-Status aktualisieren - jobs = await get_jobs() - yield f"data: {json.dumps([j.dict() for j in jobs])}\n\n" - await asyncio.sleep(1) - + jobs = await get_jobs() + yield f"data: {json.dumps([j.dict() for j in jobs])}\n\n" + await asyncio.sleep(2) + return StreamingResponse(event_generator(), media_type="text/event-stream") diff --git a/docker/api/test_api_smoke.py b/docker/api/test_api_smoke.py new file mode 100644 index 0000000..0eba54f --- /dev/null +++ b/docker/api/test_api_smoke.py @@ -0,0 +1,32 @@ +"""Import-Smoke-Test: bricht, wenn main.py kaputte Imports oder Verdrahtung hat. + +Warum: Kein anderer Test importiert main.py — ein Tippfehler dort fiele sonst +erst beim Container-Start auf (und die Ampel bliebe fälschlich grün). +Läuft nur unter Linux (detection.py nutzt fcntl/ioctl), also genau dort, +wo auch die Ampel läuft. +""" + +import sys + +import pytest + +if sys.platform == "win32": # pragma: no cover + pytest.skip("detection.py braucht fcntl (Linux)", allow_module_level=True) + + +def test_main_importierbar_und_routen_verdrahtet(): + from main import app + + routen = {route.path for route in app.routes} + for pfad in ("/health", "/jobs", "/devices", "/logs", "/settings", "/prescan"): + assert pfad in routen, f"Route {pfad} fehlt" + + +def test_worker_task_name_passt_zum_celery_client(): + """API schickt an 'worker.tasks.rip_disc' — der Name ist Vertrag mit dem Worker.""" + import inspect + + import celery_client + + quelle = inspect.getsource(celery_client.start_rip) + assert '"worker.tasks.rip_disc"' in quelle From eee3a83d0eeaf7b2aec8e5c03b2137c267fd6615 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Thu, 23 Jul 2026 15:02:13 +0200 Subject: [PATCH 3/6] Compose: Laufwerk als devices: statt Bind-Mount, Ausgabe aufs media-Volume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bind-Mounts unter volumes: geben dem Container den Geräteknoten, aber keine Device-Cgroup-Erlaubnis — jedes open() scheiterte mit EPERM. Totes disc:-Volume raus (kein udevd legt dort je Symlinks an), depends_on wartet jetzt auf healthy, Worker-read_only bis zur Härtung ausgesetzt (MakeMKV braucht HOME). Co-Authored-By: Claude Fable 5 --- docker-compose.yml | 36 ++++++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index ea0681b..8134ddf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,9 @@ # Docker Compose für Rippy +# +# Geräte-Zugriff (22.07./23.07.-Lehre): Optische Laufwerke MÜSSEN als devices: +# eingebunden werden. Bind-Mounts unter volumes: geben dem Container zwar den +# Geräteknoten, aber KEINE Berechtigung im Device-Cgroup → jedes open() scheitert +# mit EPERM. Voraussetzung: Die VM sieht das Laufwerk (USB-Passthrough auf pve). services: api: @@ -23,13 +28,15 @@ services: volumes: - media:/app/media - temp:/app/temp - - /dev/cdrom:/dev/cdrom:ro - - /dev/sr0:/dev/sr0:ro + devices: + - /dev/sr0:/dev/sr0 networks: - rippy-net depends_on: - - postgres - - redis + postgres: + condition: service_healthy + redis: + condition: service_healthy restart: unless-stopped read_only: false @@ -40,23 +47,25 @@ services: environment: - DATABASE_URL=postgresql://rippy:rippy@postgres:5432/rippy - REDIS_URL=redis://redis:6379/0 - - TMDB_API_KEY=${TMDB_API_KEY} - - THETVDB_API_KEY=${THETVDB_API_KEY} - - JWT_SECRET_KEY=${JWT_SECRET_KEY} + - RIP_OUTPUT_DIR=/app/media + - MAKEMKV_APP_KEY=${MAKEMKV_APP_KEY} - LOG_LEVEL=INFO volumes: - media:/app/media - temp:/app/temp - - disc:/dev/disc - - /dev/cdrom:/dev/cdrom:ro - - /dev/sr0:/dev/sr0:ro + devices: + - /dev/sr0:/dev/sr0 networks: - rippy-net depends_on: - - postgres - - redis + postgres: + condition: service_healthy + redis: + condition: service_healthy restart: unless-stopped - read_only: true + # read_only bleibt Ziel (Etappe 6), aber MakeMKV/abcde brauchen HOME + + # Settings — bis zur Härtung schreibbar, Ausgabe geht ohnehin aufs Volume. + read_only: false tmpfs: - /app/tmp - /run @@ -112,6 +121,5 @@ networks: volumes: media: temp: - disc: postgres-data: redis-data: From f2da1ad525180691b5660a22de76daf3469b2f93 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Thu, 23 Jul 2026 15:02:39 +0200 Subject: [PATCH 4/6] UI: echter Build hinter nginx, relative API-Pfade, Logs-Seite ehrlich MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vorher lief der Vite-ENTWICKLUNGSSERVER als Produktion, und vier Dateien riefen hartkodiert http://localhost:8000 auf — vom PC aus fragte der Browser damit den PC selbst nach Laufwerken (deshalb blieb das UI immer leer); Settings nutzte ein nie gesetztes VITE_API_URL. Die Logs-Seite zeigte fest einprogrammierte Fantasie-Einträge ("The Matrix gerippt"). - lib/api.ts: axios-Instanz mit baseURL /api; nginx proxied /api → api:8000 (ein Origin, kein CORS), SSE-tauglich (proxy_buffering off) - Dockerfile: Vite-Build → nginx:alpine statt npm run dev - Logs + LiveLog von echtem /logs-Endpoint; MetadataPreview: echte Laufwerke aus /devices statt erfundener /dev/bluray-Optionen, startet echten Job Co-Authored-By: Claude Fable 5 --- docker/ui/Dockerfile | 21 +++++--- docker/ui/nginx.conf | 25 ++++++++++ docker/ui/src/components/DeviceDiscovery.tsx | 6 +-- docker/ui/src/components/LiveLogSection.tsx | 26 ++++++---- docker/ui/src/pages/Dashboard.tsx | 8 ++- docker/ui/src/pages/Logs.tsx | 25 +++------- docker/ui/src/pages/MetadataPreview.tsx | 52 +++++++++++++++----- docker/ui/src/pages/Settings.tsx | 9 ++-- 8 files changed, 111 insertions(+), 61 deletions(-) create mode 100644 docker/ui/nginx.conf diff --git a/docker/ui/Dockerfile b/docker/ui/Dockerfile index 21c1e29..c143c6b 100644 --- a/docker/ui/Dockerfile +++ b/docker/ui/Dockerfile @@ -1,14 +1,21 @@ -FROM node:22-alpine +# UI-Image: Vite-Build → statisch via nginx (KONZEPT-konform). +# Vorher lief hier `npm run dev` — der Vite-ENTWICKLUNGSSERVER — als +# "Produktion". Jetzt: echter Build, nginx liefert aus und proxied /api. + +FROM node:22-alpine AS build WORKDIR /app -RUN apk add --no-cache curl - -COPY docker/ui/package.json docker/ui/package-lock.json* ./ -RUN npm ci 2>/dev/null || npm install +COPY docker/ui/package.json docker/ui/package-lock.json ./ +RUN npm ci --no-audit --no-fund COPY docker/ui/ . +RUN npm run build + + +FROM nginx:alpine + +COPY docker/ui/nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html EXPOSE 80 - -CMD ["npm", "run", "dev"] diff --git a/docker/ui/nginx.conf b/docker/ui/nginx.conf new file mode 100644 index 0000000..6a593df --- /dev/null +++ b/docker/ui/nginx.conf @@ -0,0 +1,25 @@ +# Rippy UI: statische Dateien + API-Proxy. +# /api/* wird an den api-Container durchgereicht (Präfix wird entfernt), +# alles andere ist das gebaute React-UI. Damit braucht der Browser nur +# EINEN Origin — kein hartkodiertes localhost, kein CORS-Gefrickel. + +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + location /api/ { + proxy_pass http://api:8000/; + proxy_http_version 1.1; + proxy_set_header Host $host; + # SSE (/api/stream/jobs) braucht ungepufferte, lange Verbindungen: + proxy_buffering off; + proxy_read_timeout 3600s; + } + + location / { + try_files $uri /index.html; + } +} diff --git a/docker/ui/src/components/DeviceDiscovery.tsx b/docker/ui/src/components/DeviceDiscovery.tsx index b7c8a96..195f7ca 100644 --- a/docker/ui/src/components/DeviceDiscovery.tsx +++ b/docker/ui/src/components/DeviceDiscovery.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react' -import axios from 'axios' import { HardDrive, Disc, AlertCircle, CheckCircle, RefreshCw } from 'lucide-react' +import { api } from '../lib/api' import { useDarkMode } from '../context/ThemeContext' interface Device { @@ -13,8 +13,6 @@ interface Device { model?: string } -const API_URL = 'http://localhost:8000' - export default function DeviceDiscovery() { const [devices, setDevices] = useState([]) const [loading, setLoading] = useState(true) @@ -22,7 +20,7 @@ export default function DeviceDiscovery() { const refreshDevices = async () => { try { - const response = await axios.get(`${API_URL}/devices`) + const response = await api.get('/devices') setDevices(response.data) } catch (error) { console.error('Fehler beim Laden der Geräte:', error) diff --git a/docker/ui/src/components/LiveLogSection.tsx b/docker/ui/src/components/LiveLogSection.tsx index da72d4d..5094679 100644 --- a/docker/ui/src/components/LiveLogSection.tsx +++ b/docker/ui/src/components/LiveLogSection.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react' -import axios from 'axios' -import { Terminal, Disc, Play, CheckCircle, AlertCircle, Clock, Download } from 'lucide-react' +import { Terminal, Play, CheckCircle, AlertCircle } from 'lucide-react' +import { api } from '../lib/api' import { useDarkMode } from '../context/ThemeContext' interface LiveLogEntry { @@ -16,12 +16,10 @@ export default function LiveLogSection() { const [currentJob, setCurrentJob] = useState(null) const { theme } = useDarkMode() - const API_URL = 'http://localhost:8000' - useEffect(() => { const fetchJobs = async () => { try { - const response = await axios.get(`${API_URL}/jobs`) + const response = await api.get('/jobs') const jobs = response.data const current = jobs.find((j: any) => j.status === 'processing') setCurrentJob(current || jobs.find((j: any) => j.status === 'pending')) @@ -30,15 +28,23 @@ export default function LiveLogSection() { } } - fetchJobs() - const jobInterval = setInterval(fetchJobs, 5000) + const fetchLogs = async () => { + try { + const response = await api.get('/logs', { params: { limit: 50 } }) + setLogs(response.data) + } catch (error) { + console.error('Fehler beim Laden der Logs:', error) + } + } - // TODO: WebSocket/SSE für Echtzeit-Logs implementieren - // const logInterval = setInterval(fetchLogs, 1000) + fetchJobs() + fetchLogs() + const jobInterval = setInterval(fetchJobs, 5000) + const logInterval = setInterval(fetchLogs, 5000) return () => { clearInterval(jobInterval) - // clearInterval(logInterval) + clearInterval(logInterval) } }, []) diff --git a/docker/ui/src/pages/Dashboard.tsx b/docker/ui/src/pages/Dashboard.tsx index 7e9ed10..6cffd5f 100644 --- a/docker/ui/src/pages/Dashboard.tsx +++ b/docker/ui/src/pages/Dashboard.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' -import axios from 'axios' import { Clock, Activity, HardDrive, BarChart, AlertCircle, CheckCircle, Disc, Play, Pause, SkipForward } from 'lucide-react' +import { api } from '../lib/api' import { useDarkMode } from '../context/ThemeContext' import DeviceDiscovery from '../components/DeviceDiscovery' import LiveLogSection from '../components/LiveLogSection' @@ -24,11 +24,9 @@ interface Device { status: 'empty' | 'ready' | 'ripping' } -const API_URL = 'http://localhost:8000' - async function fetchJobs(): Promise { try { - const response = await axios.get(`${API_URL}/jobs`) + const response = await api.get('/jobs') return response.data } catch { return [] @@ -37,7 +35,7 @@ async function fetchJobs(): Promise { async function fetchDevices(): Promise { try { - const response = await axios.get(`${API_URL}/devices`) + const response = await api.get('/devices') return response.data } catch { return [] diff --git a/docker/ui/src/pages/Logs.tsx b/docker/ui/src/pages/Logs.tsx index 6f6c06a..6f3def2 100644 --- a/docker/ui/src/pages/Logs.tsx +++ b/docker/ui/src/pages/Logs.tsx @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react' -import { Terminal, Download, AlertCircle, CheckCircle, Clock, RefreshCw } from 'lucide-react' +import { Terminal, AlertCircle, CheckCircle, RefreshCw } from 'lucide-react' +import { api } from '../lib/api' import { useDarkMode } from '../context/ThemeContext' interface LogEntry { @@ -18,25 +19,11 @@ export default function LogsPage() { const { theme } = useDarkMode() const refreshLogs = async () => { + // Echte Ereignisse aus der Datenbank — die Mock-Einträge („The Matrix + // gerippt") sind raus. Was hier steht, ist passiert; sonst ist es leer. try { - // TODO: API endpoint für Logs implementieren - // const response = await axios.get(`${API_URL}/logs`) - // setLogs(response.data) - - // Mock-Daten für Demo - const mockLogs: LogEntry[] = [ - { id: '1', timestamp: '2024-01-15 14:30:22', level: 'info', source: 'udev-daemon', message: 'Disc-Einwurf erkannt' }, - { id: '2', timestamp: '2024-01-15 14:30:25', level: 'info', source: 'worker', message: 'Job erstellt: DVD, Device: /dev/disc/PLEXTOR_BD_XD' }, - { id: '3', timestamp: '2024-01-15 14:30:30', level: 'success', source: 'prescan', message: 'Pre-Scan abgeschlossen, Confidence: 0.92' }, - { id: '4', timestamp: '2024-01-15 14:30:35', level: 'info', source: 'tmdb', message: 'Metadaten lookup erfolgreich: "The Matrix"' }, - { id: '5', timestamp: '2024-01-15 14:31:00', level: 'info', source: 'ripping', message: 'Starte MakeMKV-Ripping...' }, - { id: '6', timestamp: '2024-01-15 14:35:22', level: 'warning', source: 'ripping', message: 'Kapitel 5 übersprungen (Trailer)' }, - { id: '7', timestamp: '2024-01-15 14:40:15', level: 'success', source: 'ripping', message: 'Ripping abgeschlossen, Dateigröße: 4.2 GB' }, - { id: '8', timestamp: '2024-01-15 14:40:20', level: 'info', source: 'jellyfin', message: 'NFO-Datei generiert' }, - { id: '9', timestamp: '2024-01-15 14:40:25', level: 'info', source: 'jellyfin', message: 'Poster heruntergeladen' }, - { id: '10', timestamp: '2024-01-15 14:40:30', level: 'success', source: 'worker', message: 'Job abgeschlossen' }, - ] - setLogs(mockLogs) + const response = await api.get('/logs', { params: { limit: 200 } }) + setLogs(response.data) } catch (error) { console.error('Fehler beim Laden der Logs:', error) } finally { diff --git a/docker/ui/src/pages/MetadataPreview.tsx b/docker/ui/src/pages/MetadataPreview.tsx index e795f21..e5c5283 100644 --- a/docker/ui/src/pages/MetadataPreview.tsx +++ b/docker/ui/src/pages/MetadataPreview.tsx @@ -1,10 +1,14 @@ import { useState, useEffect } from 'react' -import axios from 'axios' import { BookOpen, Music, Film, AlertCircle, CheckCircle, Search, Loader2 } from 'lucide-react' import type { LucideIcon } from 'lucide-react' +import { api } from '../lib/api' import { useDarkMode } from '../context/ThemeContext' -const API_URL = 'http://localhost:8000' +interface DeviceOption { + path: string + name: string + status: string +} interface Metadata { type: 'movie' | 'tv' | 'music' | 'unknown' @@ -35,12 +39,32 @@ interface PrescanResult { export default function MetadataPreview() { const [device, setDevice] = useState('') + const [devices, setDevices] = useState([]) const [result, setResult] = useState(null) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [confirming, setConfirming] = useState(false) const { theme } = useDarkMode() + // Echte Laufwerke laden — vorher standen hier erfundene Festwerte + // (/dev/dvd, /dev/bluray), die es im Container nie gab. + useEffect(() => { + const loadDevices = async () => { + try { + const response = await api.get('/devices') + setDevices(response.data) + if (response.data.length === 1) { + setDevice(response.data[0].path) + } + } catch { + setDevices([]) + } + } + loadDevices() + const interval = setInterval(loadDevices, 5000) + return () => clearInterval(interval) + }, []) + const handleScan = async () => { if (!device) { setError('Bitte wählen Sie ein Gerät aus.') @@ -51,7 +75,7 @@ export default function MetadataPreview() { setError(null) try { - const response = await axios.post(`${API_URL}/prescan`, { device_path: device }) + const response = await api.post('/prescan', { device_path: device }) setResult(response.data) } catch (err) { setError('Pre-Scan fehlgeschlagen.') @@ -66,16 +90,16 @@ export default function MetadataPreview() { setConfirming(true) try { - await axios.post(`${API_URL}/metadata/confirm`, { + await api.post('/metadata/confirm', { title: result.title, year: result.year, metadata: result.metadata }) - // Job erstellen - await axios.post(`${API_URL}/jobs`, { - device: device, - type: result.disc_type + // Job erstellen — POST /jobs existiert seit 23.07. wirklich + await api.post('/jobs', { + device_path: device, + title: result.title }) } catch (err) { setError('Bestätigung fehlgeschlagen.') @@ -119,10 +143,14 @@ export default function MetadataPreview() { onChange={(e) => setDevice(e.target.value)} className={`flex-1 px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent ${theme === 'dark' ? 'bg-slate-700 border-slate-600 text-slate-100' : 'bg-white border-gray-300 text-slate-900'}`} > - - - - + + {devices.map((d) => ( + + ))}