57171b4c88
Ampel / ampel (push) Successful in 28s
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>
728 lines
33 KiB
TypeScript
728 lines
33 KiB
TypeScript
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 & 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 & 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 & 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 & 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 && 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>
|
||
)
|
||
}
|