diff --git a/docker/ui/src/components/FirstRunWizard.tsx b/docker/ui/src/components/FirstRunWizard.tsx index 6686381..6cb2be5 100644 --- a/docker/ui/src/components/FirstRunWizard.tsx +++ b/docker/ui/src/components/FirstRunWizard.tsx @@ -14,6 +14,7 @@ interface WorkerInfo { const ENCODER_LABELS: Record = { 'cpu-x264': 'CPU · H.264 (x264)', 'cpu-x265': 'CPU · H.265 (x265)', + 'cpu-av1': 'CPU · AV1 (SVT-AV1)', 'vaapi': 'Hardware · VAAPI (AMD/Intel)', 'nvenc': 'Hardware · NVENC (NVIDIA)', } diff --git a/docker/ui/src/components/RipTargetModal.tsx b/docker/ui/src/components/RipTargetModal.tsx index 470c3d1..be8a591 100644 --- a/docker/ui/src/components/RipTargetModal.tsx +++ b/docker/ui/src/components/RipTargetModal.tsx @@ -38,7 +38,8 @@ interface WorkerWahl { } const ENCODER_KURZ: Record = { - 'cpu-x264': 'H.264', 'cpu-x265': 'H.265', 'vaapi': 'VAAPI⚡', 'nvenc': 'NVENC⚡', + 'cpu-x264': 'H.264', 'cpu-x265': 'H.265', 'cpu-av1': 'AV1', + 'vaapi': 'VAAPI⚡', 'nvenc': 'NVENC⚡', } interface TitelInfo { diff --git a/docker/ui/src/components/WorkerVerwaltung.tsx b/docker/ui/src/components/WorkerVerwaltung.tsx index 2cf85bc..8f31c99 100644 --- a/docker/ui/src/components/WorkerVerwaltung.tsx +++ b/docker/ui/src/components/WorkerVerwaltung.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react' import { Server, RefreshCw, Copy, CheckCircle, Trash2, Download } from 'lucide-react' import { api } from '../lib/api' +import { simdWarnung } from '../lib/encoder' import { useToast } from '../context/ToastContext' import { Card, CardHeader, CardTitle, CardContent } from './ui/Card' import { Button } from './ui/Button' @@ -16,16 +17,22 @@ interface WorkerInfo { ip?: string makemkv?: string handbrake?: string + cpu_modell?: string + cpu_kerne?: string + cpu_simd?: string + handbrake_encoder?: string } } const ENCODER_LABELS: Record = { 'cpu-x264': 'CPU · H.264', 'cpu-x265': 'CPU · H.265', + 'cpu-av1': 'CPU · AV1 (SVT)', 'vaapi': 'GPU · VAAPI (AMD/Intel)', 'nvenc': 'GPU · NVENC (NVIDIA)', } + function relativeZeit(iso?: string): string { if (!iso) return 'nie' const sekunden = Math.floor((Date.now() - new Date(iso).getTime()) / 1000) @@ -134,6 +141,18 @@ export default function WorkerVerwaltung() { {w.info?.ip ? ` · IP ${w.info.ip}` : ''} {w.info?.hostname && w.info.hostname !== w.name ? ` · ID ${w.info.hostname}` : ''}

+ {(w.info?.cpu_kerne || w.info?.cpu_simd) && ( +

+ {w.info?.cpu_kerne ? `${w.info.cpu_kerne} Kerne` : ''} + {w.info?.cpu_kerne && w.info?.cpu_simd ? ' · ' : ''} + {w.info?.cpu_simd ? `Vektorbefehle ${w.info.cpu_simd}` : ''} +

+ )} + {simdWarnung(w.info?.cpu_simd) && ( +

+ {simdWarnung(w.info?.cpu_simd)} +

+ )}
diff --git a/docker/ui/src/lib/design.ts b/docker/ui/src/lib/design.ts index ee62395..fe84198 100644 --- a/docker/ui/src/lib/design.ts +++ b/docker/ui/src/lib/design.ts @@ -80,6 +80,7 @@ export const LOG_LEVEL_STYLES = { export const ENCODER_BADGES: Record = { 'cpu-x264': { label: 'H.264 (CPU)', highlight: false }, 'cpu-x265': { label: 'H.265 (CPU)', highlight: false }, + 'cpu-av1': { label: 'AV1 (CPU)', highlight: false }, 'vaapi': { label: 'VAAPI ⚡', highlight: true }, 'nvenc': { label: 'NVENC ⚡', highlight: true }, } diff --git a/docker/ui/src/lib/encoder.ts b/docker/ui/src/lib/encoder.ts new file mode 100644 index 0000000..4aebd19 --- /dev/null +++ b/docker/ui/src/lib/encoder.ts @@ -0,0 +1,24 @@ +// Encoder-Fähigkeiten eines Workers in Klartext übersetzen. +// +// Befund 25.07.2026: Die Rippy-VM lief auf dem generischen QEMU-CPU-Modell +// („QEMU Virtual CPU version 2.5+") und hatte deshalb kein AVX2, nur sse4_2. +// x265 lebt von diesen Vektorbefehlen — ein 4K-Encode brauchte dort gemessene +// 28-55 Stunden, und nirgends im UI war das zu sehen. Der Worker meldet die +// Angaben seit dieser Runde selbst (worker/caps.py), hier werden sie gedeutet. + +// Stufen, mit denen Software-Encoding brauchbar schnell ist. +export const SIMD_SCHNELL = ['avx512f', 'avx2'] + +/** + * Warnt, wenn die CPU dieses Workers zu schwach für Software-Encoding ist. + * Gibt null zurück, wenn alles in Ordnung ist ODER die Stufe unbekannt ist — + * lieber nichts sagen als etwas Falsches behaupten. + */ +export function simdWarnung(simd?: string): string | null { + if (!simd || simd === 'unbekannt') return null + if (SIMD_SCHNELL.includes(simd)) return null + return `Diese CPU kann nur ${simd} (kein AVX2) — Software-Encoding ist hier ` + + 'sehr langsam. Bei einer virtuellen Maschine hilft es meist, den ' + + 'CPU-Typ auf "host" zu stellen; sonst besser einen Worker mit ' + + 'Hardware-Encoder wählen.' +} diff --git a/docker/ui/src/pages/Settings.tsx b/docker/ui/src/pages/Settings.tsx index 5df4d5b..6e74f22 100644 --- a/docker/ui/src/pages/Settings.tsx +++ b/docker/ui/src/pages/Settings.tsx @@ -10,6 +10,7 @@ import { Button } from '../components/ui/Button' import { Input, Select, Toggle } from '../components/ui/Input' import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer' import { ENCODER_BADGES } from '../lib/design' +import { simdWarnung } from '../lib/encoder' interface SettingsState { tmdbApiKey: string @@ -19,7 +20,6 @@ interface SettingsState { movieDir: string seriesDir: string musicDir: string - ripAllTracks: boolean mainFeatureOnly: boolean autoEject: boolean autoRipStart: boolean @@ -48,7 +48,6 @@ const defaultSettings: SettingsState = { movieDir: 'movies', seriesDir: 'series', musicDir: 'music', - ripAllTracks: true, mainFeatureOnly: false, autoEject: true, autoRipStart: false, @@ -74,7 +73,15 @@ interface WorkerInfo { // keydb: "ja" | "nein" | "unbekannt" — sagt, ob DIESER Worker eine KEYDB.cfg // in seinem MakeMKV-Datenverzeichnis sieht. Nur der Worker, der wirklich // rippt, zaehlt, deshalb steht die Angabe pro Worker und nicht global. - info?: { makemkv?: string, handbrake?: string, makemkv_key?: string, keydb?: string } + // cpu_kerne/cpu_simd: Rechenleistung fuers Software-Encodieren. Ohne diese + // Angaben war am 25.07.2026 nicht zu sehen, dass ein 4K-Encode auf der + // Rippy-VM Tage statt Stunden braucht (kein AVX2, 4 Kerne). + // handbrake_encoder: die ungefilterte Encoder-Auskunft von HandBrake selbst. + info?: { + makemkv?: string, handbrake?: string, makemkv_key?: string, keydb?: string, + cpu_modell?: string, cpu_kerne?: string, cpu_simd?: string, + handbrake_encoder?: string, + } } // Antwort von GET/POST/DELETE /system/keydb (Feldnamen exakt wie die API sie liefert). @@ -427,14 +434,16 @@ export default function SettingsPage() { 🎵 Audio-CD

-
- handleChange('ripAllTracks', v)} - /> -
+ {/* Der Schalter „Alle Tracks rippen" ist am 25.07.2026 entfallen. + Er war ein Placebo: abcde bekommt keine Track-Auswahl (siehe + build_abcde_cmd) und es gibt auch keine Oberflaeche, um eine + Teilmenge zu waehlen — eine Audio-CD wurde also immer + vollstaendig gerippt, egal wie der Schalter stand. Statt einer + Wahl, die nichts bewirkt, steht hier jetzt die Wahrheit. */} +

+ 💿 Eine Audio-CD wird immer vollständig gerippt — alle Tracks als + FLAC, mit MusicBrainz-Tags. +

Allgemein @@ -555,6 +564,24 @@ export default function SettingsPage() { {ENCODER_BADGES[e]?.label || e} ))} + {(w.info?.cpu_kerne || w.info?.cpu_simd) && ( +

+ {w.info?.cpu_modell ? `${w.info.cpu_modell} · ` : ''} + {w.info?.cpu_kerne ? `${w.info.cpu_kerne} Kerne` : ''} + {w.info?.cpu_kerne && w.info?.cpu_simd ? ' · ' : ''} + {w.info?.cpu_simd ? `Vektorbefehle ${w.info.cpu_simd}` : ''} +

+ )} + {simdWarnung(w.info?.cpu_simd) && ( +

+ {simdWarnung(w.info?.cpu_simd)} +

+ )} + {w.info?.handbrake_encoder && ( +

+ HandBrake meldet: {w.info.handbrake_encoder} +

+ )}
)) )} diff --git a/docker/worker/caps.py b/docker/worker/caps.py index 791198e..f01d2bf 100644 --- a/docker/worker/caps.py +++ b/docker/worker/caps.py @@ -6,26 +6,133 @@ Remote-GPU-Worker meldet sich hier genauso wie der eingebaute CPU-Worker. """ import os +import platform import re import shutil import subprocess -def erkenne_encoder() -> list: - """Liste der verfügbaren Encoder-Backends auf dieser Maschine.""" - gefunden = ["cpu-x264", "cpu-x265"] # HandBrake-Software-Encoder, immer dabei +HB_ENCODER_KOPF = re.compile(r"^-e,\s*--encoder\b") - # VAAPI: AMD (VCN) und Intel (QuickSync) melden sich über /dev/dri - if os.path.exists("/dev/dri/renderD128"): +# Reihenfolge = Anzeige-Reihenfolge im UI. +SIMD_STUFEN = ("avx512f", "avx2", "avx", "sse4_2") + + +def parse_encoder_liste(hilfe_text: str) -> list: + """Video-Encoder, die DIESES HandBrake wirklich anbietet. + + Quelle: `HandBrakeCLI --help`, Abschnitt `-e, --encoder ` + (AGENTS Regel D — im Worker-Image am 25.07.2026 gemessen, nicht geraten): + + -e, --encoder Select video encoder: + svt_av1 + x264 + x265 + ... + --encoder-preset + + Der Block endet an der nächsten Option — also an der ersten Zeile, deren + erstes nicht-leeres Zeichen ein Bindestrich ist. + """ + encoder = [] + im_block = False + for zeile in (hilfe_text or "").splitlines(): + inhalt = zeile.strip() + if not im_block: + if HB_ENCODER_KOPF.match(inhalt): + im_block = True + continue + if not inhalt: + continue + if inhalt.startswith("-"): + break + encoder.append(inhalt.split()[0]) + return encoder + + +def leite_backends_ab(hb_encoder, dri_da: bool, nvidia_da: bool) -> list: + """Übersetzt die HandBrake-Encoder-Namen in die Backends, die das UI zeigt. + + Zwei Ehrlichkeits-Regeln, die vorher fehlten (Befund 25.07.2026): + * Ohne HandBrake gibt es KEINE Encoder. Vorher standen `cpu-x264` und + `cpu-x265` fest verdrahtet in der Liste („immer dabei") — ein reiner + Rip-Worker ohne HandBrake behauptete damit, komprimieren zu können. + * Hardware zählt nur, wenn das Gerät da ist UND HandBrake den Encoder + überhaupt kennt. Vorher genügte `/dev/dri` — das Worker-Image kann + laut eigener Auskunft aber gar keinen Hardware-Encoder. + """ + namen = {e.lower() for e in (hb_encoder or [])} + gefunden = [] + if any(n.startswith("x264") for n in namen): + gefunden.append("cpu-x264") + if any(n.startswith("x265") for n in namen): + gefunden.append("cpu-x265") + if any(n.startswith("svt_av1") for n in namen): + gefunden.append("cpu-av1") + if dri_da and any(("qsv" in n) or ("vce" in n) or ("vaapi" in n) for n in namen): gefunden.append("vaapi") - - # NVENC: NVIDIA-Treiber im Container sichtbar - if shutil.which("nvidia-smi") or os.path.exists("/usr/lib/x86_64-linux-gnu/libnvidia-encode.so.1"): + if nvidia_da and any("nvenc" in n for n in namen): gefunden.append("nvenc") - return gefunden +def beste_simd(flags_text: str) -> str: + """Höchste Vektor-Befehlsstufe der CPU — entscheidend fürs Software-Encodieren. + + Befund 25.07.2026: Die Rippy-VM läuft auf dem generischen QEMU-CPU-Modell + („QEMU Virtual CPU version 2.5+") und hat deshalb KEIN AVX2, nur sse4_2. + x265 lebt von diesen Befehlen; ein 4K-Encode brauchte dort gemessene + 28-55 Stunden. Sichtbar war das nirgends — genau diese Lücke schließt die + Anzeige. Abhilfe bei Proxmox/KVM: CPU-Typ der VM auf `host` stellen. + """ + vorhanden = set((flags_text or "").split()) + for stufe in SIMD_STUFEN: + if stufe in vorhanden: + return stufe + return "unbekannt" + + +def _lies_cpuinfo() -> tuple: + """(Modellname, Flags-Text, Kernzahl) aus /proc/cpuinfo — leer auf Windows.""" + modell, flags, kerne = "", "", 0 + try: + with open("/proc/cpuinfo", encoding="utf-8", errors="replace") as f: + for zeile in f: + if zeile.startswith("processor"): + kerne += 1 + elif not modell and zeile.startswith("model name"): + modell = zeile.split(":", 1)[-1].strip() + elif not flags and zeile.startswith("flags"): + flags = zeile.split(":", 1)[-1].strip() + except OSError: + pass + return modell, flags, kerne + + +def hole_handbrake_hilfe() -> str: + """`HandBrakeCLI --help` einmal abrufen (leer, wenn nicht installiert).""" + if not shutil.which("HandBrakeCLI"): + return "" + try: + aus = subprocess.run( + ["HandBrakeCLI", "--help"], capture_output=True, text=True, timeout=30 + ) + return (aus.stdout or "") + (aus.stderr or "") + except (OSError, subprocess.TimeoutExpired): + return "" + + +def erkenne_encoder() -> list: + """Encoder-Backends, die auf DIESER Maschine wirklich nutzbar sind.""" + hb_encoder = parse_encoder_liste(hole_handbrake_hilfe()) + dri_da = os.path.exists("/dev/dri/renderD128") + nvidia_da = bool( + shutil.which("nvidia-smi") + or os.path.exists("/usr/lib/x86_64-linux-gnu/libnvidia-encode.so.1") + ) + return leite_backends_ab(hb_encoder, dri_da, nvidia_da) + + def erkenne_ip() -> str: """Beste erratbare eigene IP (UDP-Route-Trick, KEIN echter Traffic). @@ -56,11 +163,23 @@ def werkzeug_versionen() -> dict: """ import socket + modell, flags, kerne = _lies_cpuinfo() info = { # Zuordnung im UI: hostname (für den Celery-Online-Abgleich) + IP "hostname": socket.gethostname(), "ip": erkenne_ip(), + # Rechenleistung fürs Software-Encodieren. Ohne diese drei Angaben war + # am 25.07.2026 nicht zu sehen, dass ein 4K-Encode auf dieser Maschine + # Tage statt Stunden braucht (kein AVX2, 4 Kerne). + "cpu_modell": modell or (platform.processor() or "unbekannt"), + "cpu_kerne": str(kerne or os.cpu_count() or 0), + "cpu_simd": beste_simd(flags), } + hb_encoder = parse_encoder_liste(hole_handbrake_hilfe()) + if hb_encoder: + # Die ungefilterte Auskunft von HandBrake — damit im UI nachprüfbar ist, + # woher die Backend-Liste kommt. + info["handbrake_encoder"] = ",".join(hb_encoder) if shutil.which("makemkvcon"): info["makemkv"] = os.getenv("MAKEMKV_VERSION") or "installiert" if shutil.which("HandBrakeCLI"): diff --git a/docker/worker/test_caps.py b/docker/worker/test_caps.py new file mode 100644 index 0000000..5f60748 --- /dev/null +++ b/docker/worker/test_caps.py @@ -0,0 +1,115 @@ +"""Tests der Fähigkeiten-Erkennung — reine Funktionen, keine Infrastruktur. + +Die Testdaten sind ECHTE Ausgaben, am 25.07.2026 im Worker-Image der Rippy-VM +abgenommen (AGENTS Regel D). Erfundene Formate hätten hier keinen Wert: genau +daran ist die Erkennung vorher gescheitert. +""" + +import caps + +# Wörtlich aus `docker exec rippy-worker-1 HandBrakeCLI --help` (HandBrake im +# Worker-Image, 25.07.2026). Bemerkenswert: KEIN Hardware-Encoder dabei. +HB_HILFE_ECHT = """ + -Z, --preset Select preset by name + -e, --encoder Select video encoder: + svt_av1 + svt_av1_10bit + x264 + x264_10bit + x265 + x265_10bit + x265_12bit + mpeg4 + mpeg2 + VP8 + VP9 + VP9_10bit + theora + --encoder-preset + Adjust video encoding settings +""" + +# Wie dieselbe Stelle auf einer Maschine MIT Hardware-Encodern aussieht +# (HandBrake-Windows-Build; Struktur identisch, nur mehr Namen). +HB_HILFE_MIT_HARDWARE = """ + -e, --encoder Select video encoder: + x264 + x265 + nvenc_h264 + nvenc_h265 + qsv_h264 + qsv_h265 + --encoder-preset +""" + + +def test_encoder_liste_wird_aus_echter_hilfe_gelesen(): + encoder = caps.parse_encoder_liste(HB_HILFE_ECHT) + assert encoder == [ + "svt_av1", "svt_av1_10bit", + "x264", "x264_10bit", + "x265", "x265_10bit", "x265_12bit", + "mpeg4", "mpeg2", "VP8", "VP9", "VP9_10bit", "theora", + ] + # Der Block endet an der nächsten Option — sie darf nicht mitgelesen werden + assert not any("encoder-preset" in e for e in encoder) + assert not any(e.startswith("-") for e in encoder) + + +def test_encoder_liste_ohne_handbrake_ist_leer(): + assert caps.parse_encoder_liste("") == [] + assert caps.parse_encoder_liste(None) == [] + assert caps.parse_encoder_liste("irgendein Text ohne Encoder-Abschnitt") == [] + + +def test_ohne_handbrake_keine_encoder(): + """Der wichtigste Fall: vorher standen cpu-x264/cpu-x265 fest verdrahtet in + der Liste. Ein Rip-Worker ohne HandBrake behauptete damit, komprimieren zu + können — und jeder Transcode dort endete sofort mit 'nicht installiert'.""" + assert caps.leite_backends_ab([], dri_da=True, nvidia_da=True) == [] + + +def test_backends_aus_echter_liste(): + encoder = caps.parse_encoder_liste(HB_HILFE_ECHT) + # Kein /dev/dri, kein NVIDIA — und HandBrake kann hier ohnehin keine Hardware + assert caps.leite_backends_ab(encoder, dri_da=False, nvidia_da=False) == [ + "cpu-x264", "cpu-x265", "cpu-av1", + ] + + +def test_hardware_nur_wenn_geraet_UND_handbrake_es_kann(): + """Befund 25.07.2026: Vorher genügte /dev/dri, um 'vaapi' zu melden. Das + Worker-Image kann laut eigener Auskunft aber gar keinen Hardware-Encoder — + Rippy hätte VAAPI versprochen und wäre beim Encodieren gescheitert.""" + ohne_hw = caps.parse_encoder_liste(HB_HILFE_ECHT) + assert "vaapi" not in caps.leite_backends_ab(ohne_hw, dri_da=True, nvidia_da=True) + assert "nvenc" not in caps.leite_backends_ab(ohne_hw, dri_da=True, nvidia_da=True) + + mit_hw = caps.parse_encoder_liste(HB_HILFE_MIT_HARDWARE) + # Gerät fehlt → trotz Können kein Hardware-Backend + assert caps.leite_backends_ab(mit_hw, dri_da=False, nvidia_da=False) == [ + "cpu-x264", "cpu-x265", + ] + # Beides da → beides gemeldet + beides = caps.leite_backends_ab(mit_hw, dri_da=True, nvidia_da=True) + assert "vaapi" in beides # qsv_* vorhanden + assert "nvenc" in beides + + +def test_simd_stufe_erkennt_die_hoechste(): + assert caps.beste_simd("fpu vme de sse4_2 avx avx2") == "avx2" + assert caps.beste_simd("sse4_2 avx avx2 avx512f") == "avx512f" + assert caps.beste_simd("fpu vme de pse tsc sse4_2") == "sse4_2" + assert caps.beste_simd("") == "unbekannt" + assert caps.beste_simd(None) == "unbekannt" + + +def test_simd_stufe_der_rippy_vm_ist_sse4_2(): + """Echt gemessen auf der Rippy-VM (25.07.2026): generisches QEMU-CPU-Modell, + `grep -c avx2 /proc/cpuinfo` = 0. Genau das macht Software-4K dort so lahm.""" + flags_vm = ( + "fpu de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 " + "clflush mmx fxsr sse sse2 syscall nx lm rep_good nopl cpuid " + "pni cx16 sse4_1 sse4_2 x2apic popcnt aes hypervisor lahf_lm" + ) + assert caps.beste_simd(flags_vm) == "sse4_2"