6438beec05
Ampel / ampel (push) Successful in 31s
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>
405 lines
16 KiB
Python
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
|