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)"
|
||||
value={host}
|
||||
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>
|
||||
<Input
|
||||
|
||||
@@ -204,7 +204,7 @@ export default function WorkerVerwaltung() {
|
||||
label="LAN-IP der Rippy-Maschine (Docker-Host)"
|
||||
value={rippyHost}
|
||||
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">
|
||||
Die IP der Maschine, auf der <strong>Rippy selbst</strong> (Docker) läuft — meist deine
|
||||
@@ -216,7 +216,8 @@ export default function WorkerVerwaltung() {
|
||||
{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">
|
||||
⚠️ 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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -77,10 +77,20 @@ export const LOG_LEVEL_STYLES = {
|
||||
},
|
||||
} 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 }> = {
|
||||
'cpu-x264': { label: 'H.264 (CPU)', highlight: false },
|
||||
'cpu-x265': { label: 'H.265 (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 },
|
||||
'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
|
||||
// Rippy-VM Tage statt Stunden braucht (kein AVX2, 4 Kerne).
|
||||
// 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?: {
|
||||
makemkv?: string, handbrake?: string, makemkv_key?: string, keydb?: 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 }[] = [
|
||||
{ id: 'ripping', label: 'Ripping', icon: Disc },
|
||||
{ id: 'verarbeitung', label: 'Verarbeitung', icon: Cpu },
|
||||
@@ -498,7 +534,7 @@ export default function SettingsPage() {
|
||||
label="Server-URL"
|
||||
value={settings.jellyfinUrl}
|
||||
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-1">
|
||||
@@ -732,12 +768,50 @@ export default function SettingsPage() {
|
||||
Speicherziele
|
||||
</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">
|
||||
<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
|
||||
</h3>
|
||||
<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>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{([['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
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Zwei Ehrlichkeits-Regeln, die vorher fehlten (Befund 25.07.2026):
|
||||
* Ohne HandBrake gibt es KEINE Encoder. Vorher standen `cpu-x264` und
|
||||
`cpu-x265` fest verdrahtet in der Liste („immer dabei") — ein reiner
|
||||
Rip-Worker ohne HandBrake behauptete damit, komprimieren zu können.
|
||||
* 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
|
||||
laut eigener Auskunft aber gar keinen Hardware-Encoder.
|
||||
## 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 = []
|
||||
@@ -69,10 +94,15 @@ def leite_backends_ab(hb_encoder, dri_da: bool, nvidia_da: bool) -> list:
|
||||
gefunden.append("cpu-x265")
|
||||
if any(n.startswith("svt_av1") for n in namen):
|
||||
gefunden.append("cpu-av1")
|
||||
if dri_da and any(("qsv" in n) or ("vce" in n) or ("vaapi" in n) for n in namen):
|
||||
gefunden.append("vaapi")
|
||||
if nvidia_da and any("nvenc" in n for n in namen):
|
||||
gefunden.append("nvenc")
|
||||
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
|
||||
|
||||
|
||||
@@ -109,6 +139,72 @@ def _lies_cpuinfo() -> tuple:
|
||||
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"):
|
||||
@@ -123,14 +219,13 @@ def hole_handbrake_hilfe() -> str:
|
||||
|
||||
|
||||
def erkenne_encoder() -> list:
|
||||
"""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")
|
||||
nvidia_da = bool(
|
||||
shutil.which("nvidia-smi")
|
||||
or os.path.exists("/usr/lib/x86_64-linux-gnu/libnvidia-encode.so.1")
|
||||
)
|
||||
return leite_backends_ab(hb_encoder, dri_da, nvidia_da)
|
||||
"""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:
|
||||
@@ -163,7 +258,7 @@ def werkzeug_versionen() -> dict:
|
||||
"""
|
||||
import socket
|
||||
|
||||
modell, flags, kerne = _lies_cpuinfo()
|
||||
modell, simd, kerne = cpu_merkmale()
|
||||
info = {
|
||||
# Zuordnung im UI: hostname (für den Celery-Online-Abgleich) + IP
|
||||
"hostname": socket.gethostname(),
|
||||
@@ -171,9 +266,19 @@ def werkzeug_versionen() -> dict:
|
||||
# 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 or (platform.processor() or "unbekannt"),
|
||||
"cpu_kerne": str(kerne or os.cpu_count() or 0),
|
||||
"cpu_simd": beste_simd(flags),
|
||||
"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:
|
||||
|
||||
+91
-1
@@ -700,6 +700,80 @@ def scan_tracks(device_path: str):
|
||||
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")
|
||||
def transcode_files(self, job_id: str, raw_dir: str, final_dir: str):
|
||||
"""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()
|
||||
# Native Worker (Windows) übersetzen Container-Pfade aufs Netzlaufwerk
|
||||
raw_container, final_container = raw_dir, final_dir
|
||||
raw_dir = pfad_lokal(raw_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")))
|
||||
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)
|
||||
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 Liste. Ein Rip-Worker ohne HandBrake behauptete damit, komprimieren zu
|
||||
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():
|
||||
"""Das Worker-Image der VM kann laut eigener Auskunft keine Hardware —
|
||||
entsprechend steht hier nur CPU."""
|
||||
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, dri_da=False, nvidia_da=False) == [
|
||||
"cpu-x264", "cpu-x265", "cpu-av1",
|
||||
]
|
||||
assert caps.leite_backends_ab(encoder) == ["cpu-x264", "cpu-x265", "cpu-av1"]
|
||||
|
||||
|
||||
def test_hardware_nur_wenn_geraet_UND_handbrake_es_kann():
|
||||
"""Befund 25.07.2026: Vorher genügte /dev/dri, um 'vaapi' zu melden. Das
|
||||
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)
|
||||
def test_handbrakes_liste_ist_die_auskunft_ohne_geraetepruefung():
|
||||
"""Regel-Wechsel 25.07.2026, weil die alte Fassung einen Bug hatte.
|
||||
|
||||
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)
|
||||
# Gerät fehlt → trotz Können kein Hardware-Backend
|
||||
assert caps.leite_backends_ab(mit_hw, dri_da=False, nvidia_da=False) == [
|
||||
"cpu-x264", "cpu-x265",
|
||||
]
|
||||
# Beides da → beides gemeldet
|
||||
beides = caps.leite_backends_ab(mit_hw, dri_da=True, nvidia_da=True)
|
||||
assert "vaapi" in beides # qsv_* vorhanden
|
||||
assert "nvenc" in beides
|
||||
backends = caps.leite_backends_ab(mit_hw)
|
||||
assert "nvenc" in backends
|
||||
assert "qsv" in backends
|
||||
# Ohne Geräteprüfung — es gibt keine Parameter mehr, die das verhindern
|
||||
assert caps.leite_backends_ab(mit_hw) == backends
|
||||
|
||||
|
||||
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():
|
||||
|
||||
Reference in New Issue
Block a user