"""Encoder-Fähigkeiten dieses Workers — ehrlich erkannt, nicht behauptet. Jeder Worker meldet beim Start, was auf SEINER Maschine wirklich verfügbar ist (Commander-Anforderung 23.07.: das UI zeigt an, WAS da ist). Ein Remote-GPU-Worker meldet sich hier genauso wie der eingebaute CPU-Worker. """ import os import platform import re import shutil import subprocess HB_ENCODER_KOPF = re.compile(r"^-e,\s*--encoder\b") # 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 # Hardware-Encoder-Familien, wie HandBrake sie benennt → Backend-Kennung. # Reihenfolge = Anzeige-Reihenfolge; AV1 zuerst, weil es das modernste ist. HW_FAMILIEN = ( ("nvenc", "nvenc"), # NVIDIA ("qsv", "qsv"), # Intel QuickSync ("vce", "vce"), # AMD VCE/VCN ("vaapi", "vaapi"), # generisch (Linux) ) def leite_backends_ab(hb_encoder) -> list: """Übersetzt die HandBrake-Encoder-Namen in die Backends, die das UI zeigt. ## HandBrakes Liste IST die Auskunft — keine zweite Meinung nötig Frühere Fassung (25.07.2026) verlangte zusätzlich ein Gerät: `/dev/dri` für VAAPI/QSV/VCE bzw. `nvidia-smi` für NVENC. Das war doppelt falsch: 1. **Unter Windows gibt es beides nicht.** Der Commander-PC (RX 9070 XT) meldete deshalb nur CPU-Encoder, obwohl HandBrakes Windows-Build `vce_*`, `nvenc_*` und `qsv_*` beherrscht. Ein Bug, der genau den Anwendungsfall traf, für den externe Worker gedacht sind. 2. **Die Prüfung war überflüssig.** HandBrake probiert Hardware-Encoder beim Start selbst an und listet nur, was es wirklich benutzen kann. Auf der Rippy-VM belegt: `--version` meldet „qsv: not available on this system" und „Cannot load libnvidia-encode.so.1" — und genau diese Encoder fehlen dann in `--help`. Wer die Liste hat, hat die Antwort. Bleibt die eine Regel, die weiter gilt: **ohne HandBrake gibt es keine Encoder.** Vorher standen `cpu-x264`/`cpu-x265` fest verdrahtet drin, und ein reiner Rip-Worker behauptete damit, komprimieren zu können. Hardware wird jetzt nach Familie unterschieden (nvenc/qsv/vce/vaapi) statt alles in „vaapi" zu werfen — sonst kann das UI nicht sagen, welches Preset passt, und AMD-Karten liefen unter einem Intel-Namen. """ 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") for kennzeichen, backend in HW_FAMILIEN: treffer = [n for n in namen if kennzeichen in n] if not treffer: continue gefunden.append(backend) # AV1 in Hardware ist die derzeit beste Kombination aus Tempo und # Dateigröße — das muss sichtbar sein, nicht in „nvenc" verschwinden. if any("av1" in n for n in treffer): gefunden.append(f"{backend}-av1") return gefunden def parse_preset_liste(text: str) -> list: """Preset-NAMEN, die DIESES HandBrake wirklich kennt. Quelle: `HandBrakeCLI --preset-list`, am 26.07.2026 im Worker-Image gemessen (HandBrake 1.6.1) — AGENTS Regel D, nicht aus dem Kopf: General/ Very Fast 2160p60 4K AV1 AV1 video (up to 2160p60) and AAC stereo audio, in an MP4 container. Hardware/ H.265 VCN 2160p 4K Die Struktur ist die Einrückung: Kategorien ohne Einrückung (mit „/" am Ende), Preset-Namen mit GENAU vier Leerzeichen, Beschreibungen mit acht. HandBrakes Logzeilen („[10:26:05] qsv: not available on this system") und Meldungen wie „Cannot load libnvidia-encode.so.1" haben keine Einrückung und fallen damit von selbst weg. ⚠️ Wichtige Richtigstellung zum SAVEPOINT v3.16: Dort steht, die Namen der HARDWARE-Presets seien auf der Rippy-VM „nicht ermittelbar", weil deren HandBrake keinen Hardware-Encoder hat. Gemessen ist das falsch — die Kategorie `Hardware/` steht vollständig in der Liste (VCN, NVENC, QSV, MF). HandBrake trennt beides: `--preset-list` nennt alle mitgelieferten Presets, `--help` nur die Encoder, die diese Maschine wirklich benutzen kann. Für „welches Preset gibt es" ist die Preset-Liste die Quelle, für „läuft das hier" die Encoder-Liste. Deshalb werden beide gemeldet. """ presets = [] for zeile in (text or "").splitlines(): if not zeile.strip(): continue einrueckung = len(zeile) - len(zeile.lstrip(" ")) if einrueckung == 4: presets.append(zeile.strip()) return presets def hole_handbrake_presets() -> list: """`HandBrakeCLI --preset-list` einmal abrufen (leer, wenn nicht installiert).""" if not shutil.which("HandBrakeCLI"): return [] try: aus = subprocess.run( ["HandBrakeCLI", "--preset-list"], capture_output=True, text=True, timeout=30, ) return parse_preset_liste((aus.stdout or "") + (aus.stderr or "")) except (OSError, subprocess.TimeoutExpired): return [] 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 # Merkmals-Nummern für IsProcessorFeaturePresent (Win32, winnt.h). # Absteigend nach Stärke — der erste Treffer ist die höchste Stufe. WIN_MERKMALE = ( ("avx512f", 41), # PF_AVX512F_INSTRUCTIONS_AVAILABLE ("avx2", 40), # PF_AVX2_INSTRUCTIONS_AVAILABLE ("avx", 39), # PF_AVX_INSTRUCTIONS_AVAILABLE ("sse4_2", 38), # PF_SSE4_2_INSTRUCTIONS_AVAILABLE ) def _simd_windows() -> str: """Höchste Vektor-Befehlsstufe unter Windows. Es gibt dort kein /proc/cpuinfo, deshalb meldete der Windows-Worker bis 25.07.2026 „Vektorbefehle unbekannt" — gerade auf der Maschine, die das Encodieren übernehmen soll, also genau da, wo die Angabe zählt. Gefragt wird `IsProcessorFeaturePresent` aus kernel32 (Win32-API, in winnt.h dokumentiert). Kein Zusatzpaket, kein Registry-Gefummel, keine Fremdprozesse. """ try: import ctypes kernel32 = ctypes.windll.kernel32 except (ImportError, AttributeError, OSError): return "unbekannt" for name, nummer in WIN_MERKMALE: try: if kernel32.IsProcessorFeaturePresent(nummer): return name except OSError: return "unbekannt" return "unbekannt" def _modell_windows() -> str: """Lesbarer CPU-Name unter Windows. platform.processor() liefert dort nur „AMD64 Family 26 Model 68 …" — die Registry kennt den Namen, den auch der Aufkleber trägt. """ try: import winreg with winreg.OpenKey( winreg.HKEY_LOCAL_MACHINE, r"HARDWARE\DESCRIPTION\System\CentralProcessor\0", ) as schluessel: name, _ = winreg.QueryValueEx(schluessel, "ProcessorNameString") return " ".join(str(name).split()) except (ImportError, OSError): return "" def cpu_merkmale() -> tuple: """(Modell, SIMD-Stufe, Kernzahl) — plattformübergreifend, nie leer.""" modell, flags, kerne = _lies_cpuinfo() simd = beste_simd(flags) if simd == "unbekannt" and platform.system() == "Windows": simd = _simd_windows() if not modell: modell = _modell_windows() or platform.processor() or "unbekannt" return modell, simd, (kerne or os.cpu_count() or 0) 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. Gefragt wird ausschließlich HandBrake selbst — es probiert Hardware beim Start an und listet nur Nutzbares. Eine zusätzliche Geräteprüfung wäre plattformabhängig (und war unter Windows falsch), siehe leite_backends_ab. """ return leite_backends_ab(parse_encoder_liste(hole_handbrake_hilfe())) def erkenne_ip() -> str: """Beste erratbare eigene IP (UDP-Route-Trick, KEIN echter Traffic). Ehrlicher Vorbehalt: im Docker-Bridge-Netz ist das die Container-IP — für die Maschinen-Zuordnung ist deshalb WORKER_NAME (Anzeigename in compose/.env) die verlässliche Größe; die IP ist Zusatz-Indiz. """ import socket try: s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) try: s.connect(("192.0.2.1", 9)) # TEST-NET — nur Routing, kein Paket nötig return s.getsockname()[0] finally: s.close() except OSError: return "" def werkzeug_versionen() -> dict: """Kern-Werkzeuge dieses Workers — fürs UI (Einstellungen → System). MakeMKV-Version kommt aus dem Build (ENV MAKEMKV_VERSION im Dockerfile) — makemkvcon hat keinen dokumentierten --version-Schalter (usage.txt). HandBrakeCLI --version ist dokumentiert (handbrake.fr/docs, CLI Options) und gibt z.B. "HandBrake 1.6.1" aus. """ import socket modell, simd, kerne = cpu_merkmale() 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, "cpu_kerne": str(kerne), "cpu_simd": simd, # Läuft dieser Worker INNERHALB des Rippy-Containers oder auf einer # fremden Maschine? Entscheidend, weil ein externer Worker die # Container-Pfade (/app/media, /app/temp) nur über eine Freigabe plus # RIPPY_PATH_MAP erreicht. Das UI kann damit VOR dem Rip warnen, statt # den Nutzer eine Stunde rippen zu lassen (Vorfall 25.07.2026). # /app ist im Rippy-Image immer vorhanden — kein Ratespiel. "extern": "nein" if os.path.isdir("/app") else "ja", # Ist die Pfad-Übersetzung gesetzt? Ohne sie kann ein externer Worker # grundsätzlich nicht komprimieren. "pfad_map": os.getenv("RIPPY_PATH_MAP", ""), } 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) # Die Preset-NAMEN dieses HandBrake. Damit hört das Raten auf: Das UI bietet # genau an, was der jeweilige Worker kennt — die Namen unterscheiden sich # zwischen HandBrake-Versionen, und ein erfundener Name lässt die Kompression # scheitern (Commander-Anforderung „immer das Beste auswählen" braucht eine # echte Liste, nicht eine geratene). presets = hole_handbrake_presets() if presets: info["presets"] = presets if shutil.which("makemkvcon"): info["makemkv"] = os.getenv("MAKEMKV_VERSION") or "installiert" if shutil.which("HandBrakeCLI"): try: aus = subprocess.run( ["HandBrakeCLI", "--version"], capture_output=True, text=True, timeout=15, ) treffer = re.search(r"HandBrake\s+([\w.]+)", (aus.stdout or "") + (aus.stderr or "")) info["handbrake"] = treffer.group(1) if treffer else "installiert" except (OSError, subprocess.TimeoutExpired): info["handbrake"] = "installiert" # Woher kommt der MakeMKV-Key? UI-Setting schlägt Env — ehrlich anzeigen. try: import db ui_key = (db.get_settings().get("makemkvAppKey") or "").strip() except Exception: ui_key = "" if ui_key: info["makemkv_key"] = "ui" elif os.getenv("MAKEMKV_APP_KEY"): info["makemkv_key"] = "env" else: info["makemkv_key"] = "keiner" # Sieht DIESER Worker eine KEYDB.cfg? Die API zeigt ihren eigenen Mount — # bei einem Remote-Worker kann das etwas ganz anderes sein, und nur das # Verzeichnis des rippenden Workers zählt (Befund 25.07.2026). # # Gemeldet wird die Angabe NUR, wenn das Datenverzeichnis hier wirklich # eingehängt ist (os.path.ismount). Ein Remote-Transcode-Worker benutzt # dasselbe Image — makemkvcon ist dort also vorhanden und der entrypoint # legt den Ordner an —, er bekommt den Mount aber nicht und rippt nie. # Ohne diese Prüfung trüge er im UI dauerhaft die Warnung "keine # KEYDB.cfg", obwohl ihn das gar nichts angeht. try: import makemkv_daten daten_dir = makemkv_daten.DATEN_DIR except Exception: daten_dir = "" if shutil.which("makemkvcon") and daten_dir and os.path.ismount(daten_dir): try: info["keydb"] = "ja" if makemkv_daten.keydb_status().get("vorhanden") else "nein" except Exception: info["keydb"] = "unbekannt" # Die entscheidende Zahl für 4K-UHD: wie viele Disc-Schlüssel kennt # dieser Worker? 0 heißt, dass jede unbekannte UHD-Disc scheitert — # makemkvcon holt sie unter Linux nie selbst (Befund 25.07.2026). try: info["schluessel"] = str(makemkv_daten.schluesselspeicher_status().get("schluessel", 0)) except Exception: info["schluessel"] = "unbekannt" return info