e165e2a0d3
Ampel / ampel (push) Failing after 29s
- Versionsanzeige für den Windows-Worker im UI inkl. Prüfung - Absicherung der install.sh gegen fehlendes systemd - Serien-Episoden-Erkennung und Laufzeitabgleich anhand TMDB-Daten (Heuristik) - Umstellung der Windows-Worker-Installation auf SchTasks (Dienst-Ersatz) - Kodi-Bibliotheks-Refresh über JSON-RPC integriert
417 lines
16 KiB
Python
417 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"
|
|
|
|
# Version für den Windows-Worker (aus version.txt) oder Linux-Worker (aus Env)
|
|
try:
|
|
ver_pfad = os.path.join(os.path.dirname(__file__), "version.txt")
|
|
if os.path.isfile(ver_pfad):
|
|
with open(ver_pfad, "r", encoding="utf-8") as f:
|
|
info["rippy_version"] = f.read().strip()
|
|
else:
|
|
info["rippy_version"] = os.getenv("RIPPY_VERSION", "dev")
|
|
except Exception:
|
|
info["rippy_version"] = "unbekannt"
|
|
|
|
return info
|