2 Commits

Author SHA1 Message Date
Hitonabi fd1feaaee3 fix(ui): die Ablage war im UI ueberhaupt nicht einstellbar
Ampel / ampel (push) Successful in 28s
Der Grund, warum der erste Rip mit externem Encoder scheitern MUSSTE - und
Commander-Vorgabe: "Wenn ein externes Ziel eingehaengt ist, soll Rippy das
ausgewaehlte als Arbeitsziel verwenden. Das stellt man ja sowieso in den
Einstellungen ein. Das muss auch fuer den Automatik-Modus so sein."

Genau das ging nicht. outputDir stand in den Einstellungen, wurde von der
Vollautomatik (main.py) und der Schnellwahl (RipTargetModal) gelesen - aber es
gab NIRGENDS ein Eingabefeld. Die Ablage klebte auf /app/media, der
Container-Platte. Das Arbeitsverzeichnis hatte eine Auswahlliste der
eingehaengten Ziele, das Ziel nicht.

Folge im Praxistest: Rohdaten auf der NAS (die der PC erreicht), Ziel auf der
VM-Platte (die er nicht erreicht, kein Samba dort). Der externe Encoder konnte
lesen, aber nicht schreiben - und das haette man mit den vorhandenen
Bedienelementen gar nicht anders einstellen koennen.

JETZT: Ablage-Auswahl in Einstellungen -> Ripping, mit derselben Liste
eingehaengter Ziele wie das Arbeitsverzeichnis. Damit wirkt sie automatisch
auch in der Vollautomatik und in der Schnellwahl, weil beide outputDir lesen -
also genau so, wie der Commander es beschrieben hat.

DAZU die Pruefung, die den Vorfall verhindert haette: Sobald ein EXTERNER
Worker gemeldet ist, warnt das UI, wenn Ablage und Arbeitsverzeichnis nicht
beide auf einer erreichbaren Freigabe liegen. Drei Faelle, jeweils mit der
Konsequenz im Klartext:
  - Arbeitsverzeichnis auf der Container-Platte -> externer Encoder kann nicht lesen
  - Arbeit auf Freigabe, Ablage lokal -> kann lesen, nicht schreiben (der Vorfall)
  - beide auf VERSCHIEDENEN Freigaben -> laeuft, kostet aber eine Vollkopie

"Extern" ist dabei keine Heuristik ueber IPs oder Namen: caps.py meldet es
selbst (`extern`), weil /app im Rippy-Image immer existiert und ausserhalb nie.
Zusaetzlich meldet der Worker jetzt seine `pfad_map` - damit kann das UI im
naechsten Schritt sagen, ob die Uebersetzung ueberhaupt gesetzt ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 11:58:58 +02:00
Hitonabi fe12401e34 fix(worker): Hardware-Encoder und CPU-Merkmale auf Windows - plus ehrliche Transcode-Fehler
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>
2026-07-26 11:51:47 +02:00
7 changed files with 357 additions and 52 deletions
+1 -1
View File
@@ -378,7 +378,7 @@ export default function StorageMounts() {
label="Rechner (dein PC oder NAS — Name oder IP)" label="Rechner (dein PC oder NAS — Name oder IP)"
value={host} value={host}
onChange={e => setHost(e.target.value)} onChange={e => setHost(e.target.value)}
placeholder="192.168.178.20 oder TOBIS-PC" placeholder="z. B. 192.168.1.20 oder MEIN-NAS"
/> />
</div> </div>
<Input <Input
@@ -204,7 +204,7 @@ export default function WorkerVerwaltung() {
label="LAN-IP der Rippy-Maschine (Docker-Host)" label="LAN-IP der Rippy-Maschine (Docker-Host)"
value={rippyHost} value={rippyHost}
onChange={e => setRippyHost(e.target.value.trim())} onChange={e => setRippyHost(e.target.value.trim())}
placeholder="z. B. 192.168.178.162" placeholder="z. B. 192.168.1.10"
/> />
<p className="text-xs mt-2 text-slate-500 dark:text-slate-400"> <p className="text-xs mt-2 text-slate-500 dark:text-slate-400">
Die IP der Maschine, auf der <strong>Rippy selbst</strong> (Docker) läuft meist deine Die IP der Maschine, auf der <strong>Rippy selbst</strong> (Docker) läuft meist deine
@@ -216,7 +216,8 @@ export default function WorkerVerwaltung() {
{siehtOeffentlichAus && ( {siehtOeffentlichAus && (
<p className="text-xs mt-2 p-3 rounded-xl bg-amber-500/10 border border-amber-500/20 text-amber-700 dark:text-amber-300"> <p className="text-xs mt-2 p-3 rounded-xl bg-amber-500/10 border border-amber-500/20 text-amber-700 dark:text-amber-300">
Das sieht nach einer externen Domain aus (Reverse-Proxy) die geht nicht. Bitte die Das sieht nach einer externen Domain aus (Reverse-Proxy) die geht nicht. Bitte die
<strong> LAN-IP</strong> der Rippy-Maschine eintragen, z. B. 192.168.178.162. <strong> LAN-IP</strong> der Rippy-Maschine eintragen die beginnt im Heimnetz
fast immer mit 192.168., 10. oder 172.16.172.31.
</p> </p>
)} )}
</div> </div>
+11 -1
View File
@@ -77,10 +77,20 @@ export const LOG_LEVEL_STYLES = {
}, },
} as const } as const
// Encoder-Backends, wie worker/caps.py sie meldet. Hardware wird seit
// 25.07.2026 nach FAMILIE unterschieden (nvenc/qsv/vce/vaapi) — vorher landete
// eine AMD-Karte unter dem Intel-Namen „vaapi", und AV1 in Hardware war gar
// nicht sichtbar, obwohl es die beste Kombination aus Tempo und Größe ist.
export const ENCODER_BADGES: Record<string, { label: string; highlight: boolean }> = { export const ENCODER_BADGES: Record<string, { label: string; highlight: boolean }> = {
'cpu-x264': { label: 'H.264 (CPU)', highlight: false }, 'cpu-x264': { label: 'H.264 (CPU)', highlight: false },
'cpu-x265': { label: 'H.265 (CPU)', highlight: false }, 'cpu-x265': { label: 'H.265 (CPU)', highlight: false },
'cpu-av1': { label: 'AV1 (CPU)', highlight: false }, 'cpu-av1': { label: 'AV1 (CPU)', highlight: false },
'nvenc': { label: 'NVENC ⚡ (NVIDIA)', highlight: true },
'nvenc-av1': { label: 'AV1 · NVENC ⚡', highlight: true },
'qsv': { label: 'QuickSync ⚡ (Intel)', highlight: true },
'qsv-av1': { label: 'AV1 · QuickSync ⚡', highlight: true },
'vce': { label: 'VCE ⚡ (AMD)', highlight: true },
'vce-av1': { label: 'AV1 · VCE ⚡', highlight: true },
'vaapi': { label: 'VAAPI ⚡', highlight: true }, 'vaapi': { label: 'VAAPI ⚡', highlight: true },
'nvenc': { label: 'NVENC ⚡', highlight: true }, 'vaapi-av1': { label: 'AV1 · VAAPI ⚡', highlight: true },
} }
+77 -3
View File
@@ -77,10 +77,12 @@ interface WorkerInfo {
// Angaben war am 25.07.2026 nicht zu sehen, dass ein 4K-Encode auf der // Angaben war am 25.07.2026 nicht zu sehen, dass ein 4K-Encode auf der
// Rippy-VM Tage statt Stunden braucht (kein AVX2, 4 Kerne). // Rippy-VM Tage statt Stunden braucht (kein AVX2, 4 Kerne).
// handbrake_encoder: die ungefilterte Encoder-Auskunft von HandBrake selbst. // handbrake_encoder: die ungefilterte Encoder-Auskunft von HandBrake selbst.
// extern: "ja" = läuft AUSSERHALB des Rippy-Containers, sieht /app/... also
// nur über eine Freigabe plus pfad_map. Der Worker meldet das selbst.
info?: { info?: {
makemkv?: string, handbrake?: string, makemkv_key?: string, keydb?: string, makemkv?: string, handbrake?: string, makemkv_key?: string, keydb?: string,
cpu_modell?: string, cpu_kerne?: string, cpu_simd?: string, cpu_modell?: string, cpu_kerne?: string, cpu_simd?: string,
handbrake_encoder?: string, handbrake_encoder?: string, extern?: string, pfad_map?: string,
} }
} }
@@ -345,6 +347,40 @@ export default function SettingsPage() {
) )
} }
// Erreichbarkeit für EXTERNE Encoder. Ein Worker auf einem anderen Rechner
// sieht die Container-Platte nicht — nur eingehängte Netzwerk-Freigaben, die
// er selbst auch erreicht. Liegen Ablage und Arbeitsverzeichnis nicht BEIDE
// auf einer solchen Freigabe, scheitert die Kompression dort zwangsläufig.
//
// Genau das ist am 25.07.2026 passiert: Rohdaten auf der NAS (erreichbar),
// Ziel auf der VM-Platte (nicht erreichbar) — der Fehler kam erst nach einer
// Stunde Rippen. Diese Prüfung sagt es vorher.
// Der Worker sagt selbst, ob er außerhalb des Containers läuft (caps.py
// meldet `extern`) — keine Heuristik über IPs oder Namen nötig.
const externerWorkerVorhanden = workers.some(w => w.info?.extern === 'ja')
const istFreigabe = (pfad: string) =>
ziele.some(z => z.path === pfad && z.is_mount)
const ablageWarnung = (() => {
const ablage = settings.outputDir || '/app/media'
const arbeit = settings.workDir
if (!arbeit) {
return 'Das Arbeitsverzeichnis steht auf der Container-Platte. Ein Encoder auf '
+ 'einem anderen Rechner kann die Rohdaten dort nicht lesen — lege beides auf '
+ 'dieselbe Netzwerk-Freigabe (Einstellungen → Verarbeitung).'
}
if (!istFreigabe(ablage) && istFreigabe(arbeit)) {
return 'Arbeitsverzeichnis liegt auf einer Freigabe, die Ablage aber auf der '
+ 'Container-Platte. Ein externer Encoder kann dann lesen, aber nicht schreiben — '
+ 'genau daran scheiterte der erste Versuch. Stelle die Ablage auf dieselbe Freigabe.'
}
if (istFreigabe(ablage) && istFreigabe(arbeit) && ablage !== arbeit) {
return 'Ablage und Arbeitsverzeichnis liegen auf VERSCHIEDENEN Freigaben. Das '
+ 'funktioniert, kostet am Ende aber eine Vollkopie statt eines Umhängens. '
+ 'Dieselbe Freigabe für beides ist deutlich schneller.'
}
return ''
})()
const tabs: { id: SettingsTab; label: string; icon: any }[] = [ const tabs: { id: SettingsTab; label: string; icon: any }[] = [
{ id: 'ripping', label: 'Ripping', icon: Disc }, { id: 'ripping', label: 'Ripping', icon: Disc },
{ id: 'verarbeitung', label: 'Verarbeitung', icon: Cpu }, { id: 'verarbeitung', label: 'Verarbeitung', icon: Cpu },
@@ -498,7 +534,7 @@ export default function SettingsPage() {
label="Server-URL" label="Server-URL"
value={settings.jellyfinUrl} value={settings.jellyfinUrl}
onChange={(e) => handleChange('jellyfinUrl', e.target.value)} onChange={(e) => handleChange('jellyfinUrl', e.target.value)}
placeholder="http://192.168.178.50:8096" placeholder="z. B. http://192.168.1.50:8096"
/> />
<div className="flex gap-2 items-end"> <div className="flex gap-2 items-end">
<div className="flex-1"> <div className="flex-1">
@@ -732,12 +768,50 @@ export default function SettingsPage() {
Speicherziele Speicherziele
</h2> </h2>
{/* ABLAGE — bis 26.07.2026 gab es dieses Feld im UI GAR NICHT.
outputDir stand in den Einstellungen und wurde von der
Vollautomatik und der Schnellwahl gelesen, ließ sich aber
nirgends ändern: die Ablage klebte auf /app/media, also der
Container-Platte. Deshalb konnte man das Arbeitsverzeichnis auf
eine NAS legen (dafür gab es eine Auswahl), das ZIEL aber nicht
— und genau daran scheiterte der erste Rip mit externem
Encoder: Rohdaten auf der erreichbaren NAS, Ziel auf der
VM-Platte, die der andere Rechner nicht sieht. */}
<div className="p-5 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 space-y-3">
<h3 className="font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
<HardDrive size={16} className="text-amber-500" /> Ablage — wohin die fertigen Filme kommen
</h3>
<Select
label="Ablage"
value={settings.outputDir}
onChange={(e) => handleChange('outputDir', e.target.value)}
>
<option value="/app/media">Container-Platte (Standard)</option>
{ziele.filter(z => z.path !== '/app/media').map(z => (
<option key={z.path} value={z.path}>
{z.name}{z.is_mount ? ' (Netzwerk-Freigabe)' : ''}
{z.free_gb != null ? ` — ${z.free_gb} GB frei` : ''}
</option>
))}
</Select>
<p className="text-xs text-slate-500 dark:text-slate-400">
Gilt für die Schnellwahl im Rip-Dialog <strong className="text-slate-700 dark:text-slate-300">und
für die Vollautomatik</strong> — dort fragt niemand nach, also entscheidet dieser Wert.
Die Unterordner darunter kommen aus der Schnellwahl.
</p>
{externerWorkerVorhanden && ablageWarnung && (
<p className="text-xs p-3 rounded-lg bg-amber-500/10 border border-amber-500/30 text-amber-700 dark:text-amber-300">
⚠️ {ablageWarnung}
</p>
)}
</div>
<div className="p-5 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 space-y-3"> <div className="p-5 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 space-y-3">
<h3 className="font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100"> <h3 className="font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
<Database size={16} className="text-amber-500" /> Schnellwahl beim Rippen <Database size={16} className="text-amber-500" /> Schnellwahl beim Rippen
</h3> </h3>
<p className="text-xs text-slate-500 dark:text-slate-400"> <p className="text-xs text-slate-500 dark:text-slate-400">
Name der Ordner im „Rippen starten"-Dialog (🎥/📺/🎵). Name der Ordner im „Rippen starten"-Dialog (🎥/📺/🎵) jeweils unterhalb der Ablage.
</p> </p>
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
{([['movieDir', '🎥 Filme'], ['seriesDir', '📺 Serien'], ['musicDir', '🎵 Musik']] as const).map(([feld, label]) => ( {([['movieDir', '🎥 Filme'], ['seriesDir', '📺 Serien'], ['musicDir', '🎵 Musik']] as const).map(([feld, label]) => (
+129 -24
View File
@@ -50,16 +50,41 @@ def parse_encoder_liste(hilfe_text: str) -> list:
return encoder 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. """Übersetzt die HandBrake-Encoder-Namen in die Backends, die das UI zeigt.
Zwei Ehrlichkeits-Regeln, die vorher fehlten (Befund 25.07.2026): ## HandBrakes Liste IST die Auskunft — keine zweite Meinung nötig
* Ohne HandBrake gibt es KEINE Encoder. Vorher standen `cpu-x264` und
`cpu-x265` fest verdrahtet in der Liste („immer dabei") — ein reiner Frühere Fassung (25.07.2026) verlangte zusätzlich ein Gerät: `/dev/dri`
Rip-Worker ohne HandBrake behauptete damit, komprimieren zu können. für VAAPI/QSV/VCE bzw. `nvidia-smi` für NVENC. Das war doppelt falsch:
* 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 1. **Unter Windows gibt es beides nicht.** Der Commander-PC (RX 9070 XT)
laut eigener Auskunft aber gar keinen Hardware-Encoder. 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 [])} namen = {e.lower() for e in (hb_encoder or [])}
gefunden = [] gefunden = []
@@ -69,10 +94,15 @@ def leite_backends_ab(hb_encoder, dri_da: bool, nvidia_da: bool) -> list:
gefunden.append("cpu-x265") gefunden.append("cpu-x265")
if any(n.startswith("svt_av1") for n in namen): if any(n.startswith("svt_av1") for n in namen):
gefunden.append("cpu-av1") gefunden.append("cpu-av1")
if dri_da and any(("qsv" in n) or ("vce" in n) or ("vaapi" in n) for n in namen): for kennzeichen, backend in HW_FAMILIEN:
gefunden.append("vaapi") treffer = [n for n in namen if kennzeichen in n]
if nvidia_da and any("nvenc" in n for n in namen): if not treffer:
gefunden.append("nvenc") 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 return gefunden
@@ -109,6 +139,72 @@ def _lies_cpuinfo() -> tuple:
return modell, flags, kerne 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: def hole_handbrake_hilfe() -> str:
"""`HandBrakeCLI --help` einmal abrufen (leer, wenn nicht installiert).""" """`HandBrakeCLI --help` einmal abrufen (leer, wenn nicht installiert)."""
if not shutil.which("HandBrakeCLI"): if not shutil.which("HandBrakeCLI"):
@@ -123,14 +219,13 @@ def hole_handbrake_hilfe() -> str:
def erkenne_encoder() -> list: def erkenne_encoder() -> list:
"""Encoder-Backends, die auf DIESER Maschine wirklich nutzbar sind.""" """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") Gefragt wird ausschließlich HandBrake selbst — es probiert Hardware beim
nvidia_da = bool( Start an und listet nur Nutzbares. Eine zusätzliche Geräteprüfung wäre
shutil.which("nvidia-smi") plattformabhängig (und war unter Windows falsch), siehe leite_backends_ab.
or os.path.exists("/usr/lib/x86_64-linux-gnu/libnvidia-encode.so.1") """
) return leite_backends_ab(parse_encoder_liste(hole_handbrake_hilfe()))
return leite_backends_ab(hb_encoder, dri_da, nvidia_da)
def erkenne_ip() -> str: def erkenne_ip() -> str:
@@ -163,7 +258,7 @@ def werkzeug_versionen() -> dict:
""" """
import socket import socket
modell, flags, kerne = _lies_cpuinfo() modell, simd, kerne = cpu_merkmale()
info = { info = {
# Zuordnung im UI: hostname (für den Celery-Online-Abgleich) + IP # Zuordnung im UI: hostname (für den Celery-Online-Abgleich) + IP
"hostname": socket.gethostname(), "hostname": socket.gethostname(),
@@ -171,9 +266,19 @@ def werkzeug_versionen() -> dict:
# Rechenleistung fürs Software-Encodieren. Ohne diese drei Angaben war # Rechenleistung fürs Software-Encodieren. Ohne diese drei Angaben war
# am 25.07.2026 nicht zu sehen, dass ein 4K-Encode auf dieser Maschine # am 25.07.2026 nicht zu sehen, dass ein 4K-Encode auf dieser Maschine
# Tage statt Stunden braucht (kein AVX2, 4 Kerne). # Tage statt Stunden braucht (kein AVX2, 4 Kerne).
"cpu_modell": modell or (platform.processor() or "unbekannt"), "cpu_modell": modell,
"cpu_kerne": str(kerne or os.cpu_count() or 0), "cpu_kerne": str(kerne),
"cpu_simd": beste_simd(flags), "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()) hb_encoder = parse_encoder_liste(hole_handbrake_hilfe())
if hb_encoder: if hb_encoder:
+91 -1
View File
@@ -700,6 +700,80 @@ def scan_tracks(device_path: str):
return {"status": "error", "error": str(e)} 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") @celery_app.task(bind=True, name="worker.tasks.transcode_files")
def transcode_files(self, job_id: str, raw_dir: str, final_dir: str): def transcode_files(self, job_id: str, raw_dir: str, final_dir: str):
"""Stufe 2: HandBrake komprimiert die Roh-MKVs auf Arbeitsgröße. """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() db.init_db()
# Native Worker (Windows) übersetzen Container-Pfade aufs Netzlaufwerk # Native Worker (Windows) übersetzen Container-Pfade aufs Netzlaufwerk
raw_container, final_container = raw_dir, final_dir
raw_dir = pfad_lokal(raw_dir) raw_dir = pfad_lokal(raw_dir)
final_dir = pfad_lokal(final_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"))) quellen = sorted(glob.glob(os.path.join(raw_dir, "*.mkv")))
if not quellen: 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) _job_abschliessen(job_id, ergebnis)
return ergebnis return ergebnis
+45 -20
View File
@@ -66,34 +66,59 @@ def test_ohne_handbrake_keine_encoder():
"""Der wichtigste Fall: vorher standen cpu-x264/cpu-x265 fest verdrahtet in """Der wichtigste Fall: vorher standen cpu-x264/cpu-x265 fest verdrahtet in
der Liste. Ein Rip-Worker ohne HandBrake behauptete damit, komprimieren zu der Liste. Ein Rip-Worker ohne HandBrake behauptete damit, komprimieren zu
können — und jeder Transcode dort endete sofort mit 'nicht installiert'.""" 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(): 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) 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) == ["cpu-x264", "cpu-x265", "cpu-av1"]
assert caps.leite_backends_ab(encoder, dri_da=False, nvidia_da=False) == [
"cpu-x264", "cpu-x265", "cpu-av1",
]
def test_hardware_nur_wenn_geraet_UND_handbrake_es_kann(): def test_handbrakes_liste_ist_die_auskunft_ohne_geraetepruefung():
"""Befund 25.07.2026: Vorher genügte /dev/dri, um 'vaapi' zu melden. Das """Regel-Wechsel 25.07.2026, weil die alte Fassung einen Bug hatte.
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)
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) mit_hw = caps.parse_encoder_liste(HB_HILFE_MIT_HARDWARE)
# Gerät fehlt → trotz Können kein Hardware-Backend backends = caps.leite_backends_ab(mit_hw)
assert caps.leite_backends_ab(mit_hw, dri_da=False, nvidia_da=False) == [ assert "nvenc" in backends
"cpu-x264", "cpu-x265", assert "qsv" in backends
] # Ohne Geräteprüfung — es gibt keine Parameter mehr, die das verhindern
# Beides da → beides gemeldet assert caps.leite_backends_ab(mit_hw) == backends
beides = caps.leite_backends_ab(mit_hw, dri_da=True, nvidia_da=True)
assert "vaapi" in beides # qsv_* vorhanden
assert "nvenc" in beides 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(): def test_simd_stufe_erkennt_die_hoechste():