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:
@@ -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
|
||||||
@@ -1,16 +1,72 @@
|
|||||||
FROM python:3.12-slim
|
# Worker-Image: MakeMKV (verlustfrei, Etappe 10) + abcde/cdparanoia (CD→FLAC).
|
||||||
|
#
|
||||||
|
# Multi-Stage: makemkv-oss wird aus Quellen gebaut (GPL-Teil), makemkv-bin ist
|
||||||
|
# das proprietäre Binärpaket (EULA wird per tmp/eula_accepted akzeptiert —
|
||||||
|
# Standard-Mechanismus des Makefiles für nicht-interaktive Builds).
|
||||||
|
# Beide Stages sind auf bookworm gepinnt, damit die libavcodec-Version passt.
|
||||||
|
#
|
||||||
|
# Vorher stand hier `makepkg` (ein Arch-Linux-Werkzeug, existiert in Debian
|
||||||
|
# nicht) — das Image war seit dem 23.07. gar nicht mehr baubar. Und es fehlte
|
||||||
|
# schlicht JEDES Ripping-Werkzeug: weder HandBrake noch abcde noch MakeMKV
|
||||||
|
# waren installiert.
|
||||||
|
|
||||||
WORKDIR /app
|
FROM python:3.12-slim-bookworm AS makemkv-build
|
||||||
|
|
||||||
|
ARG MAKEMKV_VERSION=1.18.4
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
gcc \
|
build-essential \
|
||||||
makepkg \
|
pkg-config \
|
||||||
syslinux-utils \
|
ca-certificates \
|
||||||
|
curl \
|
||||||
|
libssl-dev \
|
||||||
|
libexpat1-dev \
|
||||||
|
libavcodec-dev \
|
||||||
|
zlib1g-dev \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
RUN curl -fsSL -o oss.tar.gz "https://www.makemkv.com/download/makemkv-oss-${MAKEMKV_VERSION}.tar.gz" \
|
||||||
|
&& curl -fsSL -o bin.tar.gz "https://www.makemkv.com/download/makemkv-bin-${MAKEMKV_VERSION}.tar.gz" \
|
||||||
|
&& tar xzf oss.tar.gz \
|
||||||
|
&& tar xzf bin.tar.gz
|
||||||
|
|
||||||
|
RUN cd "makemkv-oss-${MAKEMKV_VERSION}" \
|
||||||
|
&& ./configure --disable-gui --prefix=/usr/local \
|
||||||
|
&& make -j"$(nproc)" \
|
||||||
|
&& make install
|
||||||
|
|
||||||
|
RUN cd "makemkv-bin-${MAKEMKV_VERSION}" \
|
||||||
|
&& mkdir -p tmp \
|
||||||
|
&& touch tmp/eula_accepted \
|
||||||
|
&& make \
|
||||||
|
&& make install
|
||||||
|
|
||||||
|
|
||||||
|
FROM python:3.12-slim-bookworm
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libssl3 \
|
||||||
|
libexpat1 \
|
||||||
|
zlib1g \
|
||||||
|
libavcodec59 \
|
||||||
|
abcde \
|
||||||
|
cdparanoia \
|
||||||
|
cd-discid \
|
||||||
|
flac \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=makemkv-build /usr/local /usr/local
|
||||||
|
RUN ldconfig
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
COPY docker/worker/requirements.txt .
|
COPY docker/worker/requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
COPY docker/worker/ .
|
COPY docker/worker/ .
|
||||||
|
RUN chmod +x /app/entrypoint.sh
|
||||||
|
|
||||||
|
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||||
CMD ["celery", "-A", "celery_app", "worker", "--loglevel=info"]
|
CMD ["celery", "-A", "celery_app", "worker", "--loglevel=info"]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
)
|
||||||
@@ -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"
|
||||||
@@ -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
@@ -1,11 +1,14 @@
|
|||||||
"""Ripping-Tasks: DVD/Blu-ray via HandBrake, CD via abcde.
|
"""Ripping-Tasks: DVD/Blu-ray verlustfrei via MakeMKV (Etappe 10), CD via abcde.
|
||||||
|
|
||||||
Review-Fixes 22.07.2026:
|
Etappe 10 (23.07.2026): HandBrake ist aus dem Ripp-Pfad entfernt — das KONZEPT
|
||||||
- Fortschritt läuft jetzt über Celery `update_state` (Standard) — vorher gingen
|
verlangt verlustfreies Sichern (MakeMKV als Muss-Feature). HandBrake war ohnehin
|
||||||
send_task-Aufrufe an einen Task `update_progress`, den es NIE gab.
|
nie im Worker-Image installiert; jeder Rip endete sofort mit "nicht installiert".
|
||||||
- abcde-Kommando korrigiert: `-o` ist das AUSGABEFORMAT (nicht das Verzeichnis!),
|
|
||||||
das Zielverzeichnis geht als OUTPUTDIR über eine Config-Datei (-c). Vorher wurde
|
MakeMKV-Aufruf und Fortschritts-Format sind dokumentiert unter
|
||||||
das Verzeichnis als Format geparst — CD-Ripping war nie funktionsfähig.
|
https://www.makemkv.com/developers/usage.txt (Robot-Mode `-r`):
|
||||||
|
PRGV:current,total,max → Fortschritt gesamt = total/max
|
||||||
|
MSG:code,flags,... → Meldungen
|
||||||
|
(AGENTS Regel D: externe Schnittstellen nie aus dem Kopf.)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -14,14 +17,12 @@ import shutil
|
|||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
from celery_app import celery_app
|
RIP_OUTPUT_DIR = os.getenv("RIP_OUTPUT_DIR", "/app/media")
|
||||||
|
|
||||||
HANDBRAKE_PRESET = "Fast 1080p30"
|
|
||||||
|
|
||||||
|
|
||||||
def check_handbrake_installed() -> bool:
|
def check_makemkv_installed() -> bool:
|
||||||
"""Prüft, ob HandBrakeCLI installiert ist."""
|
"""Prüft, ob makemkvcon installiert ist."""
|
||||||
return shutil.which("HandBrakeCLI") is not None
|
return shutil.which("makemkvcon") is not None
|
||||||
|
|
||||||
|
|
||||||
def check_abcde_installed() -> bool:
|
def check_abcde_installed() -> bool:
|
||||||
@@ -34,31 +35,39 @@ def check_cdparanoia_installed() -> bool:
|
|||||||
return shutil.which("cdparanoia") is not None
|
return shutil.which("cdparanoia") is not None
|
||||||
|
|
||||||
|
|
||||||
def get_progress_from_line(line: str) -> int:
|
def build_makemkv_cmd(device_path: str, output_dir: str) -> list:
|
||||||
"""Extrahiert Fortschritt in Prozent aus HandBrake-Ausgabe.
|
"""Baut das MakeMKV-Kommando (pure Funktion, testbar).
|
||||||
|
|
||||||
Testfund 22.07.: echtes HandBrake schreibt „45.50 %" MIT Leerzeichen vor
|
-r Robot-Mode: maschinenlesbare Ausgabe (PRGV/MSG-Zeilen)
|
||||||
dem Prozentzeichen — die alte Regex ohne \\s* hat NIE einen Fortschritt
|
--progress=-same Fortschritt in denselben Stream wie die Meldungen
|
||||||
aus echter Ausgabe geparst.
|
mkv dev:<pfad> all <ziel> alle Titel der Disc verlustfrei als MKV
|
||||||
"""
|
"""
|
||||||
match = re.search(r'(\d+\.\d+)\s*%', line)
|
|
||||||
if match:
|
|
||||||
return int(float(match.group(1)))
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def build_handbrake_cmd(device_path: str, output_path: str) -> list:
|
|
||||||
"""Baut das HandBrake-Kommando (pure Funktion, testbar)."""
|
|
||||||
return [
|
return [
|
||||||
"HandBrakeCLI",
|
"makemkvcon",
|
||||||
"--input", device_path,
|
"-r",
|
||||||
"--output", output_path,
|
"--progress=-same",
|
||||||
"--all",
|
"mkv",
|
||||||
"--progress",
|
f"dev:{device_path}",
|
||||||
"--preset", HANDBRAKE_PRESET
|
"all",
|
||||||
|
output_dir,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_progress_from_prgv(line: str) -> int:
|
||||||
|
"""Extrahiert Gesamt-Fortschritt (0-100) aus einer PRGV-Zeile.
|
||||||
|
|
||||||
|
Format laut MakeMKV-Doku: PRGV:current,total,max
|
||||||
|
`total` ist der Gesamtfortschritt, `max` die Skala (65536).
|
||||||
|
"""
|
||||||
|
match = re.match(r"PRGV:(\d+),(\d+),(\d+)", line.strip())
|
||||||
|
if not match:
|
||||||
|
return -1
|
||||||
|
total, maximum = int(match.group(2)), int(match.group(3))
|
||||||
|
if maximum <= 0:
|
||||||
|
return -1
|
||||||
|
return min(100, int(total * 100 / maximum))
|
||||||
|
|
||||||
|
|
||||||
def build_abcde_cmd(device_path: str, config_path: str) -> list:
|
def build_abcde_cmd(device_path: str, config_path: str) -> list:
|
||||||
"""Baut das abcde-Kommando (pure Funktion, testbar).
|
"""Baut das abcde-Kommando (pure Funktion, testbar).
|
||||||
|
|
||||||
@@ -85,95 +94,72 @@ def write_abcde_config(output_dir: str) -> str:
|
|||||||
return tmp.name
|
return tmp.name
|
||||||
|
|
||||||
|
|
||||||
def run_handbrake(device_path: str, output_path: str, progress_cb=None) -> dict:
|
def run_makemkv(device_path: str, output_dir: str, progress_cb=None) -> dict:
|
||||||
"""Rippt eine DVD/Blu-ray mit HandBrakeCLI; meldet Fortschritt via Callback."""
|
"""Rippt eine DVD/Blu-ray verlustfrei mit makemkvcon; meldet Fortschritt."""
|
||||||
if not check_handbrake_installed():
|
if not check_makemkv_installed():
|
||||||
return {"status": "error", "error": "HandBrakeCLI ist nicht installiert"}
|
return {"status": "error", "error": "makemkvcon ist nicht installiert"}
|
||||||
|
|
||||||
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
process = subprocess.Popen(
|
process = subprocess.Popen(
|
||||||
build_handbrake_cmd(device_path, output_path),
|
build_makemkv_cmd(device_path, output_dir),
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.STDOUT,
|
stderr=subprocess.STDOUT,
|
||||||
text=True,
|
text=True,
|
||||||
bufsize=1
|
bufsize=1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
letzte_meldung = ""
|
||||||
for line in process.stdout:
|
for line in process.stdout:
|
||||||
progress = get_progress_from_line(line)
|
progress = get_progress_from_prgv(line)
|
||||||
if progress > 0 and progress_cb:
|
if progress >= 0 and progress_cb:
|
||||||
progress_cb(progress)
|
progress_cb(progress)
|
||||||
|
elif line.startswith("MSG:"):
|
||||||
|
# MSG:code,flags,count,"message",... — Klartext ist Feld 4
|
||||||
|
teile = line.split(",", 4)
|
||||||
|
if len(teile) >= 4:
|
||||||
|
letzte_meldung = teile[3].strip('"')
|
||||||
|
|
||||||
process.wait()
|
process.wait()
|
||||||
|
|
||||||
if process.returncode == 0:
|
mkv_dateien = [
|
||||||
|
os.path.join(output_dir, f)
|
||||||
|
for f in sorted(os.listdir(output_dir))
|
||||||
|
if f.endswith(".mkv")
|
||||||
|
]
|
||||||
|
|
||||||
|
if process.returncode == 0 and mkv_dateien:
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"output_path": output_path,
|
"output_dir": output_dir,
|
||||||
"return_code": process.returncode
|
"files": mkv_dateien,
|
||||||
|
"return_code": process.returncode,
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"error": f"HandBrake failed with code {process.returncode}",
|
"error": (
|
||||||
"return_code": process.returncode
|
f"makemkvcon endete mit Code {process.returncode}"
|
||||||
|
+ (f" — letzte Meldung: {letzte_meldung}" if letzte_meldung else "")
|
||||||
|
+ ("" if mkv_dateien else " — keine MKV-Datei entstanden")
|
||||||
|
),
|
||||||
|
"return_code": process.returncode,
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"status": "error", "error": str(e)}
|
return {"status": "error", "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
def detect_disc_type(device_path: str) -> str:
|
def rip_video(device_path: str, disc_id: str, disc_type: str = "dvd", progress_cb=None) -> dict:
|
||||||
"""Erkennt den Disc-Typ anhand des Gerätepfads."""
|
"""Rippt eine DVD oder Blu-ray verlustfrei mit MakeMKV.
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
Bewusst KEIN eigener Celery-Task: der einzige Task ist worker.tasks.rip_disc,
|
||||||
["file", "-L", device_path],
|
der hier mit seinem eigenen Fortschritts-Callback durchgreift.
|
||||||
capture_output=True,
|
"""
|
||||||
text=True,
|
output_dir = os.path.join(RIP_OUTPUT_DIR, disc_type, disc_id)
|
||||||
timeout=10
|
return run_makemkv(device_path, output_dir, progress_cb=progress_cb)
|
||||||
)
|
|
||||||
output = result.stdout.lower()
|
|
||||||
if "dvd" in output or "video_ts" in output:
|
|
||||||
return "dvd"
|
|
||||||
elif "bluray" in output or "bdmv" in output:
|
|
||||||
return "bluray"
|
|
||||||
elif "audio" in output or "cda" in output:
|
|
||||||
return "cd"
|
|
||||||
return "unknown"
|
|
||||||
except Exception:
|
|
||||||
return "unknown"
|
|
||||||
|
|
||||||
|
|
||||||
def _progress_melder(task):
|
def rip_cd(device_path: str, disc_id: str, progress_cb=None) -> dict:
|
||||||
"""Baut einen Fortschritts-Callback, der Celery-Standard update_state nutzt."""
|
|
||||||
def melde(progress: int, message: str = ""):
|
|
||||||
task.update_state(
|
|
||||||
state="PROGRESS",
|
|
||||||
meta={"progress": progress, "status": "ripping", "message": message}
|
|
||||||
)
|
|
||||||
return melde
|
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task(bind=True, name="worker.ripping.rip_dvd")
|
|
||||||
def rip_dvd(self, device_path: str, disc_id: str) -> dict:
|
|
||||||
"""Rippt eine DVD mit HandBrake."""
|
|
||||||
output_dir = f"/output/dvd/{disc_id}"
|
|
||||||
os.makedirs(output_dir, exist_ok=True)
|
|
||||||
output_path = f"{output_dir}/dvd_{disc_id}.mkv"
|
|
||||||
melde = _progress_melder(self)
|
|
||||||
return run_handbrake(device_path, output_path, progress_cb=melde)
|
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task(bind=True, name="worker.ripping.rip_bluray")
|
|
||||||
def rip_bluray(self, device_path: str, disc_id: str) -> dict:
|
|
||||||
"""Rippt eine Blu-ray mit HandBrake."""
|
|
||||||
output_dir = f"/output/bluray/{disc_id}"
|
|
||||||
os.makedirs(output_dir, exist_ok=True)
|
|
||||||
output_path = f"{output_dir}/bluray_{disc_id}.mkv"
|
|
||||||
melde = _progress_melder(self)
|
|
||||||
return run_handbrake(device_path, output_path, progress_cb=melde)
|
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task(bind=True, name="worker.ripping.rip_cd")
|
|
||||||
def rip_cd(self, device_path: str, disc_id: str) -> dict:
|
|
||||||
"""Rippt eine CD mit abcde (FLAC)."""
|
"""Rippt eine CD mit abcde (FLAC)."""
|
||||||
if not check_abcde_installed():
|
if not check_abcde_installed():
|
||||||
return {
|
return {
|
||||||
@@ -186,9 +172,13 @@ def rip_cd(self, device_path: str, disc_id: str) -> dict:
|
|||||||
"error": "cdparanoia ist nicht installiert. Installiere abcde und cdparanoia für CD-Ripping."
|
"error": "cdparanoia ist nicht installiert. Installiere abcde und cdparanoia für CD-Ripping."
|
||||||
}
|
}
|
||||||
|
|
||||||
output_dir = f"/output/cd/{disc_id}"
|
output_dir = os.path.join(RIP_OUTPUT_DIR, "cd", disc_id)
|
||||||
os.makedirs(output_dir, exist_ok=True)
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
melde = _progress_melder(self)
|
|
||||||
|
def melde(progress: int, message: str = ""):
|
||||||
|
if progress_cb:
|
||||||
|
progress_cb(progress, message)
|
||||||
|
|
||||||
config_path = write_abcde_config(output_dir)
|
config_path = write_abcde_config(output_dir)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
+60
-15
@@ -1,24 +1,69 @@
|
|||||||
|
"""Zentraler Rip-Task: erkennt den Disc-Typ, rippt und schreibt Status nach Postgres.
|
||||||
|
|
||||||
|
Die API legt beim POST /jobs die Job-Zeile an und schickt diesen Task los —
|
||||||
|
der Worker hält die Zeile aktuell (running → completed/failed) und schreibt
|
||||||
|
Ereignisse ins Log. Das UI liest beides über die API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import db
|
||||||
from celery_app import celery_app
|
from celery_app import celery_app
|
||||||
from ripping import rip_dvd, rip_bluray, rip_cd, detect_disc_type
|
from detection import detect_disc_type
|
||||||
|
from ripping import rip_cd, rip_video
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task(bind=True, name="worker.tasks.rip_disc")
|
@celery_app.task(bind=True, name="worker.tasks.rip_disc")
|
||||||
def rip_disc(self, device_path: str, disc_id: str = None):
|
def rip_disc(self, device_path: str, job_id: str):
|
||||||
"""Rippt eine Disc basierend auf ihrem Typ."""
|
"""Rippt eine Disc basierend auf ihrem Typ; job_id ist die DB-Zeile der API."""
|
||||||
|
db.init_db()
|
||||||
disc_type = detect_disc_type(device_path)
|
disc_type = detect_disc_type(device_path)
|
||||||
|
|
||||||
if not disc_id:
|
if disc_type in ("no_disc", "unknown"):
|
||||||
disc_id = device_path.replace("/", "_")
|
fehler = (
|
||||||
|
"Keine Disc im Laufwerk" if disc_type == "no_disc"
|
||||||
|
else "Disc-Typ nicht erkennbar"
|
||||||
|
)
|
||||||
|
db.update_job(
|
||||||
|
job_id, status="failed", error=fehler, finished_at=db.utcnow()
|
||||||
|
)
|
||||||
|
db.add_log("error", "worker", f"Job {job_id}: {fehler} ({device_path})")
|
||||||
|
return {"status": "error", "error": fehler, "disc_type": disc_type}
|
||||||
|
|
||||||
|
db.update_job(job_id, status="running", disc_type=disc_type)
|
||||||
|
db.add_log("info", "worker", f"Job {job_id}: {disc_type}-Rip gestartet ({device_path})")
|
||||||
|
|
||||||
|
letzter = [-1]
|
||||||
|
|
||||||
|
def fortschritt(progress: int, message: str = ""):
|
||||||
|
# MakeMKV liefert viele PRGV-Zeilen pro Sekunde — DB nur bei Änderung.
|
||||||
|
if progress == letzter[0]:
|
||||||
|
return
|
||||||
|
letzter[0] = progress
|
||||||
|
self.update_state(
|
||||||
|
state="PROGRESS",
|
||||||
|
meta={"progress": progress, "status": "ripping", "message": message},
|
||||||
|
)
|
||||||
|
db.update_job(job_id, progress=progress)
|
||||||
|
|
||||||
if disc_type == "cd":
|
if disc_type == "cd":
|
||||||
return rip_cd(device_path, disc_id)
|
ergebnis = rip_cd(device_path, job_id, progress_cb=fortschritt)
|
||||||
elif disc_type == "dvd":
|
|
||||||
return rip_dvd(device_path, disc_id)
|
|
||||||
elif disc_type == "bluray":
|
|
||||||
return rip_bluray(device_path, disc_id)
|
|
||||||
else:
|
else:
|
||||||
return {
|
ergebnis = rip_video(device_path, job_id, disc_type, progress_cb=fortschritt)
|
||||||
"status": "error",
|
if ergebnis.get("status") == "success":
|
||||||
"message": f"Unbekannter Disc-Typ: {disc_type}",
|
db.update_job(
|
||||||
"disc_type": disc_type
|
job_id,
|
||||||
}
|
status="completed",
|
||||||
|
progress=100,
|
||||||
|
output_path=ergebnis.get("output_dir"),
|
||||||
|
finished_at=db.utcnow(),
|
||||||
|
)
|
||||||
|
db.add_log("success", "worker", f"Job {job_id}: Rip abgeschlossen → {ergebnis.get('output_dir')}")
|
||||||
|
else:
|
||||||
|
db.update_job(
|
||||||
|
job_id,
|
||||||
|
status="failed",
|
||||||
|
error=ergebnis.get("error", "unbekannter Fehler"),
|
||||||
|
finished_at=db.utcnow(),
|
||||||
|
)
|
||||||
|
db.add_log("error", "worker", f"Job {job_id}: {ergebnis.get('error', 'unbekannter Fehler')}")
|
||||||
|
|
||||||
|
return ergebnis
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -1,25 +1,43 @@
|
|||||||
"""Tests für ripping.py: Fortschritts-Parsing, Kommando-Bau, Disc-Erkennung.
|
"""Tests für ripping.py: Kommando-Bau und Fortschritts-Parsing.
|
||||||
|
|
||||||
Deckt genau die Stellen ab, an denen im Review 22.07. erfundene Schnittstellen
|
Deckt genau die Stellen ab, an denen Reviews erfundene Schnittstellen fanden
|
||||||
gefunden wurden (abcde-Flags, Celery-API) — damit so etwas nie wieder still liegt.
|
(abcde-Flags, Celery-API, HandBrake-Regex) — damit so etwas nie wieder still liegt.
|
||||||
|
Das PRGV-Format stammt aus der MakeMKV-Doku (makemkv.com/developers/usage.txt).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
import ripping
|
|
||||||
from ripping import (
|
from ripping import (
|
||||||
build_abcde_cmd,
|
build_abcde_cmd,
|
||||||
build_handbrake_cmd,
|
build_makemkv_cmd,
|
||||||
detect_disc_type,
|
get_progress_from_prgv,
|
||||||
get_progress_from_line,
|
|
||||||
write_abcde_config,
|
write_abcde_config,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_progress_parsing():
|
def test_makemkv_cmd_vollstaendig():
|
||||||
assert get_progress_from_line("Encoding: task 1 of 1, 45.50 %") == 45
|
cmd = build_makemkv_cmd("/dev/sr0", "/app/media/dvd/x")
|
||||||
assert get_progress_from_line("Encoding: task 1 of 1, 100.00 %") == 100
|
assert cmd[0] == "makemkvcon"
|
||||||
assert get_progress_from_line("kein Fortschritt hier") == 0
|
assert "-r" in cmd # Robot-Mode: maschinenlesbar
|
||||||
|
assert "--progress=-same" in cmd # Fortschritt im selben Stream
|
||||||
|
assert "mkv" in cmd
|
||||||
|
assert "dev:/dev/sr0" in cmd # Geräte-Notation laut Doku
|
||||||
|
assert cmd[-2:] == ["all", "/app/media/dvd/x"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_prgv_parsing():
|
||||||
|
# PRGV:current,total,max — total/max ist der Gesamtfortschritt
|
||||||
|
assert get_progress_from_prgv("PRGV:100,32768,65536") == 50
|
||||||
|
assert get_progress_from_prgv("PRGV:0,65536,65536") == 100
|
||||||
|
assert get_progress_from_prgv("PRGV:0,0,65536") == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_prgv_parsing_ignoriert_fremde_zeilen():
|
||||||
|
# -1 heißt „keine Fortschrittszeile" — MSG-Zeilen dürfen NIE als 0% gelten,
|
||||||
|
# sonst springt die Anzeige ständig auf null zurück.
|
||||||
|
assert get_progress_from_prgv('MSG:1005,0,1,"MakeMKV gestartet","%1","x"') == -1
|
||||||
|
assert get_progress_from_prgv("irgendwas") == -1
|
||||||
|
assert get_progress_from_prgv("PRGV:kaputt") == -1
|
||||||
|
|
||||||
|
|
||||||
def test_abcde_cmd_hat_genau_ein_ausgabeformat():
|
def test_abcde_cmd_hat_genau_ein_ausgabeformat():
|
||||||
@@ -34,46 +52,11 @@ def test_abcde_cmd_hat_genau_ein_ausgabeformat():
|
|||||||
|
|
||||||
|
|
||||||
def test_abcde_config_enthaelt_zielverzeichnis():
|
def test_abcde_config_enthaelt_zielverzeichnis():
|
||||||
pfad = write_abcde_config("/output/cd/test123")
|
pfad = write_abcde_config("/app/media/cd/test123")
|
||||||
try:
|
try:
|
||||||
with open(pfad, encoding="utf-8") as f:
|
with open(pfad, encoding="utf-8") as f:
|
||||||
inhalt = f.read()
|
inhalt = f.read()
|
||||||
assert "OUTPUTDIR='/output/cd/test123'" in inhalt
|
assert "OUTPUTDIR='/app/media/cd/test123'" in inhalt
|
||||||
assert "INTERACTIVE=n" in inhalt
|
assert "INTERACTIVE=n" in inhalt
|
||||||
finally:
|
finally:
|
||||||
os.unlink(pfad)
|
os.unlink(pfad)
|
||||||
|
|
||||||
|
|
||||||
def test_handbrake_cmd_vollstaendig():
|
|
||||||
cmd = build_handbrake_cmd("/dev/sr0", "/output/dvd/x/film.mkv")
|
|
||||||
assert cmd[0] == "HandBrakeCLI"
|
|
||||||
assert cmd[cmd.index("--input") + 1] == "/dev/sr0"
|
|
||||||
assert cmd[cmd.index("--output") + 1] == "/output/dvd/x/film.mkv"
|
|
||||||
assert "--preset" in cmd
|
|
||||||
|
|
||||||
|
|
||||||
class _FakeResult:
|
|
||||||
def __init__(self, stdout):
|
|
||||||
self.stdout = stdout
|
|
||||||
|
|
||||||
|
|
||||||
def test_disc_typ_erkennung(monkeypatch):
|
|
||||||
faelle = [
|
|
||||||
("UDF filesystem data 'MEIN_FILM' DVD Video", "dvd"),
|
|
||||||
("data, BDMV bluray structure", "bluray"),
|
|
||||||
("Audio CD, cda tracks", "cd"),
|
|
||||||
("irgendwas anderes", "unknown"),
|
|
||||||
]
|
|
||||||
for ausgabe, erwartet in faelle:
|
|
||||||
monkeypatch.setattr(
|
|
||||||
ripping.subprocess, "run", lambda *a, _out=ausgabe, **k: _FakeResult(_out)
|
|
||||||
)
|
|
||||||
assert detect_disc_type("/dev/sr0") == erwartet
|
|
||||||
|
|
||||||
|
|
||||||
def test_disc_typ_erkennung_fehler_gibt_unknown(monkeypatch):
|
|
||||||
def kaputt(*a, **k):
|
|
||||||
raise OSError("kein Geraet")
|
|
||||||
|
|
||||||
monkeypatch.setattr(ripping.subprocess, "run", kaputt)
|
|
||||||
assert detect_disc_type("/dev/sr0") == "unknown"
|
|
||||||
|
|||||||
Reference in New Issue
Block a user