Drei Praxis-Bugs: tote NAS-Mounts reparierbar, HandBrake-Versionen konsistent, Encoder-Wahl beim Rip
Ampel / ampel (push) Successful in 28s
Ampel / ampel (push) Successful in 28s
1) Speicher-Mounts robust (Befund: toter CIFS-Mount nach NAS-Ausfall/Rebuild —
mounted:false, verschwand aus 'Verfuegbare Ziele', Neu-Anlegen -> 409, man
sass fest):
- mounts.py: ist_erreichbar() (listdir, soft-Mount bricht schnell ab),
ist_gemountet() faengt OSError toter Mounts, aushaengen() mit
umount -l Fallback, reparieren() (lazy abhaengen + frisch mounten).
- /storage-mounts liefert 'reachable'; POST bei existierendem Namen:
aktiv -> 409, tot -> automatische Reparatur mit neuen Angaben;
neuer POST /storage-mounts/{name}/repair (gespeicherte Zugangsdaten).
- /storage-targets crasht nicht mehr an totem Mount (os.path.ismount
OSError abgefangen).
- UI: eigene 'Netzwerk-Mounts'-Liste mit Status (aktiv/nicht erreichbar/
getrennt) + Reparieren- und Entfernen-Knopf — tote Mounts sind sichtbar
und wiederherstellbar statt zu verschwinden.
2) HandBrake-Versionen konsistent (Befund: Docker 1.6.1, Windows-Skript
fest 1.9.2, Update-Check meldet 1.11.2 — verwirrend):
- Windows-Installer zieht jetzt DYNAMISCH die neueste Version (GitHub
latest, Fallback 1.11.2) — passt zum Update-Check.
- Update-UI erklaert klar: Docker = stabiles Debian-Paket (bewusst aelter,
kein Fehler), Windows = neueste. MakeMKV-Update zeigt den Befehl.
3) Encoder-/Worker-Auswahl beim Rip (Feature):
- Celery worker_direct=True: jeder Worker konsumiert zusaetzlich seine
Direkt-Queue. API-Helper transcode_queue(node) routet gezielt an den
gewaehlten Worker, faellt aber sicher auf die geteilte transcode-Queue
zurueck, wenn er offline ist (kein Haengenbleiben).
- /capabilities liefert den Celery-Node je Worker; POST /jobs nimmt
transcode_node (-> Job-meta); rip_disc + retry-transcode routen danach.
- Rip-Dialog: Encoder-/Worker-Dropdown, sichtbar ab 2 Online-Workern.
- ping_worker-Task zum Verifizieren des gezielten Routings.
- Nebenfund gefixt: DeviceDiscovery leitete die Titel-Auswahl (titles)
gar nicht an die API weiter — jetzt titles + transcode_node.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -88,6 +88,8 @@ export default function DeviceDiscovery() {
|
||||
...(targetDir ? { target_dir: targetDir } : {}),
|
||||
...(optionen?.series ? { series: optionen.series, season: optionen.season } : {}),
|
||||
...(optionen?.mainFeatureOnly !== undefined ? { main_feature_only: optionen.mainFeatureOnly } : {}),
|
||||
...(optionen?.titles && optionen.titles.length ? { titles: optionen.titles } : {}),
|
||||
...(optionen?.transcodeNode ? { transcode_node: optionen.transcodeNode } : {}),
|
||||
})
|
||||
setActionFeedback(`✓ Job angelegt (${response.data.id.slice(0, 8)}…) — Fortschritt im Dashboard`)
|
||||
toast('success', 'Rip gestartet — Fortschritt unten bei „Neueste Jobs"')
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Folder, FolderOpen, File, ArrowUp, CheckCircle, Film, Tv, Music } from 'lucide-react'
|
||||
import { Folder, FolderOpen, File, ArrowUp, CheckCircle, Film, Tv, Music, Cpu } from 'lucide-react'
|
||||
import { api } from '../lib/api'
|
||||
import { Modal } from './ui/Modal'
|
||||
import { Button } from './ui/Button'
|
||||
import { Input } from './ui/Input'
|
||||
import { Input, Select } from './ui/Input'
|
||||
|
||||
interface TargetConfig {
|
||||
id: string
|
||||
@@ -18,6 +18,18 @@ export interface RipOptionen {
|
||||
season?: number
|
||||
mainFeatureOnly?: boolean
|
||||
titles?: number[]
|
||||
transcodeNode?: string // gewählter Encoder-Worker (Celery-Node) oder leer = auto
|
||||
}
|
||||
|
||||
interface WorkerWahl {
|
||||
name: string
|
||||
node: string | null
|
||||
online?: boolean
|
||||
encoders: string[]
|
||||
}
|
||||
|
||||
const ENCODER_KURZ: Record<string, string> = {
|
||||
'cpu-x264': 'H.264', 'cpu-x265': 'H.265', 'vaapi': 'VAAPI⚡', 'nvenc': 'NVENC⚡',
|
||||
}
|
||||
|
||||
interface TitelInfo {
|
||||
@@ -56,6 +68,8 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
||||
const [scanFehler, setScanFehler] = useState('')
|
||||
const [titelListe, setTitelListe] = useState<TitelInfo[]>([])
|
||||
const [gewaehlt, setGewaehlt] = useState<Set<number>>(new Set())
|
||||
const [workers, setWorkers] = useState<WorkerWahl[]>([])
|
||||
const [encoderNode, setEncoderNode] = useState('') // '' = automatisch
|
||||
|
||||
const scanStarten = async () => {
|
||||
if (!deviceId) return
|
||||
@@ -109,6 +123,11 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
||||
setScanFehler('')
|
||||
setTitelListe([])
|
||||
setGewaehlt(new Set())
|
||||
setEncoderNode('')
|
||||
// Online-Worker für die Encoder-Wahl (nur relevant, wenn ≥2 verfügbar)
|
||||
api.get('/capabilities').then(r => {
|
||||
setWorkers((r.data.workers || []).filter((w: WorkerWahl) => w.online && w.node))
|
||||
}).catch(() => setWorkers([]))
|
||||
api.get('/settings').then(r => {
|
||||
const s = r.data || {}
|
||||
setNurHauptfilm(!!s.mainFeatureOnly)
|
||||
@@ -144,6 +163,7 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
||||
if (scanStatus === 'done' && gewaehlt.size > 0 && gewaehlt.size < titelListe.length) {
|
||||
optionen.titles = [...gewaehlt].sort((a, b) => a - b)
|
||||
}
|
||||
if (encoderNode) optionen.transcodeNode = encoderNode
|
||||
const target = targets.find(t => t.type === selectedType)
|
||||
if (customPath) {
|
||||
onSave({ id: 'custom', name: 'Eigener Ordner', path: customPath, type: selectedType, isActive: true }, optionen)
|
||||
@@ -225,6 +245,28 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* Encoder-/Worker-Wahl — nur wenn mehrere Worker online sind
|
||||
(sonst gibt es nichts zu wählen). Musik wird nicht komprimiert. */}
|
||||
{selectedType !== 'music' && workers.length >= 2 && (
|
||||
<div>
|
||||
<Select
|
||||
label="Encoder / Worker für die Kompression"
|
||||
value={encoderNode}
|
||||
onChange={e => setEncoderNode(e.target.value)}
|
||||
>
|
||||
<option value="">Automatisch (erster freier Worker)</option>
|
||||
{workers.map(w => (
|
||||
<option key={w.node!} value={w.node!}>
|
||||
{w.name} — {w.encoders.map(e => ENCODER_KURZ[e] || e).join(', ')}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<p className="text-xs mt-1.5 text-slate-500 dark:text-slate-400 flex items-center gap-1.5">
|
||||
<Cpu size={13} /> Wähle gezielt eine Maschine (z. B. die mit GPU) — sonst nimmt der erste freie Worker.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Titel-Auswahl (Track-Tabelle) */}
|
||||
{deviceId && selectedType !== 'music' && (
|
||||
<div className="rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden bg-slate-50/50 dark:bg-slate-950/50">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { HardDrive, Plus, Trash2, CheckCircle, AlertCircle, RefreshCw, Folder, File, ArrowUp, FolderPlus } from 'lucide-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'
|
||||
@@ -11,6 +11,7 @@ interface Mount {
|
||||
type: string
|
||||
source: string
|
||||
mounted: boolean
|
||||
reachable?: boolean
|
||||
has_credentials: boolean
|
||||
}
|
||||
|
||||
@@ -149,6 +150,20 @@ export default function StorageMounts() {
|
||||
}
|
||||
}
|
||||
|
||||
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 */}
|
||||
@@ -190,6 +205,56 @@ export default function StorageMounts() {
|
||||
</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
|
||||
|
||||
@@ -653,15 +653,32 @@ export default function SettingsPage() {
|
||||
{updatesBusy ? 'Prüfe…' : 'Auf Updates prüfen'}
|
||||
</Button>
|
||||
</div>
|
||||
{updates && (
|
||||
<div className="space-y-2 text-sm text-slate-700 dark:text-slate-300">
|
||||
<div>
|
||||
{updates ? (
|
||||
<div className="space-y-3 text-sm text-slate-700 dark:text-slate-300">
|
||||
<div className={updates.makemkv?.update ? 'text-amber-600 dark:text-amber-400' : ''}>
|
||||
<strong>MakeMKV:</strong> installiert {updates.makemkv?.installiert || '—'}, aktuell {updates.makemkv?.verfuegbar || 'unbekannt'}
|
||||
{updates.makemkv?.update && (
|
||||
<p className="text-xs mt-1">
|
||||
Update verfügbar — in der .env <code>MAKEMKV_VERSION={updates.makemkv.verfuegbar}</code> setzen,
|
||||
dann auf der Rippy-Maschine <code>docker compose build worker && docker compose up -d worker</code>.
|
||||
Neue Versionen bringen auch die neueste Disc-Schlüssel-Datenbank mit.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<strong>HandBrake:</strong> installiert {updates.handbrake?.installiert || '—'}, verfügbar {updates.handbrake?.verfuegbar || 'unbekannt'}
|
||||
<strong>HandBrake:</strong> Docker-Worker {updates.handbrake?.installiert || '—'}, neueste offizielle {updates.handbrake?.verfuegbar || 'unbekannt'}
|
||||
<p className="text-xs mt-1 text-slate-500 dark:text-slate-400">
|
||||
Das ist <strong>kein Fehler</strong>: Im Docker-Worker kommt HandBrake aus dem stabilen
|
||||
Debian-Paket und hinkt der neuesten Version bewusst hinterher (funktioniert einwandfrei,
|
||||
keine Eigenbauten). Der native <strong>Windows-Worker holt automatisch die neueste</strong>
|
||||
Version — deshalb die unterschiedlichen Nummern.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Prüft gegen makemkv.com und die HandBrake-GitHub-Releases (Ergebnis wird 12 h gemerkt).
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user