fe12401e34
Aus dem gescheiterten Test-Rip des Commanders (Job 95afdc89) und seiner
UI-Durchsicht. Der Reihe nach.
## 1. Der externe Encoder wurde SEHR WOHL gesehen (Diagnose-Korrektur)
Der Commander schloss aus der Meldung, das System sehe seinen PC nicht. Live
gemessen sagt das Gegenteil:
meta.transcode_node = tobisnicerpc@TobisNicerPC
VM-Worker-Log = nur rip_disc, KEIN transcode_files
22:19:41.271 Kompression eingereiht
22:19:41.453 "Keine Roh-MKVs gefunden" <- 182 ms spaeter
Das Routing hat funktioniert. Sein PC nahm die Aufgabe an und lehnte sie sofort
ab, weil er /app/media/rippy/<job> nicht finden kann - ein Pfad, der nur INNEN
im Container existiert. Der Rip war vollstaendig (79,6 GB auf der NAS).
## 2. Der Hardware-Encoder-Bug war meiner
leite_backends_ab() verlangte zusaetzlich ein Geraet: /dev/dri fuer
VAAPI/QSV/VCE bzw. nvidia-smi fuer NVENC. Unter WINDOWS gibt es beides nicht -
der PC des Commanders (RX 9070 XT) meldete deshalb nur CPU-Encoder, obwohl
HandBrakes Windows-Build vce_*/nvenc_*/qsv_* beherrscht. Der Bug traf genau den
Anwendungsfall, fuer den externe Worker gedacht sind.
Die Pruefung war ausserdem ueberfluessig: HandBrake probiert Hardware beim
Start selbst an und listet nur Nutzbares - auf der VM belegt durch
"qsv: not available on this system" bei gleichzeitig fehlendem qsv_* in --help.
Jetzt ist HandBrakes Liste die einzige Auskunft.
Dazu nach FAMILIE unterschieden (nvenc/qsv/vce/vaapi) statt alles in "vaapi" zu
werfen - eine AMD-Karte lief unter dem Intel-Namen. Hardware-AV1 bekommt eine
eigene Kennung, weil es die beste Kombination aus Tempo und Groesse ist und
sonst unsichtbar bliebe.
## 3. Vektorbefehle unter Windows - gemessen, nicht "unbekannt"
Es gibt dort kein /proc/cpuinfo, also meldete der Windows-Worker
"Vektorbefehle unbekannt" - gerade auf der Maschine, die encodieren soll.
Jetzt ueber IsProcessorFeaturePresent (kernel32, winnt.h dokumentiert) plus
CPU-Name aus der Registry. Auf dem Commander-PC gegengeprueft:
vorher: AMD64 Family 26 Model 68 Stepping 0 / Vektorbefehle unbekannt
jetzt: AMD Ryzen 7 9700X 8-Core Processor / avx512f / 16 Kerne
Damit verschwindet die AVX2-Warnung fuer diesen Worker von selbst - genau das
hatte der Commander gefordert.
## 4. Ehrliche Fehlermeldung statt "Keine Roh-MKVs gefunden"
Neues _erreichbarkeit_pruefen() unterscheidet jetzt drei Faelle statt einem:
Pfad nicht erreichbar UND kein Mapping gesetzt (nennt RIPPY_PATH_MAP samt
Beispiel), Mapping gesetzt aber greift nicht, oder Pfad einfach nicht da. Und
es prueft BEIDE Pfade - im Fall des Commanders lag die Quelle auf der
erreichbaren NAS, das ZIEL aber auf der VM-Platte ohne Samba; das waere erst
beim Schreiben nach Stunden Rechenzeit aufgefallen.
Jede Meldung sagt jetzt ausdruecklich, dass die Rohdaten NICHT verloren sind
und "Neu komprimieren" genuegt.
## 5. Persoenliche Werte raus
Vier Platzhalter trugen die Umgebung des Commanders (192.168.178.20,
TOBIS-PC, seine Jellyfin- und Rippy-IP). In einem Repo, das weitergegeben
wird, hat das nichts verloren.
Nebenbei dreimal in die eigene Falle getappt: deutsche Anfuehrungszeichen in
DOPPELT gequoteten Python-Strings beenden den String vorzeitig. Der Bestand
nutzt dafuer einfach gequotete f-Strings - jetzt auch meine.
NOCH OFFEN und wichtig: RIPPY_PATH_MAP wird von niemandem GESETZT (Installer,
UI, Compose). Ein Windows-Worker kann damit grundsaetzlich nicht transcodieren.
Das ist der naechste Schritt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
330 lines
13 KiB
Python
330 lines
13 KiB
Python
"""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 <string>`
|
|
(AGENTS Regel D — im Worker-Image am 25.07.2026 gemessen, nicht geraten):
|
|
|
|
-e, --encoder <string> Select video encoder:
|
|
svt_av1
|
|
x264
|
|
x265
|
|
...
|
|
--encoder-preset <string>
|
|
|
|
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 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,
|
|
}
|
|
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"):
|
|
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
|