Files
rippy/docker/ui/src/pages/Settings.tsx
T
Hitonabi 57171b4c88
Ampel / ampel (push) Successful in 28s
ui(system): Versionslage exakt darstellen — MakeMKV aktualisierbar, Docker-HandBrake bewusst Debian-stabil
Commander-Wunsch: das UI soll die Werkzeug-Versionslage genau so zeigen,
wie sie ist. Klarstellung im System-Tab:
- Hinweis bei 'Installierte Werkzeuge': MakeMKV aktuell haltbar (wichtig
  wegen Schluessel-DB), HandBrake im Docker-Worker bewusst Debian-stabil
  (kein separates Update), native Worker holen die neueste.
- Update-Check: MakeMKV mit gruenem '✓ aktuell' bzw. Update-Befehl (echtes
  To-do). HandBrake NICHT mehr als 'Update noetig' alarmieren — neutral als
  'Debian-Version, bewusst so' + Info, dass die nativen Worker die neueste
  nutzen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 18:42:01 +02:00

728 lines
33 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { 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'
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
}
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)
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)
}
}
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')
setSettings({ ...defaultSettings, ...response.data })
} catch {
setSettings(defaultSettings)
} finally {
setLoading(false)
}
}
loadSettings()
}, [])
const handleChange = (field: keyof SettingsState, value: any) => {
setSettings(prev => ({ ...prev, [field]: value }))
setSaved(false)
}
const handleSave = async () => {
setSaving(true)
try {
await api.post('/settings', settings)
setSaved(true)
toast('success', 'Einstellungen gespeichert')
setTimeout(() => setSaved(false), 3000)
} catch {
toast('error', 'Speichern fehlgeschlagen — läuft die API?')
} finally {
setSaving(false)
}
}
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-amber-500"></div>
</div>
)
}
const tabs: { id: SettingsTab; label: string; icon: any }[] = [
{ id: 'ripping', label: 'Ripping', icon: Disc },
{ id: 'verarbeitung', label: 'Verarbeitung', icon: Cpu },
{ id: 'worker', label: 'Worker', icon: Cpu },
{ id: 'speicherziele', label: 'Speicherziele', icon: HardDrive },
{ id: 'apis', label: 'APIs', icon: Globe },
{ id: 'benachrichtigungen', label: 'Benachrichtigungen', icon: AlertCircle },
{ id: 'system', label: 'System', icon: Wrench },
]
return (
<div className="max-w-6xl mx-auto space-y-6">
<PageHeader
title="Einstellungen"
subtitle="Konfiguriere Ripping-Parameter, Speicherziele, APIs und Benachrichtigungen"
/>
{saved && (
<div className="p-4 rounded-xl flex items-center gap-3 font-medium bg-emerald-500/15 border border-emerald-500/30 text-emerald-600 dark:text-emerald-400">
<CheckCircle className="flex-shrink-0" size={22} />
<span>Einstellungen erfolgreich gespeichert!</span>
</div>
)}
<Card className="overflow-hidden">
{/* Navigation Tabs */}
<div className="flex border-b border-slate-800 overflow-x-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden bg-[#0c101d]">
{tabs.map((tab) => {
const isActive = activeTab === tab.id
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-5 py-3.5 text-sm font-medium transition-all flex items-center gap-2 border-b-2 whitespace-nowrap ${
isActive
? 'border-amber-500 text-amber-400 bg-[#131929] font-bold'
: 'border-transparent text-slate-400 hover:text-slate-200 hover:bg-slate-900/50'
}`}
>
<tab.icon size={17} className={isActive ? 'text-amber-400' : ''} />
<span>{tab.label}</span>
</button>
)
})}
</div>
{/* Tab Content */}
<CardContent className="p-6 space-y-8">
{/* Ripping Settings */}
{activeTab === 'ripping' && (
<section className="space-y-6">
<div>
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
<Disc size={20} className="text-amber-500" />
Ripping-Einstellungen
</h2>
</div>
<div className="space-y-4">
<p className="text-xs font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500">
📀 Video DVD · Blu-ray · 4K UHD
</p>
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
<Toggle
label="Vollautomatik: sofort rippen"
description="Disc erkannt → Rip startet ohne Nachfrage in den passenden Ordner."
checked={settings.autoRipStart}
onChange={(v) => handleChange('autoRipStart', v)}
/>
</div>
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
<Toggle
label="Nur Hauptfeature"
description="Rippe bei DVDs/Blu-rays nur das Hauptfeature (längster Titel)."
checked={settings.mainFeatureOnly}
onChange={(v) => handleChange('mainFeatureOnly', v)}
/>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400 px-1">
🌐 Sprachen &amp; Untertitel: MakeMKV sichert grundsätzlich ALLE Ton-Spuren und Untertitel verlustfrei.
</p>
<p className="text-xs font-bold uppercase tracking-wider pt-2 text-slate-400 dark:text-slate-500">
🎵 Audio-CD
</p>
<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="Alle Tracks rippen"
description="Rippe alle Tracks einer CD (FLAC + MusicBrainz-Tags)."
checked={settings.ripAllTracks}
onChange={(v) => handleChange('ripAllTracks', v)}
/>
</div>
<p className="text-xs font-bold uppercase tracking-wider pt-2 text-slate-400 dark:text-slate-500">
Allgemein
</p>
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
<Toggle
label="Automatischer Auswurf"
description="Disc nach erfolgreichem Ripping automatisch auswerfen."
checked={settings.autoEject}
onChange={(v) => handleChange('autoEject', v)}
/>
</div>
{/* Media-Server Integration */}
<div className="p-5 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 space-y-4">
<h3 className="font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
<Tv size={18} className="text-amber-500" />
Media-Server (Jellyfin &amp; Co.)
</h3>
<p className="text-sm text-slate-500 dark:text-slate-400">
Rippy benennt fertige Rips als Titel (Jahr)" und legt NFO + Poster dazu.
</p>
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
{MEDIA_SERVER_OPTIONEN.map(o => (
<button
key={o.wert}
type="button"
onClick={() => handleChange('mediaServer', o.wert)}
title={o.hinweis}
className={`px-3 py-2 rounded-xl text-sm font-medium border transition-all ${
settings.mediaServer === o.wert
? 'bg-amber-500/20 border-amber-500 text-amber-600 dark:text-amber-300 font-semibold'
: 'bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-400 hover:border-slate-300 dark:hover:border-slate-600'
}`}
>
{o.label}
</button>
))}
</div>
<p className="text-xs text-slate-500 dark:text-slate-400">
{MEDIA_SERVER_OPTIONEN.find(o => o.wert === settings.mediaServer)?.hinweis}
</p>
{(settings.mediaServer === 'jellyfin' || settings.mediaServer === 'emby') && (
<div className="pt-3 border-t border-slate-200 dark:border-slate-800 space-y-3">
<p className="text-xs text-slate-600 dark:text-slate-300">
<strong>Bibliotheks-Refresh</strong>: Server-URL + API-Key für automatischen Scan nach dem Rip.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 items-end">
<Input
label="Server-URL"
value={settings.jellyfinUrl}
onChange={(e) => handleChange('jellyfinUrl', e.target.value)}
placeholder="http://192.168.178.50:8096"
/>
<div className="flex gap-2 items-end">
<div className="flex-1">
<Input
type="password"
label="API-Key"
value={settings.jellyfinApiKey}
onChange={(e) => handleChange('jellyfinApiKey', e.target.value)}
placeholder="API-Key"
/>
</div>
<Button
variant="amber"
size="md"
onClick={async () => {
try {
await api.post('/mediaserver/refresh', {
url: settings.jellyfinUrl, api_key: settings.jellyfinApiKey,
})
toast('success', 'Verbunden — Bibliotheks-Scan wurde angestoßen!')
} catch (e: any) {
toast('error', e?.response?.data?.detail || 'Verbindung fehlgeschlagen')
}
}}
disabled={!settings.jellyfinUrl || !settings.jellyfinApiKey}
>
Testen
</Button>
</div>
</div>
</div>
)}
</div>
</div>
</section>
)}
{/* Verarbeitung */}
{activeTab === 'verarbeitung' && (
<section className="space-y-6">
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
<Cpu size={20} className="text-amber-500" />
Verarbeitung (Kompression)
</h2>
<p className="text-sm text-slate-500 dark:text-slate-400">
MakeMKV rippt verlustfrei, HandBrake komprimiert danach auf Arbeitsgröße.
</p>
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
<p className="text-sm font-medium mb-2 text-slate-900 dark:text-slate-200">Verfügbare Encoder</p>
{workers.length === 0 ? (
<p className="text-sm text-slate-500 dark:text-slate-400">Kein Worker gemeldet.</p>
) : (
workers.map(w => (
<div key={w.name} className="mb-1">
<span className="text-xs font-mono mr-2 text-slate-500 dark:text-slate-400">{w.name}: </span>
{w.encoders.map(e => (
<span key={e} className={`inline-block text-xs px-2.5 py-0.5 rounded-md font-medium border mr-1 ${
e.startsWith('cpu')
? 'bg-slate-200 dark:bg-slate-800 text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-700'
: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30'
}`}>
{ENCODER_BADGES[e]?.label || e}
</span>
))}
</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>
<Select
label="HandBrake-Preset"
value={settings.transcodePreset}
onChange={(e) => handleChange('transcodePreset', e.target.value)}
>
<option value="H.265 MKV 1080p30">H.265 MKV 1080p30 klein &amp; modern (Standard)</option>
<option value="H.265 MKV 2160p60 4K">H.265 MKV 2160p60 4K für UHD-Quellen</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>
<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>
<Input
label="Arbeitsverzeichnis für Roh-Rips (optional)"
value={settings.workDir}
onChange={(e) => handleChange('workDir', e.target.value)}
placeholder="leer = /app/temp (Container-Platte)"
/>
<p className="text-xs text-slate-500 dark:text-slate-400">
Wichtig für 4K-UHD: Rohdaten sind bis 100 GB groß.
{systemInfo?.plaetze?.length ? (
<> Aktuell frei: {systemInfo.plaetze.map(p => `${p.name}: ${p.frei_gb} GB`).join(' · ')}</>
) : null}
</p>
</div>
</section>
)}
{/* Worker */}
{activeTab === 'worker' && (
<section className="space-y-6">
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
<Cpu size={20} className="text-amber-500" />
Encoding-Worker
</h2>
<WorkerVerwaltung />
</section>
)}
{/* Speicherziele */}
{activeTab === 'speicherziele' && (
<section className="space-y-6">
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
<HardDrive size={20} className="text-amber-500" />
Speicherziele
</h2>
<div className="p-5 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 space-y-3">
<h3 className="font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
<Database size={16} className="text-amber-500" /> Schnellwahl beim Rippen
</h3>
<p className="text-xs text-slate-500 dark:text-slate-400">
Name der Ordner im Rippen starten"-Dialog (🎥/📺/🎵).
</p>
<div className="grid grid-cols-3 gap-3">
{([['movieDir', '🎥 Filme'], ['seriesDir', '📺 Serien'], ['musicDir', '🎵 Musik']] as const).map(([feld, label]) => (
<Input
key={feld}
label={label}
value={settings[feld]}
onChange={(e) => handleChange(feld, e.target.value)}
/>
))}
</div>
</div>
<StorageMounts />
</section>
)}
{/* APIs */}
{activeTab === 'apis' && (
<section className="space-y-6">
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
<Globe size={20} className="text-amber-500" />
API-Schlüssel
</h2>
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 flex flex-wrap items-center gap-3">
<Button variant="amber" size="sm" onClick={quellenPruefen} disabled={quellenBusy}>
{quellenBusy ? 'Prüfe…' : 'Verbindung prüfen'}
</Button>
{quellenStatus ? (
<div className="flex flex-wrap gap-2">
{Object.entries(quellenStatus).map(([quelle, status]) => (
<span
key={quelle}
className={`text-xs px-2.5 py-1 rounded-full font-medium border ${
status === 'ok' ? 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30'
: status === 'kein_key' ? 'bg-slate-200 dark:bg-slate-800 text-slate-500 border-slate-300 dark:border-slate-700'
: 'bg-rose-500/15 text-rose-600 dark:text-rose-400 border-rose-500/30'
}`}
>
{quelle.toUpperCase()}: {status === 'ok' ? '✓ funktioniert' : status === 'kein_key' ? 'kein Key' : '✗ Fehler'}
</span>
))}
</div>
) : (
<span className="text-xs text-slate-500 dark:text-slate-400">
Prüft live gegen TMDB/OMDb — nach dem Speichern eines neuen Keys hier klicken.
</span>
)}
</div>
<div className="space-y-4">
<Input
type="password"
label="TMDB API Key"
value={settings.tmdbApiKey}
onChange={(e) => handleChange('tmdbApiKey', e.target.value)}
placeholder="Dein TMDB API Key"
/>
<Input
type="password"
label="TVDb API Key"
value={settings.tvdbApiKey}
onChange={(e) => handleChange('tvdbApiKey', e.target.value)}
placeholder="Dein TVDb API Key"
/>
<Input
type="password"
label="OMDb API Key (Fallback)"
value={settings.omdbApiKey}
onChange={(e) => handleChange('omdbApiKey', e.target.value)}
placeholder="Dein OMDb API Key"
/>
</div>
</section>
)}
{/* Benachrichtigungen */}
{activeTab === 'benachrichtigungen' && (
<section className="space-y-6">
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
<AlertCircle size={20} className="text-amber-500" />
Webhook-Benachrichtigungen
</h2>
<p className="text-sm text-slate-500 dark:text-slate-400">
Rippy meldet sich, sobald ein Job <strong>fertig</strong> ist, <strong>fehlschlägt</strong> oder <strong>abgebrochen</strong> wird.
</p>
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 space-y-3 text-sm">
<div>
<p className="font-semibold text-slate-900 dark:text-slate-200">💬 Discord (einfachster Weg)</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
Kanal-Zahnrad → Integrationen → Webhooks → „Neuer Webhook" URL kopieren.
</p>
</div>
<div>
<p className="font-semibold text-slate-900 dark:text-slate-200">📱 ntfy (Push aufs Handy)</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
Thema abonnieren und https://ntfy.sh/mein-thema eintragen.
</p>
</div>
</div>
<div className="space-y-2">
<div className="flex gap-2 items-end">
<div className="flex-1">
<Input
label="Webhook URL"
value={settings.notificationWebhook}
onChange={(e) => handleChange('notificationWebhook', e.target.value)}
placeholder="https://discord.com/api/webhooks/… oder https://ntfy.sh/mein-thema"
/>
</div>
<Button
variant="amber"
size="md"
onClick={webhookTesten}
disabled={!settings.notificationWebhook || testBusy}
>
<Send size={15} />
{testBusy ? 'Sende…' : 'Test senden'}
</Button>
</div>
</div>
</section>
)}
{/* System */}
{activeTab === 'system' && (
<section className="space-y-6">
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
<Wrench size={20} className="text-amber-500" />
System &amp; Kern-Werkzeuge
</h2>
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
<p className="text-sm font-semibold mb-2 text-slate-900 dark:text-slate-200">Installierte Werkzeuge</p>
{!systemInfo || systemInfo.workers.length === 0 ? (
<p className="text-sm text-slate-500 dark:text-slate-400">Noch kein Worker gemeldet.</p>
) : (
systemInfo.workers.map(w => (
<div key={w.name} className="flex flex-wrap items-center gap-2 mb-1.5">
<span className="text-xs font-mono text-slate-500 dark:text-slate-400">{w.name}</span>
{w.info?.makemkv && (
<span className="text-xs px-2.5 py-0.5 rounded-md font-medium bg-slate-200 dark:bg-slate-800 text-slate-700 dark:text-slate-300 border border-slate-300 dark:border-slate-700">
MakeMKV {w.info.makemkv}
</span>
)}
{w.info?.handbrake && (
<span className="text-xs px-2.5 py-0.5 rounded-md font-medium bg-slate-200 dark:bg-slate-800 text-slate-700 dark:text-slate-300 border border-slate-300 dark:border-slate-700">
HandBrake {w.info.handbrake}
</span>
)}
</div>
))
)}
<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) wichtig wegen der
Disc-Schlüssel-Datenbank.
{' '}<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”)"
/>
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-slate-900 dark:text-slate-200">Update-Check</p>
<Button variant="secondary" size="sm" onClick={updatesPruefen} disabled={updatesBusy}>
{updatesBusy ? 'Prüfe…' : 'Auf Updates prüfen'}
</Button>
</div>
{updates ? (
<div className="space-y-3 text-sm text-slate-700 dark:text-slate-300">
{/* MakeMKV = aktualisierbar → hier ist ein Update ein echtes To-do */}
<div className={updates.makemkv?.update ? 'text-amber-600 dark:text-amber-400' : ''}>
<strong>MakeMKV:</strong> installiert {updates.makemkv?.installiert || '—'}, neueste {updates.makemkv?.verfuegbar || 'unbekannt'}
{updates.makemkv?.update
? (
<p className="text-xs mt-1">
Update verfügbar in der .env <code>MAKEMKV_VERSION={updates.makemkv.verfuegbar}</code> setzen,
dann auf der Rippy-Maschine <code>docker compose build worker &amp;&amp; docker compose up -d worker</code>.
Neue Versionen bringen auch die neueste Disc-Schlüssel-Datenbank mit.
</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>
)
}