Files
rippy/docker/ui/src/pages/Settings.tsx
T
Hitonabi 35cfcbcb07
Ampel / ampel (push) Successful in 27s
style: echte Umlaute im ganzen Projekt + Installer im Rippy-Look
Commander-Rueckmeldung: Umlaute fehlen. Ausloeser war sichtbar der Installer -
"Diese Maschine uebernimmt die Video-Kompression fuer Rippy" stand woertlich im
Screenshot.

## Die .ps1-Falle war loesbar, nicht unumgehbar

Bisher galt: ausgelieferte .ps1 MUESSEN ASCII sein, weil PowerShell 5.1 sie
ohne BOM als ANSI liest. Das ist nur die halbe Wahrheit - gemessen mit echtem
powershell.exe 5.1:

    ohne BOM:  $s = "Größe: äöü"   + Unerwartetes Token -> Skript kaputt
    mit  BOM:  Groesse: aeoeue         + laeuft, Length 16 korrekt

Alle drei Skripte sind jetzt UTF-8 MIT BOM und tragen echte Umlaute; unter 5.1
gegengeprueft (BOM vorhanden, Parser fehlerfrei, Text korrekt gelesen). Der
Kopfkommentar sagt das jetzt richtig statt "ASCII-only".

## Umstellung: Text ja, Bezeichner nein

Umlaute gehoeren in Kommentare und Anzeigetexte, nicht in Funktionsnamen oder
Datenschluessel. Deshalb je Sprache das passende Werkzeug:

- Python: ueber den TOKENIZER - angefasst wurden ausschliesslich COMMENT- und
  STRING-Tokens. 156 Stellen. Code ist damit garantiert unberuehrt.
- TypeScript: nur // und /* */ Kommentare sowie JSX-Text (kann per Definition
  kein Bezeichner sein). 24 Stellen. `const waehlen`, `let laeuft`, `plaetze`,
  `GeraetInfo` sind nachweislich unversehrt.
- install.sh: Anzeigetext, aber die Shell-Funktionen (gruen/rot/gelb/titel) und
  der Schalter --nur-pruefen bleiben ASCII - das sind Schnittstellen.
- Markdown: 0 Aenderungen, die Doku hatte schon Umlaute.

ZWEI FEHLER MEINES KONVERTERS, beide von Werkzeugen gefangen:

1. In f-Strings steht in {...} CODE, kein Text. Aus f"{groesse}" wurde
   f"{größe}", waehrend die Variable groesse hiess - Ruff meldete F821
   "Undefined name". Der Konverter lagert Einsetzungen jetzt aus.
2. Ein Dict-Schluessel wurde umbenannt: die Wortliste enthaelt das PRAEFIX
   "uebersprung", der Tabu-Schutz prueft aber ganze Woerter. bericht[...] ist
   wieder ASCII - Umlaute in Datenschluesseln brechen JSON-Runden und DB-Felder.

## Installer im Rippy-Look

Statt hellgrau jetzt dieselben Toene wie das Web-UI (aus lib/design.ts
uebernommen): slate-900 Flaeche, dunkle Eingabefelder, Amber-Hauptknopf wie
"Los geht's" im Wizard. Oben ein Kopfbereich mit dem Farbverlauf
amber -> indigo -> purple und dem Disc-Symbol - in WinForms per Paint-Ereignis
gezeichnet, weil es dort keine Verlaeufe von der Stange gibt.

Geprueft, nicht gehofft: Das Fenster wurde headless in ein PNG gerendert
(DrawToBitmap) und angesehen - Verlauf, Symbol, Umlaute und Farben sitzen.
RippyWorkerSetup.exe neu gebaut (57344 -> 60416 Bytes); Umlaute und das
requireAdministrator-Manifest sind in der .exe verifiziert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 22:37:23 +02:00

1224 lines
61 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { 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
}
const defaultSettings: SettingsState = {
tmdbApiKey: '',
tvdbApiKey: '',
omdbApiKey: '',
outputDir: '/app/media',
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: '',
}
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.
info?: {
makemkv?: string, handbrake?: string, makemkv_key?: string, keydb?: string,
cpu_modell?: string, cpu_kerne?: string, cpu_simd?: string,
handbrake_encoder?: 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
}
// 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 [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[]>([])
const [keystore, setKeystore] = useState<KeystoreStatus | null>(null)
const [keystoreBusy, setKeystoreBusy] = useState(false)
const keystoreInput = useRef<HTMLInputElement>(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([]))
}
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([]))
}, [])
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>
)
}
const tabs: { id: SettingsTab; label: string; icon: any }[] = [
{ id: 'ripping', label: 'Ripping', icon: Disc },
{ id: 'verarbeitung', label: 'Verarbeitung', icon: Cpu },
{ id: 'worker', label: 'Worker', icon: Cpu },
{ id: 'speicherziele', label: 'Speicherziele', icon: HardDrive },
{ id: 'apis', label: 'APIs', icon: Globe },
{ id: 'benachrichtigungen', label: 'Benachrichtigungen', icon: AlertCircle },
{ id: 'system', label: 'System', icon: Wrench },
]
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 = activeTab === 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 */}
{activeTab === '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="http://192.168.178.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 */}
{activeTab === '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.
Preset-Namen stammen aus `HandBrakeCLI --preset-list` im
Worker-Image (HandBrake 1.6.1), nicht aus dem Kopf.
*/}
<div className="space-y-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>
<Select
label="Preset für 4K-UHD"
value={settings.transcodePresetUhd}
onChange={(e) => handleChange('transcodePresetUhd', e.target.value)}
>
<option value={PRESET_KEINE}>Nicht komprimieren 4K verlustfrei behalten</option>
<option value="H.265 MKV 2160p60 4K">H.265 MKV 2160p60 4K 4K erhalten</option>
<option value="HQ 2160p60 4K HEVC Surround">HQ 2160p60 4K HEVC Surround 4K, mehr Qualität</option>
<option value="Super HQ 2160p60 4K HEVC Surround">Super HQ 2160p60 4K HEVC Surround 4K, größer</option>
<option value="H.265 MKV 1080p30">H.265 MKV 1080p30 bewusst auf 1080p verkleinern</option>
</Select>
{/* 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>
)}
<Select
label="Preset für Blu-ray"
value={settings.transcodePresetBluray}
onChange={(e) => handleChange('transcodePresetBluray', e.target.value)}
>
<option value={PRESET_KEINE}>Nicht komprimieren — verlustfrei behalten</option>
<option value="H.265 MKV 1080p30">H.265 MKV 1080p30 — klein &amp; modern (empfohlen)</option>
<option value="HQ 1080p30 Surround">HQ 1080p30 Surround — H.264, maximale Kompatibilität</option>
<option value="Super HQ 1080p30 Surround">Super HQ 1080p30 Surround — größer, beste Qualität</option>
</Select>
<Select
label="Preset für DVD"
value={settings.transcodePresetDvd}
onChange={(e) => handleChange('transcodePresetDvd', e.target.value)}
>
<option value={PRESET_KEINE}>Nicht komprimieren — verlustfrei behalten</option>
<option value="H.265 MKV 576p25">H.265 MKV 576p25 — PAL-DVD, native Auflösung (empfohlen)</option>
<option value="H.265 MKV 480p30">H.265 MKV 480p30 — NTSC-DVD, native Auflösung</option>
<option value="HQ 576p25 Surround">HQ 576p25 Surround — H.264, maximale Kompatibilität</option>
<option value="H.265 MKV 1080p30">H.265 MKV 1080p30 — hochskalieren (bringt nichts)</option>
</Select>
<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>
<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.
*/}
<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>
<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ß und passen selten auf die
Container-Platte. Am besten dieselbe Freigabe wie das Ziel — dann muss Rippy die
Rohdatei am Ende nur umhängen statt sie zu kopieren.
{systemInfo?.plaetze?.length ? (
<> Aktuell frei: {systemInfo.plaetze.map(p => `${p.name}: ${p.frei_gb} GB`).join(' · ')}</>
) : null}
</p>
</div>
</section>
)}
{/* Worker */}
{activeTab === '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 */}
{activeTab === '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>
<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 (🎥/📺/🎵).
</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>
<StorageMounts />
</section>
)}
{/* APIs */}
{activeTab === '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 */}
{activeTab === '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 */}
{activeTab === '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>
<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)"
/>
{/*
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>
)
}