From 2474b683e21aedb57b744919c54d61f51e92d17f Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Thu, 23 Jul 2026 15:56:37 +0200 Subject: [PATCH] Transcode-Stufe: HandBrake komprimiert NACH dem MakeMKV-Rip + --noscan-Fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commander-Entscheid 23.07.: 40-GB-Rohdateien sind kein brauchbares Endprodukt. Architektur bleibt zweistufig, weil HandBrake AACS nicht lesen kann: MakeMKV rippt verlustfrei nach /app/temp/raw, HandBrake macht daraus x265 auf Arbeitsgroesse in /app/media, Roh-Verzeichnis wird erst NACH komplettem Erfolg geloescht (keepOriginal behaelt es). - Worker: handbrake-cli im Image, run_handbrake + _komprimiere, Job-Status "transcoding", Settings aus Postgres (transcodeEnabled/ Preset/keepOriginal), Fortschritt je Datei aggregiert - makemkvcon: --noscan im Kommando verankert — der Geraete-Scan haengt (1.18.4) bzw. crasht (1.17.7) im Container, mit --noscan + dev:-Pfad laeuft es (Befund 23.07., DRV-Zeile beweist Laufwerks-Erkennung) - UI: Settings-Tab "Verarbeitung" (Toggle/Preset/Original behalten), Status-Badge "Komprimieren", LiveLog erkennt transcoding als aktiv - KONZEPT/ROADMAP entsprechend aktualisiert; Tests fuer HandBrake-Cmd (arbeitet auf DATEI, nie am Geraet) und Progress-Regex Co-Authored-By: Claude Fable 5 --- KONZEPT.md | 9 ++- docker/api/prescan/prescan.py | 2 +- docker/ui/src/components/LiveLogSection.tsx | 2 +- docker/ui/src/pages/Dashboard.tsx | 9 ++- docker/ui/src/pages/Settings.tsx | 57 ++++++++++++- docker/worker/Dockerfile | 3 + docker/worker/db.py | 26 ++++++ docker/worker/ripping.py | 82 ++++++++++++++++++- docker/worker/tasks.py | 90 ++++++++++++++++++++- docker/worker/test_ripping_helpers.py | 23 ++++++ 10 files changed, 287 insertions(+), 16 deletions(-) diff --git a/KONZEPT.md b/KONZEPT.md index 70fa03a..b4d9b6d 100644 --- a/KONZEPT.md +++ b/KONZEPT.md @@ -44,7 +44,7 @@ Ein modular aufgebautes System, das bei Disc-Einwurf automatisch den Typ erkennt | MakeMKV als isolierte Black-Box-CLI | ✔ M | | | | GPL-v3-Compliance-Checkliste | ✔ M | | | | Source-Release-Endpoint | ✔ M | | | -| HandBrake-Transcoding (optional) | | ✔ K | | +| HandBrake-Kompression NACH dem Lossless-Rip (Stufe 2) | ✔ M | | | | Prometheus+Grafana Monitoring | | ✔ K | | | PBS-Snapshot-Backup-Hooks | | ✔ K | | | Multi-Disc-Parallelisierung | | | ✔ S | @@ -68,7 +68,12 @@ Ein modular aufgebautes System, das bei Disc-Einwurf automatisch den Typ erkennt - Commander bestätigt oder korrigiert manuell. 6. **Ripping** (nach Bestätigung): - CD: `abcde` → FLAC, Metadaten via AcoustID + MusicBrainz. - - DVD/Blu-ray: `makemkvcon --all --progress` → MKV. + - DVD/Blu-ray: `makemkvcon` → verlustfreies MKV als ZWISCHENPRODUKT in /app/temp + (MakeMKV ist der einzige Weg durch AACS — HandBrake kann verschlüsselte + Discs nicht lesen), danach **HandBrake-Kompression auf Arbeitsgröße** + (x265; Commander-Entscheid 23.07.2026: 40-GB-Rohdateien sind kein + brauchbares Endprodukt). Roh-Datei wird nach Erfolg gelöscht + (Setting keepOriginal behält sie). 7. **Post-Processing & Jellyfin-Formatierung**: - Dateien in Jellyfin-konformer Ordnerstruktur. - NFO-Dateien im Kodi/NFO-Schema. diff --git a/docker/api/prescan/prescan.py b/docker/api/prescan/prescan.py index 164867a..11dc158 100644 --- a/docker/api/prescan/prescan.py +++ b/docker/api/prescan/prescan.py @@ -195,7 +195,7 @@ class PreScan: # Titel-Quelle 2 (optional, falls makemkvcon doch da ist): if shutil.which("makemkvcon"): result = subprocess.run( - ["makemkvcon", "-r", "--minlength=300", "info", f"dev:{device_path}"], + ["makemkvcon", "-r", "--noscan", "--minlength=300", "info", f"dev:{device_path}"], capture_output=True, text=True, timeout=120 diff --git a/docker/ui/src/components/LiveLogSection.tsx b/docker/ui/src/components/LiveLogSection.tsx index 5094679..2aa2b04 100644 --- a/docker/ui/src/components/LiveLogSection.tsx +++ b/docker/ui/src/components/LiveLogSection.tsx @@ -21,7 +21,7 @@ export default function LiveLogSection() { try { const response = await api.get('/jobs') const jobs = response.data - const current = jobs.find((j: any) => j.status === 'processing') + const current = jobs.find((j: any) => j.status === 'processing' || j.status === 'transcoding') setCurrentJob(current || jobs.find((j: any) => j.status === 'pending')) } catch (error) { console.error('Fehler beim Laden der Jobs:', error) diff --git a/docker/ui/src/pages/Dashboard.tsx b/docker/ui/src/pages/Dashboard.tsx index 4f1126d..8f89ed2 100644 --- a/docker/ui/src/pages/Dashboard.tsx +++ b/docker/ui/src/pages/Dashboard.tsx @@ -8,7 +8,7 @@ import LiveLogSection from '../components/LiveLogSection' interface Job { id: string type: 'cd' | 'dvd' | 'bluray' - status: 'pending' | 'processing' | 'completed' | 'failed' + status: 'pending' | 'processing' | 'transcoding' | 'completed' | 'failed' device: string startTime: string endTime?: string @@ -31,19 +31,22 @@ function StatusBadge({ status }: { status: string }) { ? { pending: 'bg-amber-900/30 text-amber-400 ring-amber-700/20', processing: 'bg-blue-900/30 text-blue-400 ring-blue-700/20', + transcoding: 'bg-purple-900/30 text-purple-400 ring-purple-700/20', completed: 'bg-emerald-900/30 text-emerald-400 ring-emerald-700/20', failed: 'bg-rose-900/30 text-rose-400 ring-rose-700/20', } : { pending: 'bg-amber-100 text-amber-700 ring-amber-600/20', processing: 'bg-blue-100 text-blue-700 ring-blue-600/20', + transcoding: 'bg-purple-100 text-purple-700 ring-purple-600/20', completed: 'bg-emerald-100 text-emerald-700 ring-emerald-600/20', failed: 'bg-rose-100 text-rose-700 ring-rose-600/20', } - + const labels = { pending: 'Wartend', - processing: 'Bearbeitung', + processing: 'Rippen', + transcoding: 'Komprimieren', completed: 'Fertig', failed: 'Fehler', } diff --git a/docker/ui/src/pages/Settings.tsx b/docker/ui/src/pages/Settings.tsx index 907a97d..d457dda 100644 --- a/docker/ui/src/pages/Settings.tsx +++ b/docker/ui/src/pages/Settings.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react' -import { Save, Disc, Database, Globe, AlertCircle, CheckCircle } from 'lucide-react' +import { Save, Disc, Cpu, Database, Globe, AlertCircle, CheckCircle } from 'lucide-react' import { api } from '../lib/api' import { useDarkMode } from '../context/ThemeContext' @@ -15,6 +15,9 @@ interface SettingsState { mainFeatureOnly: boolean autoEject: boolean notificationWebhook: string + transcodeEnabled: boolean + transcodePreset: string + keepOriginal: boolean } const defaultSettings: SettingsState = { @@ -29,9 +32,12 @@ const defaultSettings: SettingsState = { mainFeatureOnly: false, autoEject: true, notificationWebhook: '', + transcodeEnabled: true, + transcodePreset: 'H.265 MKV 1080p30', + keepOriginal: false, } -type SettingsTab = 'ripping' | 'verzeichnisse' | 'apis' | 'benachrichtigungen' +type SettingsTab = 'ripping' | 'verarbeitung' | 'verzeichnisse' | 'apis' | 'benachrichtigungen' export default function SettingsPage() { const [settings, setSettings] = useState(defaultSettings) @@ -98,6 +104,7 @@ export default function SettingsPage() { {/* Tabs — echte Navigation (vorher reine Dekoration ohne onClick) */}
setActiveTab('ripping')} darkMode={theme === 'dark'} /> + setActiveTab('verarbeitung')} darkMode={theme === 'dark'} /> setActiveTab('verzeichnisse')} darkMode={theme === 'dark'} /> setActiveTab('apis')} darkMode={theme === 'dark'} /> setActiveTab('benachrichtigungen')} darkMode={theme === 'dark'} /> @@ -166,6 +173,52 @@ export default function SettingsPage() {
} + {/* Verarbeitung: Kompressions-Stufe nach dem Rip */} + {activeTab === 'verarbeitung' &&
+

+ + Verarbeitung (Kompression) +

+

+ MakeMKV rippt verlustfrei (der einzige Weg durch den Kopierschutz), HandBrake + komprimiert danach auf Arbeitsgröße. Die Roh-Datei wird nach Erfolg gelöscht. +

+ +
+
+
+

Nach dem Rip komprimieren

+

x265 statt 40-GB-Rohdatei (empfohlen)

+
+ handleChange('transcodeEnabled', v)} darkMode={theme === 'dark'} /> +
+ +
+ + +
+ +
+
+

Original behalten

+

Verlustfreie Roh-Datei zusätzlich aufheben (viel Speicher!)

+
+ handleChange('keepOriginal', v)} darkMode={theme === 'dark'} /> +
+
+
} + {/* Output Directories */} {activeTab === 'verzeichnisse' &&

diff --git a/docker/worker/Dockerfile b/docker/worker/Dockerfile index 7c24dee..689d32d 100644 --- a/docker/worker/Dockerfile +++ b/docker/worker/Dockerfile @@ -59,6 +59,8 @@ RUN cd "makemkv-bin-${MAKEMKV_VERSION}" \ FROM python:3.12-slim-bookworm +# handbrake-cli: Kompressions-Stufe NACH dem verlustfreien MakeMKV-Rip +# (Commander-Entscheid 23.07.: 40-GB-Rohdateien sind nicht arbeitsfähig). RUN apt-get update && apt-get install -y --no-install-recommends \ libssl3 \ libexpat1 \ @@ -68,6 +70,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ cdparanoia \ cd-discid \ flac \ + handbrake-cli \ && rm -rf /var/lib/apt/lists/* COPY --from=makemkv-build /usr/local /usr/local diff --git a/docker/worker/db.py b/docker/worker/db.py index 2d760f4..4792bb2 100644 --- a/docker/worker/db.py +++ b/docker/worker/db.py @@ -61,6 +61,32 @@ def init_db() -> None: metadata.create_all(engine) +settings_table = Table( + "settings", + metadata, + Column("key", String(64), primary_key=True), + Column("value", Text), +) + + +def get_settings(key: str = "ui") -> dict: + """UI-Einstellungen lesen (der Worker respektiert Transcode-Optionen).""" + import json + + from sqlalchemy import select + + try: + with engine.connect() as conn: + zeile = conn.execute( + select(settings_table.c.value).where(settings_table.c.key == key) + ).first() + if zeile and zeile[0]: + return json.loads(zeile[0]) + except Exception: + pass + return {} + + 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)) diff --git a/docker/worker/ripping.py b/docker/worker/ripping.py index 6f037db..2b0d0e0 100644 --- a/docker/worker/ripping.py +++ b/docker/worker/ripping.py @@ -39,12 +39,16 @@ def build_makemkv_cmd(device_path: str, output_dir: str) -> list: """Baut das MakeMKV-Kommando (pure Funktion, testbar). -r Robot-Mode: maschinenlesbare Ausgabe (PRGV/MSG-Zeilen) + --noscan KEIN Scan über alle Geräte — der brachte MakeMKV im + Container zum Hängen (1.18.4) bzw. Segfault (1.17.7): + /sys zeigt dort auch Geräte ohne /dev-Knoten (Befund 23.07.) --progress=-same Fortschritt in denselben Stream wie die Meldungen mkv dev: all alle Titel der Disc verlustfrei als MKV """ return [ "makemkvcon", "-r", + "--noscan", "--progress=-same", "mkv", f"dev:{device_path}", @@ -68,6 +72,76 @@ def get_progress_from_prgv(line: str) -> int: return min(100, int(total * 100 / maximum)) +def check_handbrake_installed() -> bool: + """Prüft, ob HandBrakeCLI installiert ist.""" + return shutil.which("HandBrakeCLI") is not None + + +DEFAULT_HB_PRESET = "H.265 MKV 1080p30" + + +def build_handbrake_cmd(input_path: str, output_path: str, preset: str = DEFAULT_HB_PRESET) -> list: + """Baut das HandBrake-Kommando für die Kompressions-Stufe (pure Funktion). + + Arbeitet auf der MKV-DATEI aus dem MakeMKV-Rip — nie auf dem Laufwerk: + HandBrake kann AACS-verschlüsselte Blu-rays nicht lesen, MakeMKV bleibt + deshalb zwingend die erste Stufe. --all-audio/--all-subtitles behalten + alle Sprachen (Preset-Default wäre nur die erste Tonspur). + """ + return [ + "HandBrakeCLI", + "--input", input_path, + "--output", output_path, + "--preset", preset, + "--all-audio", + "--all-subtitles", + ] + + +def get_progress_from_line(line: str) -> int: + """Extrahiert Fortschritt in Prozent aus HandBrake-Ausgabe. + + Testfund 22.07.: echtes HandBrake schreibt „45.50 %" MIT Leerzeichen vor + dem Prozentzeichen — eine Regex ohne \\s* parst NIE echte Ausgabe. + """ + match = re.search(r'(\d+\.\d+)\s*%', line) + if match: + return int(float(match.group(1))) + return 0 + + +def run_handbrake(input_path: str, output_path: str, preset: str = DEFAULT_HB_PRESET, progress_cb=None) -> dict: + """Komprimiert eine MKV-Datei mit HandBrakeCLI; meldet Fortschritt.""" + if not check_handbrake_installed(): + return {"status": "error", "error": "HandBrakeCLI ist nicht installiert"} + + try: + process = subprocess.Popen( + build_handbrake_cmd(input_path, output_path, preset), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1 + ) + + for line in process.stdout: + progress = get_progress_from_line(line) + if progress > 0 and progress_cb: + progress_cb(progress) + + process.wait() + + if process.returncode == 0 and os.path.exists(output_path): + return {"status": "success", "output_path": output_path} + return { + "status": "error", + "error": f"HandBrake endete mit Code {process.returncode}", + "return_code": process.returncode, + } + except Exception as e: + return {"status": "error", "error": str(e)} + + def build_abcde_cmd(device_path: str, config_path: str) -> list: """Baut das abcde-Kommando (pure Funktion, testbar). @@ -149,13 +223,15 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None) -> dict: return {"status": "error", "error": str(e)} -def rip_video(device_path: str, disc_id: str, disc_type: str = "dvd", progress_cb=None) -> dict: +def rip_video(device_path: str, disc_id: str, disc_type: str = "dvd", progress_cb=None, output_dir: str = 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. + der hier mit seinem eigenen Fortschritts-Callback durchgreift. output_dir + überschreibt das Ziel (Transcode-Fall: Roh-Rip nach /app/temp). """ - output_dir = os.path.join(RIP_OUTPUT_DIR, disc_type, disc_id) + if output_dir is None: + output_dir = os.path.join(RIP_OUTPUT_DIR, disc_type, disc_id) return run_makemkv(device_path, output_dir, progress_cb=progress_cb) diff --git a/docker/worker/tasks.py b/docker/worker/tasks.py index fa531cc..bd021e3 100644 --- a/docker/worker/tasks.py +++ b/docker/worker/tasks.py @@ -1,14 +1,30 @@ -"""Zentraler Rip-Task: erkennt den Disc-Typ, rippt und schreibt Status nach Postgres. +"""Zentraler Rip-Task: erkennt den Disc-Typ, rippt, komprimiert, schreibt Status. 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. +der Worker hält die Zeile aktuell (running → transcoding → completed/failed) +und schreibt Ereignisse ins Log. Das UI liest beides über die API. + +Zwei Stufen (Commander-Entscheid 23.07.): MakeMKV rippt verlustfrei (einziger +Weg durch AACS — HandBrake kann verschlüsselte Discs nicht lesen), HandBrake +komprimiert danach auf Arbeitsgröße. Die Rohdatei liegt nur temporär in +/app/temp und wird nach Erfolg gelöscht (Setting keepOriginal behält sie). """ +import os +import shutil + import db from celery_app import celery_app from detection import detect_disc_type -from ripping import rip_cd, rip_video +from ripping import ( + DEFAULT_HB_PRESET, + RIP_OUTPUT_DIR, + rip_cd, + rip_video, + run_handbrake, +) + +RAW_DIR = os.getenv("RAW_DIR", "/app/temp/raw") @celery_app.task(bind=True, name="worker.tasks.rip_disc") @@ -44,10 +60,27 @@ def rip_disc(self, device_path: str, job_id: str): ) db.update_job(job_id, progress=progress) + einstellungen = db.get_settings() + transcode_an = ( + disc_type in ("dvd", "bluray") + and einstellungen.get("transcodeEnabled", True) + ) + if disc_type == "cd": ergebnis = rip_cd(device_path, job_id, progress_cb=fortschritt) + elif transcode_an: + # Stufe 1: Roh-Rip nach /app/temp (wird nach der Kompression gelöscht) + ergebnis = rip_video( + device_path, job_id, disc_type, + progress_cb=fortschritt, + output_dir=os.path.join(RAW_DIR, job_id), + ) else: ergebnis = rip_video(device_path, job_id, disc_type, progress_cb=fortschritt) + + if ergebnis.get("status") == "success" and transcode_an: + ergebnis = _komprimiere(job_id, disc_type, ergebnis, einstellungen) + if ergebnis.get("status") == "success": db.update_job( job_id, @@ -67,3 +100,52 @@ def rip_disc(self, device_path: str, job_id: str): db.add_log("error", "worker", f"Job {job_id}: {ergebnis.get('error', 'unbekannter Fehler')}") return ergebnis + + +def _komprimiere(job_id: str, disc_type: str, rip_ergebnis: dict, einstellungen: dict) -> dict: + """Stufe 2: HandBrake komprimiert die Roh-MKVs auf Arbeitsgröße. + + Erst wenn ALLE Dateien sauber komprimiert sind, wird das Roh-Verzeichnis + gelöscht — bricht die Kompression ab, bleibt das Original in /app/temp + liegen (kein Datenverlust wie bei ARMs berüchtigtem Move-Bug #1530). + """ + quellen = rip_ergebnis.get("files", []) + final_dir = os.path.join(RIP_OUTPUT_DIR, disc_type, job_id) + os.makedirs(final_dir, exist_ok=True) + preset = einstellungen.get("transcodePreset") or DEFAULT_HB_PRESET + original_behalten = einstellungen.get("keepOriginal", False) + + db.update_job(job_id, status="transcoding", progress=0) + db.add_log( + "info", "worker", + f"Job {job_id}: Kompression gestartet ({len(quellen)} Datei(en), Preset '{preset}')", + ) + + anzahl = max(1, len(quellen)) + for index, quelle in enumerate(quellen): + ziel = os.path.join(final_dir, os.path.basename(quelle)) + + def datei_fortschritt(p, _index=index): + gesamt = int((_index * 100 + p) / anzahl) + db.update_job(job_id, progress=min(99, gesamt)) + + hb = run_handbrake(quelle, ziel, preset=preset, progress_cb=datei_fortschritt) + if hb.get("status") != "success": + return { + "status": "error", + "error": ( + f"Kompression fehlgeschlagen bei {os.path.basename(quelle)}: " + f"{hb.get('error')} — Roh-Datei bleibt in /app/temp erhalten" + ), + } + + raw_dir = os.path.dirname(quellen[0]) if quellen else None + if raw_dir: + if original_behalten: + ziel_original = os.path.join(final_dir, "original") + shutil.move(raw_dir, ziel_original) + db.add_log("info", "worker", f"Job {job_id}: Original behalten unter {ziel_original}") + else: + shutil.rmtree(raw_dir, ignore_errors=True) + + return {"status": "success", "output_dir": final_dir} diff --git a/docker/worker/test_ripping_helpers.py b/docker/worker/test_ripping_helpers.py index b60ba5b..8479a5d 100644 --- a/docker/worker/test_ripping_helpers.py +++ b/docker/worker/test_ripping_helpers.py @@ -9,7 +9,9 @@ import os from ripping import ( build_abcde_cmd, + build_handbrake_cmd, build_makemkv_cmd, + get_progress_from_line, get_progress_from_prgv, write_abcde_config, ) @@ -19,12 +21,33 @@ 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 "--noscan" in cmd # Scan hängt/crasht im Container (23.07.) 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_handbrake_cmd_arbeitet_auf_datei_nicht_geraet(): + """HandBrake kann AACS nicht — es bekommt IMMER die MKV-Datei aus dem + MakeMKV-Rip, nie das Laufwerk (die alte Direkt-am-Gerät-Pipeline war + für Blu-rays prinzipiell funktionsunfähig).""" + cmd = build_handbrake_cmd("/app/temp/raw/x/t00.mkv", "/app/media/bluray/x/t00.mkv") + assert cmd[0] == "HandBrakeCLI" + assert cmd[cmd.index("--input") + 1] == "/app/temp/raw/x/t00.mkv" + assert cmd[cmd.index("--output") + 1] == "/app/media/bluray/x/t00.mkv" + assert "--preset" in cmd + assert "--all-audio" in cmd # alle Sprachen behalten + assert "--all-subtitles" in cmd + + +def test_handbrake_progress_parsing(): + # Testfund 22.07.: echtes HandBrake schreibt „45.50 %" MIT Leerzeichen + 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_prgv_parsing(): # PRGV:current,total,max — total/max ist der Gesamtfortschritt assert get_progress_from_prgv("PRGV:100,32768,65536") == 50