Commander: „Das Rippen der disk funktioniert nicht. makemkvcon endete mit
Code 11 — letzte Meldung: Das Öffnen der Disk schlug fehl — keine MKV-Datei
entstanden"
In diesem einen Satz steckten drei Fehler. Alle drei an seinem Laufwerk
gemessen, nicht hergeleitet.
## 1. Die Quellenangabe — das war der Abbruch
dev:\.\G: MSG:2024 „Unknown device" -> MSG:5010 Öffnen schlug fehl
dev:G: 68 TINFO-Zeilen, „Die Aufgabe wurde erfolgreich abgeschlossen"
`f"dev:{device_path}"` stand an VIER Stellen. Unter Linux richtig (/dev/sr0),
unter Windows nicht: `rippy.drives.windows` meldet den Geraete-Namensraum
`\.\G:`, den `CreateFileW` fuer die IOCTLs braucht — MakeMKV kennt ihn
nicht.
## 2. „Ö" statt „Ö"
`Ö` als UTF-8 (C3 96), gelesen als cp1252. `subprocess` stand auf `text=True`
ohne `encoding=`, nahm also die Gebietsschema-Kodierung; Rippy stellt seine
Konsole beim Start aber auf UTF-8 (sonst stirbt der Start an einer
Umlaut-Logzeile). Direkt gemessen kommen die Bytes je nach Umgebung als UTF-8
ODER cp1252 — deshalb wird jetzt bestimmt statt behauptet.
## 3. Die Ursache fehlte im Fehlertext
Gesucht wurde nach ENGLISCHEN Textbausteinen („evaluation period has
expired"). Auf einem deutschen Windows trifft keiner. Jetzt zaehlen die
Meldungs-NUMMERN, die sprachunabhaengig sind.
⚠️ Beim ersten Anlauf hatte ich 5021 aus dem Kopf mit „Volume-Key unbekannt"
beschriftet — falsch. Meine eigene Ausgabe zeigte nur meine Beschriftung
statt MakeMKVs Text, sodass es fast durchgegangen waere. Der echte Wortlaut
steht jetzt im Code.
## Nebenbefund: der Beta-Key lag zweimal am falschen Ort
`makemkv_daten.DATEN_DIR` war fest `/root/.MakeMKV`. Nach der Reparatur des
Ordners blieb MSG:5051 trotzdem stehen — weil MakeMKV unter Windows seine
Einstellungen in der REGISTRY haelt (HKCU\Software\MakeMKV), nicht in einer
settings.conf. Nachgesehen statt vermutet.
Und der wichtigste Teil davon: **Ein abgelehnter Schluessel ist schlimmer als
gar keiner.** Ohne Key las MakeMKV die Disc noch (68 TINFO), mit dem
abgelehnten verweigerte es alles (0 Titel). Deshalb wird jetzt geprueft und
bei Ablehnung zurueckgenommen.
## Beweis
Echter Rip ueber `ripping.run_makemkv`, kuerzester Titel:
Status success, Code 0, „Evangelion 2.22_t03.mkv" 217,9 MB
5036 „Das Kopieren wurde abgeschlossen. 1 Titel wurden gesichert."
828 Tests gruen, ruff sauber.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1656 lines
82 KiB
TypeScript
1656 lines
82 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 { useBetrieb } from '../lib/useBetrieb'
|
||
import { PageHeader } from '../components/ui/PageHeader'
|
||
import { Card, CardHeader, CardTitle, CardContent } from '../components/ui/Card'
|
||
import { Button } from '../components/ui/Button'
|
||
import { Input, Select, Toggle } from '../components/ui/Input'
|
||
import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer'
|
||
import { ENCODER_BADGES } from '../lib/design'
|
||
import { PRESET_KEINE, schwacheEncoderCpu, simdWarnung } from '../lib/encoder'
|
||
|
||
interface SettingsState {
|
||
tmdbApiKey: string
|
||
tvdbApiKey: string
|
||
omdbApiKey: string
|
||
outputDir: string
|
||
movieDir: string
|
||
seriesDir: string
|
||
musicDir: string
|
||
mainFeatureOnly: boolean
|
||
autoEject: boolean
|
||
autoRipStart: boolean
|
||
notificationWebhook: string
|
||
transcodeEnabled: boolean
|
||
// transcodePreset bleibt als Rückfall für Bestandsinstallationen bestehen —
|
||
// solange die drei Felder darunter nicht gespeichert sind, gilt weiter dieser
|
||
// Wert (siehe preset_fuer() in docker/worker/ripping.py).
|
||
transcodePreset: string
|
||
transcodePresetDvd: string
|
||
transcodePresetBluray: string
|
||
transcodePresetUhd: string
|
||
keepOriginal: boolean
|
||
mediaServer: string
|
||
jellyfinUrl: string
|
||
jellyfinApiKey: string
|
||
workDir: string
|
||
makemkvAppKey: string
|
||
// Wunschsprachen (ISO-639-2, Komma-Liste). Greifen bei der Kompression und
|
||
// sind im Rip-Dialog vorausgewählt. Leer = alle Sprachen behalten.
|
||
audioSprachen: string
|
||
untertitelSprachen: string
|
||
}
|
||
|
||
const defaultSettings: SettingsState = {
|
||
tmdbApiKey: '',
|
||
tvdbApiKey: '',
|
||
omdbApiKey: '',
|
||
// Kein fester Container-Pfad: Auf Windows gibt es kein /app/media. Der
|
||
// echte Vorgabewert kommt aus GET /betrieb (ablage_vorgabe) und wird
|
||
// beim Laden gesetzt.
|
||
outputDir: '',
|
||
movieDir: 'movies',
|
||
seriesDir: 'series',
|
||
musicDir: 'music',
|
||
mainFeatureOnly: false,
|
||
autoEject: true,
|
||
autoRipStart: false,
|
||
notificationWebhook: '',
|
||
transcodeEnabled: true,
|
||
transcodePreset: 'H.265 MKV 1080p30',
|
||
transcodePresetDvd: 'H.265 MKV 576p25',
|
||
transcodePresetBluray: 'H.265 MKV 1080p30',
|
||
transcodePresetUhd: 'H.265 MKV 2160p60 4K',
|
||
keepOriginal: false,
|
||
mediaServer: 'none',
|
||
jellyfinUrl: '',
|
||
jellyfinApiKey: '',
|
||
workDir: '',
|
||
makemkvAppKey: '',
|
||
// Leer = alles behalten. Bewusst kein Vorgabewert: Eine stille Vorauswahl
|
||
// („deu") würde bei einem japanischen Original die Originaltonspur wegwerfen,
|
||
// ohne dass jemand danach gefragt hat.
|
||
audioSprachen: '',
|
||
untertitelSprachen: '',
|
||
}
|
||
|
||
type SettingsTab = 'ripping' | 'verarbeitung' | 'worker' | 'speicherziele' | 'apis' | 'benachrichtigungen' | 'system'
|
||
|
||
interface WorkerInfo {
|
||
name: string
|
||
encoders: string[]
|
||
// keydb: "ja" | "nein" | "unbekannt" — sagt, ob DIESER Worker eine KEYDB.cfg
|
||
// in seinem MakeMKV-Datenverzeichnis sieht. Nur der Worker, der wirklich
|
||
// rippt, zählt, deshalb steht die Angabe pro Worker und nicht global.
|
||
// cpu_kerne/cpu_simd: Rechenleistung fürs Software-Encodieren. Ohne diese
|
||
// Angaben war am 25.07.2026 nicht zu sehen, dass ein 4K-Encode auf der
|
||
// Rippy-VM Tage statt Stunden braucht (kein AVX2, 4 Kerne).
|
||
// handbrake_encoder: die ungefilterte Encoder-Auskunft von HandBrake selbst.
|
||
// extern: "ja" = läuft AUSSERHALB des Rippy-Containers, sieht /app/... also
|
||
// nur über eine Freigabe plus pfad_map. Der Worker meldet das selbst.
|
||
info?: {
|
||
makemkv?: string, handbrake?: string, makemkv_key?: string, keydb?: string,
|
||
cpu_modell?: string, cpu_kerne?: string, cpu_simd?: string,
|
||
handbrake_encoder?: string, extern?: string, pfad_map?: string,
|
||
}
|
||
}
|
||
|
||
// Antwort von GET/POST/DELETE /system/keydb (Feldnamen exakt wie die API sie liefert).
|
||
interface KeydbStatus {
|
||
vorhanden: boolean
|
||
pfad: string
|
||
groesse_bytes: number
|
||
eintraege: number
|
||
geaendert: string
|
||
}
|
||
|
||
// Ein Ablageziel aus GET /storage-targets (Feldnamen exakt wie die API liefert).
|
||
interface StorageZiel {
|
||
name: string
|
||
path: string
|
||
is_mount: boolean
|
||
free_gb: number | null
|
||
}
|
||
|
||
// Antwort von GET/POST /system/keystore — MakeMKVs eigener Schlüsselvorrat.
|
||
interface KeystoreStatus {
|
||
vorhanden: boolean
|
||
pfad: string
|
||
groesse_bytes: number
|
||
schluessel: number
|
||
geaendert: string
|
||
}
|
||
|
||
// Ein AACS-Dump aus GET /system/aacs-dumps.
|
||
interface AacsDump {
|
||
name: string
|
||
groesse_bytes: number
|
||
geaendert: string
|
||
}
|
||
|
||
interface SystemInfo {
|
||
api_version: string
|
||
plaetze: { name: string, frei_gb: number, gesamt_gb: number }[]
|
||
workers: WorkerInfo[]
|
||
makemkv_key_ui: boolean
|
||
webhook_gesetzt: boolean
|
||
}
|
||
|
||
// Antwort von GET /presets — die Preset-Namen kommen aus
|
||
// `HandBrakeCLI --preset-list` des jeweiligen Workers, die Empfehlung samt
|
||
// Begründung aus api/presets.py (dort getestet). Bis 26.07.2026 standen die
|
||
// Namen fest verdrahtet in dieser Datei; sie unterscheiden sich aber zwischen
|
||
// HandBrake-Versionen, und ein Name, den das HandBrake des Workers nicht kennt,
|
||
// lässt die Kompression scheitern.
|
||
interface PresetEmpfehlung {
|
||
preset: string
|
||
grund: string
|
||
gefunden: boolean
|
||
}
|
||
|
||
interface PresetTyp {
|
||
empfehlung: PresetEmpfehlung
|
||
auswahl: { name: string, verkleinert: boolean }[]
|
||
}
|
||
|
||
interface PresetUebersicht {
|
||
quelle: 'worker' | 'rueckfall'
|
||
hardware: string[]
|
||
simd_schnell: boolean
|
||
typen: Record<'uhd' | 'bluray' | 'dvd', PresetTyp>
|
||
}
|
||
|
||
// Welches Einstellungs-Feld gehört zu welchem Disc-Typ.
|
||
const PRESET_FELD: Record<'uhd' | 'bluray' | 'dvd', keyof SettingsState> = {
|
||
uhd: 'transcodePresetUhd',
|
||
bluray: 'transcodePresetBluray',
|
||
dvd: 'transcodePresetDvd',
|
||
}
|
||
|
||
// Byte-Zahl menschenlesbar — eine echte KEYDB.cfg ist mehrere MB groß.
|
||
function bytesLesbar(bytes: number): string {
|
||
if (!bytes) return '0 B'
|
||
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||
if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`
|
||
return `${bytes} B`
|
||
}
|
||
|
||
// Die API liefert ISO-8601 in UTC (oder einen leeren String). Ohne Zonen-Kennung
|
||
// würde der Browser den Zeitstempel als Ortszeit lesen und die Uhrzeit um den
|
||
// Zonen-Versatz verschieben — deshalb notfalls ein Z anhaengen.
|
||
function zeitLesbar(iso: string): string {
|
||
if (!iso) return 'unbekannt'
|
||
const mitZone = /[Zz]$|[+-]\d{2}:?\d{2}$/.test(iso) ? iso : `${iso}Z`
|
||
const d = new Date(mitZone)
|
||
return isNaN(d.getTime()) ? 'unbekannt' : d.toLocaleString('de-DE')
|
||
}
|
||
|
||
export default function SettingsPage() {
|
||
const [settings, setSettings] = useState<SettingsState>(defaultSettings)
|
||
const betrieb = useBetrieb()
|
||
const [activeTab, setActiveTab] = useState<SettingsTab>('ripping')
|
||
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
||
const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null)
|
||
const [loading, setLoading] = useState(true)
|
||
const [saving, setSaving] = useState(false)
|
||
const [saved, setSaved] = useState(false)
|
||
const [testBusy, setTestBusy] = useState(false)
|
||
const [quellenStatus, setQuellenStatus] = useState<Record<string, string> | null>(null)
|
||
const [quellenBusy, setQuellenBusy] = useState(false)
|
||
const [updates, setUpdates] = useState<Record<string, { installiert?: string, verfuegbar?: string, update?: boolean }> | null>(null)
|
||
const [updatesBusy, setUpdatesBusy] = useState(false)
|
||
// KEYDB.cfg und AACS-Dumps sind DATEIEN im MakeMKV-Datenverzeichnis, keine
|
||
// Einstellungen — deshalb bewusst NICHT in SettingsState: der globale
|
||
// Speichern-Knopf postet dieses Objekt komplett und würde sie mitschleifen.
|
||
const [keydb, setKeydb] = useState<KeydbStatus | null>(null)
|
||
const [keydbBusy, setKeydbBusy] = useState(false)
|
||
const [dumps, setDumps] = useState<AacsDump[]>([])
|
||
const keydbInput = useRef<HTMLInputElement>(null)
|
||
// Ablageziele unter /app/media inkl. eingehängter Freigaben — speist die
|
||
// Auswahl des Arbeitsverzeichnisses (vorher musste man den Pfad tippen).
|
||
const [ziele, setZiele] = useState<StorageZiel[]>([])
|
||
// Beta-Key: läuft gerade ein Abruf, und was kam zuletzt dabei heraus?
|
||
const [keyHolt, setKeyHolt] = useState(false)
|
||
const [keyMeldung, setKeyMeldung] = useState('')
|
||
const [keystore, setKeystore] = useState<KeystoreStatus | null>(null)
|
||
const [keystoreBusy, setKeystoreBusy] = useState(false)
|
||
const keystoreInput = useRef<HTMLInputElement>(null)
|
||
// Preset-Namen + Empfehlung, beides von der API (siehe PresetUebersicht).
|
||
const [presetInfo, setPresetInfo] = useState<PresetUebersicht | null>(null)
|
||
|
||
const { toast } = useToast()
|
||
|
||
const updatesPruefen = async () => {
|
||
setUpdatesBusy(true)
|
||
try {
|
||
const r = await api.get('/system/updates')
|
||
setUpdates(r.data)
|
||
if (r.data?.makemkv?.update) {
|
||
toast('info', `MakeMKV ${r.data.makemkv.verfuegbar} ist verfügbar (installiert: ${r.data.makemkv.installiert})`)
|
||
} else {
|
||
toast('success', 'Update-Check abgeschlossen')
|
||
}
|
||
} catch {
|
||
toast('error', 'Update-Check fehlgeschlagen (makemkv.com/GitHub nicht erreichbar?)')
|
||
} finally {
|
||
setUpdatesBusy(false)
|
||
}
|
||
}
|
||
|
||
// Status der KEYDB.cfg + Liste der AACS-Dumps frisch holen. Wird beim Laden
|
||
// der Seite und nach jeder Aktion aufgerufen — die API ist die Wahrheit,
|
||
// nicht das, was wir gerade hochgeschickt haben.
|
||
const keydbLaden = () => {
|
||
api.get('/system/keydb').then(r => setKeydb(r.data)).catch(() => setKeydb(null))
|
||
api.get('/system/keystore').then(r => setKeystore(r.data)).catch(() => setKeystore(null))
|
||
api.get('/system/aacs-dumps').then(r => setDumps(r.data?.dumps || [])).catch(() => setDumps([]))
|
||
// Die Worker-Plaketten oben kommen aus /capabilities und stammen aus dem
|
||
// letzten Herzschlag des Workers (minuetlich) — ohne dieses Nachladen
|
||
// widerspraeche die Kachel nach einem Upload sichtbar der Erfolgsmeldung.
|
||
// Ganz sofort ist sie trotzdem nicht: der Worker meldet erst beim
|
||
// nächsten Herzschlag neu. Genau so steht es auch im Erklaertext.
|
||
api.get('/capabilities').then(r => setWorkers(r.data.workers || [])).catch(() => setWorkers([]))
|
||
}
|
||
|
||
/*
|
||
* Den Beta-Key JETZT holen, statt auf die tägliche Schleife zu warten.
|
||
*
|
||
* Der geholte Key wird nicht angezeigt — er wandert in die Einstellungen,
|
||
* und die werden hier ohnehin neu geladen. Zwei Wege zu demselben Wert sind
|
||
* zwei Wege, ihn zu verlieren.
|
||
*/
|
||
const keyJetztHolen = async () => {
|
||
setKeyHolt(true)
|
||
setKeyMeldung('')
|
||
try {
|
||
const r = await api.post('/system/makemkv-key/holen')
|
||
const antwort = await api.get('/settings')
|
||
setSettings({ ...defaultSettings, ...antwort.data })
|
||
if (r.data?.angenommen === false) {
|
||
// Geholt ist nicht angenommen. Am 29.08.2026 lehnte MakeMKV 1.18.4
|
||
// den aktuellen Key ab („Programmversion zu alt") — ohne diesen
|
||
// Hinweis stünde hier ein Erfolg, und der nächste Rip scheiterte
|
||
// trotzdem.
|
||
setKeyMeldung('Fehlgeschlagen: ' + (r.data.grund || 'MakeMKV nimmt den Key nicht an.'))
|
||
toast('error', r.data.grund || 'MakeMKV nimmt den Key nicht an.')
|
||
} else {
|
||
setKeyMeldung(r.data?.geaendert
|
||
? `Neuer Key geholt und angenommen (endet auf …${r.data.endet_auf}).`
|
||
: `Schon aktuell (endet auf …${r.data?.endet_auf || '?'}).`)
|
||
toast('success', 'MakeMKV-Beta-Key aus dem Forum geholt.')
|
||
}
|
||
} catch (e: any) {
|
||
const grund = e?.response?.data?.detail
|
||
|| 'Das Forum ist nicht erreichbar.'
|
||
setKeyMeldung('Fehlgeschlagen: ' + grund)
|
||
toast('error', grund)
|
||
} finally {
|
||
setKeyHolt(false)
|
||
}
|
||
}
|
||
|
||
const keydbHochladen = async (datei: File) => {
|
||
setKeydbBusy(true)
|
||
try {
|
||
// Bewusst als JSON-Text und nicht als Multipart-Upload: der API fehlt
|
||
// python-multipart, ein File()-Endpunkt würde sie beim Import killen.
|
||
const inhalt = await datei.text()
|
||
await api.post('/system/keydb', { inhalt })
|
||
keydbLaden()
|
||
toast('success', 'KEYDB.cfg gespeichert — sie wirkt ab dem nächsten Rip.')
|
||
} catch (e: any) {
|
||
toast('error', e?.response?.data?.detail || 'Hochladen fehlgeschlagen — ist das wirklich eine KEYDB.cfg, und läuft die API?')
|
||
} finally {
|
||
setKeydbBusy(false)
|
||
}
|
||
}
|
||
|
||
const keystoreHochladen = async (datei: File) => {
|
||
setKeystoreBusy(true)
|
||
try {
|
||
// Die Datei wandert als roher Anfrage-Körper zur API — _private_data.tar
|
||
// ist binär, JSON oder Base64 wäre nur unnötiger Ballast, und Multipart
|
||
// kann die API nicht (kein python-multipart).
|
||
await api.post('/system/keystore', datei, {
|
||
headers: { 'Content-Type': 'application/octet-stream' },
|
||
})
|
||
keydbLaden()
|
||
toast('success', 'Schlüsselspeicher übernommen — wirkt ab dem nächsten Rip.')
|
||
} catch (e: any) {
|
||
toast('error', e?.response?.data?.detail
|
||
|| 'Übernahme fehlgeschlagen — ist das wirklich die Datei _private_data.tar, und läuft die API?')
|
||
} finally {
|
||
setKeystoreBusy(false)
|
||
}
|
||
}
|
||
|
||
const keydbEntfernen = async () => {
|
||
setKeydbBusy(true)
|
||
try {
|
||
await api.delete('/system/keydb')
|
||
keydbLaden()
|
||
toast('success', 'KEYDB.cfg entfernt — Rippy nutzt jetzt keine Schlüsseldatei mehr.')
|
||
} catch (e: any) {
|
||
toast('error', e?.response?.data?.detail || 'Entfernen fehlgeschlagen — läuft die API?')
|
||
} finally {
|
||
setKeydbBusy(false)
|
||
}
|
||
}
|
||
|
||
const quellenPruefen = async () => {
|
||
setQuellenBusy(true)
|
||
try {
|
||
const r = await api.get('/metadata/status')
|
||
setQuellenStatus(r.data)
|
||
const kaputt = Object.entries(r.data).filter(([, v]) => v === 'fehler')
|
||
if (kaputt.length === 0) {
|
||
toast('success', 'Metadaten-Quellen geprüft — alles in Ordnung')
|
||
} else {
|
||
toast('error', `Quelle mit Problem: ${kaputt.map(([k]) => k.toUpperCase()).join(', ')}`)
|
||
}
|
||
} catch {
|
||
toast('error', 'Prüfung fehlgeschlagen — läuft die API?')
|
||
} finally {
|
||
setQuellenBusy(false)
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
api.get('/capabilities')
|
||
.then(r => setWorkers(r.data.workers || []))
|
||
.catch(() => setWorkers([]))
|
||
api.get('/system/info')
|
||
.then(r => setSystemInfo(r.data))
|
||
.catch(() => setSystemInfo(null))
|
||
api.get('/system/keydb')
|
||
.then(r => setKeydb(r.data))
|
||
.catch(() => setKeydb(null))
|
||
api.get('/system/keystore')
|
||
.then(r => setKeystore(r.data))
|
||
.catch(() => setKeystore(null))
|
||
api.get('/storage-targets')
|
||
.then(r => setZiele(Array.isArray(r.data) ? r.data : []))
|
||
.catch(() => setZiele([]))
|
||
api.get('/system/aacs-dumps')
|
||
.then(r => setDumps(r.data?.dumps || []))
|
||
.catch(() => setDumps([]))
|
||
api.get('/presets')
|
||
.then(r => setPresetInfo(r.data))
|
||
.catch(() => setPresetInfo(null))
|
||
}, [])
|
||
|
||
// „Bestes automatisch wählen" (Commander-Anforderung 26.07.2026). Setzt alle
|
||
// drei Disc-Typen auf die Empfehlung der API — und nur solche, die wirklich
|
||
// gefunden wurden, damit nie ein Name landet, den HandBrake nicht kennt.
|
||
const bestePresetsUebernehmen = () => {
|
||
if (!presetInfo) return
|
||
const neu: Partial<SettingsState> = {}
|
||
const uebernommen: string[] = []
|
||
for (const typ of ['uhd', 'bluray', 'dvd'] as const) {
|
||
const e = presetInfo.typen[typ]?.empfehlung
|
||
if (!e?.gefunden || !e.preset) continue
|
||
neu[PRESET_FELD[typ]] = e.preset as any
|
||
uebernommen.push(`${typ.toUpperCase()}: ${e.preset === PRESET_KEINE ? 'nicht komprimieren' : e.preset}`)
|
||
}
|
||
if (uebernommen.length === 0) {
|
||
toast('error', 'Kein Preset übernommen — die Worker melden keine bekannten Namen.')
|
||
return
|
||
}
|
||
setSettings(prev => ({ ...prev, ...neu }))
|
||
setSaved(false)
|
||
toast('success', `Übernommen — ${uebernommen.join(' · ')}. Noch speichern!`)
|
||
}
|
||
|
||
// Eine Preset-Auswahl je Disc-Typ. Die Optionen kommen vom WORKER (siehe
|
||
// PresetUebersicht), nicht aus dieser Datei. Der aktuell gespeicherte Wert
|
||
// steht immer mit drin — sonst würde eine Bestandseinstellung beim Öffnen der
|
||
// Seite still auf etwas anderes springen.
|
||
const presetAuswahl = (
|
||
typ: 'uhd' | 'bluray' | 'dvd', label: string, keineText: string,
|
||
) => {
|
||
const feld = PRESET_FELD[typ]
|
||
const wert = (settings[feld] as string) || ''
|
||
const daten = presetInfo?.typen?.[typ]
|
||
const empfehlung = daten?.empfehlung
|
||
const passend = (daten?.auswahl || []).filter(o => !o.verkleinert)
|
||
const kleiner = (daten?.auswahl || []).filter(o => o.verkleinert)
|
||
const bekannt = wert === PRESET_KEINE
|
||
|| (daten?.auswahl || []).some(o => o.name === wert)
|
||
return (
|
||
<div className="space-y-1.5">
|
||
<Select label={label} value={wert} onChange={(e) => handleChange(feld, e.target.value)}>
|
||
<option value={PRESET_KEINE}>{keineText}</option>
|
||
{/* Ein gespeicherter Wert, den dieser Worker nicht kennt, wird
|
||
BENANNT statt verschluckt — genau so fällt eine falsche
|
||
Bestandseinstellung überhaupt auf. */}
|
||
{!bekannt && wert && (
|
||
<option value={wert}>{wert} — gespeichert, dieser Worker kennt es nicht</option>
|
||
)}
|
||
{passend.length > 0 && (
|
||
<optgroup label="Passt zur Auflösung">
|
||
{passend.map(o => (
|
||
<option key={o.name} value={o.name}>
|
||
{o.name}{empfehlung?.gefunden && empfehlung.preset === o.name ? ' ★ empfohlen' : ''}
|
||
</option>
|
||
))}
|
||
</optgroup>
|
||
)}
|
||
{kleiner.length > 0 && (
|
||
<optgroup label="Bewusst verkleinern — die 4K-Auflösung ist danach weg">
|
||
{kleiner.map(o => <option key={o.name} value={o.name}>{o.name}</option>)}
|
||
</optgroup>
|
||
)}
|
||
</Select>
|
||
{empfehlung && empfehlung.preset !== wert && (
|
||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||
{empfehlung.gefunden ? (
|
||
<>
|
||
Empfohlen wäre{' '}
|
||
<strong className="text-slate-700 dark:text-slate-300">
|
||
{empfehlung.preset === PRESET_KEINE ? 'nicht komprimieren' : empfehlung.preset}
|
||
</strong>
|
||
{' — '}{empfehlung.grund}
|
||
</>
|
||
) : empfehlung.grund}
|
||
</p>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const webhookTesten = async () => {
|
||
if (!settings.notificationWebhook) return
|
||
setTestBusy(true)
|
||
try {
|
||
await api.post('/notifications/test', { url: settings.notificationWebhook })
|
||
toast('success', 'Test-Nachricht gesendet — prüfe deinen Kanal!')
|
||
} catch (e: any) {
|
||
toast('error', e?.response?.data?.detail || 'Test fehlgeschlagen')
|
||
} finally {
|
||
setTestBusy(false)
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
const loadSettings = async () => {
|
||
try {
|
||
const response = await api.get('/settings')
|
||
setSettings({ ...defaultSettings, ...response.data })
|
||
} catch {
|
||
setSettings(defaultSettings)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
loadSettings()
|
||
}, [])
|
||
|
||
const handleChange = (field: keyof SettingsState, value: any) => {
|
||
setSettings(prev => ({ ...prev, [field]: value }))
|
||
setSaved(false)
|
||
}
|
||
|
||
const handleSave = async () => {
|
||
setSaving(true)
|
||
try {
|
||
await api.post('/settings', settings)
|
||
setSaved(true)
|
||
toast('success', 'Einstellungen gespeichert')
|
||
setTimeout(() => setSaved(false), 3000)
|
||
} catch {
|
||
toast('error', 'Speichern fehlgeschlagen — läuft die API?')
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
}
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="flex items-center justify-center min-h-[60vh]">
|
||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-amber-500"></div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Erreichbarkeit für EXTERNE Encoder. Ein Worker auf einem anderen Rechner
|
||
// sieht die Container-Platte nicht — nur eingehängte Netzwerk-Freigaben, die
|
||
// er selbst auch erreicht. Liegen Ablage und Arbeitsverzeichnis nicht BEIDE
|
||
// auf einer solchen Freigabe, scheitert die Kompression dort zwangsläufig.
|
||
//
|
||
// Genau das ist am 25.07.2026 passiert: Rohdaten auf der NAS (erreichbar),
|
||
// Ziel auf der VM-Platte (nicht erreichbar) — der Fehler kam erst nach einer
|
||
// Stunde Rippen. Diese Prüfung sagt es vorher.
|
||
// Der Worker sagt selbst, ob er außerhalb des Containers läuft (caps.py
|
||
// meldet `extern`) — keine Heuristik über IPs oder Namen nötig.
|
||
const externerWorkerVorhanden = workers.some(w => w.info?.extern === 'ja')
|
||
const istFreigabe = (pfad: string) =>
|
||
ziele.some(z => z.path === pfad && z.is_mount)
|
||
const ablageWarnung = (() => {
|
||
// ⚠️ Die ganze Warnung dreht sich um einen Encoder auf einem ANDEREN
|
||
// Rechner, der die Container-Platte nicht sieht. Ohne externe Worker gibt
|
||
// es weder den einen noch die andere — der Satz stand am 28.08.2026
|
||
// trotzdem im Windows-Fenster und riet dort zu etwas, das es nicht gibt.
|
||
if (!betrieb.kann.externe_worker) return ''
|
||
const ablage = settings.outputDir || betrieb.ablage_vorgabe
|
||
const arbeit = settings.workDir
|
||
if (!arbeit) {
|
||
return 'Das Arbeitsverzeichnis steht auf der Container-Platte. Ein Encoder auf '
|
||
+ 'einem anderen Rechner kann die Rohdaten dort nicht lesen — lege beides auf '
|
||
+ 'dieselbe Netzwerk-Freigabe (Einstellungen → Verarbeitung).'
|
||
}
|
||
if (!istFreigabe(ablage) && istFreigabe(arbeit)) {
|
||
return 'Arbeitsverzeichnis liegt auf einer Freigabe, die Ablage aber auf der '
|
||
+ 'Container-Platte. Ein externer Encoder kann dann lesen, aber nicht schreiben — '
|
||
+ 'genau daran scheiterte der erste Versuch. Stelle die Ablage auf dieselbe Freigabe.'
|
||
}
|
||
if (istFreigabe(ablage) && istFreigabe(arbeit) && ablage !== arbeit) {
|
||
return 'Ablage und Arbeitsverzeichnis liegen auf VERSCHIEDENEN Freigaben. Das '
|
||
+ 'funktioniert, kostet am Ende aber eine Vollkopie statt eines Umhängens. '
|
||
+ 'Dieselbe Freigabe für beides ist deutlich schneller.'
|
||
}
|
||
return ''
|
||
})()
|
||
|
||
/*
|
||
* Die Reiter haengen am BETRIEB.
|
||
*
|
||
* Commander-Befund 28.08.2026: „Das gilt fuer die ganze standalone version
|
||
* fuer Windows, auch fuer die settings und die Anleitung usw."
|
||
*
|
||
* Ein Reiter „Worker" fuehrt in einer Standalone-Installation zu einer
|
||
* Maske, die Rechner in einem Netz verwaltet, das es nicht gibt. Ein Reiter
|
||
* „Speicherziele" bietet dort das Einhaengen von Freigaben an — etwas, das
|
||
* unter Windows niemand braucht (dort gibt man einen Ordner oder UNC-Pfad
|
||
* an). Beides ist keine kosmetische Frage: Wer darauf klickt, bekommt
|
||
* Bedienelemente, die ins Leere fuehren.
|
||
*/
|
||
const tabs: { id: SettingsTab; label: string; icon: any }[] = [
|
||
{ id: 'ripping', label: 'Ripping', icon: Disc },
|
||
{ id: 'verarbeitung', label: 'Verarbeitung', icon: Cpu },
|
||
...(betrieb.kann.externe_worker
|
||
? [{ id: 'worker' as SettingsTab, label: 'Worker', icon: Cpu }]
|
||
: []),
|
||
// Speicherziele bleibt IMMER. Hier steht die Ablage — „wo will ich das
|
||
// hinspeichern" war die ausdrueckliche Frage des Commanders. Nur das
|
||
// EINHAENGEN von Freigaben faellt weg, wenn der Betrieb es nicht kann;
|
||
// das steckt weiter unten in einer eigenen Bedingung.
|
||
{ id: 'speicherziele', label: 'Speicherziele', icon: HardDrive },
|
||
{ id: 'apis', label: 'APIs', icon: Globe },
|
||
{ id: 'benachrichtigungen', label: 'Benachrichtigungen', icon: AlertCircle },
|
||
{ id: 'system', label: 'System', icon: Wrench },
|
||
]
|
||
|
||
/*
|
||
* Ein Reiter, den es nicht mehr gibt, darf nicht ausgewaehlt bleiben.
|
||
*
|
||
* Der Betrieb kommt ERST NACH dem ersten Rendern an (ein Abruf). Wer in der
|
||
* Zwischenzeit auf „Worker" steht — oder wer den Reiter beim Neuladen aus
|
||
* dem Zustand mitbringt — saehe sonst eine leere Seite ohne jeden Hinweis.
|
||
*/
|
||
const sichtbareReiter = tabs.map(t => t.id)
|
||
const aktiverReiter = sichtbareReiter.includes(activeTab) ? activeTab : 'ripping'
|
||
|
||
return (
|
||
<div className="max-w-6xl mx-auto space-y-6">
|
||
<PageHeader
|
||
title="Einstellungen"
|
||
subtitle="Konfiguriere Ripping-Parameter, Speicherziele, APIs und Benachrichtigungen"
|
||
/>
|
||
|
||
{saved && (
|
||
<div className="p-4 rounded-xl flex items-center gap-3 font-medium bg-emerald-500/15 border border-emerald-500/30 text-emerald-600 dark:text-emerald-400">
|
||
<CheckCircle className="flex-shrink-0" size={22} />
|
||
<span>Einstellungen erfolgreich gespeichert!</span>
|
||
</div>
|
||
)}
|
||
|
||
<Card className="overflow-hidden">
|
||
{/* Navigation Tabs */}
|
||
<div className="flex border-b border-slate-800 overflow-x-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden bg-[#0c101d]">
|
||
{tabs.map((tab) => {
|
||
const isActive = aktiverReiter === tab.id
|
||
return (
|
||
<button
|
||
key={tab.id}
|
||
onClick={() => setActiveTab(tab.id)}
|
||
className={`px-5 py-3.5 text-sm font-medium transition-all flex items-center gap-2 border-b-2 whitespace-nowrap ${
|
||
isActive
|
||
? 'border-amber-500 text-amber-400 bg-[#131929] font-bold'
|
||
: 'border-transparent text-slate-400 hover:text-slate-200 hover:bg-slate-900/50'
|
||
}`}
|
||
>
|
||
<tab.icon size={17} className={isActive ? 'text-amber-400' : ''} />
|
||
<span>{tab.label}</span>
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
{/* Tab Content */}
|
||
<CardContent className="p-6 space-y-8">
|
||
{/* Ripping Settings */}
|
||
{aktiverReiter === 'ripping' && (
|
||
<section className="space-y-6">
|
||
<div>
|
||
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||
<Disc size={20} className="text-amber-500" />
|
||
Ripping-Einstellungen
|
||
</h2>
|
||
</div>
|
||
|
||
<div className="space-y-4">
|
||
<p className="text-xs font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500">
|
||
📀 Video — DVD · Blu-ray · 4K UHD
|
||
</p>
|
||
|
||
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
|
||
<Toggle
|
||
label="Vollautomatik: sofort rippen"
|
||
description="Disc erkannt → Rip startet ohne Nachfrage in den passenden Ordner."
|
||
checked={settings.autoRipStart}
|
||
onChange={(v) => handleChange('autoRipStart', v)}
|
||
/>
|
||
</div>
|
||
|
||
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
|
||
<Toggle
|
||
label="Nur Hauptfeature"
|
||
description="Rippe bei DVDs/Blu-rays nur das Hauptfeature (längster Titel)."
|
||
checked={settings.mainFeatureOnly}
|
||
onChange={(v) => handleChange('mainFeatureOnly', v)}
|
||
/>
|
||
</div>
|
||
|
||
<p className="text-xs text-slate-500 dark:text-slate-400 px-1">
|
||
🌐 Sprachen & 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 */}
|
||
{aktiverReiter === 'verarbeitung' && (
|
||
<section className="space-y-6">
|
||
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||
<Cpu size={20} className="text-amber-500" />
|
||
Verarbeitung (Kompression)
|
||
</h2>
|
||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||
MakeMKV rippt verlustfrei, HandBrake komprimiert danach auf Arbeitsgröße.
|
||
</p>
|
||
|
||
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
|
||
<p className="text-sm font-medium mb-2 text-slate-900 dark:text-slate-200">Verfügbare Encoder</p>
|
||
{workers.length === 0 ? (
|
||
<p className="text-sm text-slate-500 dark:text-slate-400">Kein Worker gemeldet.</p>
|
||
) : (
|
||
workers.map(w => (
|
||
<div key={w.name} className="mb-1">
|
||
<span className="text-xs font-mono mr-2 text-slate-500 dark:text-slate-400">{w.name}: </span>
|
||
{w.encoders.map(e => (
|
||
<span key={e} className={`inline-block text-xs px-2.5 py-0.5 rounded-md font-medium border mr-1 ${
|
||
e.startsWith('cpu')
|
||
? 'bg-slate-200 dark:bg-slate-800 text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-700'
|
||
: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30'
|
||
}`}>
|
||
{ENCODER_BADGES[e]?.label || e}
|
||
</span>
|
||
))}
|
||
{(w.info?.cpu_kerne || w.info?.cpu_simd) && (
|
||
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400" title={w.info?.cpu_modell || ''}>
|
||
{w.info?.cpu_modell ? `${w.info.cpu_modell} · ` : ''}
|
||
{w.info?.cpu_kerne ? `${w.info.cpu_kerne} Kerne` : ''}
|
||
{w.info?.cpu_kerne && w.info?.cpu_simd ? ' · ' : ''}
|
||
{w.info?.cpu_simd ? `Vektorbefehle ${w.info.cpu_simd}` : ''}
|
||
</p>
|
||
)}
|
||
{simdWarnung(w.info?.cpu_simd) && (
|
||
<p className="mt-1 text-xs text-amber-600 dark:text-amber-400">
|
||
{simdWarnung(w.info?.cpu_simd)}
|
||
</p>
|
||
)}
|
||
{w.info?.handbrake_encoder && (
|
||
<p className="mt-1 text-xs text-slate-400 dark:text-slate-500 font-mono break-all">
|
||
HandBrake meldet: {w.info.handbrake_encoder}
|
||
</p>
|
||
)}
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
|
||
<div className="space-y-4">
|
||
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
|
||
<Toggle
|
||
label="Nach dem Rip komprimieren"
|
||
description="x265 statt 40-GB-Rohdatei (empfohlen)."
|
||
checked={settings.transcodeEnabled}
|
||
onChange={(v) => handleChange('transcodeEnabled', v)}
|
||
/>
|
||
</div>
|
||
|
||
{/*
|
||
Ein Preset JE DISC-TYP (25.07.2026). Vorher lief alles durch
|
||
dasselbe Preset — eine 4K-UHD wurde damit auf 1080p
|
||
heruntergerechnet und eine DVD auf 1080p hochskaliert.
|
||
|
||
Seit 26.07.2026 kommen die NAMEN vom Worker selbst
|
||
(`HandBrakeCLI --preset-list`, siehe worker/caps.py) und die
|
||
Empfehlung von GET /presets. Vorher standen sie hier fest
|
||
verdrahtet — sie unterscheiden sich aber je HandBrake-Version,
|
||
und ein Name, den das HandBrake des Workers nicht kennt, lässt
|
||
die Kompression scheitern.
|
||
*/}
|
||
<div className="space-y-3">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||
Ein Preset je Quelle — Rippy erkennt den Disc-Typ selbst und wählt passend aus.
|
||
<strong className="text-slate-700 dark:text-slate-300"> Eine 4K-UHD gehört auf ein
|
||
2160p-Preset</strong>, sonst rechnet HandBrake sie auf 1080p herunter und die
|
||
4K-Auflösung ist weg.
|
||
</p>
|
||
<Button
|
||
variant="secondary"
|
||
onClick={bestePresetsUebernehmen}
|
||
disabled={!presetInfo}
|
||
className="flex-shrink-0"
|
||
>
|
||
Bestes wählen
|
||
</Button>
|
||
</div>
|
||
|
||
{presetInfo && (
|
||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||
{presetInfo.quelle === 'worker' ? (
|
||
<>
|
||
Die Liste kommt von deinen Workern selbst.
|
||
{presetInfo.hardware.length > 0
|
||
? ` Hardware-Encoder gemeldet: ${presetInfo.hardware.join(', ')} — damit geht 4K in Minuten statt Tagen.`
|
||
: presetInfo.simd_schnell
|
||
? ' Kein Hardware-Encoder, aber ein Worker mit AVX2 — Software-H.265 ist damit schnell genug.'
|
||
: ' Kein Hardware-Encoder und kein AVX2 — hier ist Software-4K keine Option (gemessen: 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.
|
||
*/}
|
||
{!betrieb.kann.container_pfade ? (
|
||
/*
|
||
* Ein echtes Pfadfeld statt einer Auswahl — dieselbe
|
||
* Entscheidung wie bei der Ablage darüber, und aus demselben
|
||
* Grund.
|
||
*
|
||
* Commander am 28.08.2026: „Wäre es möglich das
|
||
* Arbeitsverzeichnis zu ändern? momentan geht das nicht."
|
||
*
|
||
* Es ging nicht, weil `ziele` auf Windows LEER war: Die Liste
|
||
* kommt aus /storage-targets, und das las `/app/media` — ein
|
||
* Ordner, den es dort nicht gibt. Übrig blieb eine Auswahl mit
|
||
* genau einem Eintrag, und der hieß „Container-Platte".
|
||
*
|
||
* Die Liste ist inzwischen repariert (sie bringt jetzt die
|
||
* Laufwerke mit freiem Platz). Aber die richtige Antwort auf
|
||
* „wohin mit 100 GB" ist auf Windows oft ein Ort, den keine
|
||
* Liste kennt — D:\Rippy-Arbeit oder eine UNC-Freigabe.
|
||
* Deshalb hier ein Feld, in das man ihn schreiben kann.
|
||
*/
|
||
<Input
|
||
label="Arbeitsverzeichnis für Roh-Rips (Standard)"
|
||
value={settings.workDir}
|
||
placeholder={betrieb.arbeits_vorgabe}
|
||
onChange={(e) => handleChange('workDir', e.target.value)}
|
||
/>
|
||
) : (
|
||
<Select
|
||
label="Arbeitsverzeichnis für Roh-Rips (Standard)"
|
||
value={settings.workDir}
|
||
onChange={(e) => handleChange('workDir', e.target.value)}
|
||
>
|
||
<option value="">
|
||
Container-Platte (Standard) — klein, nur für DVD/Blu-ray
|
||
</option>
|
||
{ziele.map(z => (
|
||
<option key={z.path} value={z.path}>
|
||
{z.name}{z.is_mount ? ' (Netzwerk-Freigabe)' : ''}
|
||
{z.free_gb != null ? ` — ${z.free_gb} GB frei` : ''}
|
||
</option>
|
||
))}
|
||
</Select>
|
||
)}
|
||
{!betrieb.kann.container_pfade && !!ziele.length && (
|
||
/* Die Laufwerke als Ein-Klick-Wahl. Wer den Platz sieht,
|
||
wählt anders — genau daran ist am 25.07. eine Platte
|
||
vollgelaufen. */
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{ziele.map(z => (
|
||
<button
|
||
key={z.path}
|
||
onClick={() => handleChange('workDir', z.path)}
|
||
className={`px-2.5 py-1 text-xs rounded-lg border transition-colors ${
|
||
settings.workDir === z.path
|
||
? 'border-amber-500 bg-amber-500/10 text-amber-700 dark:text-amber-300'
|
||
: 'border-slate-200 dark:border-slate-700 text-slate-500 dark:text-slate-400 hover:border-slate-400'
|
||
}`}
|
||
>
|
||
{z.name}{z.free_gb != null ? ` — ${z.free_gb} GB frei` : ''}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||
<strong className="text-slate-700 dark:text-slate-300">Der Standard</strong> — beim
|
||
„Rippen starten" kannst du für jede Disc etwas anderes wählen. <strong className="text-slate-700 dark:text-slate-300">
|
||
Läuft die Vollautomatik</strong>, fragt dich niemand: dann gilt genau dieser Wert.
|
||
Wichtig für 4K-UHD: Rohdaten sind bis 100 GB groß.
|
||
{betrieb.kann.container_pfade
|
||
? ' Auf die Container-Platte passen die selten. Am besten dieselbe Freigabe wie das Ziel — dann muss Rippy die Rohdatei am Ende nur umhängen statt sie zu kopieren.'
|
||
: ' Am besten dasselbe Laufwerk wie die Ablage — dann muss Rippy die Rohdatei am Ende nur umhängen statt sie über die Platte zu kopieren.'}
|
||
{systemInfo?.plaetze?.length ? (
|
||
<> Aktuell frei: {systemInfo.plaetze.map(p => `${p.name}: ${p.frei_gb} GB`).join(' · ')}</>
|
||
) : null}
|
||
</p>
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{/* Worker */}
|
||
{aktiverReiter === 'worker' && (
|
||
<section className="space-y-6">
|
||
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||
<Cpu size={20} className="text-amber-500" />
|
||
Encoding-Worker
|
||
</h2>
|
||
<WorkerVerwaltung />
|
||
</section>
|
||
)}
|
||
|
||
{/* Speicherziele */}
|
||
{aktiverReiter === 'speicherziele' && (
|
||
<section className="space-y-6">
|
||
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||
<HardDrive size={20} className="text-amber-500" />
|
||
Speicherziele
|
||
</h2>
|
||
|
||
{/* ABLAGE — bis 26.07.2026 gab es dieses Feld im UI GAR NICHT.
|
||
outputDir stand in den Einstellungen und wurde von der
|
||
Vollautomatik und der Schnellwahl gelesen, ließ sich aber
|
||
nirgends ändern: die Ablage klebte auf /app/media, also der
|
||
Container-Platte. Deshalb konnte man das Arbeitsverzeichnis auf
|
||
eine NAS legen (dafür gab es eine Auswahl), das ZIEL aber nicht
|
||
— und genau daran scheiterte der erste Rip mit externem
|
||
Encoder: Rohdaten auf der erreichbaren NAS, Ziel auf der
|
||
VM-Platte, die der andere Rechner nicht sieht. */}
|
||
<div className="p-5 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 space-y-3">
|
||
<h3 className="font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||
<HardDrive size={16} className="text-amber-500" /> Ablage — wohin die fertigen Filme kommen
|
||
</h3>
|
||
{!betrieb.kann.container_pfade ? (
|
||
/* Kein Auswahlfeld, sondern ein echtes Pfadfeld: Auf einem
|
||
Windows-PC gibt es keine Liste von Container-Zielen, aus
|
||
der man waehlen koennte — es gibt Laufwerke, Ordner und
|
||
UNC-Pfade. Eine Auswahl mit einem Eintrag waere eine
|
||
Bedienung, die nichts bedient. */
|
||
<Input
|
||
label="Ordner"
|
||
value={settings.outputDir}
|
||
placeholder={betrieb.ablage_vorgabe}
|
||
onChange={(e) => handleChange('outputDir', e.target.value)}
|
||
/>
|
||
) : (
|
||
<Select
|
||
label="Ablage"
|
||
value={settings.outputDir}
|
||
onChange={(e) => handleChange('outputDir', e.target.value)}
|
||
>
|
||
<option value="/app/media">Container-Platte (Standard)</option>
|
||
{ziele.filter(z => z.path !== '/app/media').map(z => (
|
||
<option key={z.path} value={z.path}>
|
||
{z.name}{z.is_mount ? ' (Netzwerk-Freigabe)' : ''}
|
||
{z.free_gb != null ? ` — ${z.free_gb} GB frei` : ''}
|
||
</option>
|
||
))}
|
||
</Select>
|
||
)}
|
||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||
Gilt für die Schnellwahl im Rip-Dialog <strong className="text-slate-700 dark:text-slate-300">und
|
||
für die Vollautomatik</strong> — dort fragt niemand nach, also entscheidet dieser Wert.
|
||
Die Unterordner darunter kommen aus der Schnellwahl.
|
||
</p>
|
||
{externerWorkerVorhanden && ablageWarnung && (
|
||
<p className="text-xs p-3 rounded-lg bg-amber-500/10 border border-amber-500/30 text-amber-700 dark:text-amber-300">
|
||
⚠️ {ablageWarnung}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
<div className="p-5 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 space-y-3">
|
||
<h3 className="font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||
<Database size={16} className="text-amber-500" /> Schnellwahl beim Rippen
|
||
</h3>
|
||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||
Name der Ordner im „Rippen starten"-Dialog (🎥/📺/🎵) — jeweils unterhalb der Ablage.
|
||
</p>
|
||
<div className="grid grid-cols-3 gap-3">
|
||
{([['movieDir', '🎥 Filme'], ['seriesDir', '📺 Serien'], ['musicDir', '🎵 Musik']] as const).map(([feld, label]) => (
|
||
<Input
|
||
key={feld}
|
||
label={label}
|
||
value={settings[feld]}
|
||
onChange={(e) => handleChange(feld, e.target.value)}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Freigaben EINHAENGEN kann nur der Container-Betrieb.
|
||
Unter Windows gibt man einen Ordner oder einen UNC-Pfad an —
|
||
eine Maske zum Einhaengen fuehrte dort ins Leere. */}
|
||
{betrieb.kann.freigaben_einhaengen && <StorageMounts />}
|
||
</section>
|
||
)}
|
||
|
||
{/* APIs */}
|
||
{aktiverReiter === 'apis' && (
|
||
<section className="space-y-6">
|
||
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||
<Globe size={20} className="text-amber-500" />
|
||
API-Schlüssel
|
||
</h2>
|
||
|
||
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 flex flex-wrap items-center gap-3">
|
||
<Button variant="amber" size="sm" onClick={quellenPruefen} disabled={quellenBusy}>
|
||
{quellenBusy ? 'Prüfe…' : 'Verbindung prüfen'}
|
||
</Button>
|
||
{quellenStatus ? (
|
||
<div className="flex flex-wrap gap-2">
|
||
{Object.entries(quellenStatus).map(([quelle, status]) => (
|
||
<span
|
||
key={quelle}
|
||
className={`text-xs px-2.5 py-1 rounded-full font-medium border ${
|
||
status === 'ok' ? 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30'
|
||
: status === 'kein_key' ? 'bg-slate-200 dark:bg-slate-800 text-slate-500 border-slate-300 dark:border-slate-700'
|
||
: 'bg-rose-500/15 text-rose-600 dark:text-rose-400 border-rose-500/30'
|
||
}`}
|
||
>
|
||
{quelle.toUpperCase()}: {status === 'ok' ? '✓ funktioniert' : status === 'kein_key' ? 'kein Key' : '✗ Fehler'}
|
||
</span>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||
Prüft live gegen TMDB/OMDb — nach dem Speichern eines neuen Keys hier klicken.
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
<div className="space-y-4">
|
||
<Input
|
||
type="password"
|
||
label="TMDB API Key"
|
||
value={settings.tmdbApiKey}
|
||
onChange={(e) => handleChange('tmdbApiKey', e.target.value)}
|
||
placeholder="Dein TMDB API Key"
|
||
/>
|
||
<Input
|
||
type="password"
|
||
label="TVDb API Key"
|
||
value={settings.tvdbApiKey}
|
||
onChange={(e) => handleChange('tvdbApiKey', e.target.value)}
|
||
placeholder="Dein TVDb API Key"
|
||
/>
|
||
<Input
|
||
type="password"
|
||
label="OMDb API Key (Fallback)"
|
||
value={settings.omdbApiKey}
|
||
onChange={(e) => handleChange('omdbApiKey', e.target.value)}
|
||
placeholder="Dein OMDb API Key"
|
||
/>
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{/* Benachrichtigungen */}
|
||
{aktiverReiter === 'benachrichtigungen' && (
|
||
<section className="space-y-6">
|
||
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||
<AlertCircle size={20} className="text-amber-500" />
|
||
Webhook-Benachrichtigungen
|
||
</h2>
|
||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||
Rippy meldet sich, sobald ein Job <strong>fertig</strong> ist, <strong>fehlschlägt</strong> oder <strong>abgebrochen</strong> wird.
|
||
</p>
|
||
|
||
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 space-y-3 text-sm">
|
||
<div>
|
||
<p className="font-semibold text-slate-900 dark:text-slate-200">💬 Discord (einfachster Weg)</p>
|
||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||
Kanal-Zahnrad → Integrationen → Webhooks → „Neuer Webhook" → URL kopieren.
|
||
</p>
|
||
</div>
|
||
<div>
|
||
<p className="font-semibold text-slate-900 dark:text-slate-200">📱 ntfy (Push aufs Handy)</p>
|
||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||
Thema abonnieren und https://ntfy.sh/mein-thema eintragen.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<div className="flex gap-2 items-end">
|
||
<div className="flex-1">
|
||
<Input
|
||
label="Webhook URL"
|
||
value={settings.notificationWebhook}
|
||
onChange={(e) => handleChange('notificationWebhook', e.target.value)}
|
||
placeholder="https://discord.com/api/webhooks/… oder https://ntfy.sh/mein-thema"
|
||
/>
|
||
</div>
|
||
<Button
|
||
variant="amber"
|
||
size="md"
|
||
onClick={webhookTesten}
|
||
disabled={!settings.notificationWebhook || testBusy}
|
||
>
|
||
<Send size={15} />
|
||
{testBusy ? 'Sende…' : 'Test senden'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{/* System */}
|
||
{aktiverReiter === 'system' && (
|
||
<section className="space-y-6">
|
||
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||
<Wrench size={20} className="text-amber-500" />
|
||
System & Kern-Werkzeuge
|
||
</h2>
|
||
|
||
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
|
||
<p className="text-sm font-semibold mb-2 text-slate-900 dark:text-slate-200">Installierte Werkzeuge</p>
|
||
{!systemInfo || systemInfo.workers.length === 0 ? (
|
||
<p className="text-sm text-slate-500 dark:text-slate-400">Noch kein Worker gemeldet.</p>
|
||
) : (
|
||
systemInfo.workers.map(w => (
|
||
<div key={w.name} className="flex flex-wrap items-center gap-2 mb-1.5">
|
||
<span className="text-xs font-mono text-slate-500 dark:text-slate-400">{w.name}</span>
|
||
{w.info?.makemkv && (
|
||
<span className="text-xs px-2.5 py-0.5 rounded-md font-medium bg-slate-200 dark:bg-slate-800 text-slate-700 dark:text-slate-300 border border-slate-300 dark:border-slate-700">
|
||
MakeMKV {w.info.makemkv}
|
||
</span>
|
||
)}
|
||
{w.info?.handbrake && (
|
||
<span className="text-xs px-2.5 py-0.5 rounded-md font-medium bg-slate-200 dark:bg-slate-800 text-slate-700 dark:text-slate-300 border border-slate-300 dark:border-slate-700">
|
||
HandBrake {w.info.handbrake}
|
||
</span>
|
||
)}
|
||
{/*
|
||
Nur das Verzeichnis des rippenden Workers zählt — deshalb je Worker.
|
||
Die Zahl der Disc-Schluessel ist die entscheidende Angabe für 4K-UHD:
|
||
0 heißt, dass JEDE unbekannte UHD-Disc scheitert.
|
||
*/}
|
||
{w.info?.schluessel && w.info.schluessel !== '0' && w.info.schluessel !== 'unbekannt' && (
|
||
<span className="text-xs px-2.5 py-0.5 rounded-md font-medium bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border border-emerald-500/30">
|
||
{w.info.schluessel} Disc-Schlüssel
|
||
</span>
|
||
)}
|
||
{w.info?.schluessel === '0' && (
|
||
<span className="text-xs px-2.5 py-0.5 rounded-md font-medium bg-amber-500/15 text-amber-600 dark:text-amber-400 border border-amber-500/30">
|
||
keine Disc-Schlüssel — 4K-UHD scheitert
|
||
</span>
|
||
)}
|
||
{w.info?.keydb === 'ja' && (
|
||
<span className="text-xs px-2.5 py-0.5 rounded-md font-medium bg-slate-200 dark:bg-slate-800 text-slate-500 dark:text-slate-400 border border-slate-300 dark:border-slate-700">
|
||
+ KEYDB.cfg
|
||
</span>
|
||
)}
|
||
</div>
|
||
))
|
||
)}
|
||
{/*
|
||
Bis 25.07.2026 stand hier, MakeMKV-Updates braechten die neueste
|
||
Disc-Schluessel-Datenbank mit. Nachgemessen: falsch — MakeMKV liefert
|
||
gar keine Schluessel mit, es holt sie zur Laufzeit. Und die
|
||
Linux-Version holt sie nie (kein einziger Verbindungsversuch, auf
|
||
beiden Maschinen verglichen). Deshalb der Schlüsselspeicher unten.
|
||
*/}
|
||
<p className="text-xs mt-3 pt-3 border-t border-slate-200/60 dark:border-slate-800 text-slate-500 dark:text-slate-400">
|
||
<strong className="text-slate-700 dark:text-slate-300">MakeMKV</strong> lässt sich aktuell halten
|
||
(Update-Check unten; Version über <code>MAKEMKV_VERSION</code> + Rebuild) — neue Versionen bringen
|
||
vor allem Laufwerks-Unterstützung und Fehlerbehebungen. <strong>Disc-Schlüssel für 4K-UHD kommen
|
||
NICHT aus einem Update</strong> — die holt MakeMKV zur Laufzeit, und die Linux-Version tut das
|
||
nie. Deshalb der Block „Disc-Schlüssel für 4K-UHD" weiter unten.
|
||
{' '}<strong className="text-slate-700 dark:text-slate-300">HandBrake</strong> im eingebauten
|
||
Docker-Worker ist bewusst die stabile <strong>Debian-Version</strong> — für die Kompression völlig
|
||
ausreichend und wird <strong>nicht separat aktualisiert</strong>. Native Worker (Windows) holen
|
||
dagegen automatisch die neueste HandBrake-Version.
|
||
</p>
|
||
</div>
|
||
|
||
{/*
|
||
Der Beta-Key holt sich selbst — man sah es nur nirgends.
|
||
|
||
Commander am 28.08.2026: „Bezüglich des MKV Beta Keys — der
|
||
könnte theoretisch auch automatisch ausgelesen werden, ich
|
||
glaube das web rippy kann das." Er hatte recht:
|
||
`makemkv_key.refresh_loop()` läuft täglich mit, seit es die
|
||
Datei gibt (gemessen: Forum antwortet in 3,3 s, Key mit 62
|
||
Zeichen). Hier stand aber nur ein Passwortfeld — ob der Wert
|
||
darin von Hand kam oder von selbst, war nicht zu erkennen.
|
||
Eine Automatik, die man nicht sehen kann, ist für den
|
||
Benutzer keine.
|
||
*/}
|
||
<div className="space-y-2">
|
||
<Input
|
||
type="password"
|
||
label="MakeMKV Beta-Key"
|
||
value={settings.makemkvAppKey}
|
||
onChange={(e) => handleChange('makemkvAppKey', e.target.value)}
|
||
placeholder="T-… (Forum-Thread „MakeMKV is free while in beta”)"
|
||
/>
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<Button
|
||
variant="secondary"
|
||
size="sm"
|
||
disabled={keyHolt}
|
||
onClick={keyJetztHolen}
|
||
>
|
||
{keyHolt ? 'wird geholt …' : 'Jetzt aus dem Forum holen'}
|
||
</Button>
|
||
<span className={`text-xs ${keyMeldung.startsWith('Fehlgeschlagen')
|
||
? 'text-red-600 dark:text-red-400'
|
||
: 'text-slate-500 dark:text-slate-400'}`}>
|
||
{keyMeldung || 'Rippy holt ihn ohnehin täglich selbst — '
|
||
+ 'der Key wechselt etwa monatlich.'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/*
|
||
Schlüsselspeicher — der Hauptweg für 4K-UHD (Befund 25.07.2026,
|
||
auf beiden Maschinen gemessen): makemkvcon holt Disc-Schluessel
|
||
unter Linux NIE selbst, die Windows-Version tut es (Meldung 3338,
|
||
Verbindung nach 185.84.108.20:443). Deshalb wird der Vorrat hier
|
||
von Hand hereingereicht. Frueher stand an dieser Stelle die These,
|
||
MakeMKVs Schluessel-Kanal sei abgeschaltet — das war falsch.
|
||
*/}
|
||
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<p className="text-sm font-semibold text-slate-900 dark:text-slate-200 flex items-center gap-2">
|
||
<Database size={16} className="text-amber-500" />
|
||
Disc-Schlüssel für 4K-UHD
|
||
</p>
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
type="file"
|
||
className="hidden"
|
||
ref={keystoreInput}
|
||
accept=".tar"
|
||
onChange={(e) => {
|
||
const datei = e.target.files?.[0]
|
||
e.target.value = ''
|
||
if (datei) keystoreHochladen(datei)
|
||
}}
|
||
/>
|
||
<Button
|
||
variant="amber"
|
||
size="sm"
|
||
onClick={() => keystoreInput.current?.click()}
|
||
disabled={keystoreBusy}
|
||
>
|
||
<Upload size={14} />
|
||
{keystoreBusy ? 'Übernehme…' : '_private_data.tar übernehmen'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{keystore?.vorhanden && keystore.schluessel > 0 ? (
|
||
<div className="p-3 rounded-lg text-sm bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border border-emerald-500/30">
|
||
<p className="font-semibold">{keystore.schluessel} Disc-Schlüssel vorhanden.</p>
|
||
<p className="text-xs mt-1">
|
||
{bytesLesbar(keystore.groesse_bytes)} · zuletzt geändert {zeitLesbar(keystore.geaendert)}
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<div className="p-3 rounded-lg text-sm bg-amber-500/15 text-amber-600 dark:text-amber-400 border border-amber-500/30">
|
||
<p className="font-semibold">Kein einziger Disc-Schlüssel vorhanden.</p>
|
||
<p className="text-xs mt-1">
|
||
DVDs und normale Blu-rays laufen trotzdem. 4K-UHD-Discs scheitern dagegen mit
|
||
„The volume key is unknown for this disc" — solange hier nichts liegt, jede einzelne.
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||
<strong className="text-slate-700 dark:text-slate-300">Warum das nötig ist:</strong> MakeMKV
|
||
holt sich diese Schlüssel eigentlich selbst aus dem Netz. Die <strong>Linux-Version tut das
|
||
nicht</strong> — am 25.07.2026 nachgemessen: sie baut dabei nicht eine einzige Verbindung auf.
|
||
Die Windows-Version schon. Ein MakeMKV-Update ändert daran nichts, es ist kein Fehler in Rippy.
|
||
</p>
|
||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||
<strong className="text-slate-700 dark:text-slate-300">So füllst du den Vorrat:</strong> MakeMKV
|
||
auf einem Windows-PC installieren (gleicher Beta-Key), das Laufwerk dort anstecken und die Disc
|
||
einmal öffnen. MakeMKV lädt die Schlüssel dabei nach. Danach unter <em>Preferences → General</em>
|
||
das „MakeMKV data directory" nachschlagen, die Datei <code>_private_data.tar</code> daraus hier
|
||
hochladen — fertig. Für neue Discs von Zeit zu Zeit wiederholen.
|
||
</p>
|
||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||
Das ist der Zwischenspeicher <strong className="text-slate-700 dark:text-slate-300">deiner
|
||
eigenen</strong> MakeMKV-Installation, über MakeMKVs offiziellen Weg mit deiner eigenen Lizenz
|
||
geholt. Rippy liefert keine Schlüssel mit und lädt keine herunter.
|
||
</p>
|
||
</div>
|
||
|
||
{/*
|
||
KEYDB.cfg — der NOTNAGEL, nicht der Hauptweg. Sie hilft bei Pressungen,
|
||
die auch MakeMKV selbst nicht kennt. Der Regelfall läuft über den
|
||
Schlüsselspeicher im Block darüber. Dateiname GROSS geschrieben, unter
|
||
Linux case-sensitiv. Rippy stellt nur den Platz bereit.
|
||
*/}
|
||
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<p className="text-sm font-semibold text-slate-900 dark:text-slate-200 flex items-center gap-2">
|
||
<KeyRound size={16} className="text-amber-500" />
|
||
KEYDB.cfg (Notnagel)
|
||
</p>
|
||
<div className="flex items-center gap-2">
|
||
{/*
|
||
Versteckter Datei-Dialog: der Inhalt wird im Browser gelesen und als
|
||
JSON gepostet. Kein Multipart — der API fehlt python-multipart.
|
||
*/}
|
||
<input
|
||
type="file"
|
||
className="hidden"
|
||
ref={keydbInput}
|
||
accept=".cfg,text/plain"
|
||
onChange={(e) => {
|
||
const datei = e.target.files?.[0]
|
||
// Wert leeren, damit dieselbe Datei erneut gewählt werden kann.
|
||
e.target.value = ''
|
||
if (datei) keydbHochladen(datei)
|
||
}}
|
||
/>
|
||
<Button
|
||
variant="amber"
|
||
size="sm"
|
||
onClick={() => keydbInput.current?.click()}
|
||
disabled={keydbBusy}
|
||
>
|
||
<Upload size={14} />
|
||
{keydbBusy ? 'Arbeite…' : 'KEYDB.cfg hochladen'}
|
||
</Button>
|
||
{keydb?.vorhanden && (
|
||
<Button variant="danger" size="sm" onClick={keydbEntfernen} disabled={keydbBusy}>
|
||
<Trash2 size={14} />
|
||
Entfernen
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{keydb?.vorhanden ? (
|
||
<div className="p-3 rounded-lg text-sm bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border border-emerald-500/30">
|
||
<p className="font-semibold">Eine KEYDB.cfg liegt bereit.</p>
|
||
<p className="text-xs mt-1">
|
||
{bytesLesbar(keydb.groesse_bytes)} · {keydb.eintraege} Zeilen mit Disc-Kennung ·
|
||
zuletzt geändert {zeitLesbar(keydb.geaendert)}
|
||
</p>
|
||
<p className="text-xs mt-1 font-mono opacity-80">{keydb.pfad}</p>
|
||
</div>
|
||
) : (
|
||
<div className="p-3 rounded-lg text-sm bg-slate-200 dark:bg-slate-800 text-slate-500 dark:text-slate-400 border border-slate-300 dark:border-slate-700">
|
||
<p className="font-semibold">Keine KEYDB.cfg hinterlegt.</p>
|
||
<p className="text-xs mt-1">
|
||
Das ist normal und meistens auch nicht nötig — der Regelfall läuft über den
|
||
Schlüsselspeicher oben.
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||
Die <strong className="text-slate-700 dark:text-slate-300">KEYDB.cfg</strong> ist eine reine
|
||
Textdatei mit Disc-Schlüsseln. Sie ist der <strong className="text-slate-700 dark:text-slate-300">
|
||
Notnagel</strong> für den Fall, dass eine Pressung selbst über den Schlüsselspeicher nicht
|
||
aufgeht — also auch MakeMKV sie nicht kennt. Zuerst immer den Weg darüber versuchen.
|
||
</p>
|
||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||
<strong className="text-slate-700 dark:text-slate-300">Wichtig:</strong> Rippy liefert keine
|
||
Schlüssel mit, lädt keine herunter und verteilt keine. Rippy stellt nur den Platz für eine Datei
|
||
bereit, die du selbst mitbringst, und zeigt dir ehrlich an, was dort liegt. Die Zahl oben ist
|
||
genau das: die gezählten Zeilen mit einer Disc-Kennung in deiner Datei — keine von MakeMKV
|
||
bestätigte Anzahl brauchbarer Schlüssel.
|
||
</p>
|
||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||
Eine hochgeladene Datei ersetzt die bisherige und wirkt
|
||
<strong className="text-slate-700 dark:text-slate-300"> ab dem nächsten Rip</strong> — laufende
|
||
Jobs bleiben unberührt, ein Neustart ist nicht nötig. Die Plakette am Worker weiter oben
|
||
folgt erst mit dessen nächstem Herzschlag (etwa eine Minute) — dieser Kasten hier ist sofort
|
||
aktuell.
|
||
</p>
|
||
</div>
|
||
|
||
{/*
|
||
AACS-Dumps: MakeMKV legt sie bei einer unbekannten UHD-Disc ab (Meldung 3332).
|
||
Seit das Datenverzeichnis persistent gemountet ist, überleben sie den
|
||
Container-Neustart — vorher waren sie nach jedem Rip weg.
|
||
*/}
|
||
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<p className="text-sm font-semibold text-slate-900 dark:text-slate-200">AACS-Dumps</p>
|
||
<Button variant="secondary" size="sm" onClick={keydbLaden}>
|
||
Liste aktualisieren
|
||
</Button>
|
||
</div>
|
||
{dumps.length === 0 ? (
|
||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||
Noch kein Dump vorhanden. MakeMKV legt hier automatisch einen ab, sobald eine 4K-UHD-Disc
|
||
an einem unbekannten Schlüssel scheitert.
|
||
</p>
|
||
) : (
|
||
<div className="space-y-1.5">
|
||
{dumps.map(d => (
|
||
<a
|
||
key={d.name}
|
||
href={`/api/system/aacs-dumps/${encodeURIComponent(d.name)}`}
|
||
download
|
||
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors text-amber-600 dark:text-amber-300 hover:bg-slate-100 dark:hover:bg-slate-800"
|
||
>
|
||
<Download size={14} className="flex-shrink-0" />
|
||
<span className="truncate min-w-0 font-mono">{d.name}</span>
|
||
<span className="ml-auto text-xs flex-shrink-0 text-slate-500 dark:text-slate-400 font-mono">
|
||
{bytesLesbar(d.groesse_bytes)} · {zeitLesbar(d.geaendert)}
|
||
</span>
|
||
</a>
|
||
))}
|
||
</div>
|
||
)}
|
||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||
Ein AACS-Dump ist das, was das Laufwerk von der Disc gelesen hat, bevor der Schlüssel fehlte.
|
||
Er enthält keinen Schlüssel und nützt dir allein nichts — aber du kannst ihn herunterladen und
|
||
im MakeMKV-Forum im Bereich <strong className="text-slate-700 dark:text-slate-300">Ultra HD
|
||
Blu-ray</strong> einreichen. Dort können Leute daraus den Schlüssel für deine Pressung ermitteln;
|
||
den trägst du dann in deine KEYDB.cfg ein.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<p className="text-sm font-semibold text-slate-900 dark:text-slate-200">Update-Check</p>
|
||
<Button variant="secondary" size="sm" onClick={updatesPruefen} disabled={updatesBusy}>
|
||
{updatesBusy ? 'Prüfe…' : 'Auf Updates prüfen'}
|
||
</Button>
|
||
</div>
|
||
{updates ? (
|
||
<div className="space-y-3 text-sm text-slate-700 dark:text-slate-300">
|
||
{/* MakeMKV = aktualisierbar → hier ist ein Update ein echtes To-do */}
|
||
<div className={updates.makemkv?.update ? 'text-amber-600 dark:text-amber-400' : ''}>
|
||
<strong>MakeMKV:</strong> installiert {updates.makemkv?.installiert || '—'}, neueste {updates.makemkv?.verfuegbar || 'unbekannt'}
|
||
{updates.makemkv?.update
|
||
? (
|
||
<p className="text-xs mt-1">
|
||
⚠️ Update verfügbar — in der .env <code>MAKEMKV_VERSION={updates.makemkv.verfuegbar}</code> setzen,
|
||
dann auf der Rippy-Maschine <code>docker compose build worker && 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>
|
||
)
|
||
}
|