diff --git a/docker/worker/caps.py b/docker/worker/caps.py
index f01d2bf..b6ba212 100644
--- a/docker/worker/caps.py
+++ b/docker/worker/caps.py
@@ -50,16 +50,41 @@ def parse_encoder_liste(hilfe_text: str) -> list:
return encoder
-def leite_backends_ab(hb_encoder, dri_da: bool, nvidia_da: bool) -> list:
+# 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.
- 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.
+ ## 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 = []
@@ -69,10 +94,15 @@ def leite_backends_ab(hb_encoder, dri_da: bool, nvidia_da: bool) -> list:
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")
- if nvidia_da and any("nvenc" in n for n in namen):
- gefunden.append("nvenc")
+ 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
@@ -109,6 +139,72 @@ def _lies_cpuinfo() -> tuple:
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"):
@@ -123,14 +219,13 @@ def hole_handbrake_hilfe() -> str:
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)
+ """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:
@@ -163,7 +258,7 @@ def werkzeug_versionen() -> dict:
"""
import socket
- modell, flags, kerne = _lies_cpuinfo()
+ modell, simd, kerne = cpu_merkmale()
info = {
# Zuordnung im UI: hostname (für den Celery-Online-Abgleich) + IP
"hostname": socket.gethostname(),
@@ -171,9 +266,9 @@ def werkzeug_versionen() -> dict:
# 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),
+ "cpu_modell": modell,
+ "cpu_kerne": str(kerne),
+ "cpu_simd": simd,
}
hb_encoder = parse_encoder_liste(hole_handbrake_hilfe())
if hb_encoder:
diff --git a/docker/worker/tasks.py b/docker/worker/tasks.py
index c78094c..f56ae54 100644
--- a/docker/worker/tasks.py
+++ b/docker/worker/tasks.py
@@ -700,6 +700,80 @@ def scan_tracks(device_path: str):
return {"status": "error", "error": str(e)}
+def _ist_uebersetzt(container_pfad: str, lokal_pfad: str) -> bool:
+ """Hat RIPPY_PATH_MAP diesen Pfad wirklich angefasst? (pure, testbar)"""
+ return container_pfad != lokal_pfad
+
+
+def _erreichbarkeit_pruefen(raw_container: str, raw_lokal: str,
+ final_container: str, final_lokal: str) -> str:
+ """Kann DIESER Worker Quelle und Ziel überhaupt sehen? Klartext oder "".
+
+ Befund 25.07.2026 am Job 95afdc89, live: Das gezielte Routing an den
+ Windows-PC des Commanders funktionierte einwandfrei — der Worker nahm die
+ Aufgabe an und lehnte sie 182 ms später ab mit „Keine Roh-MKVs in
+ /app/media/rippy/… gefunden". Diese Meldung klang nach einem kaputten Rip,
+ obwohl der Rip vollständig war (79,6 GB lagen auf der NAS). Der Commander
+ schloss daraus, das System sähe den externen Encoder nicht — verständlich
+ und falsch.
+
+ Die eigentliche Ursache: `/app/media/...` sind CONTAINER-Pfade. Ein nativer
+ Worker sieht sie nur, wenn RIPPY_PATH_MAP sie auf eine Freigabe übersetzt —
+ und dieses Mapping wurde von NIEMANDEM gesetzt. Es war also nicht möglich,
+ dass diese Kombination je funktioniert.
+
+ Zweiter Stolperstein im selben Fall: Die Rohdaten lagen auf der NAS (für den
+ PC erreichbar), das ZIEL aber auf der VM-Platte (nicht erreichbar, dort
+ läuft kein Samba). Deshalb werden hier BEIDE Pfade geprüft, nicht nur die
+ Quelle — sonst scheitert es erst beim Schreiben, nach Stunden Rechenzeit.
+ """
+ mapping = os.getenv("RIPPY_PATH_MAP", "")
+ fremder_worker = not os.path.isdir("/app")
+
+ for zweck, container, lokal in (
+ ("Quelle (Rohdaten)", raw_container, raw_lokal),
+ ("Ziel (fertige Datei)", final_container, final_lokal),
+ ):
+ if os.path.isdir(lokal):
+ continue
+ # Ziel darf fehlen, solange sein Elternordner erreichbar ist — es wird
+ # gleich angelegt. Nur ein unerreichbarer ELTERN-Pfad ist ein Problem.
+ if zweck.startswith("Ziel") and os.path.isdir(os.path.dirname(lokal) or "."):
+ continue
+
+ text = [f"Dieser Worker erreicht die {zweck} nicht: {lokal}"]
+ if fremder_worker and not mapping:
+ text.append(
+ "Ursache: RIPPY_PATH_MAP ist auf dieser Maschine nicht gesetzt. "
+ f'„{container}" ist ein Pfad INNERHALB des Rippy-Containers — ein '
+ 'externer Worker sieht ihn nur, wenn er auf eine Netzwerk-Freigabe '
+ 'übersetzt wird.'
+ )
+ text.append(
+ "Abhilfe: Freigabe auf diesem Rechner erreichbar machen und in "
+ "start-tray.bat setzen, z. B. "
+ "set RIPPY_PATH_MAP=/app/media=\\\\NAS\\rippy-media"
+ )
+ elif fremder_worker and mapping:
+ text.append(f"RIPPY_PATH_MAP ist gesetzt ({mapping}), deckt diesen Pfad aber nicht ab.")
+ if not _ist_uebersetzt(container, lokal):
+ text.append(
+ f'„{container}" wurde von keinem Eintrag übersetzt — fehlt ein '
+ 'Präfix, oder ist es ein anderes Verzeichnis als erwartet?'
+ )
+ else:
+ text.append(
+ "Der Pfad existiert nicht. Liegt das Arbeitsverzeichnis auf einer "
+ "Freigabe, die gerade nicht eingehängt ist?"
+ )
+ text.append(
+ 'WICHTIG: Die Rohdaten sind NICHT verloren — nach der Korrektur '
+ 'genügt „Neu komprimieren", ohne die Disc erneut zu rippen.'
+ )
+ return " ".join(text)
+ return ""
+
+
@celery_app.task(bind=True, name="worker.tasks.transcode_files")
def transcode_files(self, job_id: str, raw_dir: str, final_dir: str):
"""Stufe 2: HandBrake komprimiert die Roh-MKVs auf Arbeitsgröße.
@@ -711,11 +785,27 @@ def transcode_files(self, job_id: str, raw_dir: str, final_dir: str):
"""
db.init_db()
# Native Worker (Windows) übersetzen Container-Pfade aufs Netzlaufwerk
+ raw_container, final_container = raw_dir, final_dir
raw_dir = pfad_lokal(raw_dir)
final_dir = pfad_lokal(final_dir)
+
+ fehler = _erreichbarkeit_pruefen(raw_container, raw_dir, final_container, final_dir)
+ if fehler:
+ ergebnis = {"status": "error", "error": fehler}
+ _job_abschliessen(job_id, ergebnis)
+ return ergebnis
+
quellen = sorted(glob.glob(os.path.join(raw_dir, "*.mkv")))
if not quellen:
- ergebnis = {"status": "error", "error": f"Keine Roh-MKVs in {raw_dir} gefunden"}
+ ergebnis = {
+ "status": "error",
+ "error": (
+ f"Das Verzeichnis {raw_dir} ist erreichbar, enthält aber keine "
+ "MKV-Datei. Der Rip hat also nichts abgelegt (oder jemand hat die "
+ "Datei entfernt). Die Kompression lässt sich nach einem neuen Rip "
+ "erneut anstoßen."
+ ),
+ }
_job_abschliessen(job_id, ergebnis)
return ergebnis
diff --git a/docker/worker/test_caps.py b/docker/worker/test_caps.py
index 5f60748..9efabc9 100644
--- a/docker/worker/test_caps.py
+++ b/docker/worker/test_caps.py
@@ -66,34 +66,59 @@ 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) == []
+ assert caps.leite_backends_ab([]) == []
+ assert caps.leite_backends_ab(None) == []
def test_backends_aus_echter_liste():
+ """Das Worker-Image der VM kann laut eigener Auskunft keine Hardware —
+ entsprechend steht hier nur CPU."""
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",
- ]
+ assert caps.leite_backends_ab(encoder) == ["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)
+def test_handbrakes_liste_ist_die_auskunft_ohne_geraetepruefung():
+ """Regel-Wechsel 25.07.2026, weil die alte Fassung einen Bug hatte.
+ Vorher wurde zusätzlich ein Gerät verlangt: /dev/dri für VAAPI/QSV/VCE bzw.
+ nvidia-smi für 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, für den externe Worker gedacht sind.
+
+ Die Prüfung war zudem überflüssig: HandBrake probiert Hardware beim Start
+ selbst an und listet nur Nutzbares (auf der VM belegt — 'qsv: not available
+ on this system', und qsv_* fehlt dann in --help). Wer die Liste hat, hat die
+ Antwort; eine zweite Meinung braucht es nicht.
+ """
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
+ backends = caps.leite_backends_ab(mit_hw)
+ assert "nvenc" in backends
+ assert "qsv" in backends
+ # Ohne Geräteprüfung — es gibt keine Parameter mehr, die das verhindern
+ assert caps.leite_backends_ab(mit_hw) == backends
+
+
+def test_hardware_wird_nach_familie_unterschieden():
+ """Vorher landete alles in „vaapi" — eine AMD-Karte lief damit unter dem
+ Intel-Namen, und das UI konnte nicht sagen, welches Preset passt."""
+ amd = caps.leite_backends_ab(["x265", "vce_h264", "vce_h265"])
+ assert "vce" in amd
+ assert "qsv" not in amd and "nvenc" not in amd
+
+ intel = caps.leite_backends_ab(["x265", "qsv_h265"])
+ assert "qsv" in intel and "vce" not in intel
+
+
+def test_hardware_av1_bekommt_eigene_kennung():
+ """AV1 in Hardware ist derzeit die beste Kombination aus Tempo und
+ Dateigröße — das darf nicht in „nvenc" verschwinden, sonst kann das UI es
+ nicht empfehlen."""
+ backends = caps.leite_backends_ab(["x265", "nvenc_h265", "nvenc_av1"])
+ assert "nvenc" in backends
+ assert "nvenc-av1" in backends
+ # Ohne AV1 in der Liste gibt es die Kennung auch nicht
+ assert "nvenc-av1" not in caps.leite_backends_ab(["nvenc_h265"])
def test_simd_stufe_erkennt_die_hoechste():