148ac494c2
Ampel / ampel (push) Successful in 29s
Commander-Anforderung: "Die Disc hat Material in X Sprachen und X Untertiteln -
Rippy muss VOR dem Rip fragen: Was genau willst du haben? In der Automatik muss
das ebenfalls einstellbar sein."
Die Auskunft lag laengst vor und wurde weggeworfen: Derselbe Titel-Scan, der die
Titel-Tabelle fuellt, liefert in derselben makemkvcon-Ausgabe die Streams mit.
Ein zweiter Info-Lauf haette eine Minute Wartezeit gekostet - jetzt kommt beides
aus einem Aufruf.
Format an der Akira-Blu-ray im Laufwerk gemessen (AGENTS Regel D):
SINFO:<titel>,<stream>,<attribut>,<code>,"<wert>"
1 = Typ ("Audio"/"Subtitles"), 3 = Sprachcode, 4 = Sprachname,
6 = Codec, 14 = Kanaele, 30 = Beschreibung
DIE FALLE dabei: Die Sprache steht in 3/4, NICHT in 28/29. Die tragen auf JEDEM
Stream "eng"/"English" - auch auf einer deutschen Tonspur und auf dem
Videostream; das ist MakeMKVs eigene Anzeigesprache. Wer 28 nimmt, haelt jede
Disc fuer englisch. Ein Test haelt das fest.
Angewendet wird die Wahl bei der KOMPRESSION, nicht beim Rippen - drei Gruende:
der Rip bleibt vollstaendig und verlustfrei (Muss-Feature laut KONZEPT); HandBrake
hat dafuer dokumentierte Schalter (--audio-lang-list / --subtitle-lang-list, die
genau die ISO-639-2-Codes nehmen, die MakeMKV liefert - beides gegengeprueft);
und wer spaeter andere Sprachen will, komprimiert neu statt die Disc wieder
einzulegen. Genau das sagt der Dialog auch, sonst glaubt man, es werde
unvollstaendig gerippt.
Nichts angeklickt heisst "alles behalten" - das Verhalten von vorher. Auch die
Einstellung ist bewusst LEER vorbelegt: Ein stilles "deu" wuerde bei einem
japanischen Original die Originaltonspur wegwerfen, ohne dass jemand gefragt hat.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1478 lines
73 KiB
TypeScript
1478 lines
73 KiB
TypeScript
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
|
||
// 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: '',
|
||
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: '',
|
||
// 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 [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)
|
||
// 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([]))
|
||
}
|
||
|
||
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 = (() => {
|
||
const ablage = settings.outputDir || '/app/media'
|
||
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 ''
|
||
})()
|
||
|
||
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 & 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 & 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 */}
|
||
{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.
|
||
|
||
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: 28–55 h je Film).'}
|
||
</>
|
||
) : (
|
||
<>
|
||
Kein Worker meldet gerade eine Preset-Liste (alter Worker-Stand oder
|
||
keiner online) — angezeigt wird deshalb eine geprüfte Standardauswahl.
|
||
</>
|
||
)}
|
||
</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 20–100 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.
|
||
*/}
|
||
<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>
|
||
|
||
{/* 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>
|
||
<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>
|
||
|
||
<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 & 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 && 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>
|
||
)
|
||
}
|