import { useState, useEffect } from 'react' import { Save, Disc, Cpu, Database, Globe, AlertCircle, CheckCircle, HardDrive, Wrench, Send, Tv } from 'lucide-react' import { api } from '../lib/api' import { useDarkMode } from '../context/ThemeContext' import { useToast } from '../context/ToastContext' import StorageMounts from '../components/StorageMounts' import WorkerVerwaltung from '../components/WorkerVerwaltung' interface SettingsState { tmdbApiKey: string tvdbApiKey: string omdbApiKey: string outputDir: string movieDir: string seriesDir: string musicDir: string ripAllTracks: boolean mainFeatureOnly: boolean autoEject: boolean autoRipStart: boolean notificationWebhook: string transcodeEnabled: boolean transcodePreset: 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', ripAllTracks: true, mainFeatureOnly: false, autoEject: true, autoRipStart: false, notificationWebhook: '', transcodeEnabled: true, transcodePreset: 'H.265 MKV 1080p30', keepOriginal: false, mediaServer: 'none', jellyfinUrl: '', jellyfinApiKey: '', workDir: '', makemkvAppKey: '', } type SettingsTab = 'ripping' | 'verarbeitung' | 'worker' | 'speicherziele' | 'apis' | 'benachrichtigungen' | 'system' interface WorkerInfo { name: string encoders: string[] info?: { makemkv?: string, handbrake?: string, makemkv_key?: string } } interface SystemInfo { api_version: string plaetze: { name: string, frei_gb: number, gesamt_gb: number }[] workers: WorkerInfo[] makemkv_key_ui: boolean webhook_gesetzt: boolean } import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer' const ENCODER_LABELS: Record = { 'cpu-x264': 'CPU · H.264', 'cpu-x265': 'CPU · H.265', 'vaapi': 'Hardware · VAAPI (AMD/Intel)', 'nvenc': 'Hardware · NVENC (NVIDIA)', } export default function SettingsPage() { const [settings, setSettings] = useState(defaultSettings) const [activeTab, setActiveTab] = useState('ripping') const [workers, setWorkers] = useState([]) const [systemInfo, setSystemInfo] = useState(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 | null>(null) const [quellenBusy, setQuellenBusy] = useState(false) const [updates, setUpdates] = useState | null>(null) const [updatesBusy, setUpdatesBusy] = useState(false) 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) } } const { theme } = useDarkMode() const { toast } = useToast() 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)) }, []) 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') // Leeres Objekt von der API = noch nichts gespeichert → Defaults behalten 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 (
) } return (

Einstellungen

Konfiguriere Rippy nach deinen Wünschen

{saved && (
Einstellungen erfolgreich gespeichert!
)}
{/* Tabs — echte Navigation (vorher reine Dekoration ohne onClick) */}
setActiveTab('ripping')} darkMode={theme === 'dark'} /> setActiveTab('verarbeitung')} darkMode={theme === 'dark'} /> setActiveTab('worker')} darkMode={theme === 'dark'} /> setActiveTab('speicherziele')} darkMode={theme === 'dark'} /> setActiveTab('apis')} darkMode={theme === 'dark'} /> setActiveTab('benachrichtigungen')} darkMode={theme === 'dark'} /> setActiveTab('system')} darkMode={theme === 'dark'} />
{/* Content */}
{/* Ripping Settings */} {activeTab === 'ripping' &&

Ripping-Einstellungen

{/* ── Video: DVD · Blu-ray · 4K UHD ─────────────────────── */}

📀 Video — DVD · Blu-ray · 4K UHD

Vollautomatik: sofort rippen

Disc erkannt → Rip startet ohne Nachfrage in den passenden Schnellwahl-Ordner (Serie → Serien, sonst Filme; CD → Musik). Aus = wie bisher per „Rippen starten"-Dialog.

handleChange('autoRipStart', v)} darkMode={theme === 'dark'} />

Nur Hauptfeature

Rippe bei DVDs/Blu-rays nur das Hauptfeature

handleChange('mainFeatureOnly', v)} darkMode={theme === 'dark'} />

🌐 Sprachen & Untertitel: MakeMKV sichert grundsätzlich ALLE Ton-Spuren und Untertitel verlustfrei, und auch die Kompression behält alle (--all-audio / --all-subtitles) — gerade bei Animes gehen also weder O-Ton noch Subs verloren.

{/* ── Audio-CD ──────────────────────────────────────────── */}

🎵 Audio-CD

Alle Tracks rippen

Rippe alle Tracks einer CD (FLAC + MusicBrainz-Tags)

handleChange('ripAllTracks', v)} darkMode={theme === 'dark'} />
{/* ── Allgemein ─────────────────────────────────────────── */}

Allgemein

Automatischer Auswurf

Disk nach erfolgreichem Ripping auswerfen

handleChange('autoEject', v)} darkMode={theme === 'dark'} />
{/* Media-Server: Rippy bereitet die Ablage aufs Zielsystem vor */}

Media-Server (Jellyfin & Co.)

Rippy benennt fertige Rips als „Titel (Jahr)" und legt — je nach System — movie.nfo + poster.jpg dazu. Dein Server erkennt den Film dann ohne Nacharbeit.

{MEDIA_SERVER_OPTIONEN.map(o => ( ))}

{MEDIA_SERVER_OPTIONEN.find(o => o.wert === settings.mediaServer)?.hinweis}

{/* Jellyfin/Emby: nach jedem fertigen Rip die Bibliothek scannen */} {(settings.mediaServer === 'jellyfin' || settings.mediaServer === 'emby') && (

Bibliotheks-Refresh (optional): Trag Server-URL + API-Key ein, dann stößt Rippy nach jedem fertigen Rip sofort einen Scan an — der Film erscheint ohne Warten in {settings.mediaServer === 'jellyfin' ? 'Jellyfin' : 'Emby'}. API-Key: Administration → API-Schlüssel → Neu.

handleChange('jellyfinUrl', e.target.value)} placeholder="http://192.168.178.50:8096" className={`px-3 py-2 border rounded-lg text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'}`} />
handleChange('jellyfinApiKey', e.target.value)} placeholder="API-Key" className={`flex-1 px-3 py-2 border rounded-lg text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'}`} />
)}
} {/* Verarbeitung: Kompressions-Stufe nach dem Rip */} {activeTab === 'verarbeitung' &&

Verarbeitung (Kompression)

MakeMKV rippt verlustfrei (der einzige Weg durch den Kopierschutz), HandBrake komprimiert danach auf Arbeitsgröße. Die Roh-Datei wird nach Erfolg gelöscht.

{/* Ehrliche Selbstauskunft: was können die Worker WIRKLICH? */}

Verfügbare Encoder

{workers.length === 0 ? (

Kein Worker gemeldet.

) : ( workers.map(w => (
{w.name} {w.encoders.map(e => ( {ENCODER_LABELS[e] || e} ))}
)) )}

Nach dem Rip komprimieren

x265 statt 40-GB-Rohdatei (empfohlen)

handleChange('transcodeEnabled', v)} darkMode={theme === 'dark'} />

Original behalten

Verlustfreie Roh-Datei zusätzlich aufheben (viel Speicher!)

handleChange('keepOriginal', v)} darkMode={theme === 'dark'} />
{/* Arbeitsverzeichnis: 4K-UHD-Rohdaten (bis 100 GB) passen nicht auf jede VM-Platte — hier auf eine große Freigabe umlegen */}
handleChange('workDir', e.target.value)} placeholder="leer = /app/temp (Container-Platte)" className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent font-mono text-sm ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'}`} />

Muss unter /app/media liegen (z. B. /app/media/nas-arbeit — Freigabe vorher unter Speicherziele einhängen). Wichtig für 4K-UHD: Rohdaten sind bis 100 GB groß; Rippy prüft den Platz VOR dem Rip und bricht sonst mit Klartext ab. {systemInfo?.plaetze?.length ? ( <> Aktuell frei: {systemInfo.plaetze.map(p => `${p.name}: ${p.frei_gb} GB`).join(' · ')} ) : null}

} {/* Worker: Encoding-Maschinen im Netz */} {activeTab === 'worker' &&

Encoding-Worker

} {/* Speicherziele: Netzwerk-Mounts aus dem UI */} {activeTab === 'speicherziele' &&

Speicherziele

{/* Schnellwahl-Ordner: die drei Knöpfe im "Rippen starten"-Dialog */}

Schnellwahl beim Rippen

Beim Start eines Rips fragt Rippy „Wohin?" — diese drei Ordner erscheinen dort als 🎥/📺/🎵-Schnellwahl. Hier legst du nur fest, wie sie heißen.

{([['movieDir', '🎥 Filme'], ['seriesDir', '📺 Serien'], ['musicDir', '🎵 Musik']] as const).map(([feld, label]) => (
handleChange(feld, e.target.value)} className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'}`} />
))}
} {/* API Keys */} {activeTab === 'apis' &&

API-Schlüssel

{/* Live-Prüfung: funktioniert der eingetragene Key WIRKLICH? */}
{quellenStatus ? (
{Object.entries(quellenStatus).map(([quelle, status]) => ( {quelle.toUpperCase()}: {status === 'ok' ? '✓ funktioniert' : status === 'kein_key' ? 'kein Key' : '✗ Fehler'} ))}
) : ( Prüft live gegen TMDB/OMDb — nach dem Speichern eines neuen Keys hier klicken. Beide TMDB-Key-Arten funktionieren (v3-Schlüssel und v4-Token). )}
handleChange('tmdbApiKey', e.target.value)} className={`w-full px-3 py-2 ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'} border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all`} placeholder="Dein TMDB API Key" />

Hol dir einen Key auf themoviedb.org

handleChange('tvdbApiKey', e.target.value)} className={`w-full px-3 py-2 ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'} border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all`} placeholder="Dein TVDb API Key" />
handleChange('omdbApiKey', e.target.value)} className={`w-full px-3 py-2 ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'} border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all`} placeholder="Dein OMDb API Key" />

Zweite Quelle, wenn TMDB nichts findet — kostenloser Key auf omdbapi.com

} {/* Webhook — mit Anleitung und Sofort-Test (Commander-Wunsch 24.07.) */} {activeTab === 'benachrichtigungen' &&

Webhook-Benachrichtigungen

Rippy meldet sich, sobald ein Job fertig ist, fehlschlägt oder abgebrochen wird — direkt in deinen Chat. Der Dienst wird an der URL automatisch erkannt, du musst nichts weiter einstellen.

{/* Schritt-für-Schritt: so kommst du an eine Webhook-URL */}

💬 Discord (einfachster Weg)

Server → Kanal-Zahnrad → Integrationen → Webhooks → „Neuer Webhook" → „Webhook-URL kopieren" und unten einfügen.

📱 ntfy (Push aufs Handy, ohne Account)

ntfy-App installieren, ein Thema abonnieren (z. B. „rippy-tobi-4711") und unten https://ntfy.sh/rippy-tobi-4711 eintragen. Thema = Passwort, also etwas Einmaliges wählen.

🔧 Slack / Eigenes

Slack: „Incoming Webhook" in den App-Einstellungen anlegen. Alles andere (Home Assistant, n8n, eigene Skripte) bekommt JSON: {'{'}"title", "message", "level"{'}'}.

handleChange('notificationWebhook', e.target.value)} className={`flex-1 px-3 py-2 ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'} border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all`} placeholder="https://discord.com/api/webhooks/… oder https://ntfy.sh/mein-thema" />

„Test senden" prüft die Anbindung sofort — nicht vergessen, danach zu speichern, damit auch der Worker die URL kennt.

} {/* System: Kern-Werkzeuge, Versionen, MakeMKV-Key, Speicherplatz */} {activeTab === 'system' &&

System & Kern-Werkzeuge

{/* Werkzeug-Versionen je Worker — ehrlich gemeldet, nicht behauptet */}

Installierte Werkzeuge

{!systemInfo || systemInfo.workers.length === 0 ? (

Noch kein Worker gemeldet.

) : ( systemInfo.workers.map(w => (
{w.name} {w.info?.makemkv && ( MakeMKV {w.info.makemkv} )} {w.info?.handbrake && ( HandBrake {w.info.handbrake} )} {w.info?.makemkv_key && ( {w.info.makemkv_key === 'ui' ? 'Beta-Key: aus dem UI' : w.info.makemkv_key === 'env' ? 'Beta-Key: aus .env' : 'Kein Beta-Key (Blu-ray nur im Testmodus)'} )}
)) )} {systemInfo?.plaetze && systemInfo.plaetze.length > 0 && (

Speicherplatz: {systemInfo.plaetze.map(p => `${p.name}: ${p.frei_gb} von ${p.gesamt_gb} GB frei`).join(' · ')}

)}
{/* MakeMKV-Beta-Key: der Monats-Key, OHNE Rebuild aktualisierbar */}
handleChange('makemkvAppKey', e.target.value)} className={`w-full px-3 py-2 ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'} border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all font-mono text-sm`} placeholder={'T-… (Forum-Thread „MakeMKV is free while in beta")'} />

Der Key wechselt etwa monatlich (MakeMKV-Forum, Thread t=1053). Hier eintragen + speichern — er gilt ab dem nächsten Rip, ganz ohne Neustart oder Rebuild und schlägt den Key aus der .env. DVDs gehen immer auch ohne Key.

Die MakeMKV-Version selbst steckt im Worker-Image — Update per Rebuild: docker compose build worker && docker compose up -d worker (siehe README).

{/* Update-Check: Rippy MELDET Updates ehrlich — selbst updaten kann es nicht (die Versionen stecken im Worker-Image) */}

Update-Check

{updates ? (
MakeMKV: installiert {updates.makemkv?.installiert || '—'}, aktuell {updates.makemkv?.verfuegbar || 'unbekannt'} {updates.makemkv?.update && ( Update: in der .env MAKEMKV_VERSION={updates.makemkv.verfuegbar} setzen, dann auf der Rippy-Maschine docker compose build worker && docker compose up -d worker. Neue Version bringt auch die neueste Disc-Schlüssel-Datenbank mit. )}
HandBrake: Docker-Worker {updates.handbrake?.installiert || '—'} (Debian-Paket), neueste offizielle Version {updates.handbrake?.verfuegbar || 'unbekannt'} Im Docker-Worker kommt HandBrake aus Debian und hinkt der offiziellen Version bewusst hinterher (stabil, keine Eigenbauten). Der native Windows-Worker nutzt die aktuelle Version direkt.
) : (

Prüft gegen makemkv.com und die HandBrake-GitHub-Releases (Ergebnis wird 12 h gemerkt).

)}
} {/* Save Button — nicht im Worker-Tab: dort gibt es nichts zu speichern (Liste + Anbinde-Befehle), der Knopf verwirrte nur */} {activeTab !== 'worker' && (
)}
) } function TabButton({ icon: Icon, label, isActive, onClick, darkMode }: { icon: any, label: string, isActive: boolean, onClick: () => void, darkMode: boolean }) { const { theme } = useDarkMode() return ( ) } function Toggle({ checked, onChange, darkMode }: { checked: boolean, onChange: (v: boolean) => void, darkMode: boolean }) { const { theme } = useDarkMode() return ( ) }