(null)
const { theme } = useDarkMode()
useEffect(() => {
@@ -281,9 +283,13 @@ export default function Dashboard() {
-
+ setDetailJobId(job.id)}
+ title="Details zu diesem Titel anzeigen"
+ className={`text-sm font-medium text-left hover:underline transition-colors ${theme === 'dark' ? 'text-slate-100 hover:text-indigo-300' : 'text-slate-900 hover:text-indigo-600'}`}
+ >
{job.title || 'Unbekannt'}
-
+
@@ -327,6 +333,9 @@ export default function Dashboard() {
{/* Live-Log ganz unten — Detail-Ansicht, nicht Haupt-Info */}
+
+ {/* Klick auf einen Job-Titel: Film-/Serien-Info + Job-Fakten */}
+ setDetailJobId(null)} />
)
}
diff --git a/docker/ui/src/pages/Logs.tsx b/docker/ui/src/pages/Logs.tsx
index 6f3def2..edbc1e2 100644
--- a/docker/ui/src/pages/Logs.tsx
+++ b/docker/ui/src/pages/Logs.tsx
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'
import { Terminal, AlertCircle, CheckCircle, RefreshCw } from 'lucide-react'
import { api } from '../lib/api'
import { useDarkMode } from '../context/ThemeContext'
+import { useToast } from '../context/ToastContext'
interface LogEntry {
id: string
@@ -16,18 +17,24 @@ export default function LogsPage() {
const [logs, setLogs] = useState([])
const [filter, setFilter] = useState('all')
const [loading, setLoading] = useState(true)
+ const [refreshing, setRefreshing] = useState(false)
const { theme } = useDarkMode()
+ const { toast } = useToast()
- const refreshLogs = async () => {
+ const refreshLogs = async (manuell = false) => {
// Echte Ereignisse aus der Datenbank — die Mock-Einträge („The Matrix
// gerippt") sind raus. Was hier steht, ist passiert; sonst ist es leer.
+ if (manuell) setRefreshing(true)
try {
const response = await api.get('/logs', { params: { limit: 200 } })
setLogs(response.data)
+ if (manuell) toast('success', 'Logs aktualisiert')
} catch (error) {
console.error('Fehler beim Laden der Logs:', error)
+ if (manuell) toast('error', 'Logs konnten nicht geladen werden')
} finally {
setLoading(false)
+ if (manuell) setRefreshing(false)
}
}
@@ -157,12 +164,32 @@ export default function LogsPage() {
>
Erfolg
+ setFilter('warning')}
+ className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
+ filter === 'warning'
+ ? 'bg-amber-600 text-white'
+ : theme === 'dark' ? 'bg-slate-700 text-slate-300 hover:bg-slate-600' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
+ }`}
+ >
+ Warnung
+
+ setFilter('error')}
+ className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
+ filter === 'error'
+ ? 'bg-rose-600 text-white'
+ : theme === 'dark' ? 'bg-slate-700 text-slate-300 hover:bg-slate-600' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
+ }`}
+ >
+ Fehler
+
refreshLogs(true)}
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${theme === 'dark' ? 'bg-slate-700 text-slate-300 hover:bg-slate-600' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}`}
>
-
+
Neu laden
diff --git a/docker/ui/src/pages/Settings.tsx b/docker/ui/src/pages/Settings.tsx
index 86589e3..25ee51f 100644
--- a/docker/ui/src/pages/Settings.tsx
+++ b/docker/ui/src/pages/Settings.tsx
@@ -1,7 +1,8 @@
import { useState, useEffect } from 'react'
-import { Save, Disc, Cpu, Database, Globe, AlertCircle, CheckCircle, HardDrive } from 'lucide-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'
@@ -20,6 +21,9 @@ interface SettingsState {
transcodeEnabled: boolean
transcodePreset: string
keepOriginal: boolean
+ mediaServer: string
+ workDir: string
+ makemkvAppKey: string
}
const defaultSettings: SettingsState = {
@@ -37,15 +41,29 @@ const defaultSettings: SettingsState = {
transcodeEnabled: true,
transcodePreset: 'H.265 MKV 1080p30',
keepOriginal: false,
+ mediaServer: 'none',
+ workDir: '',
+ makemkvAppKey: '',
}
-type SettingsTab = 'ripping' | 'verarbeitung' | 'worker' | 'speicherziele' | 'apis' | 'benachrichtigungen'
+type SettingsTab = 'ripping' | 'verarbeitung' | 'worker' | 'speicherziele' | 'apis' | 'benachrichtigungen' | 'system'
interface WorkerInfo {
name: string
encoders: string[]
+ info?: { makemkv?: string, handbrake?: string, makemkv_key?: string }
}
+interface SystemInfo {
+ api_version: string
+ plaetze: { name: string, frei_gb: number, gesamt_gb: number }[]
+ workers: WorkerInfo[]
+ makemkv_key_ui: boolean
+ webhook_gesetzt: boolean
+}
+
+import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer'
+
const ENCODER_LABELS: Record = {
'cpu-x264': 'CPU · H.264',
'cpu-x265': 'CPU · H.265',
@@ -57,17 +75,36 @@ export default function SettingsPage() {
const [settings, setSettings] = useState(defaultSettings)
const [activeTab, setActiveTab] = useState('ripping')
const [workers, setWorkers] = useState([])
+ const [systemInfo, setSystemInfo] = useState(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
+ const [testBusy, setTestBusy] = useState(false)
const { theme } = useDarkMode()
+ const { toast } = useToast()
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 {
@@ -93,7 +130,10 @@ export default function SettingsPage() {
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)
}
@@ -130,6 +170,7 @@ export default function SettingsPage() {
setActiveTab('speicherziele')} darkMode={theme === 'dark'} />
setActiveTab('apis')} darkMode={theme === 'dark'} />
setActiveTab('benachrichtigungen')} darkMode={theme === 'dark'} />
+ setActiveTab('system')} darkMode={theme === 'dark'} />
{/* Content */}
@@ -186,12 +227,47 @@ export default function SettingsPage() {
Disk nach erfolgreichem Ripping auswerfen
- handleChange('autoEject', v)}
+ handleChange('autoEject', v)}
darkMode={theme === 'dark'}
/>
+
+ {/* Media-Server: Rippy bereitet die Ablage aufs Zielsystem vor */}
+
+
+
+ Media-Server (Jellyfin & Co.)
+
+
+ Rippy benennt fertige Rips als „Titel (Jahr)" und legt — je nach System —
+ movie.nfo + poster.jpg dazu. Dein Server erkennt den Film dann ohne Nacharbeit.
+
+
+ {MEDIA_SERVER_OPTIONEN.map(o => (
+ 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}
+
+ ))}
+
+
+ {MEDIA_SERVER_OPTIONEN.find(o => o.wert === settings.mediaServer)?.hinweis}
+
+
}
@@ -257,6 +333,29 @@ export default function SettingsPage() {
handleChange('keepOriginal', v)} darkMode={theme === 'dark'} />
+
+ {/* Arbeitsverzeichnis: 4K-UHD-Rohdaten (bis 100 GB) passen nicht
+ auf jede VM-Platte — hier auf eine große Freigabe umlegen */}
+
+
+ Arbeitsverzeichnis für Roh-Rips (optional)
+
+
handleChange('workDir', e.target.value)}
+ placeholder="leer = /app/temp (Container-Platte)"
+ className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent font-mono text-sm ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'}`}
+ />
+
+ Muss unter /app/media liegen (z. B. /app/media/nas-arbeit — Freigabe vorher unter
+ Speicherziele einhängen). Wichtig für 4K-UHD: Rohdaten sind bis 100 GB groß;
+ Rippy prüft den Platz VOR dem Rip und bricht sonst mit Klartext ab.
+ {systemInfo?.plaetze?.length ? (
+ <> Aktuell frei: {systemInfo.plaetze.map(p => `${p.name}: ${p.frei_gb} GB`).join(' · ')}>
+ ) : null}
+
+
}
@@ -358,26 +457,134 @@ export default function SettingsPage() {
}
- {/* Webhook */}
+ {/* Webhook — mit Anleitung und Sofort-Test (Commander-Wunsch 24.07.) */}
{activeTab === 'benachrichtigungen' &&
-
+
Webhook-Benachrichtigungen
-
+
+ Rippy meldet sich, sobald ein Job fertig ist, fehlschlägt oder
+ abgebrochen wird — direkt in deinen Chat. Der Dienst wird an der URL
+ automatisch erkannt, du musst nichts weiter einstellen.
+
+
+ {/* Schritt-für-Schritt: so kommst du an eine Webhook-URL */}
+
+
+
💬 Discord (einfachster Weg)
+
+ Server → Kanal-Zahnrad → Integrationen → Webhooks → „Neuer Webhook" →
+ „Webhook-URL kopieren" und unten einfügen.
+
+
+
+
📱 ntfy (Push aufs Handy, ohne Account)
+
+ ntfy-App installieren, ein Thema abonnieren (z. B. „rippy-tobi-4711") und unten
+ https://ntfy.sh/rippy-tobi-4711 eintragen. Thema = Passwort, also etwas Einmaliges wählen.
+
+
+
+
🔧 Slack / Eigenes
+
+ Slack: „Incoming Webhook" in den App-Einstellungen anlegen. Alles andere
+ (Home Assistant, n8n, eigene Skripte) bekommt JSON: {'{'}"title", "message", "level"{'}'}.
+
+
+
+
Webhook URL
+
+ 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"
+ />
+
+
+ {testBusy ? 'Sende…' : 'Test senden'}
+
+
+
+ „Test senden" prüft die Anbindung sofort — nicht vergessen, danach zu speichern,
+ damit auch der Worker die URL kennt.
+
+
+ }
+
+ {/* System: Kern-Werkzeuge, Versionen, MakeMKV-Key, Speicherplatz */}
+ {activeTab === 'system' &&
+
+
+ System & Kern-Werkzeuge
+
+
+ {/* Werkzeug-Versionen je Worker — ehrlich gemeldet, nicht behauptet */}
+
+
Installierte Werkzeuge
+ {!systemInfo || systemInfo.workers.length === 0 ? (
+
Noch kein Worker gemeldet.
+ ) : (
+ systemInfo.workers.map(w => (
+
+ {w.name}
+ {w.info?.makemkv && (
+
+ MakeMKV {w.info.makemkv}
+
+ )}
+ {w.info?.handbrake && (
+
+ HandBrake {w.info.handbrake}
+
+ )}
+ {w.info?.makemkv_key && (
+
+ {w.info.makemkv_key === 'ui' ? 'Beta-Key: aus dem UI' :
+ w.info.makemkv_key === 'env' ? 'Beta-Key: aus .env' :
+ 'Kein Beta-Key (Blu-ray nur im Testmodus)'}
+
+ )}
+
+ ))
+ )}
+ {systemInfo?.plaetze && systemInfo.plaetze.length > 0 && (
+
+ Speicherplatz: {systemInfo.plaetze.map(p => `${p.name}: ${p.frei_gb} von ${p.gesamt_gb} GB frei`).join(' · ')}
+
+ )}
+
+
+ {/* MakeMKV-Beta-Key: der Monats-Key, OHNE Rebuild aktualisierbar */}
+
+
+ MakeMKV Beta-Key
+
handleChange('notificationWebhook', 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="https://discord.com/api/webhooks/..."
+ 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")'}
/>
- Optional: URL für Benachrichtigungen bei Job-Ende (Discord, Slack, etc.)
+ Der Key wechselt etwa monatlich (MakeMKV-Forum, Thread t=1053). Hier eintragen +
+ speichern — er gilt ab dem nächsten Rip , ganz ohne Neustart oder Rebuild
+ und schlägt den Key aus der .env. DVDs gehen immer auch ohne Key.
+
+
+ Die MakeMKV-Version selbst steckt im Worker-Image — Update per
+ Rebuild: docker compose build worker && docker compose up -d worker (siehe README).
}