Alles einzeln änderbar unter Einstellungen → Verarbeitung.
diff --git a/docker/ui/src/components/RipTargetModal.tsx b/docker/ui/src/components/RipTargetModal.tsx
index be8a591..9bee217 100644
--- a/docker/ui/src/components/RipTargetModal.tsx
+++ b/docker/ui/src/components/RipTargetModal.tsx
@@ -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 = {
@@ -120,8 +125,16 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
const [browsePath, setBrowsePath] = useState('/app/media')
const [browseParent, setBrowseParent] = useState(null)
const [browseDirs, setBrowseDirs] = useState([])
- 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
)}
+ {/* 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 && (
+
diff --git a/docker/ui/src/lib/encoder.ts b/docker/ui/src/lib/encoder.ts
index 4e301ca..ee59a52 100644
--- a/docker/ui/src/lib/encoder.ts
+++ b/docker/ui/src/lib/encoder.ts
@@ -33,6 +33,91 @@ interface WorkerFuerBewertung {
info?: { cpu_simd?: string }
}
+// --- Erreicht ein EXTERNER Worker die Pfade? ---------------------------------
+//
+// Befund 26.07.2026, live am Job 95afdc89: Das Routing an den Windows-PC des
+// Commanders funktionierte einwandfrei — der Worker nahm die Aufgabe an und
+// lehnte sie 182 ms später ab, weil `/app/media/...` Pfade INNERHALB des
+// Rippy-Containers sind. Ohne RIPPY_PATH_MAP sieht ein externer Worker sie nie.
+// Auffallen durfte das nicht erst nach einer Stunde Rip, sondern hier.
+
+interface WorkerFuerPfadPruefung {
+ name: string
+ node?: string | null
+ info?: { extern?: string, pfad_map?: string }
+}
+
+/**
+ * Deckt `RIPPY_PATH_MAP` diesen Container-Pfad ab? Gleiche Regel wie
+ * `pfad_lokal()` im Worker (docker/worker/tasks.py): Paare `quelle=ziel`,
+ * getrennt durch `;`, Treffer per Präfix.
+ */
+export function pfadAbgedeckt(pfad: string, mapping: string): boolean {
+ if (!pfad || !mapping) return false
+ return mapping.split(';').some(paar => {
+ const quelle = paar.split('=', 1)[0]
+ return !!quelle && pfad.startsWith(quelle)
+ })
+}
+
+/**
+ * Warnt, BEVOR gerippt wird, wenn der gewählte Encoder die Pfade nicht
+ * erreichen kann. null = alles in Ordnung (oder der Worker läuft im Container,
+ * dann gibt es nichts zu übersetzen).
+ *
+ * `arbeitsPfad` leer heißt: Container-Standard /app/temp/raw — ein Docker-Volume,
+ * an das ein externer Worker grundsätzlich nicht herankommt.
+ */
+export function externWarnung(
+ worker: WorkerFuerPfadPruefung | undefined,
+ zielPfad: string,
+ arbeitsPfad: string,
+): string | null {
+ if (!worker || worker.info?.extern !== 'ja') return null
+ const mapping = (worker.info?.pfad_map || '').trim()
+ if (!mapping) {
+ return `„${worker.name}" läuft auf einer anderen Maschine und hat keine `
+ + 'Pfad-Übersetzung (RIPPY_PATH_MAP). Er kann die Rohdaten deshalb nicht '
+ + 'finden und würde sofort abbrechen. Abhilfe: den Windows-Installer erneut '
+ + 'ausführen — er holt den Wert jetzt selbst von Rippy. Bis dahin besser '
+ + '„Automatisch" wählen.'
+ }
+ if (!arbeitsPfad) {
+ return `„${worker.name}" arbeitet auf einer anderen Maschine. Das `
+ + 'Arbeitsverzeichnis steht auf der Container-Platte (/app/temp) — die ist '
+ + 'von außen nicht erreichbar. Oben eine Netzwerk-Freigabe als '
+ + 'Arbeitsverzeichnis wählen.'
+ }
+ const fehlt: string[] = []
+ if (!pfadAbgedeckt(arbeitsPfad, mapping)) fehlt.push(`Rohdaten (${arbeitsPfad})`)
+ if (!pfadAbgedeckt(zielPfad, mapping)) fehlt.push(`Ziel (${zielPfad})`)
+ if (fehlt.length === 0) return null
+ return `„${worker.name}" erreicht ${fehlt.join(' und ')} nicht — seine `
+ + `Übersetzung deckt nur „${mapping}" ab. Beides muss auf einer Freigabe `
+ + 'liegen, die diese Maschine sieht, sonst scheitert die Kompression '
+ + '(die Rohdaten bleiben dabei erhalten).'
+}
+
+/**
+ * Dasselbe Risiko bei „Automatisch": dort nimmt die geteilte Queue den ersten
+ * freien Worker — auch einen externen, der die Pfade nicht erreicht. Nennt die
+ * betroffenen Namen, damit man gezielt einen anderen wählt.
+ */
+export function automatikWarnung(
+ workers: WorkerFuerPfadPruefung[] | undefined,
+ zielPfad: string,
+ arbeitsPfad: string,
+): string | null {
+ const betroffen = (workers || [])
+ .filter(w => externWarnung(w, zielPfad, arbeitsPfad) !== null)
+ .map(w => w.name)
+ if (betroffen.length === 0) return null
+ return `Bei „Automatisch" nimmt die Aufgabe der erste freie Worker — das kann `
+ + `${betroffen.join(', ')} sein, und dort scheitert sie an den Pfaden. `
+ + 'Wähle oben gezielt eine Maschine, die die Freigabe erreicht — oder rüste '
+ + 'die Pfad-Übersetzung nach (Windows-Installer erneut ausführen).'
+}
+
/**
* Kann von den gemeldeten Workern KEINER 4K in Software brauchbar schnell
* encodieren? Wahr nur, wenn das auch belegt ist: mindestens ein Worker hat
diff --git a/docker/ui/src/pages/Settings.tsx b/docker/ui/src/pages/Settings.tsx
index 78bd373..2963c2b 100644
--- a/docker/ui/src/pages/Settings.tsx
+++ b/docker/ui/src/pages/Settings.tsx
@@ -127,6 +127,37 @@ interface SystemInfo {
webhook_gesetzt: boolean
}
+// Antwort von GET /presets — die Preset-Namen kommen aus
+// `HandBrakeCLI --preset-list` des jeweiligen Workers, die Empfehlung samt
+// Begründung aus api/presets.py (dort getestet). Bis 26.07.2026 standen die
+// Namen fest verdrahtet in dieser Datei; sie unterscheiden sich aber zwischen
+// HandBrake-Versionen, und ein Name, den das HandBrake des Workers nicht kennt,
+// lässt die Kompression scheitern.
+interface PresetEmpfehlung {
+ preset: string
+ grund: string
+ gefunden: boolean
+}
+
+interface PresetTyp {
+ empfehlung: PresetEmpfehlung
+ auswahl: { name: string, verkleinert: boolean }[]
+}
+
+interface PresetUebersicht {
+ quelle: 'worker' | 'rueckfall'
+ hardware: string[]
+ simd_schnell: boolean
+ typen: Record<'uhd' | 'bluray' | 'dvd', PresetTyp>
+}
+
+// Welches Einstellungs-Feld gehört zu welchem Disc-Typ.
+const PRESET_FELD: Record<'uhd' | 'bluray' | 'dvd', keyof SettingsState> = {
+ uhd: 'transcodePresetUhd',
+ bluray: 'transcodePresetBluray',
+ dvd: 'transcodePresetDvd',
+}
+
// Byte-Zahl menschenlesbar — eine echte KEYDB.cfg ist mehrere MB groß.
function bytesLesbar(bytes: number): string {
if (!bytes) return '0 B'
@@ -171,6 +202,8 @@ export default function SettingsPage() {
const [keystore, setKeystore] = useState(null)
const [keystoreBusy, setKeystoreBusy] = useState(false)
const keystoreInput = useRef(null)
+ // Preset-Namen + Empfehlung, beides von der API (siehe PresetUebersicht).
+ const [presetInfo, setPresetInfo] = useState(null)
const { toast } = useToast()
@@ -291,8 +324,90 @@ export default function SettingsPage() {
api.get('/system/aacs-dumps')
.then(r => setDumps(r.data?.dumps || []))
.catch(() => setDumps([]))
+ api.get('/presets')
+ .then(r => setPresetInfo(r.data))
+ .catch(() => setPresetInfo(null))
}, [])
+ // „Bestes automatisch wählen" (Commander-Anforderung 26.07.2026). Setzt alle
+ // drei Disc-Typen auf die Empfehlung der API — und nur solche, die wirklich
+ // gefunden wurden, damit nie ein Name landet, den HandBrake nicht kennt.
+ const bestePresetsUebernehmen = () => {
+ if (!presetInfo) return
+ const neu: Partial = {}
+ const uebernommen: string[] = []
+ for (const typ of ['uhd', 'bluray', 'dvd'] as const) {
+ const e = presetInfo.typen[typ]?.empfehlung
+ if (!e?.gefunden || !e.preset) continue
+ neu[PRESET_FELD[typ]] = e.preset as any
+ uebernommen.push(`${typ.toUpperCase()}: ${e.preset === PRESET_KEINE ? 'nicht komprimieren' : e.preset}`)
+ }
+ if (uebernommen.length === 0) {
+ toast('error', 'Kein Preset übernommen — die Worker melden keine bekannten Namen.')
+ return
+ }
+ setSettings(prev => ({ ...prev, ...neu }))
+ setSaved(false)
+ toast('success', `Übernommen — ${uebernommen.join(' · ')}. Noch speichern!`)
+ }
+
+ // Eine Preset-Auswahl je Disc-Typ. Die Optionen kommen vom WORKER (siehe
+ // PresetUebersicht), nicht aus dieser Datei. Der aktuell gespeicherte Wert
+ // steht immer mit drin — sonst würde eine Bestandseinstellung beim Öffnen der
+ // Seite still auf etwas anderes springen.
+ const presetAuswahl = (
+ typ: 'uhd' | 'bluray' | 'dvd', label: string, keineText: string,
+ ) => {
+ const feld = PRESET_FELD[typ]
+ const wert = (settings[feld] as string) || ''
+ const daten = presetInfo?.typen?.[typ]
+ const empfehlung = daten?.empfehlung
+ const passend = (daten?.auswahl || []).filter(o => !o.verkleinert)
+ const kleiner = (daten?.auswahl || []).filter(o => o.verkleinert)
+ const bekannt = wert === PRESET_KEINE
+ || (daten?.auswahl || []).some(o => o.name === wert)
+ return (
+
+ )
+ }
+
const webhookTesten = async () => {
if (!settings.notificationWebhook) return
setTestBusy(true)
@@ -637,28 +752,54 @@ export default function SettingsPage() {
Ein Preset JE DISC-TYP (25.07.2026). Vorher lief alles durch
dasselbe Preset — eine 4K-UHD wurde damit auf 1080p
heruntergerechnet und eine DVD auf 1080p hochskaliert.
- Preset-Namen stammen aus `HandBrakeCLI --preset-list` im
- Worker-Image (HandBrake 1.6.1), nicht aus dem Kopf.
+
+ Seit 26.07.2026 kommen die NAMEN vom Worker selbst
+ (`HandBrakeCLI --preset-list`, siehe worker/caps.py) und die
+ Empfehlung von GET /presets. Vorher standen sie hier fest
+ verdrahtet — sie unterscheiden sich aber je HandBrake-Version,
+ und ein Name, den das HandBrake des Workers nicht kennt, lässt
+ die Kompression scheitern.
*/}
-
- Ein Preset je Quelle — Rippy erkennt den Disc-Typ selbst und wählt passend aus.
- Eine 4K-UHD gehört auf ein
- 2160p-Preset, sonst rechnet HandBrake sie auf 1080p herunter und die
- 4K-Auflösung ist weg.
-
+
+
+ Ein Preset je Quelle — Rippy erkennt den Disc-Typ selbst und wählt passend aus.
+ Eine 4K-UHD gehört auf ein
+ 2160p-Preset, sonst rechnet HandBrake sie auf 1080p herunter und die
+ 4K-Auflösung ist weg.
+
+
+
-
+ {presetInfo && (
+
+ {presetInfo.quelle === 'worker' ? (
+ <>
+ Die Liste kommt von deinen Workern selbst.
+ {presetInfo.hardware.length > 0
+ ? ` Hardware-Encoder gemeldet: ${presetInfo.hardware.join(', ')} — damit geht 4K in Minuten statt Tagen.`
+ : presetInfo.simd_schnell
+ ? ' Kein Hardware-Encoder, aber ein Worker mit AVX2 — Software-H.265 ist damit schnell genug.'
+ : ' Kein Hardware-Encoder und kein AVX2 — hier ist Software-4K keine Option (gemessen: 28–55 h je Film).'}
+ >
+ ) : (
+ <>
+ Kein Worker meldet gerade eine Preset-Liste (alter Worker-Stand oder
+ keiner online) — angezeigt wird deshalb eine geprüfte Standardauswahl.
+ >
+ )}
+
+ )}
+
+ {presetAuswahl('uhd', 'Preset für 4K-UHD',
+ 'Nicht komprimieren — 4K verlustfrei behalten')}
{/* Die Warnung, die den 50-Stunden-Lauf verhindert haette. Sie
steht nur da, wenn die Maschine sie WIRKLICH braucht -
gemessen, nicht vermutet (worker/caps.py meldet cpu_simd). */}
@@ -673,28 +814,10 @@ export default function SettingsPage() {
)}
-
-
-
+ {presetAuswahl('bluray', 'Preset für Blu-ray',
+ 'Nicht komprimieren — verlustfrei behalten')}
+ {presetAuswahl('dvd', 'Preset für DVD',
+ 'Nicht komprimieren — verlustfrei behalten')}
„Nicht komprimieren" heißt: Der Rip bleibt so, wie MakeMKV ihn