3987c31b1e
Ampel / ampel (push) Successful in 28s
1. Update-Erkennung (Commander-Frage 'wie kriegen wir Updates mit?'): GET /system/updates vergleicht installierte Versionen (Worker-Info) mit makemkv.com (Versionsnummer der Download-Seite) und der HandBrake- GitHub-Release-API (releases/latest), 12 h gecacht. Einstellungen -> System: 'Auf Updates prüfen' + fertiger Update-Befehl bei MakeMKV. Selbst-Update gibt es bewusst NICHT (waere Docker-Socket-Zugriff); dafuer ist MAKEMKV_VERSION jetzt per .env uebersteuerbar — Update ohne Code-Aenderung. HandBrake-Abweichung wird als Info dargestellt (Debian-Paket hinkt der offiziellen Version bewusst hinterher). 2. Worker-Anbindung: editierbares Adressfeld statt stillschweigend window.location.hostname — Befund: ueber die externe Domain kopierte Befehle liefen in den Reverse-Proxy (401) und Worker koennen Redis/ Postgres eh nur im Heimnetz erreichen. Amber-Warnung bei oeffentlich aussehender Adresse, Anleitung ergaenzt. 3. 'Einstellungen speichern' ist im Worker-Tab ausgeblendet — dort gibt es nichts zu speichern, der Knopf verwirrte nur. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
861 lines
47 KiB
TypeScript
861 lines
47 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 { 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<string, string> = {
|
|
'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<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 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 (
|
|
<div className="flex items-center justify-center min-h-[60vh]">
|
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className={`max-w-4xl space-y-6 transition-colors duration-300 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
|
<div className="flex flex-col gap-2 transition-colors duration-300">
|
|
<h1 className={`text-3xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Einstellungen</h1>
|
|
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Konfiguriere Rippy nach deinen Wünschen</p>
|
|
</div>
|
|
|
|
{saved && (
|
|
<div className={`p-4 rounded-lg flex items-center gap-3 font-medium transition-colors duration-300 ${theme === 'dark' ? 'bg-emerald-900/30 border border-emerald-700 text-emerald-400' : 'bg-emerald-50 border border-emerald-200 text-emerald-700'}`}>
|
|
<CheckCircle className="flex-shrink-0" size={24} />
|
|
<span>Einstellungen erfolgreich gespeichert!</span>
|
|
</div>
|
|
)}
|
|
|
|
<div className={`rounded-xl shadow-sm overflow-hidden transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border border-slate-700' : 'bg-white border border-slate-200'}`}>
|
|
{/* Tabs — echte Navigation (vorher reine Dekoration ohne onClick) */}
|
|
<div className={`flex transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'} border-b`}>
|
|
<TabButton icon={Disc} label="Ripping" isActive={activeTab === 'ripping'} onClick={() => setActiveTab('ripping')} darkMode={theme === 'dark'} />
|
|
<TabButton icon={Cpu} label="Verarbeitung" isActive={activeTab === 'verarbeitung'} onClick={() => setActiveTab('verarbeitung')} darkMode={theme === 'dark'} />
|
|
<TabButton icon={Cpu} label="Worker" isActive={activeTab === 'worker'} onClick={() => setActiveTab('worker')} darkMode={theme === 'dark'} />
|
|
<TabButton icon={HardDrive} label="Speicherziele" isActive={activeTab === 'speicherziele'} onClick={() => setActiveTab('speicherziele')} darkMode={theme === 'dark'} />
|
|
<TabButton icon={Globe} label="APIs" isActive={activeTab === 'apis'} onClick={() => setActiveTab('apis')} darkMode={theme === 'dark'} />
|
|
<TabButton icon={AlertCircle} label="Benachrichtigungen" isActive={activeTab === 'benachrichtigungen'} onClick={() => setActiveTab('benachrichtigungen')} darkMode={theme === 'dark'} />
|
|
<TabButton icon={Wrench} label="System" isActive={activeTab === 'system'} onClick={() => setActiveTab('system')} darkMode={theme === 'dark'} />
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="p-6 space-y-8 transition-colors duration-300">
|
|
{/* Ripping Settings */}
|
|
{activeTab === 'ripping' && <section>
|
|
<h2 className={`text-lg font-semibold mb-4 flex items-center gap-2 transition-colors duration-300 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
|
<Disc size={20} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} />
|
|
Ripping-Einstellungen
|
|
</h2>
|
|
|
|
<div className="space-y-6 transition-colors duration-300">
|
|
{/* ── Video: DVD · Blu-ray · 4K UHD ─────────────────────── */}
|
|
<p className={`text-xs font-semibold uppercase tracking-wider ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
|
📀 Video — DVD · Blu-ray · 4K UHD
|
|
</p>
|
|
|
|
<div className={`flex items-center justify-between p-4 transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-700/50 rounded-lg' : 'bg-slate-50 rounded-lg'}`}>
|
|
<div className="flex items-center gap-3">
|
|
<div className={`p-2 rounded-lg transition-colors duration-300 ${theme === 'dark' ? 'bg-indigo-900/30 text-indigo-400' : 'bg-indigo-100 text-indigo-600'}`}>
|
|
<Disc size={20} />
|
|
</div>
|
|
<div>
|
|
<h3 className={`font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Vollautomatik: sofort rippen</h3>
|
|
<p className={`text-sm transition-colors duration-300 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
|
Disc erkannt → Rip startet ohne Nachfrage in den passenden Schnellwahl-Ordner
|
|
(Serie → Serien, sonst Filme; CD → Musik). Aus = wie bisher per „Rippen starten"-Dialog.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<Toggle
|
|
checked={settings.autoRipStart}
|
|
onChange={(v) => handleChange('autoRipStart', v)}
|
|
darkMode={theme === 'dark'}
|
|
/>
|
|
</div>
|
|
|
|
<div className={`flex items-center justify-between p-4 transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-700/50 rounded-lg' : 'bg-slate-50 rounded-lg'}`}>
|
|
<div className="flex items-center gap-3">
|
|
<div className={`p-2 rounded-lg transition-colors duration-300 ${theme === 'dark' ? 'bg-purple-900/30 text-purple-400' : 'bg-purple-100 text-purple-600'}`}>
|
|
<Disc size={20} />
|
|
</div>
|
|
<div>
|
|
<h3 className={`font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Nur Hauptfeature</h3>
|
|
<p className={`text-sm transition-colors duration-300 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Rippe bei DVDs/Blu-rays nur das Hauptfeature</p>
|
|
</div>
|
|
</div>
|
|
<Toggle
|
|
checked={settings.mainFeatureOnly}
|
|
onChange={(v) => handleChange('mainFeatureOnly', v)}
|
|
darkMode={theme === 'dark'}
|
|
/>
|
|
</div>
|
|
|
|
<p className={`text-xs px-1 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
|
🌐 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.
|
|
</p>
|
|
|
|
{/* ── Audio-CD ──────────────────────────────────────────── */}
|
|
<p className={`text-xs font-semibold uppercase tracking-wider pt-2 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
|
🎵 Audio-CD
|
|
</p>
|
|
|
|
<div className={`flex items-center justify-between p-4 transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-700/50 rounded-lg' : 'bg-slate-50 rounded-lg'}`}>
|
|
<div className="flex items-center gap-3">
|
|
<div className={`p-2 rounded-lg transition-colors duration-300 ${theme === 'dark' ? 'bg-blue-900/30 text-blue-400' : 'bg-blue-100 text-blue-600'}`}>
|
|
<Disc size={20} />
|
|
</div>
|
|
<div>
|
|
<h3 className={`font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Alle Tracks rippen</h3>
|
|
<p className={`text-sm transition-colors duration-300 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Rippe alle Tracks einer CD (FLAC + MusicBrainz-Tags)</p>
|
|
</div>
|
|
</div>
|
|
<Toggle
|
|
checked={settings.ripAllTracks}
|
|
onChange={(v) => handleChange('ripAllTracks', v)}
|
|
darkMode={theme === 'dark'}
|
|
/>
|
|
</div>
|
|
|
|
{/* ── Allgemein ─────────────────────────────────────────── */}
|
|
<p className={`text-xs font-semibold uppercase tracking-wider pt-2 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
|
Allgemein
|
|
</p>
|
|
|
|
<div className={`flex items-center justify-between p-4 transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-700/50 rounded-lg' : 'bg-slate-50 rounded-lg'}`}>
|
|
<div className="flex items-center gap-3">
|
|
<div className={`p-2 rounded-lg transition-colors duration-300 ${theme === 'dark' ? 'bg-emerald-900/30 text-emerald-400' : 'bg-emerald-100 text-emerald-600'}`}>
|
|
<Disc size={20} />
|
|
</div>
|
|
<div>
|
|
<h3 className={`font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Automatischer Auswurf</h3>
|
|
<p className={`text-sm transition-colors duration-300 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Disk nach erfolgreichem Ripping auswerfen</p>
|
|
</div>
|
|
</div>
|
|
<Toggle
|
|
checked={settings.autoEject}
|
|
onChange={(v) => handleChange('autoEject', v)}
|
|
darkMode={theme === 'dark'}
|
|
/>
|
|
</div>
|
|
|
|
{/* Media-Server: Rippy bereitet die Ablage aufs Zielsystem vor */}
|
|
<div className={`p-4 rounded-lg ${theme === 'dark' ? 'bg-slate-700/50' : 'bg-slate-50'}`}>
|
|
<h3 className={`font-medium mb-1 flex items-center gap-2 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
|
<Tv size={18} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} />
|
|
Media-Server (Jellyfin & Co.)
|
|
</h3>
|
|
<p className={`text-sm mb-3 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
|
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.
|
|
</p>
|
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
|
{MEDIA_SERVER_OPTIONEN.map(o => (
|
|
<button
|
|
key={o.wert}
|
|
onClick={() => handleChange('mediaServer', o.wert)}
|
|
title={o.hinweis}
|
|
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-all ${
|
|
settings.mediaServer === o.wert
|
|
? theme === 'dark'
|
|
? 'bg-indigo-900/40 border-indigo-500 text-indigo-300'
|
|
: 'bg-indigo-50 border-indigo-600 text-indigo-700'
|
|
: theme === 'dark'
|
|
? 'bg-slate-800 border-slate-600 text-slate-400 hover:border-slate-500'
|
|
: 'bg-white border-slate-300 text-slate-600 hover:border-slate-400'
|
|
}`}
|
|
>
|
|
{o.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<p className={`text-xs mt-2 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
|
{MEDIA_SERVER_OPTIONEN.find(o => o.wert === settings.mediaServer)?.hinweis}
|
|
</p>
|
|
|
|
{/* Jellyfin/Emby: nach jedem fertigen Rip die Bibliothek scannen */}
|
|
{(settings.mediaServer === 'jellyfin' || settings.mediaServer === 'emby') && (
|
|
<div className={`mt-4 pt-4 border-t space-y-3 ${theme === 'dark' ? 'border-slate-600' : 'border-slate-200'}`}>
|
|
<p className={`text-sm ${theme === 'dark' ? 'text-slate-300' : 'text-slate-600'}`}>
|
|
<strong>Bibliotheks-Refresh</strong> (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.
|
|
</p>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
<input
|
|
type="text"
|
|
value={settings.jellyfinUrl}
|
|
onChange={(e) => 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'}`}
|
|
/>
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="password"
|
|
value={settings.jellyfinApiKey}
|
|
onChange={(e) => 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'}`}
|
|
/>
|
|
<button
|
|
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}
|
|
className="px-3 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 transition-colors disabled:opacity-40"
|
|
>
|
|
Testen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</section>}
|
|
|
|
{/* Verarbeitung: Kompressions-Stufe nach dem Rip */}
|
|
{activeTab === 'verarbeitung' && <section>
|
|
<h2 className={`text-lg font-semibold mb-4 flex items-center gap-2 transition-colors duration-300 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
|
<Cpu size={20} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} />
|
|
Verarbeitung (Kompression)
|
|
</h2>
|
|
<p className={`text-sm mb-4 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
|
MakeMKV rippt verlustfrei (der einzige Weg durch den Kopierschutz), HandBrake
|
|
komprimiert danach auf Arbeitsgröße. Die Roh-Datei wird nach Erfolg gelöscht.
|
|
</p>
|
|
|
|
{/* Ehrliche Selbstauskunft: was können die Worker WIRKLICH? */}
|
|
<div className={`rounded-lg p-4 mb-4 ${theme === 'dark' ? 'bg-slate-700/50' : 'bg-slate-50'}`}>
|
|
<p className={`text-sm font-medium mb-2 ${theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}`}>Verfügbare Encoder</p>
|
|
{workers.length === 0 ? (
|
|
<p className={`text-sm ${theme === 'dark' ? 'text-slate-500' : '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 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>{w.name}</span>
|
|
{w.encoders.map(e => (
|
|
<span key={e} className={`inline-block text-xs px-2 py-0.5 rounded mr-1 ${e.startsWith('cpu') ? (theme === 'dark' ? 'bg-slate-800 text-slate-300' : 'bg-slate-200 text-slate-600') : 'bg-emerald-600/20 text-emerald-500 font-medium'}`}>
|
|
{ENCODER_LABELS[e] || e}
|
|
</span>
|
|
))}
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-4 transition-colors duration-300">
|
|
<div className={`flex items-center justify-between p-4 rounded-lg ${theme === 'dark' ? 'bg-slate-700/50' : 'bg-slate-50'}`}>
|
|
<div>
|
|
<h3 className={`font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Nach dem Rip komprimieren</h3>
|
|
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>x265 statt 40-GB-Rohdatei (empfohlen)</p>
|
|
</div>
|
|
<Toggle checked={settings.transcodeEnabled} onChange={(v) => handleChange('transcodeEnabled', v)} darkMode={theme === 'dark'} />
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label className={`block text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
|
|
HandBrake-Preset
|
|
</label>
|
|
<select
|
|
value={settings.transcodePreset}
|
|
onChange={(e) => handleChange('transcodePreset', 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'}`}
|
|
>
|
|
<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>
|
|
|
|
<div className={`flex items-center justify-between p-4 rounded-lg ${theme === 'dark' ? 'bg-slate-700/50' : 'bg-slate-50'}`}>
|
|
<div>
|
|
<h3 className={`font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Original behalten</h3>
|
|
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Verlustfreie Roh-Datei zusätzlich aufheben (viel Speicher!)</p>
|
|
</div>
|
|
<Toggle checked={settings.keepOriginal} onChange={(v) => handleChange('keepOriginal', v)} darkMode={theme === 'dark'} />
|
|
</div>
|
|
|
|
{/* Arbeitsverzeichnis: 4K-UHD-Rohdaten (bis 100 GB) passen nicht
|
|
auf jede VM-Platte — hier auf eine große Freigabe umlegen */}
|
|
<div className="space-y-2">
|
|
<label className={`block text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
|
|
Arbeitsverzeichnis für Roh-Rips (optional)
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={settings.workDir}
|
|
onChange={(e) => 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'}`}
|
|
/>
|
|
<p className={`text-xs ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
|
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}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</section>}
|
|
|
|
{/* Worker: Encoding-Maschinen im Netz */}
|
|
{activeTab === 'worker' && <section>
|
|
<h2 className={`text-lg font-semibold mb-4 flex items-center gap-2 transition-colors duration-300 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
|
<Cpu size={20} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} />
|
|
Encoding-Worker
|
|
</h2>
|
|
<WorkerVerwaltung />
|
|
</section>}
|
|
|
|
{/* Speicherziele: Netzwerk-Mounts aus dem UI */}
|
|
{activeTab === 'speicherziele' && <section>
|
|
<h2 className={`text-lg font-semibold mb-4 flex items-center gap-2 transition-colors duration-300 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
|
<HardDrive size={20} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} />
|
|
Speicherziele
|
|
</h2>
|
|
|
|
{/* Schnellwahl-Ordner: die drei Knöpfe im "Rippen starten"-Dialog */}
|
|
<div className={`rounded-lg p-4 mb-6 border ${theme === 'dark' ? 'border-slate-700 bg-slate-800/50' : 'border-slate-200 bg-slate-50'}`}>
|
|
<h3 className={`font-medium mb-1 flex items-center gap-2 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
|
<Database size={16} className="text-indigo-500" /> Schnellwahl beim Rippen
|
|
</h3>
|
|
<p className={`text-xs mb-3 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
|
Beim Start eines Rips fragt Rippy „Wohin?" — diese drei Ordner erscheinen
|
|
dort als 🎥/📺/🎵-Schnellwahl. Hier legst du nur fest, wie sie heißen.
|
|
</p>
|
|
<div className="grid grid-cols-3 gap-3">
|
|
{([['movieDir', '🎥 Filme'], ['seriesDir', '📺 Serien'], ['musicDir', '🎵 Musik']] as const).map(([feld, label]) => (
|
|
<div key={feld}>
|
|
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>{label}</label>
|
|
<input
|
|
type="text"
|
|
value={settings[feld]}
|
|
onChange={(e) => 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'}`}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<StorageMounts />
|
|
</section>}
|
|
|
|
{/* API Keys */}
|
|
{activeTab === 'apis' && <section>
|
|
<h2 className={`text-lg font-semibold mb-4 flex items-center gap-2 transition-colors duration-300 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
|
<Globe size={20} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} />
|
|
API-Schlüssel
|
|
</h2>
|
|
|
|
{/* Live-Prüfung: funktioniert der eingetragene Key WIRKLICH? */}
|
|
<div className={`rounded-lg p-4 mb-4 flex flex-wrap items-center gap-3 ${theme === 'dark' ? 'bg-slate-700/50' : 'bg-slate-50'}`}>
|
|
<button
|
|
onClick={quellenPruefen}
|
|
disabled={quellenBusy}
|
|
className="px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 transition-colors disabled:opacity-40"
|
|
>
|
|
{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 ${
|
|
status === 'ok' ? 'bg-emerald-600/20 text-emerald-500'
|
|
: status === 'kein_key' ? (theme === 'dark' ? 'bg-slate-800 text-slate-400' : 'bg-slate-200 text-slate-500')
|
|
: 'bg-rose-600/20 text-rose-500'
|
|
}`}
|
|
>
|
|
{quelle.toUpperCase()}: {status === 'ok' ? '✓ funktioniert' : status === 'kein_key' ? 'kein Key' : '✗ Fehler'}
|
|
</span>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<span className={`text-xs ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
|
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).
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-4 transition-colors duration-300">
|
|
<div className="space-y-2 transition-colors duration-300">
|
|
<label className={`block text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
|
|
TMDB API Key
|
|
</label>
|
|
<input
|
|
type="password"
|
|
value={settings.tmdbApiKey}
|
|
onChange={(e) => 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"
|
|
/>
|
|
<p className={`text-xs ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
|
Hol dir einen Key auf <a href="https://www.themoviedb.org/" target="_blank" rel="noopener noreferrer" className={`${theme === 'dark' ? 'text-indigo-400 hover:underline' : 'text-indigo-600 hover:underline'}`}>themoviedb.org</a>
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2 transition-colors duration-300">
|
|
<label className={`block text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
|
|
TVDb API Key
|
|
</label>
|
|
<input
|
|
type="password"
|
|
value={settings.tvdbApiKey}
|
|
onChange={(e) => 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"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2 transition-colors duration-300">
|
|
<label className={`block text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
|
|
OMDb API Key (Fallback)
|
|
</label>
|
|
<input
|
|
type="password"
|
|
value={settings.omdbApiKey}
|
|
onChange={(e) => 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"
|
|
/>
|
|
<p className={`text-xs ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
|
Zweite Quelle, wenn TMDB nichts findet — kostenloser Key auf <a href="https://www.omdbapi.com/apikey.aspx" target="_blank" rel="noopener noreferrer" className={`${theme === 'dark' ? 'text-indigo-400 hover:underline' : 'text-indigo-600 hover:underline'}`}>omdbapi.com</a>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</section>}
|
|
|
|
{/* Webhook — mit Anleitung und Sofort-Test (Commander-Wunsch 24.07.) */}
|
|
{activeTab === 'benachrichtigungen' && <section>
|
|
<h2 className={`text-lg font-semibold mb-2 flex items-center gap-2 transition-colors duration-300 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
|
<AlertCircle size={20} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} />
|
|
Webhook-Benachrichtigungen
|
|
</h2>
|
|
<p className={`text-sm mb-4 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
|
Rippy meldet sich, sobald ein Job <strong>fertig</strong> ist, <strong>fehlschlägt</strong> oder
|
|
<strong> abgebrochen</strong> wird — direkt in deinen Chat. Der Dienst wird an der URL
|
|
automatisch erkannt, du musst nichts weiter einstellen.
|
|
</p>
|
|
|
|
{/* Schritt-für-Schritt: so kommst du an eine Webhook-URL */}
|
|
<div className={`rounded-lg p-4 mb-4 space-y-3 text-sm ${theme === 'dark' ? 'bg-slate-700/50' : 'bg-slate-50'}`}>
|
|
<div>
|
|
<p className={`font-medium ${theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}`}>💬 Discord (einfachster Weg)</p>
|
|
<p className={theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}>
|
|
Server → Kanal-Zahnrad → Integrationen → Webhooks → „Neuer Webhook" →
|
|
„Webhook-URL kopieren" und unten einfügen.
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<p className={`font-medium ${theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}`}>📱 ntfy (Push aufs Handy, ohne Account)</p>
|
|
<p className={theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}>
|
|
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.
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<p className={`font-medium ${theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}`}>🔧 Slack / Eigenes</p>
|
|
<p className={theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}>
|
|
Slack: „Incoming Webhook" in den App-Einstellungen anlegen. Alles andere
|
|
(Home Assistant, n8n, eigene Skripte) bekommt JSON: {'{'}"title", "message", "level"{'}'}.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2 transition-colors duration-300">
|
|
<label className={`block text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
|
|
Webhook URL
|
|
</label>
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="text"
|
|
value={settings.notificationWebhook}
|
|
onChange={(e) => 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"
|
|
/>
|
|
<button
|
|
onClick={webhookTesten}
|
|
disabled={!settings.notificationWebhook || testBusy}
|
|
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 transition-colors disabled:opacity-40"
|
|
>
|
|
<Send size={15} />
|
|
{testBusy ? 'Sende…' : 'Test senden'}
|
|
</button>
|
|
</div>
|
|
<p className={`text-xs ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
|
„Test senden" prüft die Anbindung sofort — nicht vergessen, danach zu speichern,
|
|
damit auch der Worker die URL kennt.
|
|
</p>
|
|
</div>
|
|
</section>}
|
|
|
|
{/* System: Kern-Werkzeuge, Versionen, MakeMKV-Key, Speicherplatz */}
|
|
{activeTab === 'system' && <section>
|
|
<h2 className={`text-lg font-semibold mb-4 flex items-center gap-2 transition-colors duration-300 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
|
<Wrench size={20} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} />
|
|
System & Kern-Werkzeuge
|
|
</h2>
|
|
|
|
{/* Werkzeug-Versionen je Worker — ehrlich gemeldet, nicht behauptet */}
|
|
<div className={`rounded-lg p-4 mb-4 ${theme === 'dark' ? 'bg-slate-700/50' : 'bg-slate-50'}`}>
|
|
<p className={`text-sm font-medium mb-2 ${theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}`}>Installierte Werkzeuge</p>
|
|
{!systemInfo || systemInfo.workers.length === 0 ? (
|
|
<p className={`text-sm ${theme === 'dark' ? 'text-slate-500' : '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 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>{w.name}</span>
|
|
{w.info?.makemkv && (
|
|
<span className={`text-xs px-2 py-0.5 rounded ${theme === 'dark' ? 'bg-slate-800 text-slate-300' : 'bg-slate-200 text-slate-600'}`}>
|
|
MakeMKV {w.info.makemkv}
|
|
</span>
|
|
)}
|
|
{w.info?.handbrake && (
|
|
<span className={`text-xs px-2 py-0.5 rounded ${theme === 'dark' ? 'bg-slate-800 text-slate-300' : 'bg-slate-200 text-slate-600'}`}>
|
|
HandBrake {w.info.handbrake}
|
|
</span>
|
|
)}
|
|
{w.info?.makemkv_key && (
|
|
<span className={`text-xs px-2 py-0.5 rounded ${w.info.makemkv_key === 'keiner' ? 'bg-amber-600/20 text-amber-500' : 'bg-emerald-600/20 text-emerald-500'}`}>
|
|
{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)'}
|
|
</span>
|
|
)}
|
|
</div>
|
|
))
|
|
)}
|
|
{systemInfo?.plaetze && systemInfo.plaetze.length > 0 && (
|
|
<p className={`text-xs mt-3 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
|
Speicherplatz: {systemInfo.plaetze.map(p => `${p.name}: ${p.frei_gb} von ${p.gesamt_gb} GB frei`).join(' · ')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* MakeMKV-Beta-Key: der Monats-Key, OHNE Rebuild aktualisierbar */}
|
|
<div className="space-y-2">
|
|
<label className={`block text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
|
|
MakeMKV Beta-Key
|
|
</label>
|
|
<input
|
|
type="password"
|
|
value={settings.makemkvAppKey}
|
|
onChange={(e) => 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")'}
|
|
/>
|
|
<p className={`text-xs ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
|
Der Key wechselt etwa monatlich (MakeMKV-Forum, Thread t=1053). Hier eintragen +
|
|
speichern — er gilt ab dem <strong>nächsten Rip</strong>, ganz ohne Neustart oder Rebuild
|
|
und schlägt den Key aus der .env. DVDs gehen immer auch ohne Key.
|
|
</p>
|
|
<p className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
|
Die MakeMKV-<strong>Version</strong> selbst steckt im Worker-Image — Update per
|
|
Rebuild: <code>docker compose build worker && docker compose up -d worker</code> (siehe README).
|
|
</p>
|
|
</div>
|
|
|
|
{/* Update-Check: Rippy MELDET Updates ehrlich — selbst updaten
|
|
kann es nicht (die Versionen stecken im Worker-Image) */}
|
|
<div className={`rounded-lg p-4 mt-6 ${theme === 'dark' ? 'bg-slate-700/50' : 'bg-slate-50'}`}>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<p className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}`}>Update-Check</p>
|
|
<button
|
|
onClick={updatesPruefen}
|
|
disabled={updatesBusy}
|
|
className="px-3 py-1.5 rounded-lg bg-indigo-600 text-white text-xs font-medium hover:bg-indigo-700 transition-colors disabled:opacity-40"
|
|
>
|
|
{updatesBusy ? 'Prüfe…' : 'Auf Updates prüfen'}
|
|
</button>
|
|
</div>
|
|
{updates ? (
|
|
<div className="space-y-2 text-sm">
|
|
<div className={updates.makemkv?.update ? 'text-amber-500' : (theme === 'dark' ? 'text-slate-300' : 'text-slate-600')}>
|
|
<strong>MakeMKV:</strong> installiert {updates.makemkv?.installiert || '—'},
|
|
aktuell {updates.makemkv?.verfuegbar || 'unbekannt'}
|
|
{updates.makemkv?.update && (
|
|
<span className="block text-xs mt-1">
|
|
Update: 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 Version bringt auch die neueste Disc-Schlüssel-Datenbank mit.
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className={theme === 'dark' ? 'text-slate-300' : 'text-slate-600'}>
|
|
<strong>HandBrake:</strong> Docker-Worker {updates.handbrake?.installiert || '—'} (Debian-Paket),
|
|
neueste offizielle Version {updates.handbrake?.verfuegbar || 'unbekannt'}
|
|
<span className={`block text-xs mt-1 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
|
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.
|
|
</span>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<p className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
|
Prüft gegen makemkv.com und die HandBrake-GitHub-Releases (Ergebnis wird 12 h gemerkt).
|
|
</p>
|
|
)}
|
|
</div>
|
|
</section>}
|
|
|
|
{/* Save Button — nicht im Worker-Tab: dort gibt es nichts zu
|
|
speichern (Liste + Anbinde-Befehle), der Knopf verwirrte nur */}
|
|
{activeTab !== 'worker' && (
|
|
<div className={`flex items-center justify-end gap-4 pt-6 transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'} border-t`}>
|
|
<button
|
|
onClick={handleSave}
|
|
disabled={saving}
|
|
className="flex items-center gap-2 px-6 py-2.5 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{saving ? (
|
|
<>
|
|
<div className="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent"></div>
|
|
Speichern...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Save size={20} />
|
|
Einstellungen speichern
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function TabButton({ icon: Icon, label, isActive, onClick, darkMode }: { icon: any, label: string, isActive: boolean, onClick: () => void, darkMode: boolean }) {
|
|
const { theme } = useDarkMode()
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
className={`px-6 py-3 text-sm font-medium transition-colors border-b-2 ${
|
|
isActive
|
|
? `${darkMode ? 'border-indigo-500 text-indigo-400' : 'border-indigo-600 text-indigo-600'}`
|
|
: `${darkMode ? 'border-transparent text-slate-400 hover:text-slate-300' : 'border-transparent text-slate-500 hover:text-slate-700'}`
|
|
}`}
|
|
>
|
|
{label}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
function Toggle({ checked, onChange, darkMode }: { checked: boolean, onChange: (v: boolean) => void, darkMode: boolean }) {
|
|
const { theme } = useDarkMode()
|
|
return (
|
|
<button
|
|
onClick={() => onChange(!checked)}
|
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
|
checked ? 'bg-indigo-600' : darkMode ? 'bg-slate-600' : 'bg-slate-300'
|
|
}`}
|
|
>
|
|
<span
|
|
className={`${
|
|
checked ? 'translate-x-6' : 'translate-x-1'
|
|
} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`}
|
|
/>
|
|
</button>
|
|
)
|
|
}
|