Compare commits
2 Commits
61c38a0d14
...
fd1feaaee3
| Author | SHA1 | Date | |
|---|---|---|---|
| fd1feaaee3 | |||
| fe12401e34 |
@@ -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>
|
||||||
|
|||||||
@@ -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,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
@@ -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
@@ -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
@@ -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():
|
||||||
|
|||||||
Reference in New Issue
Block a user