Files
rippy/docker/worker/caps.py
T
Hitonabi 6438beec05
Ampel / ampel (push) Successful in 31s
fix(worker): kein CMD-Blitzen mehr - und die 7200-Sekunden-Meldung ist erklaert
Zwei Meldungen, beide auf dieselbe Wurzel zurueckgefuehrt: Der Worker startet
Konsolenprogramme, und das hat unter Windows Nebenwirkungen.

1. "Es geht immer alle paar Sekunden ne CMD auf." Kein Geist, sondern der
   HERZSCHLAG. Der Worker laeuft als pythonw.exe, also ohne eigene Konsole. Wer
   ohne Konsole ein Konsolenprogramm startet, bekommt von Windows eine NEUE - und
   die ist sichtbar. `capture_output=True` hilft nicht: es leitet die Datenstroeme
   um, unterdrueckt aber kein Fenster. Und caps.py fragt jede Minute
   `HandBrakeCLI --help`, `--preset-list` und `--version` ab, dazu makemkvcon aus
   der Schluessel-Automatik: vier bis fuenf Fenster pro Minute, in Schueben.

   Neues winlauf.py haelt CREATE_NO_WINDOW an EINER Stelle; alle elf Aufrufe in
   caps.py, ripping.py und schluessel.py benutzen es. Auf Linux ist der Wert 0 und
   creationflags=0 eine Nulloperation - im Worker-Container gegengeprueft, damit
   derselbe Code auf beiden Seiten laeuft.

2. Die 7200 Sekunden sind CELERYS EIGENE UMRECHNUNG, nicht die Uhren. Erst fiel
   auf, dass die Sekundenbruchteile nur 40 ms auseinanderliegen (.177269 vs
   .134799) - derselbe Augenblick, zweimal verschieden gerechnet. Die Quelle sagt
   warum (celery/utils/time.py):

     def utcoffset():                    # Sekunden WEST von UTC, in Stunden
         return time.altzone // 3600     # CEST -> -2 ;  UTC -> 0
     def adjust_timestamp(ts, offset, here=utcoffset):
         return ts - (offset - here()) * 3600

   Absender VM (offset 0), Empfaenger Windows-PC (here() = -2):
   ts - (0 - (-2)) * 3600 = ts - 7200. Celery verschiebt den empfangenen Stempel
   also selbst und vergleicht ihn dann mit der eigenen Uhr. Die Annahme dahinter -
   Ereignisse truegen Ortszeit - stimmt nicht, `Event()` stempelt Epoch.

   Abhilfe gemessen, nicht geraten: Mit TZ=UTC meldet Windows utcoffset 0
   (timezone=0, isdst=0 - auf dem Commander-PC geprueft), damit wird die
   Umrechnung zur Nulloperation. Beide .bat-Dateien setzen es jetzt. Nebeneffekt
   ist erwuenscht: Der Worker rechnet dann in derselben Zone wie die VM und wie
   Rippys UI.

   Fuer Rippy war die Meldung ohnehin folgenlos - /capabilities fragt per
   Celery-Ping (Frage/Antwort, ohne Zeitstempel), nicht ueber die
   Gossip-Ereignisse. Aber eine Warnung, die bei jedem Blick ins Log steht und
   nichts bedeutet, kostet Vertrauen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 16:05:54 +02:00

405 lines
16 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
from winlauf import OHNE_FENSTER
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 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,
creationflags=OHNE_FENSTER,
)
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,
creationflags=OHNE_FENSTER
)
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,
creationflags=OHNE_FENSTER,
)
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