Files
rippy/docker/ui/src/components/StorageMounts.tsx
T
HitonabiandClaude Opus 5 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

445 lines
18 KiB
TypeScript

import { useState, useEffect } from 'react'
import { HardDrive, Plus, Trash2, CheckCircle, AlertCircle, AlertTriangle, RefreshCw, Wrench, Folder, File, ArrowUp, FolderPlus } from 'lucide-react'
import { api } from '../lib/api'
import { useToast } from '../context/ToastContext'
import { Card, CardHeader, CardTitle, CardContent } from './ui/Card'
import { Button } from './ui/Button'
import { Input, Select } from './ui/Input'
interface Mount {
name: string
type: string
source: string
mounted: boolean
reachable?: boolean
has_credentials: boolean
}
interface Target {
name: string
path: string
is_mount: boolean
free_gb: number | null
}
export default function StorageMounts() {
const [mounts, setMounts] = useState<Mount[]>([])
const [targets, setTargets] = useState<Target[]>([])
const [name, setName] = useState('')
const [typ, setTyp] = useState<'nfs' | 'cifs'>('cifs')
const [source, setSource] = useState('')
const [host, setHost] = useState('')
const [shares, setShares] = useState<string[]>([])
const [selectedShare, setSelectedShare] = useState('')
const [sharesBusy, setSharesBusy] = useState(false)
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [busy, setBusy] = useState(false)
const [feedback, setFeedback] = useState<string | null>(null)
const [browsePath, setBrowsePath] = useState('/app/media')
const [browseParent, setBrowseParent] = useState<string | null>(null)
const [browseDirs, setBrowseDirs] = useState<{ name: string, path: string }[]>([])
const [browseFiles, setBrowseFiles] = useState<{ name: string, size_mb: number | null }[]>([])
const [neuerOrdner, setNeuerOrdner] = useState('')
const [browserOffen, setBrowserOffen] = useState(false)
const { toast } = useToast()
const browsen = async (pfad: string) => {
try {
const r = await api.get('/browse', { params: { path: pfad } })
setBrowsePath(r.data.path)
setBrowseParent(r.data.parent)
setBrowseDirs(r.data.dirs)
setBrowseFiles(r.data.files || [])
} catch {
setBrowseDirs([])
setBrowseFiles([])
}
}
const ordnerAnlegen = async () => {
if (!neuerOrdner) return
try {
await api.post('/browse/mkdir', { path: browsePath, name: neuerOrdner })
setNeuerOrdner('')
browsen(browsePath)
laden()
} catch (e: any) {
setFeedback(`✗ ${e?.response?.data?.detail || 'Ordner anlegen fehlgeschlagen'}`)
}
}
const laden = async (manuell = false) => {
try {
const [m, t] = await Promise.allSettled([
api.get('/storage-mounts'),
api.get('/storage-targets'),
])
if (m.status === 'fulfilled') setMounts(m.value.data || [])
if (t.status === 'fulfilled') setTargets(t.value.data || [])
if (manuell) toast('success', 'Speicherziele aktualisiert')
} catch {
if (manuell) toast('error', 'Speicherziele konnten nicht geladen werden')
}
}
useEffect(() => { laden(); browsen('/app/media') }, [])
const freigabenAuflisten = async () => {
if (!host) return
setSharesBusy(true)
setFeedback(null)
try {
const r = await api.get('/storage-mounts/shares', {
params: { host, ...(username ? { username, password } : {}) },
})
setShares(r.data.shares)
if (r.data.shares.length === 0) {
setFeedback('✗ Keine Freigaben gefunden — Rechner erreichbar und Freigaben aktiv?')
} else {
setSelectedShare(r.data.shares[0])
}
} catch (e: any) {
setFeedback(`✗ ${e?.response?.data?.detail || 'Freigaben-Abfrage fehlgeschlagen'}`)
setShares([])
} finally {
setSharesBusy(false)
}
}
const hinzufuegen = async () => {
setBusy(true)
setFeedback(null)
const quelle = typ === 'cifs' && host && selectedShare ? `//${host}/${selectedShare}` : source
const zielName = name
try {
const r = await api.post('/storage-mounts', {
name, type: typ, source: quelle,
...(username ? { username, password } : {}),
})
if (r.data.writable) {
setFeedback(`✓ „${zielName}" eingehängt — Schreibtest OK, ab sofort als Rip-Ziel wählbar`)
toast('success', `„${zielName}" eingehängt — Schreibtest OK`)
} else {
setFeedback(`⚠️ „${zielName}" eingehängt, aber NUR LESBAR — Freigabe-Rechte prüfen!`)
toast('error', `„${zielName}" ist nur lesbar — Freigabe-Rechte prüfen`)
}
setName(''); setSource(''); setHost(''); setShares([]); setSelectedShare(''); setUsername(''); setPassword('')
laden()
} catch (e: any) {
const detail = e?.response?.data?.detail || 'Einhängen fehlgeschlagen'
setFeedback(`✗ ${detail}`)
toast('error', `„${zielName}": ${detail}`)
} finally {
setBusy(false)
}
}
const entfernen = async (mountName: string) => {
setBusy(true)
try {
await api.delete(`/storage-mounts/${mountName}`)
toast('success', `„${mountName}" ausgehängt und entfernt`)
laden()
} catch (e: any) {
const detail = e?.response?.data?.detail || 'Entfernen fehlgeschlagen'
setFeedback(`✗ ${detail}`)
toast('error', detail)
} finally {
setBusy(false)
}
}
const reparieren = async (mountName: string) => {
setBusy(true)
try {
const r = await api.post(`/storage-mounts/${mountName}/repair`)
toast(r.data.writable ? 'success' : 'error',
r.data.writable ? `„${mountName}" neu verbunden` : `„${mountName}" verbunden, aber nur lesbar`)
laden()
} catch (e: any) {
toast('error', e?.response?.data?.detail || 'Reparatur fehlgeschlagen — NAS erreichbar?')
} finally {
setBusy(false)
}
}
return (
<div className="space-y-6">
{/* Vorhandene Ziele */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<HardDrive size={20} className="text-amber-500" />
<span>Verfügbare Ziele</span>
</CardTitle>
<Button variant="ghost" size="sm" onClick={() => laden(true)} title="Ziele neu laden">
<RefreshCw size={16} />
</Button>
</CardHeader>
<CardContent>
{targets.length === 0 ? (
<p className="text-sm text-slate-500 dark:text-slate-400">Noch keine Unterordner in /app/media — sie entstehen beim ersten Rip oder Mount.</p>
) : (
<div className="space-y-2.5">
{targets.map(t => (
<div key={t.path} className="flex items-center justify-between p-3.5 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50 dark:bg-slate-950/80">
<div className="flex items-center gap-3">
<HardDrive size={18} className={t.is_mount ? 'text-amber-500' : 'text-slate-400 dark:text-slate-500'} />
<div>
<span className="text-sm font-medium text-slate-900 dark:text-slate-200">{t.name}</span>
<span className="text-xs ml-2 text-slate-500 dark:text-slate-400">
{t.is_mount ? 'Netzwerk-Mount' : 'lokal'}{t.free_gb != null ? ` · ${t.free_gb} GB frei` : ''}
</span>
</div>
</div>
{mounts.some(m => m.name === t.name) && (
<button onClick={() => entfernen(t.name)} disabled={busy} className="p-1.5 rounded-lg text-rose-500 hover:bg-rose-500/10 transition-colors">
<Trash2 size={16} />
</button>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Netzwerk-Mounts mit Status — zeigt AUCH tote/getrennte Mounts, damit
man sie reparieren oder entfernen kann (Bug 24.07.: ein toter NAS-Mount
verschwand aus „Verfügbare Ziele" und ließ sich nicht neu anlegen). */}
{mounts.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<HardDrive size={20} className="text-amber-500" />
<span>Netzwerk-Mounts</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2.5">
{mounts.map(m => {
const aktiv = m.mounted && m.reachable !== false
const tot = m.mounted && m.reachable === false
return (
<div key={m.name} className="flex items-center justify-between gap-3 p-3.5 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50 dark:bg-slate-950/80">
<div className="flex items-center gap-3 min-w-0">
{aktiv
? <CheckCircle size={18} className="text-emerald-500 flex-shrink-0" />
: tot
? <AlertTriangle size={18} className="text-amber-500 flex-shrink-0" />
: <AlertCircle size={18} className="text-slate-400 flex-shrink-0" />}
<div className="min-w-0">
<span className="text-sm font-medium text-slate-900 dark:text-slate-200">{m.name}</span>
<span className="text-xs ml-2 font-mono text-slate-500 dark:text-slate-400">{m.source}</span>
<p className={`text-xs ${aktiv ? 'text-emerald-600 dark:text-emerald-400' : tot ? 'text-amber-600 dark:text-amber-400' : 'text-slate-500 dark:text-slate-400'}`}>
{aktiv ? 'aktiv & erreichbar' : tot ? 'verbunden, aber NICHT erreichbar (NAS aus/weg?)' : 'getrennt'}
</p>
</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{!aktiv && (
<Button variant="secondary" size="sm" onClick={() => reparieren(m.name)} disabled={busy} title="Neu verbinden (gespeicherte Zugangsdaten)">
<Wrench size={14} /> Reparieren
</Button>
)}
<button onClick={() => entfernen(m.name)} disabled={busy} className="p-1.5 rounded-lg text-rose-500 hover:bg-rose-500/10 transition-colors" title="Entfernen">
<Trash2 size={16} />
</button>
</div>
</div>
)
})}
</div>
</CardContent>
</Card>
)}
{/* Ordner-Verwaltung */}
<Card>
<button
onClick={() => setBrowserOffen(!browserOffen)}
className="w-full flex items-center justify-between p-6 text-left transition-colors hover:bg-slate-50/50 dark:hover:bg-slate-800/30"
>
<div>
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">
Ordner-Verwaltung (optional)
</h3>
<p className="text-xs mt-0.5 text-slate-500 dark:text-slate-400">
Unterordner in der Ablage anschauen und neue anlegen z. B. anime" oder „doku".
</p>
</div>
<span className="text-xs flex-shrink-0 ml-3 text-amber-600 dark:text-amber-400 font-medium">
{browserOffen ? 'zuklappen ▲' : 'aufklappen ▼'}
</span>
</button>
{browserOffen && (
<div className="border-t border-slate-200/80 dark:border-slate-800/80">
<div className="flex items-center gap-2 px-4 py-2.5 bg-slate-50 dark:bg-slate-950/90 border-b border-slate-200/80 dark:border-slate-800">
<button
onClick={() => browseParent && browsen(browseParent)}
disabled={!browseParent}
className="p-1.5 rounded-lg disabled:opacity-30 hover:bg-slate-200 dark:hover:bg-slate-800 text-slate-500 dark:text-slate-400 transition-colors"
title="Ordner hoch"
>
<ArrowUp size={16} />
</button>
<span className="text-xs font-mono truncate flex-1 text-slate-500 dark:text-slate-400">{browsePath}</span>
<input
value={neuerOrdner}
onChange={e => setNeuerOrdner(e.target.value)}
placeholder="neuer-ordner"
className="w-36 px-2.5 py-1 text-xs border rounded-lg bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-700 text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-amber-500/50"
/>
<button
onClick={ordnerAnlegen}
disabled={!neuerOrdner}
className="p-1.5 rounded-lg disabled:opacity-30 text-amber-500 hover:bg-amber-500/10 transition-colors"
title="Ordner anlegen"
>
<FolderPlus size={16} />
</button>
</div>
<div className="max-h-44 overflow-y-auto p-2">
{browseDirs.length === 0 && browseFiles.length === 0 ? (
<p className="text-xs p-3 text-slate-400 dark:text-slate-500">Ordner ist leer</p>
) : (
<>
{browseDirs.map(d => (
<button
key={d.path}
onClick={() => browsen(d.path)}
className="w-full flex items-center gap-2 px-3 py-2 text-sm rounded-lg text-left transition-colors text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800"
>
<Folder size={15} className="text-slate-400 dark:text-slate-500" />
{d.name}
</button>
))}
{browseFiles.map(f => (
<div
key={f.name}
className="flex items-center gap-2 px-3 py-1.5 text-sm text-slate-400 dark:text-slate-500"
>
<File size={14} className="flex-shrink-0" />
<span className="truncate">{f.name}</span>
{f.size_mb != null && (
<span className="ml-auto text-xs flex-shrink-0 font-mono">
{f.size_mb >= 1024 ? `${(f.size_mb / 1024).toFixed(1)} GB` : `${f.size_mb} MB`}
</span>
)}
</div>
))}
</>
)}
</div>
</div>
)}
</Card>
{/* Neues Netzwerk-Ziel */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Plus size={20} className="text-amber-500" />
<span>Netzwerk-Ziel hinzufügen</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label="Name (klein, ohne Leerzeichen)"
value={name}
onChange={e => setName(e.target.value.toLowerCase())}
placeholder="nas-filme"
/>
<Select
label="Typ"
value={typ}
onChange={e => setTyp(e.target.value as 'nfs' | 'cifs')}
>
<option value="nfs">NFS (NAS/Linux)</option>
<option value="cifs">SMB/CIFS (Windows-Freigabe, NAS)</option>
</Select>
{typ === 'nfs' ? (
<div className="md:col-span-2">
<Input
label="Quelle (host:/export/pfad)"
value={source}
onChange={e => setSource(e.target.value)}
placeholder="192.168.1.10:/volume1/filme"
/>
</div>
) : (
<>
<div className="md:col-span-2">
<Input
label="Rechner (dein PC oder NAS — Name oder IP)"
value={host}
onChange={e => setHost(e.target.value)}
placeholder="z. B. 192.168.1.20 oder MEIN-NAS"
/>
</div>
<Input
label="Benutzer"
value={username}
onChange={e => setUsername(e.target.value)}
autoComplete="off"
placeholder="Freigabe-Benutzer"
/>
<Input
label="Passwort"
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
autoComplete="new-password"
/>
<p className="md:col-span-2 text-xs text-slate-500 dark:text-slate-400">
Windows-PCs und die meisten NAS verlangen Benutzer + Passwort ohne Angaben versucht Rippy einen Gast-Zugriff.
</p>
<div className="md:col-span-2">
<Button
variant="secondary"
size="sm"
onClick={freigabenAuflisten}
disabled={!host || sharesBusy}
>
{sharesBusy ? 'Suche…' : 'Freigaben auflisten'}
</Button>
</div>
{shares.length > 0 && (
<div className="md:col-span-2">
<Select
label="Freigabe wählen"
value={selectedShare}
onChange={e => setSelectedShare(e.target.value)}
>
{shares.map(s => <option key={s} value={s}>{s}</option>)}
</Select>
</div>
)}
</>
)}
</div>
<Button
variant="amber"
onClick={hinzufuegen}
disabled={busy || !name || (typ === 'nfs' ? !source : !(host && selectedShare))}
>
{busy ? 'Hänge ein…' : 'Einhängen & speichern'}
</Button>
{feedback && (
<p className={`text-sm flex items-center gap-2 font-medium ${feedback.startsWith('✓') ? 'text-emerald-500' : 'text-rose-500'}`}>
{feedback.startsWith('✓') ? <CheckCircle size={16} /> : <AlertCircle size={16} />}
{feedback}
</p>
)}
</CardContent>
</Card>
</div>
)
}