feat(ui): Presets vom Worker, Warnung vor unerreichbaren Pfaden, Browser weg

Vier Commander-Punkte auf einmal, alle am selben Ort.

Presets (Punkt 1 + 3b): Die drei Auswahllisten in Einstellungen kommen jetzt
vom Worker statt aus dieser Datei, gefiltert auf die passende Aufloesung - die
4K-Liste bietet keine 1080p-Presets mehr als Normalfall an (genau dieser Griff
rechnete in v3.12 eine 4K-UHD auf 1080p herunter; bewusstes Verkleinern steht
jetzt in einer eigenen, benannten Gruppe). Knopf "Bestes waehlen" uebernimmt die
Empfehlung fuer alle drei Disc-Typen, und unter jeder Liste steht, WARUM. Ein
gespeicherter Wert, den der Worker nicht kennt, wird benannt statt verschluckt -
so faellt eine falsche Bestandseinstellung ueberhaupt auf.
Der Wizard entscheidet nicht mehr selbst, sondern nimmt dieselbe Empfehlung.
Damit gibt es nur noch EINE Stelle mit dieser Logik, und die hat Tests.

Warnung vor dem Start (Punkt 6 des Savepoints): Waehlt man einen externen
Encoder, der Quelle oder Ziel nicht erreicht, steht das JETZT im Rip-Dialog -
nicht erst nach einer Stunde Rip. Geprueft wird mit derselben Regel, die
pfad_lokal() im Worker anwendet. Bei "Automatisch" wird genannt, welcher Worker
die Aufgabe kaputtmachen koennte: die geteilte Queue nimmt den ersten freien.

Datei-Browser (Punkt 7): "Warum wird hier der Datei Browser noch angezeigt - das
ist doch quatsch." Er ist nicht wirklich redundant (nur ueber ihn geht ein
freier Zielordner fuer EINEN Rip), aber er ueberschrieb die Schnellwahl
stillschweigend. Jetzt eingeklappt - wer ihn aufklappt, entscheidet bewusst, und
/browse wird erst dann geholt. Nebenbefund: browseFiles wurde geladen und NIE
angezeigt, samt ungenutztem File-Icon - beides raus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-26 12:58:42 +02:00
parent aea26493db
commit 638edc6a9f
4 changed files with 393 additions and 100 deletions
+47 -21
View File
@@ -37,24 +37,32 @@ const ENCODER_LABELS: Record<string, string> = {
// braucht roh rund 40 GB, eine 4K-UHD bis 100 GB.
const PLATZ_WARNUNG_GB = 60
/** Was beim Abschliessen gespeichert wird — abhängig davon, was die Maschine kann. */
function presetsFuer(schwach: boolean) {
if (schwach) {
// Gemessen auf einer CPU ohne AVX2: 4K-HEVC = 28-55 h pro Film. Also 4K
// verlustfrei behalten und für den Rest H.264 nehmen (deutlich schneller
// als H.265, dafür etwas größer).
return {
transcodePreset: 'HQ 1080p30 Surround',
transcodePresetBluray: 'HQ 1080p30 Surround',
transcodePresetDvd: 'HQ 576p25 Surround',
transcodePresetUhd: PRESET_KEINE,
}
// Antwort von GET /presets — dieselbe Quelle, die auch die Einstellungen-Seite
// benutzt. Bis 26.07.2026 entschied der Wizard hier selbst und mit fest
// verdrahteten Namen; jetzt kommt beides aus api/presets.py, wo es Tests hat.
interface PresetUebersicht {
quelle: 'worker' | 'rueckfall'
hardware: string[]
typen: Record<'uhd' | 'bluray' | 'dvd', {
empfehlung: { preset: string, grund: string, gefunden: boolean }
}>
}
/** Was beim Abschliessen gespeichert wird — die Empfehlung der API. */
function presetsFuer(uebersicht: PresetUebersicht | null) {
const nimm = (typ: 'uhd' | 'bluray' | 'dvd') => {
const e = uebersicht?.typen?.[typ]?.empfehlung
return e?.gefunden ? e.preset : ''
}
const uhd = nimm('uhd')
const bluray = nimm('bluray')
const dvd = nimm('dvd')
// Leere Werte gar nicht schreiben: dann bleibt der Bestandswert stehen,
// statt dass HandBrake später ein leeres --preset bekommt.
return {
transcodePreset: 'H.265 MKV 1080p30',
transcodePresetBluray: 'H.265 MKV 1080p30',
transcodePresetDvd: 'H.265 MKV 576p25',
transcodePresetUhd: 'H.265 MKV 2160p60 4K',
...(bluray ? { transcodePreset: bluray, transcodePresetBluray: bluray } : {}),
...(dvd ? { transcodePresetDvd: dvd } : {}),
...(uhd ? { transcodePresetUhd: uhd } : {}),
}
}
@@ -66,6 +74,7 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
const [workers, setWorkers] = useState<WorkerInfo[]>([])
const [geraete, setGeraete] = useState<GeraetInfo[]>([])
const [plaetze, setPlaetze] = useState<PlatzInfo[]>([])
const [presetInfo, setPresetInfo] = useState<PresetUebersicht | null>(null)
const [geladen, setGeladen] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
@@ -77,15 +86,17 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
useEffect(() => {
let laeuft = true
const laden = async () => {
const [caps, devs, info] = await Promise.allSettled([
const [caps, devs, info, pres] = await Promise.allSettled([
api.get('/capabilities'),
api.get('/devices'),
api.get('/system/info'),
api.get('/presets'),
])
if (!laeuft) return
if (caps.status === 'fulfilled') setWorkers(caps.value.data?.workers || [])
if (devs.status === 'fulfilled') setGeraete(devs.value.data || [])
if (info.status === 'fulfilled') setPlaetze(info.value.data?.plaetze || [])
if (pres.status === 'fulfilled') setPresetInfo(pres.value.data || null)
setGeladen(true)
}
laden()
@@ -94,7 +105,7 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
}, [])
const schwach = schwacheEncoderCpu(workers)
const presets = presetsFuer(schwach)
const presets = presetsFuer(presetInfo)
const encoderWorker = workers.filter(w => w.encoders.length > 0)
const platz = plaetze.length > 0 ? Math.min(...plaetze.map(p => p.frei_gb)) : null
@@ -306,6 +317,10 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
{' '}dauern. Deshalb wird eingestellt:
</p>
</>
) : presetInfo && presetInfo.hardware.length > 0 ? (
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">
Hardware-Encoder gemeldet ({presetInfo.hardware.join(', ')}) — Rippy nimmt ihn:
</p>
) : (
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">
Deine Worker sind schnell genug für H.265 — Rippy stellt ein:
@@ -316,11 +331,22 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
<strong>4K-UHD:</strong>{' '}
{presets.transcodePresetUhd === PRESET_KEINE
? 'nicht komprimieren, verlustfrei behalten (20100 GB je Film)'
: presets.transcodePresetUhd}
: presets.transcodePresetUhd || 'unverändert (kein bekanntes Preset gemeldet)'}
</li>
<li>
<strong>Blu-ray:</strong>{' '}
{presets.transcodePresetBluray || 'unverändert (kein bekanntes Preset gemeldet)'}
</li>
<li>
<strong>DVD:</strong>{' '}
{presets.transcodePresetDvd || 'unverändert (kein bekanntes Preset gemeldet)'}
</li>
<li><strong>Blu-ray:</strong> {presets.transcodePresetBluray}</li>
<li><strong>DVD:</strong> {presets.transcodePresetDvd}</li>
</ul>
{presetInfo?.typen?.uhd?.empfehlung?.grund && (
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">
Warum: {presetInfo.typen.uhd.empfehlung.grund}
</p>
)}
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">
Alles einzeln änderbar unter Einstellungen → Verarbeitung.
</p>
+97 -38
View File
@@ -1,9 +1,10 @@
import { useState, useEffect } from 'react'
import { Folder, FolderOpen, File, ArrowUp, CheckCircle, Film, Tv, Music, Cpu, HardDrive } from 'lucide-react'
import { Folder, FolderOpen, ArrowUp, CheckCircle, Film, Tv, Music, Cpu, HardDrive, ChevronRight, ChevronDown } from 'lucide-react'
import { api } from '../lib/api'
import { Modal } from './ui/Modal'
import { Button } from './ui/Button'
import { Input, Select } from './ui/Input'
import { automatikWarnung, externWarnung } from '../lib/encoder'
interface TargetConfig {
id: string
@@ -35,6 +36,10 @@ interface WorkerWahl {
node: string | null
online?: boolean
encoders: string[]
// extern: 'ja' = läuft AUSSERHALB des Rippy-Containers und erreicht die
// Container-Pfade nur über eine Freigabe plus pfad_map. Der Worker meldet
// beides selbst (worker/caps.py).
info?: { extern?: string, pfad_map?: string }
}
const ENCODER_KURZ: Record<string, string> = {
@@ -120,8 +125,16 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
const [browsePath, setBrowsePath] = useState<string>('/app/media')
const [browseParent, setBrowseParent] = useState<string | null>(null)
const [browseDirs, setBrowseDirs] = useState<BrowseDir[]>([])
const [browseFiles, setBrowseFiles] = useState<{ name: string, size_mb: number | null }[]>([])
const [customPath, setCustomPath] = useState('')
// Der Ordner-Browser ist EINGEKLAPPT (Commander 26.07.2026: „Warum wird hier
// der Datei Browser noch angezeigt — das ist doch quatsch"). Er ist nicht
// wirklich redundant: nur über ihn lässt sich für DIESEN einen Rip ein
// beliebiger Zielordner wählen. Aber der Normalfall ist die Schnellwahl
// darüber, und der Browser überschrieb sie stillschweigend, sobald man auf
// „Diesen Ordner nutzen" klickte. Jetzt muss man ihn aufklappen — dann ist
// die Wahl bewusst. Nebenbefund beim Aufräumen: `browseFiles` wurde geladen
// und NIE angezeigt (samt ungenutztem File-Icon) — beides entfernt.
const [browserOffen, setBrowserOffen] = useState(false)
// Arbeitsverzeichnis dieses Rips (Commander-Wunsch 25.07.2026: hier wählbar,
// nicht global vorgegeben). '' = der Wert aus den Einstellungen, der auch
// bei Vollautomatik-Rips gilt, weil dort niemand gefragt wird.
@@ -141,6 +154,7 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
setGewaehlt(new Set())
setEncoderNode('')
setArbeitsDir('')
setBrowserOffen(false)
api.get('/storage-targets')
.then(r => setArbeitsZiele(Array.isArray(r.data) ? r.data : []))
.catch(() => setArbeitsZiele([]))
@@ -159,7 +173,8 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
{ id: '3', name: 'Musik', path: `${basis}/${s.musicDir || 'music'}`, type: 'music', isActive: true },
])
}).catch(() => {})
laden('/app/media')
// /browse wird erst beim Aufklappen geholt — der Dialog braucht es im
// Normalfall gar nicht.
}, [isOpen])
const laden = async (pfad: string) => {
@@ -168,13 +183,28 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
setBrowsePath(r.data.path)
setBrowseParent(r.data.parent)
setBrowseDirs(r.data.dirs)
setBrowseFiles(r.data.files || [])
} catch {
setBrowseDirs([])
setBrowseFiles([])
}
}
// Der effektive Zielpfad dieses Rips — Schnellwahl oder eigener Ordner.
const zielPfad = customPath || targets.find(t => t.type === selectedType)?.path || ''
// Das effektive Arbeitsverzeichnis: Wahl für diesen Rip → Einstellung →
// leer (= Container-Platte /app/temp, für externe Worker unerreichbar).
const arbeitsPfadEffektiv = arbeitsDir || standardArbeitsDir
// Warnung VOR dem Start, wenn der gewählte externe Encoder die Pfade nicht
// erreicht (Punkt 6 des Savepoints v3.16). Am 26.07.2026 fiel genau das erst
// NACH dem Rip auf, weil nur der Worker selbst prüfte.
const gewaehlterWorker = workers.find(w => w.node === encoderNode)
const pfadWarnung = selectedType === 'music'
? null
: encoderNode
? externWarnung(gewaehlterWorker, zielPfad, arbeitsPfadEffektiv)
// „Automatisch": die geteilte Queue nimmt den ersten freien Worker — auch
// einen, der die Pfade nicht erreicht.
: automatikWarnung(workers, zielPfad, arbeitsPfadEffektiv)
const handleSave = () => {
const optionen: RipOptionen = { mainFeatureOnly: nurHauptfilm }
if (selectedType === 'series' && serienName.trim()) {
@@ -325,6 +355,16 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
</div>
)}
{/* Die Warnung, die am 26.07.2026 gefehlt hat: Der gewählte externe
Encoder nahm die Aufgabe an und lehnte sie 182 ms später ab, weil er
die Container-Pfade nicht sieht. Sichtbar war das erst NACH dem Rip. */}
{pfadWarnung && (
<div className="text-xs p-3 rounded-lg bg-amber-500/10 border border-amber-500/30 text-amber-700 dark:text-amber-300">
<strong> Dieser Encoder kann so nicht arbeiten.</strong>
<p className="mt-1">{pfadWarnung}</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">
@@ -390,45 +430,64 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
</div>
)}
{/* Ordner-Browser */}
<div className="rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden bg-slate-50/50 dark:bg-slate-950/50">
<div className="flex items-center gap-2 px-3 py-2 border-b border-slate-200 dark:border-slate-800 bg-slate-100/50 dark:bg-slate-900/50">
<button
onClick={() => browseParent && laden(browseParent)}
disabled={!browseParent}
className="p-1 rounded-lg disabled:opacity-30 hover:bg-slate-200 dark:hover:bg-slate-800 text-slate-500 dark:text-slate-400"
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>
<Button
variant={customPath === browsePath ? 'amber' : 'secondary'}
size="sm"
onClick={() => setCustomPath(browsePath)}
>
{customPath === browsePath ? '✓ gewählt' : 'Diesen Ordner nutzen'}
</Button>
</div>
<div className="max-h-36 overflow-y-auto p-1">
{browseDirs.map(d => (
<button
key={d.path}
onClick={() => laden(d.path)}
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-left rounded-lg transition-colors text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800"
>
<Folder size={14} className="text-slate-400" />
{d.name}
</button>
))}
</div>
{/* Ordner-Browser — eingeklappt, siehe Begründung bei browserOffen */}
<div>
<button
onClick={() => {
const auf = !browserOffen
setBrowserOffen(auf)
if (auf && browseDirs.length === 0) laden('/app/media')
}}
className="flex items-center gap-1.5 text-xs text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200"
>
{browserOffen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
Anderen Zielordner wählen (nur für diesen Rip)
</button>
{browserOffen && (
<div className="mt-2 rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden bg-slate-50/50 dark:bg-slate-950/50">
<div className="flex items-center gap-2 px-3 py-2 border-b border-slate-200 dark:border-slate-800 bg-slate-100/50 dark:bg-slate-900/50">
<button
onClick={() => browseParent && laden(browseParent)}
disabled={!browseParent}
className="p-1 rounded-lg disabled:opacity-30 hover:bg-slate-200 dark:hover:bg-slate-800 text-slate-500 dark:text-slate-400"
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>
<Button
variant={customPath === browsePath ? 'amber' : 'secondary'}
size="sm"
onClick={() => setCustomPath(customPath === browsePath ? '' : browsePath)}
>
{customPath === browsePath ? '✓ gewählt' : 'Diesen Ordner nutzen'}
</Button>
</div>
<div className="max-h-36 overflow-y-auto p-1">
{browseDirs.map(d => (
<button
key={d.path}
onClick={() => laden(d.path)}
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-left rounded-lg transition-colors text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800"
>
<Folder size={14} className="text-slate-400" />
{d.name}
</button>
))}
{browseDirs.length === 0 && (
<p className="px-3 py-1.5 text-xs text-slate-400">Keine Unterordner.</p>
)}
</div>
</div>
)}
</div>
{/* Gewähltes Ziel Display */}
<div className="flex items-center gap-2 text-sm px-3.5 py-2.5 rounded-xl bg-slate-100 dark:bg-slate-900 border border-slate-200 dark:border-slate-800">
{customPath ? <FolderOpen size={16} className="text-amber-500" /> : <CheckCircle size={16} className="text-emerald-500" />}
<span className="font-mono text-xs truncate text-slate-700 dark:text-slate-300">
{customPath || targets.find(t => t.type === selectedType)?.path}
{zielPfad}
</span>
</div>