35cfcbcb07
Ampel / ampel (push) Successful in 27s
Commander-Rueckmeldung: Umlaute fehlen. Ausloeser war sichtbar der Installer -
"Diese Maschine uebernimmt die Video-Kompression fuer Rippy" stand woertlich im
Screenshot.
## Die .ps1-Falle war loesbar, nicht unumgehbar
Bisher galt: ausgelieferte .ps1 MUESSEN ASCII sein, weil PowerShell 5.1 sie
ohne BOM als ANSI liest. Das ist nur die halbe Wahrheit - gemessen mit echtem
powershell.exe 5.1:
ohne BOM: $s = "Größe: äöü" + Unerwartetes Token -> Skript kaputt
mit BOM: Groesse: aeoeue + laeuft, Length 16 korrekt
Alle drei Skripte sind jetzt UTF-8 MIT BOM und tragen echte Umlaute; unter 5.1
gegengeprueft (BOM vorhanden, Parser fehlerfrei, Text korrekt gelesen). Der
Kopfkommentar sagt das jetzt richtig statt "ASCII-only".
## Umstellung: Text ja, Bezeichner nein
Umlaute gehoeren in Kommentare und Anzeigetexte, nicht in Funktionsnamen oder
Datenschluessel. Deshalb je Sprache das passende Werkzeug:
- Python: ueber den TOKENIZER - angefasst wurden ausschliesslich COMMENT- und
STRING-Tokens. 156 Stellen. Code ist damit garantiert unberuehrt.
- TypeScript: nur // und /* */ Kommentare sowie JSX-Text (kann per Definition
kein Bezeichner sein). 24 Stellen. `const waehlen`, `let laeuft`, `plaetze`,
`GeraetInfo` sind nachweislich unversehrt.
- install.sh: Anzeigetext, aber die Shell-Funktionen (gruen/rot/gelb/titel) und
der Schalter --nur-pruefen bleiben ASCII - das sind Schnittstellen.
- Markdown: 0 Aenderungen, die Doku hatte schon Umlaute.
ZWEI FEHLER MEINES KONVERTERS, beide von Werkzeugen gefangen:
1. In f-Strings steht in {...} CODE, kein Text. Aus f"{groesse}" wurde
f"{größe}", waehrend die Variable groesse hiess - Ruff meldete F821
"Undefined name". Der Konverter lagert Einsetzungen jetzt aus.
2. Ein Dict-Schluessel wurde umbenannt: die Wortliste enthaelt das PRAEFIX
"uebersprung", der Tabu-Schutz prueft aber ganze Woerter. bericht[...] ist
wieder ASCII - Umlaute in Datenschluesseln brechen JSON-Runden und DB-Felder.
## Installer im Rippy-Look
Statt hellgrau jetzt dieselben Toene wie das Web-UI (aus lib/design.ts
uebernommen): slate-900 Flaeche, dunkle Eingabefelder, Amber-Hauptknopf wie
"Los geht's" im Wizard. Oben ein Kopfbereich mit dem Farbverlauf
amber -> indigo -> purple und dem Disc-Symbol - in WinForms per Paint-Ereignis
gezeichnet, weil es dort keine Verlaeufe von der Stange gibt.
Geprueft, nicht gehofft: Das Fenster wurde headless in ein PNG gerendert
(DrawToBitmap) und angesehen - Verlauf, Symbol, Umlaute und Farben sitzen.
RippyWorkerSetup.exe neu gebaut (57344 -> 60416 Bytes); Umlaute und das
requireAdministrator-Manifest sind in der .exe verifiziert.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1224 lines
61 KiB
TypeScript
1224 lines
61 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
|
||
}
|
||
|
||
const defaultSettings: SettingsState = {
|
||
tmdbApiKey: '',
|
||
tvdbApiKey: '',
|
||
omdbApiKey: '',
|
||
outputDir: '/app/media',
|
||
movieDir: 'movies',
|
||
seriesDir: 'series',
|
||
musicDir: 'music',
|
||
mainFeatureOnly: false,
|
||
autoEject: true,
|
||
autoRipStart: false,
|
||
notificationWebhook: '',
|
||
transcodeEnabled: true,
|
||
transcodePreset: 'H.265 MKV 1080p30',
|
||
transcodePresetDvd: 'H.265 MKV 576p25',
|
||
transcodePresetBluray: 'H.265 MKV 1080p30',
|
||
transcodePresetUhd: 'H.265 MKV 2160p60 4K',
|
||
keepOriginal: false,
|
||
mediaServer: 'none',
|
||
jellyfinUrl: '',
|
||
jellyfinApiKey: '',
|
||
workDir: '',
|
||
makemkvAppKey: '',
|
||
}
|
||
|
||
type SettingsTab = 'ripping' | 'verarbeitung' | 'worker' | 'speicherziele' | 'apis' | 'benachrichtigungen' | 'system'
|
||
|
||
interface WorkerInfo {
|
||
name: string
|
||
encoders: string[]
|
||
// keydb: "ja" | "nein" | "unbekannt" — sagt, ob DIESER Worker eine KEYDB.cfg
|
||
// in seinem MakeMKV-Datenverzeichnis sieht. Nur der Worker, der wirklich
|
||
// rippt, zählt, deshalb steht die Angabe pro Worker und nicht global.
|
||
// cpu_kerne/cpu_simd: Rechenleistung fürs Software-Encodieren. Ohne diese
|
||
// Angaben war am 25.07.2026 nicht zu sehen, dass ein 4K-Encode auf der
|
||
// Rippy-VM Tage statt Stunden braucht (kein AVX2, 4 Kerne).
|
||
// handbrake_encoder: die ungefilterte Encoder-Auskunft von HandBrake selbst.
|
||
info?: {
|
||
makemkv?: string, handbrake?: string, makemkv_key?: string, keydb?: string,
|
||
cpu_modell?: string, cpu_kerne?: string, cpu_simd?: string,
|
||
handbrake_encoder?: string,
|
||
}
|
||
}
|
||
|
||
// Antwort von GET/POST/DELETE /system/keydb (Feldnamen exakt wie die API sie liefert).
|
||
interface KeydbStatus {
|
||
vorhanden: boolean
|
||
pfad: string
|
||
groesse_bytes: number
|
||
eintraege: number
|
||
geaendert: string
|
||
}
|
||
|
||
// Ein Ablageziel aus GET /storage-targets (Feldnamen exakt wie die API liefert).
|
||
interface StorageZiel {
|
||
name: string
|
||
path: string
|
||
is_mount: boolean
|
||
free_gb: number | null
|
||
}
|
||
|
||
// Antwort von GET/POST /system/keystore — MakeMKVs eigener Schlüsselvorrat.
|
||
interface KeystoreStatus {
|
||
vorhanden: boolean
|
||
pfad: string
|
||
groesse_bytes: number
|
||
schluessel: number
|
||
geaendert: string
|
||
}
|
||
|
||
// Ein AACS-Dump aus GET /system/aacs-dumps.
|
||
interface AacsDump {
|
||
name: string
|
||
groesse_bytes: number
|
||
geaendert: string
|
||
}
|
||
|
||
interface SystemInfo {
|
||
api_version: string
|
||
plaetze: { name: string, frei_gb: number, gesamt_gb: number }[]
|
||
workers: WorkerInfo[]
|
||
makemkv_key_ui: boolean
|
||
webhook_gesetzt: boolean
|
||
}
|
||
|
||
// Byte-Zahl menschenlesbar — eine echte KEYDB.cfg ist mehrere MB groß.
|
||
function bytesLesbar(bytes: number): string {
|
||
if (!bytes) return '0 B'
|
||
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||
if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`
|
||
return `${bytes} B`
|
||
}
|
||
|
||
// Die API liefert ISO-8601 in UTC (oder einen leeren String). Ohne Zonen-Kennung
|
||
// würde der Browser den Zeitstempel als Ortszeit lesen und die Uhrzeit um den
|
||
// Zonen-Versatz verschieben — deshalb notfalls ein Z anhaengen.
|
||
function zeitLesbar(iso: string): string {
|
||
if (!iso) return 'unbekannt'
|
||
const mitZone = /[Zz]$|[+-]\d{2}:?\d{2}$/.test(iso) ? iso : `${iso}Z`
|
||
const d = new Date(mitZone)
|
||
return isNaN(d.getTime()) ? 'unbekannt' : d.toLocaleString('de-DE')
|
||
}
|
||
|
||
export default function SettingsPage() {
|
||
const [settings, setSettings] = useState<SettingsState>(defaultSettings)
|
||
const [activeTab, setActiveTab] = useState<SettingsTab>('ripping')
|
||
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
||
const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null)
|
||
const [loading, setLoading] = useState(true)
|
||
const [saving, setSaving] = useState(false)
|
||
const [saved, setSaved] = useState(false)
|
||
const [testBusy, setTestBusy] = useState(false)
|
||
const [quellenStatus, setQuellenStatus] = useState<Record<string, string> | null>(null)
|
||
const [quellenBusy, setQuellenBusy] = useState(false)
|
||
const [updates, setUpdates] = useState<Record<string, { installiert?: string, verfuegbar?: string, update?: boolean }> | null>(null)
|
||
const [updatesBusy, setUpdatesBusy] = useState(false)
|
||
// KEYDB.cfg und AACS-Dumps sind DATEIEN im MakeMKV-Datenverzeichnis, keine
|
||
// Einstellungen — deshalb bewusst NICHT in SettingsState: der globale
|
||
// Speichern-Knopf postet dieses Objekt komplett und würde sie mitschleifen.
|
||
const [keydb, setKeydb] = useState<KeydbStatus | null>(null)
|
||
const [keydbBusy, setKeydbBusy] = useState(false)
|
||
const [dumps, setDumps] = useState<AacsDump[]>([])
|
||
const keydbInput = useRef<HTMLInputElement>(null)
|
||
// Ablageziele unter /app/media inkl. eingehängter Freigaben — speist die
|
||
// Auswahl des Arbeitsverzeichnisses (vorher musste man den Pfad tippen).
|
||
const [ziele, setZiele] = useState<StorageZiel[]>([])
|
||
const [keystore, setKeystore] = useState<KeystoreStatus | null>(null)
|
||
const [keystoreBusy, setKeystoreBusy] = useState(false)
|
||
const keystoreInput = useRef<HTMLInputElement>(null)
|
||
|
||
const { toast } = useToast()
|
||
|
||
const updatesPruefen = async () => {
|
||
setUpdatesBusy(true)
|
||
try {
|
||
const r = await api.get('/system/updates')
|
||
setUpdates(r.data)
|
||
if (r.data?.makemkv?.update) {
|
||
toast('info', `MakeMKV ${r.data.makemkv.verfuegbar} ist verfügbar (installiert: ${r.data.makemkv.installiert})`)
|
||
} else {
|
||
toast('success', 'Update-Check abgeschlossen')
|
||
}
|
||
} catch {
|
||
toast('error', 'Update-Check fehlgeschlagen (makemkv.com/GitHub nicht erreichbar?)')
|
||
} finally {
|
||
setUpdatesBusy(false)
|
||
}
|
||
}
|
||
|
||
// Status der KEYDB.cfg + Liste der AACS-Dumps frisch holen. Wird beim Laden
|
||
// der Seite und nach jeder Aktion aufgerufen — die API ist die Wahrheit,
|
||
// nicht das, was wir gerade hochgeschickt haben.
|
||
const keydbLaden = () => {
|
||
api.get('/system/keydb').then(r => setKeydb(r.data)).catch(() => setKeydb(null))
|
||
api.get('/system/keystore').then(r => setKeystore(r.data)).catch(() => setKeystore(null))
|
||
api.get('/system/aacs-dumps').then(r => setDumps(r.data?.dumps || [])).catch(() => setDumps([]))
|
||
// Die Worker-Plaketten oben kommen aus /capabilities und stammen aus dem
|
||
// letzten Herzschlag des Workers (minuetlich) — ohne dieses Nachladen
|
||
// widerspraeche die Kachel nach einem Upload sichtbar der Erfolgsmeldung.
|
||
// Ganz sofort ist sie trotzdem nicht: der Worker meldet erst beim
|
||
// nächsten Herzschlag neu. Genau so steht es auch im Erklaertext.
|
||
api.get('/capabilities').then(r => setWorkers(r.data.workers || [])).catch(() => setWorkers([]))
|
||
}
|
||
|
||
const keydbHochladen = async (datei: File) => {
|
||
setKeydbBusy(true)
|
||
try {
|
||
// Bewusst als JSON-Text und nicht als Multipart-Upload: der API fehlt
|
||
// python-multipart, ein File()-Endpunkt würde sie beim Import killen.
|
||
const inhalt = await datei.text()
|
||
await api.post('/system/keydb', { inhalt })
|
||
keydbLaden()
|
||
toast('success', 'KEYDB.cfg gespeichert — sie wirkt ab dem nächsten Rip.')
|
||
} catch (e: any) {
|
||
toast('error', e?.response?.data?.detail || 'Hochladen fehlgeschlagen — ist das wirklich eine KEYDB.cfg, und läuft die API?')
|
||
} finally {
|
||
setKeydbBusy(false)
|
||
}
|
||
}
|
||
|
||
const keystoreHochladen = async (datei: File) => {
|
||
setKeystoreBusy(true)
|
||
try {
|
||
// Die Datei wandert als roher Anfrage-Körper zur API — _private_data.tar
|
||
// ist binär, JSON oder Base64 wäre nur unnötiger Ballast, und Multipart
|
||
// kann die API nicht (kein python-multipart).
|
||
await api.post('/system/keystore', datei, {
|
||
headers: { 'Content-Type': 'application/octet-stream' },
|
||
})
|
||
keydbLaden()
|
||
toast('success', 'Schlüsselspeicher übernommen — wirkt ab dem nächsten Rip.')
|
||
} catch (e: any) {
|
||
toast('error', e?.response?.data?.detail
|
||
|| 'Übernahme fehlgeschlagen — ist das wirklich die Datei _private_data.tar, und läuft die API?')
|
||
} finally {
|
||
setKeystoreBusy(false)
|
||
}
|
||
}
|
||
|
||
const keydbEntfernen = async () => {
|
||
setKeydbBusy(true)
|
||
try {
|
||
await api.delete('/system/keydb')
|
||
keydbLaden()
|
||
toast('success', 'KEYDB.cfg entfernt — Rippy nutzt jetzt keine Schlüsseldatei mehr.')
|
||
} catch (e: any) {
|
||
toast('error', e?.response?.data?.detail || 'Entfernen fehlgeschlagen — läuft die API?')
|
||
} finally {
|
||
setKeydbBusy(false)
|
||
}
|
||
}
|
||
|
||
const quellenPruefen = async () => {
|
||
setQuellenBusy(true)
|
||
try {
|
||
const r = await api.get('/metadata/status')
|
||
setQuellenStatus(r.data)
|
||
const kaputt = Object.entries(r.data).filter(([, v]) => v === 'fehler')
|
||
if (kaputt.length === 0) {
|
||
toast('success', 'Metadaten-Quellen geprüft — alles in Ordnung')
|
||
} else {
|
||
toast('error', `Quelle mit Problem: ${kaputt.map(([k]) => k.toUpperCase()).join(', ')}`)
|
||
}
|
||
} catch {
|
||
toast('error', 'Prüfung fehlgeschlagen — läuft die API?')
|
||
} finally {
|
||
setQuellenBusy(false)
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
api.get('/capabilities')
|
||
.then(r => setWorkers(r.data.workers || []))
|
||
.catch(() => setWorkers([]))
|
||
api.get('/system/info')
|
||
.then(r => setSystemInfo(r.data))
|
||
.catch(() => setSystemInfo(null))
|
||
api.get('/system/keydb')
|
||
.then(r => setKeydb(r.data))
|
||
.catch(() => setKeydb(null))
|
||
api.get('/system/keystore')
|
||
.then(r => setKeystore(r.data))
|
||
.catch(() => setKeystore(null))
|
||
api.get('/storage-targets')
|
||
.then(r => setZiele(Array.isArray(r.data) ? r.data : []))
|
||
.catch(() => setZiele([]))
|
||
api.get('/system/aacs-dumps')
|
||
.then(r => setDumps(r.data?.dumps || []))
|
||
.catch(() => setDumps([]))
|
||
}, [])
|
||
|
||
const webhookTesten = async () => {
|
||
if (!settings.notificationWebhook) return
|
||
setTestBusy(true)
|
||
try {
|
||
await api.post('/notifications/test', { url: settings.notificationWebhook })
|
||
toast('success', 'Test-Nachricht gesendet — prüfe deinen Kanal!')
|
||
} catch (e: any) {
|
||
toast('error', e?.response?.data?.detail || 'Test fehlgeschlagen')
|
||
} finally {
|
||
setTestBusy(false)
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
const loadSettings = async () => {
|
||
try {
|
||
const response = await api.get('/settings')
|
||
setSettings({ ...defaultSettings, ...response.data })
|
||
} catch {
|
||
setSettings(defaultSettings)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
loadSettings()
|
||
}, [])
|
||
|
||
const handleChange = (field: keyof SettingsState, value: any) => {
|
||
setSettings(prev => ({ ...prev, [field]: value }))
|
||
setSaved(false)
|
||
}
|
||
|
||
const handleSave = async () => {
|
||
setSaving(true)
|
||
try {
|
||
await api.post('/settings', settings)
|
||
setSaved(true)
|
||
toast('success', 'Einstellungen gespeichert')
|
||
setTimeout(() => setSaved(false), 3000)
|
||
} catch {
|
||
toast('error', 'Speichern fehlgeschlagen — läuft die API?')
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
}
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="flex items-center justify-center min-h-[60vh]">
|
||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-amber-500"></div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const tabs: { id: SettingsTab; label: string; icon: any }[] = [
|
||
{ id: 'ripping', label: 'Ripping', icon: Disc },
|
||
{ id: 'verarbeitung', label: 'Verarbeitung', icon: Cpu },
|
||
{ id: 'worker', label: 'Worker', icon: Cpu },
|
||
{ id: 'speicherziele', label: 'Speicherziele', icon: HardDrive },
|
||
{ id: 'apis', label: 'APIs', icon: Globe },
|
||
{ id: 'benachrichtigungen', label: 'Benachrichtigungen', icon: AlertCircle },
|
||
{ id: 'system', label: 'System', icon: Wrench },
|
||
]
|
||
|
||
return (
|
||
<div className="max-w-6xl mx-auto space-y-6">
|
||
<PageHeader
|
||
title="Einstellungen"
|
||
subtitle="Konfiguriere Ripping-Parameter, Speicherziele, APIs und Benachrichtigungen"
|
||
/>
|
||
|
||
{saved && (
|
||
<div className="p-4 rounded-xl flex items-center gap-3 font-medium bg-emerald-500/15 border border-emerald-500/30 text-emerald-600 dark:text-emerald-400">
|
||
<CheckCircle className="flex-shrink-0" size={22} />
|
||
<span>Einstellungen erfolgreich gespeichert!</span>
|
||
</div>
|
||
)}
|
||
|
||
<Card className="overflow-hidden">
|
||
{/* Navigation Tabs */}
|
||
<div className="flex border-b border-slate-800 overflow-x-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden bg-[#0c101d]">
|
||
{tabs.map((tab) => {
|
||
const isActive = activeTab === tab.id
|
||
return (
|
||
<button
|
||
key={tab.id}
|
||
onClick={() => setActiveTab(tab.id)}
|
||
className={`px-5 py-3.5 text-sm font-medium transition-all flex items-center gap-2 border-b-2 whitespace-nowrap ${
|
||
isActive
|
||
? 'border-amber-500 text-amber-400 bg-[#131929] font-bold'
|
||
: 'border-transparent text-slate-400 hover:text-slate-200 hover:bg-slate-900/50'
|
||
}`}
|
||
>
|
||
<tab.icon size={17} className={isActive ? 'text-amber-400' : ''} />
|
||
<span>{tab.label}</span>
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
{/* Tab Content */}
|
||
<CardContent className="p-6 space-y-8">
|
||
{/* Ripping Settings */}
|
||
{activeTab === 'ripping' && (
|
||
<section className="space-y-6">
|
||
<div>
|
||
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||
<Disc size={20} className="text-amber-500" />
|
||
Ripping-Einstellungen
|
||
</h2>
|
||
</div>
|
||
|
||
<div className="space-y-4">
|
||
<p className="text-xs font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500">
|
||
📀 Video — DVD · Blu-ray · 4K UHD
|
||
</p>
|
||
|
||
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
|
||
<Toggle
|
||
label="Vollautomatik: sofort rippen"
|
||
description="Disc erkannt → Rip startet ohne Nachfrage in den passenden Ordner."
|
||
checked={settings.autoRipStart}
|
||
onChange={(v) => handleChange('autoRipStart', v)}
|
||
/>
|
||
</div>
|
||
|
||
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
|
||
<Toggle
|
||
label="Nur Hauptfeature"
|
||
description="Rippe bei DVDs/Blu-rays nur das Hauptfeature (längster Titel)."
|
||
checked={settings.mainFeatureOnly}
|
||
onChange={(v) => handleChange('mainFeatureOnly', v)}
|
||
/>
|
||
</div>
|
||
|
||
<p className="text-xs text-slate-500 dark:text-slate-400 px-1">
|
||
🌐 Sprachen & 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="http://192.168.178.50:8096"
|
||
/>
|
||
<div className="flex gap-2 items-end">
|
||
<div className="flex-1">
|
||
<Input
|
||
type="password"
|
||
label="API-Key"
|
||
value={settings.jellyfinApiKey}
|
||
onChange={(e) => handleChange('jellyfinApiKey', e.target.value)}
|
||
placeholder="API-Key"
|
||
/>
|
||
</div>
|
||
<Button
|
||
variant="amber"
|
||
size="md"
|
||
onClick={async () => {
|
||
try {
|
||
await api.post('/mediaserver/refresh', {
|
||
url: settings.jellyfinUrl, api_key: settings.jellyfinApiKey,
|
||
})
|
||
toast('success', 'Verbunden — Bibliotheks-Scan wurde angestoßen!')
|
||
} catch (e: any) {
|
||
toast('error', e?.response?.data?.detail || 'Verbindung fehlgeschlagen')
|
||
}
|
||
}}
|
||
disabled={!settings.jellyfinUrl || !settings.jellyfinApiKey}
|
||
>
|
||
Testen
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{/* Verarbeitung */}
|
||
{activeTab === 'verarbeitung' && (
|
||
<section className="space-y-6">
|
||
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||
<Cpu size={20} className="text-amber-500" />
|
||
Verarbeitung (Kompression)
|
||
</h2>
|
||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||
MakeMKV rippt verlustfrei, HandBrake komprimiert danach auf Arbeitsgröße.
|
||
</p>
|
||
|
||
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
|
||
<p className="text-sm font-medium mb-2 text-slate-900 dark:text-slate-200">Verfügbare Encoder</p>
|
||
{workers.length === 0 ? (
|
||
<p className="text-sm text-slate-500 dark:text-slate-400">Kein Worker gemeldet.</p>
|
||
) : (
|
||
workers.map(w => (
|
||
<div key={w.name} className="mb-1">
|
||
<span className="text-xs font-mono mr-2 text-slate-500 dark:text-slate-400">{w.name}: </span>
|
||
{w.encoders.map(e => (
|
||
<span key={e} className={`inline-block text-xs px-2.5 py-0.5 rounded-md font-medium border mr-1 ${
|
||
e.startsWith('cpu')
|
||
? 'bg-slate-200 dark:bg-slate-800 text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-700'
|
||
: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30'
|
||
}`}>
|
||
{ENCODER_BADGES[e]?.label || e}
|
||
</span>
|
||
))}
|
||
{(w.info?.cpu_kerne || w.info?.cpu_simd) && (
|
||
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400" title={w.info?.cpu_modell || ''}>
|
||
{w.info?.cpu_modell ? `${w.info.cpu_modell} · ` : ''}
|
||
{w.info?.cpu_kerne ? `${w.info.cpu_kerne} Kerne` : ''}
|
||
{w.info?.cpu_kerne && w.info?.cpu_simd ? ' · ' : ''}
|
||
{w.info?.cpu_simd ? `Vektorbefehle ${w.info.cpu_simd}` : ''}
|
||
</p>
|
||
)}
|
||
{simdWarnung(w.info?.cpu_simd) && (
|
||
<p className="mt-1 text-xs text-amber-600 dark:text-amber-400">
|
||
{simdWarnung(w.info?.cpu_simd)}
|
||
</p>
|
||
)}
|
||
{w.info?.handbrake_encoder && (
|
||
<p className="mt-1 text-xs text-slate-400 dark:text-slate-500 font-mono break-all">
|
||
HandBrake meldet: {w.info.handbrake_encoder}
|
||
</p>
|
||
)}
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
|
||
<div className="space-y-4">
|
||
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
|
||
<Toggle
|
||
label="Nach dem Rip komprimieren"
|
||
description="x265 statt 40-GB-Rohdatei (empfohlen)."
|
||
checked={settings.transcodeEnabled}
|
||
onChange={(v) => handleChange('transcodeEnabled', v)}
|
||
/>
|
||
</div>
|
||
|
||
{/*
|
||
Ein Preset JE DISC-TYP (25.07.2026). Vorher lief alles durch
|
||
dasselbe Preset — eine 4K-UHD wurde damit auf 1080p
|
||
heruntergerechnet und eine DVD auf 1080p hochskaliert.
|
||
Preset-Namen stammen aus `HandBrakeCLI --preset-list` im
|
||
Worker-Image (HandBrake 1.6.1), nicht aus dem Kopf.
|
||
*/}
|
||
<div className="space-y-3">
|
||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||
Ein Preset je Quelle — Rippy erkennt den Disc-Typ selbst und wählt passend aus.
|
||
<strong className="text-slate-700 dark:text-slate-300"> Eine 4K-UHD gehört auf ein
|
||
2160p-Preset</strong>, sonst rechnet HandBrake sie auf 1080p herunter und die
|
||
4K-Auflösung ist weg.
|
||
</p>
|
||
|
||
<Select
|
||
label="Preset für 4K-UHD"
|
||
value={settings.transcodePresetUhd}
|
||
onChange={(e) => handleChange('transcodePresetUhd', e.target.value)}
|
||
>
|
||
<option value={PRESET_KEINE}>Nicht komprimieren — 4K verlustfrei behalten</option>
|
||
<option value="H.265 MKV 2160p60 4K">H.265 MKV 2160p60 4K — 4K erhalten</option>
|
||
<option value="HQ 2160p60 4K HEVC Surround">HQ 2160p60 4K HEVC Surround — 4K, mehr Qualität</option>
|
||
<option value="Super HQ 2160p60 4K HEVC Surround">Super HQ 2160p60 4K HEVC Surround — 4K, größer</option>
|
||
<option value="H.265 MKV 1080p30">H.265 MKV 1080p30 — bewusst auf 1080p verkleinern</option>
|
||
</Select>
|
||
{/* Die Warnung, die den 50-Stunden-Lauf verhindert haette. Sie
|
||
steht nur da, wenn die Maschine sie WIRKLICH braucht -
|
||
gemessen, nicht vermutet (worker/caps.py meldet cpu_simd). */}
|
||
{settings.transcodePresetUhd !== PRESET_KEINE
|
||
&& settings.transcodePresetUhd.includes('2160')
|
||
&& schwacheEncoderCpu(workers) && (
|
||
<p className="text-xs -mt-1 p-3 rounded-lg bg-amber-500/10 border border-amber-500/30 text-amber-700 dark:text-amber-300">
|
||
⚠️ Keiner deiner Worker kann AVX2 — 4K in H.265 dauert damit
|
||
<strong> ein bis zwei Tage pro Film</strong> (gemessen, nicht geschätzt).
|
||
Besser „Nicht komprimieren" wählen und die verlustfreie Datei behalten,
|
||
oder einen Worker mit Hardware-Encoder anschließen.
|
||
</p>
|
||
)}
|
||
|
||
<Select
|
||
label="Preset für Blu-ray"
|
||
value={settings.transcodePresetBluray}
|
||
onChange={(e) => handleChange('transcodePresetBluray', e.target.value)}
|
||
>
|
||
<option value={PRESET_KEINE}>Nicht komprimieren — verlustfrei behalten</option>
|
||
<option value="H.265 MKV 1080p30">H.265 MKV 1080p30 — klein & modern (empfohlen)</option>
|
||
<option value="HQ 1080p30 Surround">HQ 1080p30 Surround — H.264, maximale Kompatibilität</option>
|
||
<option value="Super HQ 1080p30 Surround">Super HQ 1080p30 Surround — größer, beste Qualität</option>
|
||
</Select>
|
||
|
||
<Select
|
||
label="Preset für DVD"
|
||
value={settings.transcodePresetDvd}
|
||
onChange={(e) => handleChange('transcodePresetDvd', e.target.value)}
|
||
>
|
||
<option value={PRESET_KEINE}>Nicht komprimieren — verlustfrei behalten</option>
|
||
<option value="H.265 MKV 576p25">H.265 MKV 576p25 — PAL-DVD, native Auflösung (empfohlen)</option>
|
||
<option value="H.265 MKV 480p30">H.265 MKV 480p30 — NTSC-DVD, native Auflösung</option>
|
||
<option value="HQ 576p25 Surround">HQ 576p25 Surround — H.264, maximale Kompatibilität</option>
|
||
<option value="H.265 MKV 1080p30">H.265 MKV 1080p30 — hochskalieren (bringt nichts)</option>
|
||
</Select>
|
||
|
||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||
„Nicht komprimieren" heißt: Der Rip bleibt so, wie MakeMKV ihn
|
||
gesichert hat — beste Qualität, aber 20–100 GB je Film. Sinnvoll für
|
||
4K, wenn die Ablage groß genug ist.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
|
||
<Toggle
|
||
label="Original behalten"
|
||
description="Verlustfreie Roh-Datei zusätzlich aufheben (viel Speicher!)."
|
||
checked={settings.keepOriginal}
|
||
onChange={(v) => handleChange('keepOriginal', v)}
|
||
/>
|
||
</div>
|
||
|
||
{/*
|
||
Auswahl statt Eintippen (Befund 25.07.2026): Das Feld war ein
|
||
freies Textfeld — man musste den Container-Pfad KENNEN
|
||
(/app/media/…), um eine Netzwerk-Freigabe zu treffen. Genau
|
||
daran ist es gescheitert, und der 74-GB-Rohschnitt landete auf
|
||
der VM-Platte, bis sie voll war. Die Ziele kommen aus
|
||
/storage-targets — dieselbe Liste wie bei den Speicherzielen,
|
||
inklusive freiem Platz.
|
||
*/}
|
||
<Select
|
||
label="Arbeitsverzeichnis für Roh-Rips (Standard)"
|
||
value={settings.workDir}
|
||
onChange={(e) => handleChange('workDir', e.target.value)}
|
||
>
|
||
<option value="">Container-Platte (Standard) — klein, nur für DVD/Blu-ray</option>
|
||
{ziele.map(z => (
|
||
<option key={z.path} value={z.path}>
|
||
{z.name}{z.is_mount ? ' (Netzwerk-Freigabe)' : ''}
|
||
{z.free_gb != null ? ` — ${z.free_gb} GB frei` : ''}
|
||
</option>
|
||
))}
|
||
</Select>
|
||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||
<strong className="text-slate-700 dark:text-slate-300">Der Standard</strong> — beim
|
||
„Rippen starten" kannst du für jede Disc etwas anderes wählen. <strong className="text-slate-700 dark:text-slate-300">
|
||
Läuft die Vollautomatik</strong>, fragt dich niemand: dann gilt genau dieser Wert.
|
||
Wichtig für 4K-UHD: Rohdaten sind bis 100 GB groß und passen selten auf die
|
||
Container-Platte. Am besten dieselbe Freigabe wie das Ziel — dann muss Rippy die
|
||
Rohdatei am Ende nur umhängen statt sie zu kopieren.
|
||
{systemInfo?.plaetze?.length ? (
|
||
<> Aktuell frei: {systemInfo.plaetze.map(p => `${p.name}: ${p.frei_gb} GB`).join(' · ')}</>
|
||
) : null}
|
||
</p>
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{/* Worker */}
|
||
{activeTab === 'worker' && (
|
||
<section className="space-y-6">
|
||
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||
<Cpu size={20} className="text-amber-500" />
|
||
Encoding-Worker
|
||
</h2>
|
||
<WorkerVerwaltung />
|
||
</section>
|
||
)}
|
||
|
||
{/* Speicherziele */}
|
||
{activeTab === 'speicherziele' && (
|
||
<section className="space-y-6">
|
||
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||
<HardDrive size={20} className="text-amber-500" />
|
||
Speicherziele
|
||
</h2>
|
||
|
||
<div className="p-5 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 space-y-3">
|
||
<h3 className="font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||
<Database size={16} className="text-amber-500" /> Schnellwahl beim Rippen
|
||
</h3>
|
||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||
Name der Ordner im „Rippen starten"-Dialog (🎥/📺/🎵).
|
||
</p>
|
||
<div className="grid grid-cols-3 gap-3">
|
||
{([['movieDir', '🎥 Filme'], ['seriesDir', '📺 Serien'], ['musicDir', '🎵 Musik']] as const).map(([feld, label]) => (
|
||
<Input
|
||
key={feld}
|
||
label={label}
|
||
value={settings[feld]}
|
||
onChange={(e) => handleChange(feld, e.target.value)}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<StorageMounts />
|
||
</section>
|
||
)}
|
||
|
||
{/* APIs */}
|
||
{activeTab === 'apis' && (
|
||
<section className="space-y-6">
|
||
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||
<Globe size={20} className="text-amber-500" />
|
||
API-Schlüssel
|
||
</h2>
|
||
|
||
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 flex flex-wrap items-center gap-3">
|
||
<Button variant="amber" size="sm" onClick={quellenPruefen} disabled={quellenBusy}>
|
||
{quellenBusy ? 'Prüfe…' : 'Verbindung prüfen'}
|
||
</Button>
|
||
{quellenStatus ? (
|
||
<div className="flex flex-wrap gap-2">
|
||
{Object.entries(quellenStatus).map(([quelle, status]) => (
|
||
<span
|
||
key={quelle}
|
||
className={`text-xs px-2.5 py-1 rounded-full font-medium border ${
|
||
status === 'ok' ? 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30'
|
||
: status === 'kein_key' ? 'bg-slate-200 dark:bg-slate-800 text-slate-500 border-slate-300 dark:border-slate-700'
|
||
: 'bg-rose-500/15 text-rose-600 dark:text-rose-400 border-rose-500/30'
|
||
}`}
|
||
>
|
||
{quelle.toUpperCase()}: {status === 'ok' ? '✓ funktioniert' : status === 'kein_key' ? 'kein Key' : '✗ Fehler'}
|
||
</span>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||
Prüft live gegen TMDB/OMDb — nach dem Speichern eines neuen Keys hier klicken.
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
<div className="space-y-4">
|
||
<Input
|
||
type="password"
|
||
label="TMDB API Key"
|
||
value={settings.tmdbApiKey}
|
||
onChange={(e) => handleChange('tmdbApiKey', e.target.value)}
|
||
placeholder="Dein TMDB API Key"
|
||
/>
|
||
<Input
|
||
type="password"
|
||
label="TVDb API Key"
|
||
value={settings.tvdbApiKey}
|
||
onChange={(e) => handleChange('tvdbApiKey', e.target.value)}
|
||
placeholder="Dein TVDb API Key"
|
||
/>
|
||
<Input
|
||
type="password"
|
||
label="OMDb API Key (Fallback)"
|
||
value={settings.omdbApiKey}
|
||
onChange={(e) => handleChange('omdbApiKey', e.target.value)}
|
||
placeholder="Dein OMDb API Key"
|
||
/>
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{/* Benachrichtigungen */}
|
||
{activeTab === 'benachrichtigungen' && (
|
||
<section className="space-y-6">
|
||
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||
<AlertCircle size={20} className="text-amber-500" />
|
||
Webhook-Benachrichtigungen
|
||
</h2>
|
||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||
Rippy meldet sich, sobald ein Job <strong>fertig</strong> ist, <strong>fehlschlägt</strong> oder <strong>abgebrochen</strong> wird.
|
||
</p>
|
||
|
||
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 space-y-3 text-sm">
|
||
<div>
|
||
<p className="font-semibold text-slate-900 dark:text-slate-200">💬 Discord (einfachster Weg)</p>
|
||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||
Kanal-Zahnrad → Integrationen → Webhooks → „Neuer Webhook" → URL kopieren.
|
||
</p>
|
||
</div>
|
||
<div>
|
||
<p className="font-semibold text-slate-900 dark:text-slate-200">📱 ntfy (Push aufs Handy)</p>
|
||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||
Thema abonnieren und https://ntfy.sh/mein-thema eintragen.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<div className="flex gap-2 items-end">
|
||
<div className="flex-1">
|
||
<Input
|
||
label="Webhook URL"
|
||
value={settings.notificationWebhook}
|
||
onChange={(e) => handleChange('notificationWebhook', e.target.value)}
|
||
placeholder="https://discord.com/api/webhooks/… oder https://ntfy.sh/mein-thema"
|
||
/>
|
||
</div>
|
||
<Button
|
||
variant="amber"
|
||
size="md"
|
||
onClick={webhookTesten}
|
||
disabled={!settings.notificationWebhook || testBusy}
|
||
>
|
||
<Send size={15} />
|
||
{testBusy ? 'Sende…' : 'Test senden'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{/* System */}
|
||
{activeTab === 'system' && (
|
||
<section className="space-y-6">
|
||
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||
<Wrench size={20} className="text-amber-500" />
|
||
System & 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>
|
||
)
|
||
}
|