Files
rippy/docker/ui/src/pages/Settings.tsx
T
HitonabiandClaude Opus 5 954f293871 fix(windows): Rippen ging nicht — drei Container-Annahmen im Rip-Pfad
Commander: „Das Rippen der disk funktioniert nicht. makemkvcon endete mit
Code 11 — letzte Meldung: Das Öffnen der Disk schlug fehl — keine MKV-Datei
entstanden"

In diesem einen Satz steckten drei Fehler. Alle drei an seinem Laufwerk
gemessen, nicht hergeleitet.

## 1. Die Quellenangabe — das war der Abbruch

    dev:\.\G:  MSG:2024 „Unknown device" -> MSG:5010 Öffnen schlug fehl
    dev:G:      68 TINFO-Zeilen, „Die Aufgabe wurde erfolgreich abgeschlossen"

`f"dev:{device_path}"` stand an VIER Stellen. Unter Linux richtig (/dev/sr0),
unter Windows nicht: `rippy.drives.windows` meldet den Geraete-Namensraum
`\.\G:`, den `CreateFileW` fuer die IOCTLs braucht — MakeMKV kennt ihn
nicht.

## 2. „Ö" statt „Ö"

`Ö` als UTF-8 (C3 96), gelesen als cp1252. `subprocess` stand auf `text=True`
ohne `encoding=`, nahm also die Gebietsschema-Kodierung; Rippy stellt seine
Konsole beim Start aber auf UTF-8 (sonst stirbt der Start an einer
Umlaut-Logzeile). Direkt gemessen kommen die Bytes je nach Umgebung als UTF-8
ODER cp1252 — deshalb wird jetzt bestimmt statt behauptet.

## 3. Die Ursache fehlte im Fehlertext

Gesucht wurde nach ENGLISCHEN Textbausteinen („evaluation period has
expired"). Auf einem deutschen Windows trifft keiner. Jetzt zaehlen die
Meldungs-NUMMERN, die sprachunabhaengig sind.

⚠️ Beim ersten Anlauf hatte ich 5021 aus dem Kopf mit „Volume-Key unbekannt"
beschriftet — falsch. Meine eigene Ausgabe zeigte nur meine Beschriftung
statt MakeMKVs Text, sodass es fast durchgegangen waere. Der echte Wortlaut
steht jetzt im Code.

## Nebenbefund: der Beta-Key lag zweimal am falschen Ort

`makemkv_daten.DATEN_DIR` war fest `/root/.MakeMKV`. Nach der Reparatur des
Ordners blieb MSG:5051 trotzdem stehen — weil MakeMKV unter Windows seine
Einstellungen in der REGISTRY haelt (HKCU\Software\MakeMKV), nicht in einer
settings.conf. Nachgesehen statt vermutet.

Und der wichtigste Teil davon: **Ein abgelehnter Schluessel ist schlimmer als
gar keiner.** Ohne Key las MakeMKV die Disc noch (68 TINFO), mit dem
abgelehnten verweigerte es alles (0 Titel). Deshalb wird jetzt geprueft und
bei Ablehnung zurueckgenommen.

## Beweis

Echter Rip ueber `ripping.run_makemkv`, kuerzester Titel:

    Status success, Code 0, „Evangelion 2.22_t03.mkv" 217,9 MB
    5036 „Das Kopieren wurde abgeschlossen. 1 Titel wurden gesichert."

828 Tests gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 13:52:06 +02:00

1656 lines
82 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useRef } from 'react'
import { Save, Disc, Cpu, Database, Globe, AlertCircle, CheckCircle, HardDrive, Wrench, Send, Tv, KeyRound, Upload, Trash2, Download } from 'lucide-react'
import { api } from '../lib/api'
import { useToast } from '../context/ToastContext'
import StorageMounts from '../components/StorageMounts'
import WorkerVerwaltung from '../components/WorkerVerwaltung'
import { useBetrieb } from '../lib/useBetrieb'
import { PageHeader } from '../components/ui/PageHeader'
import { Card, CardHeader, CardTitle, CardContent } from '../components/ui/Card'
import { Button } from '../components/ui/Button'
import { Input, Select, Toggle } from '../components/ui/Input'
import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer'
import { ENCODER_BADGES } from '../lib/design'
import { PRESET_KEINE, schwacheEncoderCpu, simdWarnung } from '../lib/encoder'
interface SettingsState {
tmdbApiKey: string
tvdbApiKey: string
omdbApiKey: string
outputDir: string
movieDir: string
seriesDir: string
musicDir: string
mainFeatureOnly: boolean
autoEject: boolean
autoRipStart: boolean
notificationWebhook: string
transcodeEnabled: boolean
// transcodePreset bleibt als Rückfall für Bestandsinstallationen bestehen —
// solange die drei Felder darunter nicht gespeichert sind, gilt weiter dieser
// Wert (siehe preset_fuer() in docker/worker/ripping.py).
transcodePreset: string
transcodePresetDvd: string
transcodePresetBluray: string
transcodePresetUhd: string
keepOriginal: boolean
mediaServer: string
jellyfinUrl: string
jellyfinApiKey: string
workDir: string
makemkvAppKey: string
// Wunschsprachen (ISO-639-2, Komma-Liste). Greifen bei der Kompression und
// sind im Rip-Dialog vorausgewählt. Leer = alle Sprachen behalten.
audioSprachen: string
untertitelSprachen: string
}
const defaultSettings: SettingsState = {
tmdbApiKey: '',
tvdbApiKey: '',
omdbApiKey: '',
// Kein fester Container-Pfad: Auf Windows gibt es kein /app/media. Der
// echte Vorgabewert kommt aus GET /betrieb (ablage_vorgabe) und wird
// beim Laden gesetzt.
outputDir: '',
movieDir: 'movies',
seriesDir: 'series',
musicDir: 'music',
mainFeatureOnly: false,
autoEject: true,
autoRipStart: false,
notificationWebhook: '',
transcodeEnabled: true,
transcodePreset: 'H.265 MKV 1080p30',
transcodePresetDvd: 'H.265 MKV 576p25',
transcodePresetBluray: 'H.265 MKV 1080p30',
transcodePresetUhd: 'H.265 MKV 2160p60 4K',
keepOriginal: false,
mediaServer: 'none',
jellyfinUrl: '',
jellyfinApiKey: '',
workDir: '',
makemkvAppKey: '',
// Leer = alles behalten. Bewusst kein Vorgabewert: Eine stille Vorauswahl
// („deu") würde bei einem japanischen Original die Originaltonspur wegwerfen,
// ohne dass jemand danach gefragt hat.
audioSprachen: '',
untertitelSprachen: '',
}
type SettingsTab = 'ripping' | 'verarbeitung' | 'worker' | 'speicherziele' | 'apis' | 'benachrichtigungen' | 'system'
interface WorkerInfo {
name: string
encoders: string[]
// keydb: "ja" | "nein" | "unbekannt" — sagt, ob DIESER Worker eine KEYDB.cfg
// in seinem MakeMKV-Datenverzeichnis sieht. Nur der Worker, der wirklich
// rippt, zählt, deshalb steht die Angabe pro Worker und nicht global.
// cpu_kerne/cpu_simd: Rechenleistung fürs Software-Encodieren. Ohne diese
// 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, extern?: string, pfad_map?: string,
}
}
// Antwort von GET/POST/DELETE /system/keydb (Feldnamen exakt wie die API sie liefert).
interface KeydbStatus {
vorhanden: boolean
pfad: string
groesse_bytes: number
eintraege: number
geaendert: string
}
// Ein Ablageziel aus GET /storage-targets (Feldnamen exakt wie die API liefert).
interface StorageZiel {
name: string
path: string
is_mount: boolean
free_gb: number | null
}
// Antwort von GET/POST /system/keystore — MakeMKVs eigener Schlüsselvorrat.
interface KeystoreStatus {
vorhanden: boolean
pfad: string
groesse_bytes: number
schluessel: number
geaendert: string
}
// Ein AACS-Dump aus GET /system/aacs-dumps.
interface AacsDump {
name: string
groesse_bytes: number
geaendert: string
}
interface SystemInfo {
api_version: string
plaetze: { name: string, frei_gb: number, gesamt_gb: number }[]
workers: WorkerInfo[]
makemkv_key_ui: boolean
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'
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`
return `${bytes} B`
}
// Die API liefert ISO-8601 in UTC (oder einen leeren String). Ohne Zonen-Kennung
// würde der Browser den Zeitstempel als Ortszeit lesen und die Uhrzeit um den
// Zonen-Versatz verschieben — deshalb notfalls ein Z anhaengen.
function zeitLesbar(iso: string): string {
if (!iso) return 'unbekannt'
const mitZone = /[Zz]$|[+-]\d{2}:?\d{2}$/.test(iso) ? iso : `${iso}Z`
const d = new Date(mitZone)
return isNaN(d.getTime()) ? 'unbekannt' : d.toLocaleString('de-DE')
}
export default function SettingsPage() {
const [settings, setSettings] = useState<SettingsState>(defaultSettings)
const betrieb = useBetrieb()
const [activeTab, setActiveTab] = useState<SettingsTab>('ripping')
const [workers, setWorkers] = useState<WorkerInfo[]>([])
const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [testBusy, setTestBusy] = useState(false)
const [quellenStatus, setQuellenStatus] = useState<Record<string, string> | null>(null)
const [quellenBusy, setQuellenBusy] = useState(false)
const [updates, setUpdates] = useState<Record<string, { installiert?: string, verfuegbar?: string, update?: boolean }> | null>(null)
const [updatesBusy, setUpdatesBusy] = useState(false)
// KEYDB.cfg und AACS-Dumps sind DATEIEN im MakeMKV-Datenverzeichnis, keine
// Einstellungen — deshalb bewusst NICHT in SettingsState: der globale
// Speichern-Knopf postet dieses Objekt komplett und würde sie mitschleifen.
const [keydb, setKeydb] = useState<KeydbStatus | null>(null)
const [keydbBusy, setKeydbBusy] = useState(false)
const [dumps, setDumps] = useState<AacsDump[]>([])
const keydbInput = useRef<HTMLInputElement>(null)
// Ablageziele unter /app/media inkl. eingehängter Freigaben — speist die
// Auswahl des Arbeitsverzeichnisses (vorher musste man den Pfad tippen).
const [ziele, setZiele] = useState<StorageZiel[]>([])
// Beta-Key: läuft gerade ein Abruf, und was kam zuletzt dabei heraus?
const [keyHolt, setKeyHolt] = useState(false)
const [keyMeldung, setKeyMeldung] = useState('')
const [keystore, setKeystore] = useState<KeystoreStatus | null>(null)
const [keystoreBusy, setKeystoreBusy] = useState(false)
const keystoreInput = useRef<HTMLInputElement>(null)
// Preset-Namen + Empfehlung, beides von der API (siehe PresetUebersicht).
const [presetInfo, setPresetInfo] = useState<PresetUebersicht | null>(null)
const { toast } = useToast()
const updatesPruefen = async () => {
setUpdatesBusy(true)
try {
const r = await api.get('/system/updates')
setUpdates(r.data)
if (r.data?.makemkv?.update) {
toast('info', `MakeMKV ${r.data.makemkv.verfuegbar} ist verfügbar (installiert: ${r.data.makemkv.installiert})`)
} else {
toast('success', 'Update-Check abgeschlossen')
}
} catch {
toast('error', 'Update-Check fehlgeschlagen (makemkv.com/GitHub nicht erreichbar?)')
} finally {
setUpdatesBusy(false)
}
}
// Status der KEYDB.cfg + Liste der AACS-Dumps frisch holen. Wird beim Laden
// der Seite und nach jeder Aktion aufgerufen — die API ist die Wahrheit,
// nicht das, was wir gerade hochgeschickt haben.
const keydbLaden = () => {
api.get('/system/keydb').then(r => setKeydb(r.data)).catch(() => setKeydb(null))
api.get('/system/keystore').then(r => setKeystore(r.data)).catch(() => setKeystore(null))
api.get('/system/aacs-dumps').then(r => setDumps(r.data?.dumps || [])).catch(() => setDumps([]))
// Die Worker-Plaketten oben kommen aus /capabilities und stammen aus dem
// letzten Herzschlag des Workers (minuetlich) — ohne dieses Nachladen
// widerspraeche die Kachel nach einem Upload sichtbar der Erfolgsmeldung.
// Ganz sofort ist sie trotzdem nicht: der Worker meldet erst beim
// nächsten Herzschlag neu. Genau so steht es auch im Erklaertext.
api.get('/capabilities').then(r => setWorkers(r.data.workers || [])).catch(() => setWorkers([]))
}
/*
* Den Beta-Key JETZT holen, statt auf die tägliche Schleife zu warten.
*
* Der geholte Key wird nicht angezeigt — er wandert in die Einstellungen,
* und die werden hier ohnehin neu geladen. Zwei Wege zu demselben Wert sind
* zwei Wege, ihn zu verlieren.
*/
const keyJetztHolen = async () => {
setKeyHolt(true)
setKeyMeldung('')
try {
const r = await api.post('/system/makemkv-key/holen')
const antwort = await api.get('/settings')
setSettings({ ...defaultSettings, ...antwort.data })
if (r.data?.angenommen === false) {
// Geholt ist nicht angenommen. Am 29.08.2026 lehnte MakeMKV 1.18.4
// den aktuellen Key ab („Programmversion zu alt") — ohne diesen
// Hinweis stünde hier ein Erfolg, und der nächste Rip scheiterte
// trotzdem.
setKeyMeldung('Fehlgeschlagen: ' + (r.data.grund || 'MakeMKV nimmt den Key nicht an.'))
toast('error', r.data.grund || 'MakeMKV nimmt den Key nicht an.')
} else {
setKeyMeldung(r.data?.geaendert
? `Neuer Key geholt und angenommen (endet auf …${r.data.endet_auf}).`
: `Schon aktuell (endet auf …${r.data?.endet_auf || '?'}).`)
toast('success', 'MakeMKV-Beta-Key aus dem Forum geholt.')
}
} catch (e: any) {
const grund = e?.response?.data?.detail
|| 'Das Forum ist nicht erreichbar.'
setKeyMeldung('Fehlgeschlagen: ' + grund)
toast('error', grund)
} finally {
setKeyHolt(false)
}
}
const keydbHochladen = async (datei: File) => {
setKeydbBusy(true)
try {
// Bewusst als JSON-Text und nicht als Multipart-Upload: der API fehlt
// python-multipart, ein File()-Endpunkt würde sie beim Import killen.
const inhalt = await datei.text()
await api.post('/system/keydb', { inhalt })
keydbLaden()
toast('success', 'KEYDB.cfg gespeichert — sie wirkt ab dem nächsten Rip.')
} catch (e: any) {
toast('error', e?.response?.data?.detail || 'Hochladen fehlgeschlagen — ist das wirklich eine KEYDB.cfg, und läuft die API?')
} finally {
setKeydbBusy(false)
}
}
const keystoreHochladen = async (datei: File) => {
setKeystoreBusy(true)
try {
// Die Datei wandert als roher Anfrage-Körper zur API — _private_data.tar
// ist binär, JSON oder Base64 wäre nur unnötiger Ballast, und Multipart
// kann die API nicht (kein python-multipart).
await api.post('/system/keystore', datei, {
headers: { 'Content-Type': 'application/octet-stream' },
})
keydbLaden()
toast('success', 'Schlüsselspeicher übernommen — wirkt ab dem nächsten Rip.')
} catch (e: any) {
toast('error', e?.response?.data?.detail
|| 'Übernahme fehlgeschlagen — ist das wirklich die Datei _private_data.tar, und läuft die API?')
} finally {
setKeystoreBusy(false)
}
}
const keydbEntfernen = async () => {
setKeydbBusy(true)
try {
await api.delete('/system/keydb')
keydbLaden()
toast('success', 'KEYDB.cfg entfernt — Rippy nutzt jetzt keine Schlüsseldatei mehr.')
} catch (e: any) {
toast('error', e?.response?.data?.detail || 'Entfernen fehlgeschlagen — läuft die API?')
} finally {
setKeydbBusy(false)
}
}
const quellenPruefen = async () => {
setQuellenBusy(true)
try {
const r = await api.get('/metadata/status')
setQuellenStatus(r.data)
const kaputt = Object.entries(r.data).filter(([, v]) => v === 'fehler')
if (kaputt.length === 0) {
toast('success', 'Metadaten-Quellen geprüft — alles in Ordnung')
} else {
toast('error', `Quelle mit Problem: ${kaputt.map(([k]) => k.toUpperCase()).join(', ')}`)
}
} catch {
toast('error', 'Prüfung fehlgeschlagen — läuft die API?')
} finally {
setQuellenBusy(false)
}
}
useEffect(() => {
api.get('/capabilities')
.then(r => setWorkers(r.data.workers || []))
.catch(() => setWorkers([]))
api.get('/system/info')
.then(r => setSystemInfo(r.data))
.catch(() => setSystemInfo(null))
api.get('/system/keydb')
.then(r => setKeydb(r.data))
.catch(() => setKeydb(null))
api.get('/system/keystore')
.then(r => setKeystore(r.data))
.catch(() => setKeystore(null))
api.get('/storage-targets')
.then(r => setZiele(Array.isArray(r.data) ? r.data : []))
.catch(() => setZiele([]))
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<SettingsState> = {}
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 (
<div className="space-y-1.5">
<Select label={label} value={wert} onChange={(e) => handleChange(feld, e.target.value)}>
<option value={PRESET_KEINE}>{keineText}</option>
{/* Ein gespeicherter Wert, den dieser Worker nicht kennt, wird
BENANNT statt verschluckt — genau so fällt eine falsche
Bestandseinstellung überhaupt auf. */}
{!bekannt && wert && (
<option value={wert}>{wert} gespeichert, dieser Worker kennt es nicht</option>
)}
{passend.length > 0 && (
<optgroup label="Passt zur Auflösung">
{passend.map(o => (
<option key={o.name} value={o.name}>
{o.name}{empfehlung?.gefunden && empfehlung.preset === o.name ? ' ★ empfohlen' : ''}
</option>
))}
</optgroup>
)}
{kleiner.length > 0 && (
<optgroup label="Bewusst verkleinern — die 4K-Auflösung ist danach weg">
{kleiner.map(o => <option key={o.name} value={o.name}>{o.name}</option>)}
</optgroup>
)}
</Select>
{empfehlung && empfehlung.preset !== wert && (
<p className="text-xs text-slate-500 dark:text-slate-400">
{empfehlung.gefunden ? (
<>
Empfohlen wäre{' '}
<strong className="text-slate-700 dark:text-slate-300">
{empfehlung.preset === PRESET_KEINE ? 'nicht komprimieren' : empfehlung.preset}
</strong>
{' — '}{empfehlung.grund}
</>
) : empfehlung.grund}
</p>
)}
</div>
)
}
const webhookTesten = async () => {
if (!settings.notificationWebhook) return
setTestBusy(true)
try {
await api.post('/notifications/test', { url: settings.notificationWebhook })
toast('success', 'Test-Nachricht gesendet — prüfe deinen Kanal!')
} catch (e: any) {
toast('error', e?.response?.data?.detail || 'Test fehlgeschlagen')
} finally {
setTestBusy(false)
}
}
useEffect(() => {
const loadSettings = async () => {
try {
const response = await api.get('/settings')
setSettings({ ...defaultSettings, ...response.data })
} catch {
setSettings(defaultSettings)
} finally {
setLoading(false)
}
}
loadSettings()
}, [])
const handleChange = (field: keyof SettingsState, value: any) => {
setSettings(prev => ({ ...prev, [field]: value }))
setSaved(false)
}
const handleSave = async () => {
setSaving(true)
try {
await api.post('/settings', settings)
setSaved(true)
toast('success', 'Einstellungen gespeichert')
setTimeout(() => setSaved(false), 3000)
} catch {
toast('error', 'Speichern fehlgeschlagen — läuft die API?')
} finally {
setSaving(false)
}
}
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-amber-500"></div>
</div>
)
}
// 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 = (() => {
// ⚠️ Die ganze Warnung dreht sich um einen Encoder auf einem ANDEREN
// Rechner, der die Container-Platte nicht sieht. Ohne externe Worker gibt
// es weder den einen noch die andere — der Satz stand am 28.08.2026
// trotzdem im Windows-Fenster und riet dort zu etwas, das es nicht gibt.
if (!betrieb.kann.externe_worker) return ''
const ablage = settings.outputDir || betrieb.ablage_vorgabe
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 ''
})()
/*
* Die Reiter haengen am BETRIEB.
*
* Commander-Befund 28.08.2026: „Das gilt fuer die ganze standalone version
* fuer Windows, auch fuer die settings und die Anleitung usw."
*
* Ein Reiter „Worker" fuehrt in einer Standalone-Installation zu einer
* Maske, die Rechner in einem Netz verwaltet, das es nicht gibt. Ein Reiter
* „Speicherziele" bietet dort das Einhaengen von Freigaben an — etwas, das
* unter Windows niemand braucht (dort gibt man einen Ordner oder UNC-Pfad
* an). Beides ist keine kosmetische Frage: Wer darauf klickt, bekommt
* Bedienelemente, die ins Leere fuehren.
*/
const tabs: { id: SettingsTab; label: string; icon: any }[] = [
{ id: 'ripping', label: 'Ripping', icon: Disc },
{ id: 'verarbeitung', label: 'Verarbeitung', icon: Cpu },
...(betrieb.kann.externe_worker
? [{ id: 'worker' as SettingsTab, label: 'Worker', icon: Cpu }]
: []),
// Speicherziele bleibt IMMER. Hier steht die Ablage — „wo will ich das
// hinspeichern" war die ausdrueckliche Frage des Commanders. Nur das
// EINHAENGEN von Freigaben faellt weg, wenn der Betrieb es nicht kann;
// das steckt weiter unten in einer eigenen Bedingung.
{ id: 'speicherziele', label: 'Speicherziele', icon: HardDrive },
{ id: 'apis', label: 'APIs', icon: Globe },
{ id: 'benachrichtigungen', label: 'Benachrichtigungen', icon: AlertCircle },
{ id: 'system', label: 'System', icon: Wrench },
]
/*
* Ein Reiter, den es nicht mehr gibt, darf nicht ausgewaehlt bleiben.
*
* Der Betrieb kommt ERST NACH dem ersten Rendern an (ein Abruf). Wer in der
* Zwischenzeit auf „Worker" steht — oder wer den Reiter beim Neuladen aus
* dem Zustand mitbringt — saehe sonst eine leere Seite ohne jeden Hinweis.
*/
const sichtbareReiter = tabs.map(t => t.id)
const aktiverReiter = sichtbareReiter.includes(activeTab) ? activeTab : 'ripping'
return (
<div className="max-w-6xl mx-auto space-y-6">
<PageHeader
title="Einstellungen"
subtitle="Konfiguriere Ripping-Parameter, Speicherziele, APIs und Benachrichtigungen"
/>
{saved && (
<div className="p-4 rounded-xl flex items-center gap-3 font-medium bg-emerald-500/15 border border-emerald-500/30 text-emerald-600 dark:text-emerald-400">
<CheckCircle className="flex-shrink-0" size={22} />
<span>Einstellungen erfolgreich gespeichert!</span>
</div>
)}
<Card className="overflow-hidden">
{/* Navigation Tabs */}
<div className="flex border-b border-slate-800 overflow-x-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden bg-[#0c101d]">
{tabs.map((tab) => {
const isActive = aktiverReiter === tab.id
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-5 py-3.5 text-sm font-medium transition-all flex items-center gap-2 border-b-2 whitespace-nowrap ${
isActive
? 'border-amber-500 text-amber-400 bg-[#131929] font-bold'
: 'border-transparent text-slate-400 hover:text-slate-200 hover:bg-slate-900/50'
}`}
>
<tab.icon size={17} className={isActive ? 'text-amber-400' : ''} />
<span>{tab.label}</span>
</button>
)
})}
</div>
{/* Tab Content */}
<CardContent className="p-6 space-y-8">
{/* Ripping Settings */}
{aktiverReiter === 'ripping' && (
<section className="space-y-6">
<div>
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
<Disc size={20} className="text-amber-500" />
Ripping-Einstellungen
</h2>
</div>
<div className="space-y-4">
<p className="text-xs font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500">
📀 Video DVD · Blu-ray · 4K UHD
</p>
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
<Toggle
label="Vollautomatik: sofort rippen"
description="Disc erkannt → Rip startet ohne Nachfrage in den passenden Ordner."
checked={settings.autoRipStart}
onChange={(v) => handleChange('autoRipStart', v)}
/>
</div>
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
<Toggle
label="Nur Hauptfeature"
description="Rippe bei DVDs/Blu-rays nur das Hauptfeature (längster Titel)."
checked={settings.mainFeatureOnly}
onChange={(v) => handleChange('mainFeatureOnly', v)}
/>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400 px-1">
🌐 Sprachen &amp; Untertitel: MakeMKV sichert grundsätzlich ALLE Ton-Spuren und Untertitel verlustfrei.
</p>
<p className="text-xs font-bold uppercase tracking-wider pt-2 text-slate-400 dark:text-slate-500">
🎵 Audio-CD
</p>
{/* Der Schalter „Alle Tracks rippen" ist am 25.07.2026 entfallen.
Er war ein Placebo: abcde bekommt keine Track-Auswahl (siehe
build_abcde_cmd) und es gibt auch keine Oberfläche, um eine
Teilmenge zu wählen — eine Audio-CD wurde also immer
vollständig gerippt, egal wie der Schalter stand. Statt einer
Wahl, die nichts bewirkt, steht hier jetzt die Wahrheit. */}
<p className="text-xs text-slate-500 dark:text-slate-400 px-1">
💿 Eine Audio-CD wird immer vollständig gerippt alle Tracks als
FLAC, mit MusicBrainz-Tags.
</p>
<p className="text-xs font-bold uppercase tracking-wider pt-2 text-slate-400 dark:text-slate-500">
Allgemein
</p>
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
<Toggle
label="Automatischer Auswurf"
description="Disc nach erfolgreichem Ripping automatisch auswerfen."
checked={settings.autoEject}
onChange={(v) => handleChange('autoEject', v)}
/>
</div>
{/* Media-Server Integration */}
<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-4">
<h3 className="font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
<Tv size={18} className="text-amber-500" />
Media-Server (Jellyfin &amp; Co.)
</h3>
<p className="text-sm text-slate-500 dark:text-slate-400">
Rippy benennt fertige Rips als Titel (Jahr)" und legt NFO + Poster dazu.
</p>
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
{MEDIA_SERVER_OPTIONEN.map(o => (
<button
key={o.wert}
type="button"
onClick={() => handleChange('mediaServer', o.wert)}
title={o.hinweis}
className={`px-3 py-2 rounded-xl text-sm font-medium border transition-all ${
settings.mediaServer === o.wert
? 'bg-amber-500/20 border-amber-500 text-amber-600 dark:text-amber-300 font-semibold'
: 'bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-400 hover:border-slate-300 dark:hover:border-slate-600'
}`}
>
{o.label}
</button>
))}
</div>
<p className="text-xs text-slate-500 dark:text-slate-400">
{MEDIA_SERVER_OPTIONEN.find(o => o.wert === settings.mediaServer)?.hinweis}
</p>
{(settings.mediaServer === 'jellyfin' || settings.mediaServer === 'emby') && (
<div className="pt-3 border-t border-slate-200 dark:border-slate-800 space-y-3">
<p className="text-xs text-slate-600 dark:text-slate-300">
<strong>Bibliotheks-Refresh</strong>: Server-URL + API-Key für automatischen Scan nach dem Rip.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 items-end">
<Input
label="Server-URL"
value={settings.jellyfinUrl}
onChange={(e) => handleChange('jellyfinUrl', e.target.value)}
placeholder="z. B. http://192.168.1.50:8096"
/>
<div className="flex gap-2 items-end">
<div className="flex-1">
<Input
type="password"
label="API-Key"
value={settings.jellyfinApiKey}
onChange={(e) => handleChange('jellyfinApiKey', e.target.value)}
placeholder="API-Key"
/>
</div>
<Button
variant="amber"
size="md"
onClick={async () => {
try {
await api.post('/mediaserver/refresh', {
url: settings.jellyfinUrl, api_key: settings.jellyfinApiKey,
})
toast('success', 'Verbunden — Bibliotheks-Scan wurde angestoßen!')
} catch (e: any) {
toast('error', e?.response?.data?.detail || 'Verbindung fehlgeschlagen')
}
}}
disabled={!settings.jellyfinUrl || !settings.jellyfinApiKey}
>
Testen
</Button>
</div>
</div>
</div>
)}
</div>
</div>
</section>
)}
{/* Verarbeitung */}
{aktiverReiter === 'verarbeitung' && (
<section className="space-y-6">
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
<Cpu size={20} className="text-amber-500" />
Verarbeitung (Kompression)
</h2>
<p className="text-sm text-slate-500 dark:text-slate-400">
MakeMKV rippt verlustfrei, HandBrake komprimiert danach auf Arbeitsgröße.
</p>
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
<p className="text-sm font-medium mb-2 text-slate-900 dark:text-slate-200">Verfügbare Encoder</p>
{workers.length === 0 ? (
<p className="text-sm text-slate-500 dark:text-slate-400">Kein Worker gemeldet.</p>
) : (
workers.map(w => (
<div key={w.name} className="mb-1">
<span className="text-xs font-mono mr-2 text-slate-500 dark:text-slate-400">{w.name}: </span>
{w.encoders.map(e => (
<span key={e} className={`inline-block text-xs px-2.5 py-0.5 rounded-md font-medium border mr-1 ${
e.startsWith('cpu')
? 'bg-slate-200 dark:bg-slate-800 text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-700'
: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30'
}`}>
{ENCODER_BADGES[e]?.label || e}
</span>
))}
{(w.info?.cpu_kerne || w.info?.cpu_simd) && (
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400" title={w.info?.cpu_modell || ''}>
{w.info?.cpu_modell ? `${w.info.cpu_modell} · ` : ''}
{w.info?.cpu_kerne ? `${w.info.cpu_kerne} Kerne` : ''}
{w.info?.cpu_kerne && w.info?.cpu_simd ? ' · ' : ''}
{w.info?.cpu_simd ? `Vektorbefehle ${w.info.cpu_simd}` : ''}
</p>
)}
{simdWarnung(w.info?.cpu_simd) && (
<p className="mt-1 text-xs text-amber-600 dark:text-amber-400">
{simdWarnung(w.info?.cpu_simd)}
</p>
)}
{w.info?.handbrake_encoder && (
<p className="mt-1 text-xs text-slate-400 dark:text-slate-500 font-mono break-all">
HandBrake meldet: {w.info.handbrake_encoder}
</p>
)}
</div>
))
)}
</div>
<div className="space-y-4">
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
<Toggle
label="Nach dem Rip komprimieren"
description="x265 statt 40-GB-Rohdatei (empfohlen)."
checked={settings.transcodeEnabled}
onChange={(v) => handleChange('transcodeEnabled', v)}
/>
</div>
{/*
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.
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.
*/}
<div className="space-y-3">
<div className="flex items-start justify-between gap-3">
<p className="text-xs text-slate-500 dark:text-slate-400">
Ein Preset je Quelle Rippy erkennt den Disc-Typ selbst und wählt passend aus.
<strong className="text-slate-700 dark:text-slate-300"> Eine 4K-UHD gehört auf ein
2160p-Preset</strong>, sonst rechnet HandBrake sie auf 1080p herunter und die
4K-Auflösung ist weg.
</p>
<Button
variant="secondary"
onClick={bestePresetsUebernehmen}
disabled={!presetInfo}
className="flex-shrink-0"
>
Bestes wählen
</Button>
</div>
{presetInfo && (
<p className="text-xs text-slate-500 dark:text-slate-400">
{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: 2855 h je Film).'}
</>
) : (
<>
Kein Worker meldet gerade eine Preset-Liste (alter Worker-Stand oder
keiner online) angezeigt wird deshalb eine geprüfte Standardauswahl.
</>
)}
</p>
)}
{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). */}
{settings.transcodePresetUhd !== PRESET_KEINE
&& settings.transcodePresetUhd.includes('2160')
&& schwacheEncoderCpu(workers) && (
<p className="text-xs -mt-1 p-3 rounded-lg bg-amber-500/10 border border-amber-500/30 text-amber-700 dark:text-amber-300">
⚠️ Keiner deiner Worker kann AVX2 4K in H.265 dauert damit
<strong> ein bis zwei Tage pro Film</strong> (gemessen, nicht geschätzt).
Besser Nicht komprimieren" wählen und die verlustfreie Datei behalten,
oder einen Worker mit Hardware-Encoder anschließen.
</p>
)}
{presetAuswahl('bluray', 'Preset für Blu-ray',
'Nicht komprimieren — verlustfrei behalten')}
{presetAuswahl('dvd', 'Preset für DVD',
'Nicht komprimieren — verlustfrei behalten')}
<p className="text-xs text-slate-500 dark:text-slate-400">
„Nicht komprimieren" heißt: Der Rip bleibt so, wie MakeMKV ihn
gesichert hat beste Qualität, aber 20100 GB je Film. Sinnvoll für
4K, wenn die Ablage groß genug ist.
</p>
</div>
{/*
Wunschsprachen (Commander-Anforderung 26.07.2026: die Auswahl
muss „in der Automatik ebenfalls einstellbar" sein).
Sie greifen bei der KOMPRESSION, nicht beim Rippen — der Rip
bleibt vollständig und verlustfrei (Muss-Feature). Damit lässt
sich später mit anderer Sprachwahl neu komprimieren, ohne die
Disc wieder einzulegen.
*/}
<div className="space-y-3 p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
<div>
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">Wunschsprachen</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
Gilt für Vollautomatik-Rips und ist im Rip-Dialog vorausgewählt.
<strong className="text-slate-700 dark:text-slate-300"> Leer = alle Sprachen behalten.</strong>
</p>
</div>
<Input
label="Tonspuren (ISO-639-2, mit Komma getrennt)"
value={settings.audioSprachen}
onChange={(e) => handleChange('audioSprachen', e.target.value)}
placeholder="z. B. deu,eng,jpn"
/>
<Input
label="Untertitel (ISO-639-2, mit Komma getrennt)"
value={settings.untertitelSprachen}
onChange={(e) => handleChange('untertitelSprachen', e.target.value)}
placeholder="z. B. deu"
/>
<p className="text-xs text-slate-500 dark:text-slate-400">
Codes sind dreibuchstabig: <span className="font-mono">deu</span> Deutsch,
{' '}<span className="font-mono">eng</span> Englisch,
{' '}<span className="font-mono">jpn</span> Japanisch,
{' '}<span className="font-mono">fra</span> Französisch,
{' '}<span className="font-mono">spa</span> Spanisch,
{' '}<span className="font-mono">ita</span> Italienisch.
Welche eine Disc wirklich hat, zeigt Rippy beim Disc scannen" im
Rip-Dialog — dort ist die Auswahl auch je Disc änderbar.
</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
Wichtig: Diese Wahl wirkt beim <strong>Komprimieren</strong>. Der Rip
selbst sichert immer alles — sonst wäre eine spätere Änderung nur mit
neuem Rip möglich.
</p>
</div>
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
<Toggle
label="Original behalten"
description="Verlustfreie Roh-Datei zusätzlich aufheben (viel Speicher!)."
checked={settings.keepOriginal}
onChange={(v) => handleChange('keepOriginal', v)}
/>
</div>
{/*
Auswahl statt Eintippen (Befund 25.07.2026): Das Feld war ein
freies Textfeld — man musste den Container-Pfad KENNEN
(/app/media/…), um eine Netzwerk-Freigabe zu treffen. Genau
daran ist es gescheitert, und der 74-GB-Rohschnitt landete auf
der VM-Platte, bis sie voll war. Die Ziele kommen aus
/storage-targets — dieselbe Liste wie bei den Speicherzielen,
inklusive freiem Platz.
*/}
{!betrieb.kann.container_pfade ? (
/*
* Ein echtes Pfadfeld statt einer Auswahl — dieselbe
* Entscheidung wie bei der Ablage darüber, und aus demselben
* Grund.
*
* Commander am 28.08.2026: „Wäre es möglich das
* Arbeitsverzeichnis zu ändern? momentan geht das nicht."
*
* Es ging nicht, weil `ziele` auf Windows LEER war: Die Liste
* kommt aus /storage-targets, und das las `/app/media` ein
* Ordner, den es dort nicht gibt. Übrig blieb eine Auswahl mit
* genau einem Eintrag, und der hieß Container-Platte".
*
* Die Liste ist inzwischen repariert (sie bringt jetzt die
* Laufwerke mit freiem Platz). Aber die richtige Antwort auf
* „wohin mit 100 GB" ist auf Windows oft ein Ort, den keine
* Liste kennt D:\Rippy-Arbeit oder eine UNC-Freigabe.
* Deshalb hier ein Feld, in das man ihn schreiben kann.
*/
<Input
label="Arbeitsverzeichnis für Roh-Rips (Standard)"
value={settings.workDir}
placeholder={betrieb.arbeits_vorgabe}
onChange={(e) => handleChange('workDir', e.target.value)}
/>
) : (
<Select
label="Arbeitsverzeichnis für Roh-Rips (Standard)"
value={settings.workDir}
onChange={(e) => handleChange('workDir', e.target.value)}
>
<option value="">
Container-Platte (Standard) klein, nur für DVD/Blu-ray
</option>
{ziele.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>
)}
{!betrieb.kann.container_pfade && !!ziele.length && (
/* Die Laufwerke als Ein-Klick-Wahl. Wer den Platz sieht,
wählt anders — genau daran ist am 25.07. eine Platte
vollgelaufen. */
<div className="flex flex-wrap gap-1.5">
{ziele.map(z => (
<button
key={z.path}
onClick={() => handleChange('workDir', z.path)}
className={`px-2.5 py-1 text-xs rounded-lg border transition-colors ${
settings.workDir === z.path
? 'border-amber-500 bg-amber-500/10 text-amber-700 dark:text-amber-300'
: 'border-slate-200 dark:border-slate-700 text-slate-500 dark:text-slate-400 hover:border-slate-400'
}`}
>
{z.name}{z.free_gb != null ? ` — ${z.free_gb} GB frei` : ''}
</button>
))}
</div>
)}
<p className="text-xs text-slate-500 dark:text-slate-400">
<strong className="text-slate-700 dark:text-slate-300">Der Standard</strong> beim
Rippen starten" kannst du für jede Disc etwas anderes wählen. <strong className="text-slate-700 dark:text-slate-300">
Läuft die Vollautomatik</strong>, fragt dich niemand: dann gilt genau dieser Wert.
Wichtig für 4K-UHD: Rohdaten sind bis 100 GB groß.
{betrieb.kann.container_pfade
? ' Auf die Container-Platte passen die selten. Am besten dieselbe Freigabe wie das Ziel — dann muss Rippy die Rohdatei am Ende nur umhängen statt sie zu kopieren.'
: ' Am besten dasselbe Laufwerk wie die Ablage — dann muss Rippy die Rohdatei am Ende nur umhängen statt sie über die Platte zu kopieren.'}
{systemInfo?.plaetze?.length ? (
<> Aktuell frei: {systemInfo.plaetze.map(p => `${p.name}: ${p.frei_gb} GB`).join(' · ')}</>
) : null}
</p>
</div>
</section>
)}
{/* Worker */}
{aktiverReiter === 'worker' && (
<section className="space-y-6">
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
<Cpu size={20} className="text-amber-500" />
Encoding-Worker
</h2>
<WorkerVerwaltung />
</section>
)}
{/* Speicherziele */}
{aktiverReiter === 'speicherziele' && (
<section className="space-y-6">
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
<HardDrive size={20} className="text-amber-500" />
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>
{!betrieb.kann.container_pfade ? (
/* Kein Auswahlfeld, sondern ein echtes Pfadfeld: Auf einem
Windows-PC gibt es keine Liste von Container-Zielen, aus
der man waehlen koennte — es gibt Laufwerke, Ordner und
UNC-Pfade. Eine Auswahl mit einem Eintrag waere eine
Bedienung, die nichts bedient. */
<Input
label="Ordner"
value={settings.outputDir}
placeholder={betrieb.ablage_vorgabe}
onChange={(e) => handleChange('outputDir', e.target.value)}
/>
) : (
<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 (🎥/📺/🎵) jeweils unterhalb der Ablage.
</p>
<div className="grid grid-cols-3 gap-3">
{([['movieDir', '🎥 Filme'], ['seriesDir', '📺 Serien'], ['musicDir', '🎵 Musik']] as const).map(([feld, label]) => (
<Input
key={feld}
label={label}
value={settings[feld]}
onChange={(e) => handleChange(feld, e.target.value)}
/>
))}
</div>
</div>
{/* Freigaben EINHAENGEN kann nur der Container-Betrieb.
Unter Windows gibt man einen Ordner oder einen UNC-Pfad an —
eine Maske zum Einhaengen fuehrte dort ins Leere. */}
{betrieb.kann.freigaben_einhaengen && <StorageMounts />}
</section>
)}
{/* APIs */}
{aktiverReiter === 'apis' && (
<section className="space-y-6">
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
<Globe size={20} className="text-amber-500" />
API-Schlüssel
</h2>
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 flex flex-wrap items-center gap-3">
<Button variant="amber" size="sm" onClick={quellenPruefen} disabled={quellenBusy}>
{quellenBusy ? 'Prüfe…' : 'Verbindung prüfen'}
</Button>
{quellenStatus ? (
<div className="flex flex-wrap gap-2">
{Object.entries(quellenStatus).map(([quelle, status]) => (
<span
key={quelle}
className={`text-xs px-2.5 py-1 rounded-full font-medium border ${
status === 'ok' ? 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30'
: status === 'kein_key' ? 'bg-slate-200 dark:bg-slate-800 text-slate-500 border-slate-300 dark:border-slate-700'
: 'bg-rose-500/15 text-rose-600 dark:text-rose-400 border-rose-500/30'
}`}
>
{quelle.toUpperCase()}: {status === 'ok' ? '✓ funktioniert' : status === 'kein_key' ? 'kein Key' : '✗ Fehler'}
</span>
))}
</div>
) : (
<span className="text-xs text-slate-500 dark:text-slate-400">
Prüft live gegen TMDB/OMDb nach dem Speichern eines neuen Keys hier klicken.
</span>
)}
</div>
<div className="space-y-4">
<Input
type="password"
label="TMDB API Key"
value={settings.tmdbApiKey}
onChange={(e) => handleChange('tmdbApiKey', e.target.value)}
placeholder="Dein TMDB API Key"
/>
<Input
type="password"
label="TVDb API Key"
value={settings.tvdbApiKey}
onChange={(e) => handleChange('tvdbApiKey', e.target.value)}
placeholder="Dein TVDb API Key"
/>
<Input
type="password"
label="OMDb API Key (Fallback)"
value={settings.omdbApiKey}
onChange={(e) => handleChange('omdbApiKey', e.target.value)}
placeholder="Dein OMDb API Key"
/>
</div>
</section>
)}
{/* Benachrichtigungen */}
{aktiverReiter === 'benachrichtigungen' && (
<section className="space-y-6">
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
<AlertCircle size={20} className="text-amber-500" />
Webhook-Benachrichtigungen
</h2>
<p className="text-sm text-slate-500 dark:text-slate-400">
Rippy meldet sich, sobald ein Job <strong>fertig</strong> ist, <strong>fehlschlägt</strong> oder <strong>abgebrochen</strong> wird.
</p>
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 space-y-3 text-sm">
<div>
<p className="font-semibold text-slate-900 dark:text-slate-200">💬 Discord (einfachster Weg)</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
Kanal-Zahnrad Integrationen Webhooks Neuer Webhook" → URL kopieren.
</p>
</div>
<div>
<p className="font-semibold text-slate-900 dark:text-slate-200">📱 ntfy (Push aufs Handy)</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
Thema abonnieren und https://ntfy.sh/mein-thema eintragen.
</p>
</div>
</div>
<div className="space-y-2">
<div className="flex gap-2 items-end">
<div className="flex-1">
<Input
label="Webhook URL"
value={settings.notificationWebhook}
onChange={(e) => handleChange('notificationWebhook', e.target.value)}
placeholder="https://discord.com/api/webhooks/… oder https://ntfy.sh/mein-thema"
/>
</div>
<Button
variant="amber"
size="md"
onClick={webhookTesten}
disabled={!settings.notificationWebhook || testBusy}
>
<Send size={15} />
{testBusy ? 'Sende…' : 'Test senden'}
</Button>
</div>
</div>
</section>
)}
{/* System */}
{aktiverReiter === 'system' && (
<section className="space-y-6">
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
<Wrench size={20} className="text-amber-500" />
System &amp; Kern-Werkzeuge
</h2>
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
<p className="text-sm font-semibold mb-2 text-slate-900 dark:text-slate-200">Installierte Werkzeuge</p>
{!systemInfo || systemInfo.workers.length === 0 ? (
<p className="text-sm text-slate-500 dark:text-slate-400">Noch kein Worker gemeldet.</p>
) : (
systemInfo.workers.map(w => (
<div key={w.name} className="flex flex-wrap items-center gap-2 mb-1.5">
<span className="text-xs font-mono text-slate-500 dark:text-slate-400">{w.name}</span>
{w.info?.makemkv && (
<span className="text-xs px-2.5 py-0.5 rounded-md font-medium bg-slate-200 dark:bg-slate-800 text-slate-700 dark:text-slate-300 border border-slate-300 dark:border-slate-700">
MakeMKV {w.info.makemkv}
</span>
)}
{w.info?.handbrake && (
<span className="text-xs px-2.5 py-0.5 rounded-md font-medium bg-slate-200 dark:bg-slate-800 text-slate-700 dark:text-slate-300 border border-slate-300 dark:border-slate-700">
HandBrake {w.info.handbrake}
</span>
)}
{/*
Nur das Verzeichnis des rippenden Workers zählt — deshalb je Worker.
Die Zahl der Disc-Schluessel ist die entscheidende Angabe für 4K-UHD:
0 heißt, dass JEDE unbekannte UHD-Disc scheitert.
*/}
{w.info?.schluessel && w.info.schluessel !== '0' && w.info.schluessel !== 'unbekannt' && (
<span className="text-xs px-2.5 py-0.5 rounded-md font-medium bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border border-emerald-500/30">
{w.info.schluessel} Disc-Schlüssel
</span>
)}
{w.info?.schluessel === '0' && (
<span className="text-xs px-2.5 py-0.5 rounded-md font-medium bg-amber-500/15 text-amber-600 dark:text-amber-400 border border-amber-500/30">
keine Disc-Schlüssel 4K-UHD scheitert
</span>
)}
{w.info?.keydb === 'ja' && (
<span className="text-xs px-2.5 py-0.5 rounded-md font-medium bg-slate-200 dark:bg-slate-800 text-slate-500 dark:text-slate-400 border border-slate-300 dark:border-slate-700">
+ KEYDB.cfg
</span>
)}
</div>
))
)}
{/*
Bis 25.07.2026 stand hier, MakeMKV-Updates braechten die neueste
Disc-Schluessel-Datenbank mit. Nachgemessen: falsch — MakeMKV liefert
gar keine Schluessel mit, es holt sie zur Laufzeit. Und die
Linux-Version holt sie nie (kein einziger Verbindungsversuch, auf
beiden Maschinen verglichen). Deshalb der Schlüsselspeicher unten.
*/}
<p className="text-xs mt-3 pt-3 border-t border-slate-200/60 dark:border-slate-800 text-slate-500 dark:text-slate-400">
<strong className="text-slate-700 dark:text-slate-300">MakeMKV</strong> lässt sich aktuell halten
(Update-Check unten; Version über <code>MAKEMKV_VERSION</code> + Rebuild) neue Versionen bringen
vor allem Laufwerks-Unterstützung und Fehlerbehebungen. <strong>Disc-Schlüssel für 4K-UHD kommen
NICHT aus einem Update</strong> die holt MakeMKV zur Laufzeit, und die Linux-Version tut das
nie. Deshalb der Block Disc-Schlüssel für 4K-UHD" weiter unten.
{' '}<strong className="text-slate-700 dark:text-slate-300">HandBrake</strong> im eingebauten
Docker-Worker ist bewusst die stabile <strong>Debian-Version</strong> — für die Kompression völlig
ausreichend und wird <strong>nicht separat aktualisiert</strong>. Native Worker (Windows) holen
dagegen automatisch die neueste HandBrake-Version.
</p>
</div>
{/*
Der Beta-Key holt sich selbst — man sah es nur nirgends.
Commander am 28.08.2026: „Bezüglich des MKV Beta Keys — der
könnte theoretisch auch automatisch ausgelesen werden, ich
glaube das web rippy kann das." Er hatte recht:
`makemkv_key.refresh_loop()` läuft täglich mit, seit es die
Datei gibt (gemessen: Forum antwortet in 3,3 s, Key mit 62
Zeichen). Hier stand aber nur ein Passwortfeld ob der Wert
darin von Hand kam oder von selbst, war nicht zu erkennen.
Eine Automatik, die man nicht sehen kann, ist für den
Benutzer keine.
*/}
<div className="space-y-2">
<Input
type="password"
label="MakeMKV Beta-Key"
value={settings.makemkvAppKey}
onChange={(e) => handleChange('makemkvAppKey', e.target.value)}
placeholder="T-… (Forum-Thread „MakeMKV is free while in beta”)"
/>
<div className="flex items-center gap-2 flex-wrap">
<Button
variant="secondary"
size="sm"
disabled={keyHolt}
onClick={keyJetztHolen}
>
{keyHolt ? 'wird geholt …' : 'Jetzt aus dem Forum holen'}
</Button>
<span className={`text-xs ${keyMeldung.startsWith('Fehlgeschlagen')
? 'text-red-600 dark:text-red-400'
: 'text-slate-500 dark:text-slate-400'}`}>
{keyMeldung || 'Rippy holt ihn ohnehin täglich selbst — '
+ 'der Key wechselt etwa monatlich.'}
</span>
</div>
</div>
{/*
Schlüsselspeicher — der Hauptweg für 4K-UHD (Befund 25.07.2026,
auf beiden Maschinen gemessen): makemkvcon holt Disc-Schluessel
unter Linux NIE selbst, die Windows-Version tut es (Meldung 3338,
Verbindung nach 185.84.108.20:443). Deshalb wird der Vorrat hier
von Hand hereingereicht. Frueher stand an dieser Stelle die These,
MakeMKVs Schluessel-Kanal sei abgeschaltet — das war falsch.
*/}
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-slate-900 dark:text-slate-200 flex items-center gap-2">
<Database size={16} className="text-amber-500" />
Disc-Schlüssel für 4K-UHD
</p>
<div className="flex items-center gap-2">
<input
type="file"
className="hidden"
ref={keystoreInput}
accept=".tar"
onChange={(e) => {
const datei = e.target.files?.[0]
e.target.value = ''
if (datei) keystoreHochladen(datei)
}}
/>
<Button
variant="amber"
size="sm"
onClick={() => keystoreInput.current?.click()}
disabled={keystoreBusy}
>
<Upload size={14} />
{keystoreBusy ? 'Übernehme…' : '_private_data.tar übernehmen'}
</Button>
</div>
</div>
{keystore?.vorhanden && keystore.schluessel > 0 ? (
<div className="p-3 rounded-lg text-sm bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border border-emerald-500/30">
<p className="font-semibold">{keystore.schluessel} Disc-Schlüssel vorhanden.</p>
<p className="text-xs mt-1">
{bytesLesbar(keystore.groesse_bytes)} · zuletzt geändert {zeitLesbar(keystore.geaendert)}
</p>
</div>
) : (
<div className="p-3 rounded-lg text-sm bg-amber-500/15 text-amber-600 dark:text-amber-400 border border-amber-500/30">
<p className="font-semibold">Kein einziger Disc-Schlüssel vorhanden.</p>
<p className="text-xs mt-1">
DVDs und normale Blu-rays laufen trotzdem. 4K-UHD-Discs scheitern dagegen mit
The volume key is unknown for this disc" — solange hier nichts liegt, jede einzelne.
</p>
</div>
)}
<p className="text-xs text-slate-500 dark:text-slate-400">
<strong className="text-slate-700 dark:text-slate-300">Warum das nötig ist:</strong> MakeMKV
holt sich diese Schlüssel eigentlich selbst aus dem Netz. Die <strong>Linux-Version tut das
nicht</strong> — am 25.07.2026 nachgemessen: sie baut dabei nicht eine einzige Verbindung auf.
Die Windows-Version schon. Ein MakeMKV-Update ändert daran nichts, es ist kein Fehler in Rippy.
</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
<strong className="text-slate-700 dark:text-slate-300">So füllst du den Vorrat:</strong> MakeMKV
auf einem Windows-PC installieren (gleicher Beta-Key), das Laufwerk dort anstecken und die Disc
einmal öffnen. MakeMKV lädt die Schlüssel dabei nach. Danach unter <em>Preferences → General</em>
das „MakeMKV data directory" nachschlagen, die Datei <code>_private_data.tar</code> daraus hier
hochladen fertig. Für neue Discs von Zeit zu Zeit wiederholen.
</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
Das ist der Zwischenspeicher <strong className="text-slate-700 dark:text-slate-300">deiner
eigenen</strong> MakeMKV-Installation, über MakeMKVs offiziellen Weg mit deiner eigenen Lizenz
geholt. Rippy liefert keine Schlüssel mit und lädt keine herunter.
</p>
</div>
{/*
KEYDB.cfg — der NOTNAGEL, nicht der Hauptweg. Sie hilft bei Pressungen,
die auch MakeMKV selbst nicht kennt. Der Regelfall läuft über den
Schlüsselspeicher im Block darüber. Dateiname GROSS geschrieben, unter
Linux case-sensitiv. Rippy stellt nur den Platz bereit.
*/}
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-slate-900 dark:text-slate-200 flex items-center gap-2">
<KeyRound size={16} className="text-amber-500" />
KEYDB.cfg (Notnagel)
</p>
<div className="flex items-center gap-2">
{/*
Versteckter Datei-Dialog: der Inhalt wird im Browser gelesen und als
JSON gepostet. Kein Multipart — der API fehlt python-multipart.
*/}
<input
type="file"
className="hidden"
ref={keydbInput}
accept=".cfg,text/plain"
onChange={(e) => {
const datei = e.target.files?.[0]
// Wert leeren, damit dieselbe Datei erneut gewählt werden kann.
e.target.value = ''
if (datei) keydbHochladen(datei)
}}
/>
<Button
variant="amber"
size="sm"
onClick={() => keydbInput.current?.click()}
disabled={keydbBusy}
>
<Upload size={14} />
{keydbBusy ? 'Arbeite…' : 'KEYDB.cfg hochladen'}
</Button>
{keydb?.vorhanden && (
<Button variant="danger" size="sm" onClick={keydbEntfernen} disabled={keydbBusy}>
<Trash2 size={14} />
Entfernen
</Button>
)}
</div>
</div>
{keydb?.vorhanden ? (
<div className="p-3 rounded-lg text-sm bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border border-emerald-500/30">
<p className="font-semibold">Eine KEYDB.cfg liegt bereit.</p>
<p className="text-xs mt-1">
{bytesLesbar(keydb.groesse_bytes)} · {keydb.eintraege} Zeilen mit Disc-Kennung ·
zuletzt geändert {zeitLesbar(keydb.geaendert)}
</p>
<p className="text-xs mt-1 font-mono opacity-80">{keydb.pfad}</p>
</div>
) : (
<div className="p-3 rounded-lg text-sm bg-slate-200 dark:bg-slate-800 text-slate-500 dark:text-slate-400 border border-slate-300 dark:border-slate-700">
<p className="font-semibold">Keine KEYDB.cfg hinterlegt.</p>
<p className="text-xs mt-1">
Das ist normal und meistens auch nicht nötig der Regelfall läuft über den
Schlüsselspeicher oben.
</p>
</div>
)}
<p className="text-xs text-slate-500 dark:text-slate-400">
Die <strong className="text-slate-700 dark:text-slate-300">KEYDB.cfg</strong> ist eine reine
Textdatei mit Disc-Schlüsseln. Sie ist der <strong className="text-slate-700 dark:text-slate-300">
Notnagel</strong> für den Fall, dass eine Pressung selbst über den Schlüsselspeicher nicht
aufgeht also auch MakeMKV sie nicht kennt. Zuerst immer den Weg darüber versuchen.
</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
<strong className="text-slate-700 dark:text-slate-300">Wichtig:</strong> Rippy liefert keine
Schlüssel mit, lädt keine herunter und verteilt keine. Rippy stellt nur den Platz für eine Datei
bereit, die du selbst mitbringst, und zeigt dir ehrlich an, was dort liegt. Die Zahl oben ist
genau das: die gezählten Zeilen mit einer Disc-Kennung in deiner Datei keine von MakeMKV
bestätigte Anzahl brauchbarer Schlüssel.
</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
Eine hochgeladene Datei ersetzt die bisherige und wirkt
<strong className="text-slate-700 dark:text-slate-300"> ab dem nächsten Rip</strong> laufende
Jobs bleiben unberührt, ein Neustart ist nicht nötig. Die Plakette am Worker weiter oben
folgt erst mit dessen nächstem Herzschlag (etwa eine Minute) dieser Kasten hier ist sofort
aktuell.
</p>
</div>
{/*
AACS-Dumps: MakeMKV legt sie bei einer unbekannten UHD-Disc ab (Meldung 3332).
Seit das Datenverzeichnis persistent gemountet ist, überleben sie den
Container-Neustart — vorher waren sie nach jedem Rip weg.
*/}
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-slate-900 dark:text-slate-200">AACS-Dumps</p>
<Button variant="secondary" size="sm" onClick={keydbLaden}>
Liste aktualisieren
</Button>
</div>
{dumps.length === 0 ? (
<p className="text-xs text-slate-500 dark:text-slate-400">
Noch kein Dump vorhanden. MakeMKV legt hier automatisch einen ab, sobald eine 4K-UHD-Disc
an einem unbekannten Schlüssel scheitert.
</p>
) : (
<div className="space-y-1.5">
{dumps.map(d => (
<a
key={d.name}
href={`/api/system/aacs-dumps/${encodeURIComponent(d.name)}`}
download
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors text-amber-600 dark:text-amber-300 hover:bg-slate-100 dark:hover:bg-slate-800"
>
<Download size={14} className="flex-shrink-0" />
<span className="truncate min-w-0 font-mono">{d.name}</span>
<span className="ml-auto text-xs flex-shrink-0 text-slate-500 dark:text-slate-400 font-mono">
{bytesLesbar(d.groesse_bytes)} · {zeitLesbar(d.geaendert)}
</span>
</a>
))}
</div>
)}
<p className="text-xs text-slate-500 dark:text-slate-400">
Ein AACS-Dump ist das, was das Laufwerk von der Disc gelesen hat, bevor der Schlüssel fehlte.
Er enthält keinen Schlüssel und nützt dir allein nichts aber du kannst ihn herunterladen und
im MakeMKV-Forum im Bereich <strong className="text-slate-700 dark:text-slate-300">Ultra HD
Blu-ray</strong> einreichen. Dort können Leute daraus den Schlüssel für deine Pressung ermitteln;
den trägst du dann in deine KEYDB.cfg ein.
</p>
</div>
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-slate-900 dark:text-slate-200">Update-Check</p>
<Button variant="secondary" size="sm" onClick={updatesPruefen} disabled={updatesBusy}>
{updatesBusy ? 'Prüfe…' : 'Auf Updates prüfen'}
</Button>
</div>
{updates ? (
<div className="space-y-3 text-sm text-slate-700 dark:text-slate-300">
{/* MakeMKV = aktualisierbar → hier ist ein Update ein echtes To-do */}
<div className={updates.makemkv?.update ? 'text-amber-600 dark:text-amber-400' : ''}>
<strong>MakeMKV:</strong> installiert {updates.makemkv?.installiert || '—'}, neueste {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 &amp;&amp; docker compose up -d worker</code>.
{/* 25.07.2026 richtiggestellt: ein Update bringt KEINE Disc-Schluessel mit. */}
Neue Versionen bringen Laufwerks-Unterstützung und Fehlerbehebungen aber
<strong> keine Disc-Schlüssel</strong>: die kommen aus dem Schlüsselspeicher.
</p>
)
: <span className="text-xs ml-1 text-emerald-600 dark:text-emerald-400"> aktuell</span>}
</div>
{/* HandBrake im Docker = bewusst Debian-stabil → KEIN To-do, nur Info */}
<div>
<strong>HandBrake (Docker-Worker):</strong> {updates.handbrake?.installiert || '—'}
<span className="text-xs ml-1 text-slate-500 dark:text-slate-400"> stabile Debian-Version, bewusst so, kein Update nötig</span>
<p className="text-xs mt-1 text-slate-500 dark:text-slate-400">
Neueste offizielle Version wäre {updates.handbrake?.verfuegbar || 'unbekannt'} die nutzen die
<strong> nativen Worker (Windows)</strong> automatisch. Der Docker-Worker bleibt bei der
Debian-Version (für die Kompression identisch, robustere Builds).
</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>
)}
{/* Global Save Button */}
{activeTab !== 'worker' && (
<div className="flex items-center justify-end gap-4 pt-6 border-t border-slate-200/80 dark:border-slate-800">
<Button
variant="amber"
size="lg"
onClick={handleSave}
disabled={saving}
>
{saving ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-2 border-slate-950 border-t-transparent"></div>
Speichern...
</>
) : (
<>
<Save size={18} />
Einstellungen speichern
</>
)}
</Button>
</div>
)}
</CardContent>
</Card>
</div>
)
}