diff --git a/docker/ui/index.html b/docker/ui/index.html index a87e8d6..a7dc3e5 100644 --- a/docker/ui/index.html +++ b/docker/ui/index.html @@ -2,7 +2,7 @@ - + Rippy — Automatisches Ripping-System diff --git a/docker/ui/public/favicon.svg b/docker/ui/public/favicon.svg new file mode 100644 index 0000000..0155e33 --- /dev/null +++ b/docker/ui/public/favicon.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/docker/ui/src/App.tsx b/docker/ui/src/App.tsx index 587e466..28749c6 100644 --- a/docker/ui/src/App.tsx +++ b/docker/ui/src/App.tsx @@ -76,6 +76,10 @@ function App() { {/* Abmelden-Knopf entfernt (23.07.): es gibt (noch) keinen Login-Flow im UI — ein toter Knopf gaukelt nur einen vor. */} + + {/* Main Content */} diff --git a/docker/ui/src/components/DeviceDiscovery.tsx b/docker/ui/src/components/DeviceDiscovery.tsx index 5871426..5212134 100644 --- a/docker/ui/src/components/DeviceDiscovery.tsx +++ b/docker/ui/src/components/DeviceDiscovery.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react' import { HardDrive, Disc, AlertCircle, CheckCircle, RefreshCw } from 'lucide-react' import { api } from '../lib/api' import { useDarkMode } from '../context/ThemeContext' +import { useToast } from '../context/ToastContext' import RipTargetModal from './RipTargetModal' import MetadataKorrektur from './MetadataKorrektur' @@ -38,21 +39,29 @@ function posterUrl(disc?: DiscInfo): string | null { export default function DeviceDiscovery() { const [devices, setDevices] = useState([]) const [loading, setLoading] = useState(true) + const [refreshing, setRefreshing] = useState(false) const [expandedId, setExpandedId] = useState(null) const [actionFeedback, setActionFeedback] = useState(null) const [actionBusy, setActionBusy] = useState(false) const [modalDevice, setModalDevice] = useState(null) const [korrekturDevice, setKorrekturDevice] = useState(null) const { theme } = useDarkMode() + const { toast } = useToast() - const refreshDevices = async () => { + const refreshDevices = async (manuell = false) => { + // manuell = Klick auf den Refresh-Knopf: dreht das Icon und quittiert + // per Toast — vorher gab der Knopf KEIN sichtbares Feedback. + if (manuell) setRefreshing(true) try { const response = await api.get('/devices') setDevices(response.data) + if (manuell) toast('success', 'Laufwerke aktualisiert') } catch (error) { console.error('Fehler beim Laden der Geräte:', error) + if (manuell) toast('error', 'Laufwerke konnten nicht geladen werden') } finally { setLoading(false) + if (manuell) setTimeout(() => setRefreshing(false), 400) } } @@ -65,8 +74,11 @@ export default function DeviceDiscovery() { ...(targetDir ? { target_dir: targetDir } : {}), }) setActionFeedback(`✓ Job angelegt (${response.data.id.slice(0, 8)}…) — Fortschritt im Dashboard`) + toast('success', 'Rip gestartet — Fortschritt unten bei „Neueste Jobs"') } catch (error: any) { - setActionFeedback(`✗ ${error?.response?.data?.detail || 'Job konnte nicht angelegt werden'}`) + const detail = error?.response?.data?.detail || 'Job konnte nicht angelegt werden' + setActionFeedback(`✗ ${detail}`) + toast('error', detail) } finally { setActionBusy(false) } @@ -78,9 +90,12 @@ export default function DeviceDiscovery() { try { await api.post(`/devices/${device.id}/eject`) setActionFeedback('✓ Disc ausgeworfen') + toast('success', 'Disc ausgeworfen') refreshDevices() } catch (error: any) { - setActionFeedback(`✗ ${error?.response?.data?.detail || 'Auswurf fehlgeschlagen'}`) + const detail = error?.response?.data?.detail || 'Auswurf fehlgeschlagen' + setActionFeedback(`✗ ${detail}`) + toast('error', detail) } finally { setActionBusy(false) } @@ -135,11 +150,11 @@ export default function DeviceDiscovery() {

Erkannte Geräte mit automatischer Typ-Erkennung

diff --git a/docker/ui/src/components/FirstRunWizard.tsx b/docker/ui/src/components/FirstRunWizard.tsx index 31a1fb4..cb5d256 100644 --- a/docker/ui/src/components/FirstRunWizard.tsx +++ b/docker/ui/src/components/FirstRunWizard.tsx @@ -1,7 +1,8 @@ import { useState, useEffect } from 'react' -import { Disc, Cpu, Globe, CheckCircle, ArrowRight } from 'lucide-react' +import { Disc, Cpu, Globe, Tv, CheckCircle, ArrowRight } from 'lucide-react' import { api } from '../lib/api' import { useDarkMode } from '../context/ThemeContext' +import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer' // Erster Start: Rippy universell einrichten (Commander-Ziel: All-in-one, // weitergebbar — keine Handarbeit in .env-Dateien nötig). @@ -22,6 +23,7 @@ const ENCODER_LABELS: Record = { export default function FirstRunWizard({ onDone }: { onDone: () => void }) { const [tmdbKey, setTmdbKey] = useState('') const [omdbKey, setOmdbKey] = useState('') + const [mediaServer, setMediaServer] = useState('jellyfin') const [transcodeEnabled, setTranscodeEnabled] = useState(true) const [preset, setPreset] = useState('H.265 MKV 1080p30') const [workers, setWorkers] = useState([]) @@ -44,6 +46,7 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) { ...bestehende, ...(tmdbKey ? { tmdbApiKey: tmdbKey } : {}), ...(omdbKey ? { omdbApiKey: omdbKey } : {}), + mediaServer, transcodeEnabled, transcodePreset: preset, }) @@ -94,6 +97,41 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) { + {/* Media-Server: worauf soll Rippy die Ablage vorbereiten? */} +
+

+ Dein Media-Server +

+

+ Rippy benennt fertige Rips als „Titel (Jahr)" und legt für Jellyfin/Emby/Kodi + zusätzlich movie.nfo + poster.jpg dazu — dein Server erkennt alles ohne Nacharbeit. +

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

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

+
+ {/* Verarbeitung + erkannte Hardware */}

diff --git a/docker/ui/src/components/JobDetailModal.tsx b/docker/ui/src/components/JobDetailModal.tsx new file mode 100644 index 0000000..b0bd839 --- /dev/null +++ b/docker/ui/src/components/JobDetailModal.tsx @@ -0,0 +1,173 @@ +import { useState, useEffect } from 'react' +import { X, Film, Loader2, FolderOpen, AlertCircle } from 'lucide-react' +import { api } from '../lib/api' +import { useDarkMode } from '../context/ThemeContext' + +// Klick auf den Titel in „Neueste Jobs" (Commander-Wunsch 24.07.): sofort +// sehen, WAS da gerippt wurde — Poster, Jahr, Beschreibung — plus die +// nüchternen Job-Fakten (Ziel, Ausgabepfad, Fehler). + +interface JobMeta { + year?: number + overview?: string + poster_path?: string + genres?: string[] + runtime?: number + type?: string + source?: string +} + +interface JobDetail { + id: string + type: string + status: string + device: string + startTime: string + endTime?: string + progress: number + title?: string + error?: string + target_dir?: string + output_path?: string + meta?: JobMeta | null +} + +// TMDB liefert relative Poster-Pfade, OMDb/Jikan absolute URLs +function posterUrl(meta?: JobMeta | null): string | null { + const p = meta?.poster_path + if (!p) return null + return p.startsWith('http') ? p : `https://image.tmdb.org/t/p/w342${p}` +} + +const STATUS_LABEL: Record = { + pending: 'Wartend', + processing: 'Rippen', + transcoding: 'Komprimieren', + canceling: 'Wird abgebrochen…', + completed: 'Fertig', + failed: 'Fehler', +} + +export default function JobDetailModal({ jobId, onClose }: { jobId: string | null, onClose: () => void }) { + const [detail, setDetail] = useState(null) + const [laedt, setLaedt] = useState(false) + const { theme } = useDarkMode() + + useEffect(() => { + if (!jobId) { + setDetail(null) + return + } + setLaedt(true) + api.get(`/jobs/${jobId}/detail`) + .then(r => setDetail(r.data)) + .catch(() => setDetail(null)) + .finally(() => setLaedt(false)) + }, [jobId]) + + if (!jobId) return null + + const meta = detail?.meta + const poster = posterUrl(meta) + + return ( +
+
e.stopPropagation()} + > +
+

+ {detail?.title || 'Job-Details'} +

+ +
+ + {laedt ? ( +
+ +
+ ) : !detail ? ( +

+ Job nicht gefunden. +

+ ) : ( +
+ {/* Film/Serien-Info aus der Disc-Erkennung */} +
+ {poster ? ( + {detail.title + ) : ( +
+ +
+ )} +
+

+ {detail.title || 'Unbekannt'} + {meta?.year ? ({meta.year}) : null} +

+

+ {meta?.type === 'tv' ? 'Serie' : 'Film'} + {meta?.source ? ` · Quelle: ${meta.source}` : ''} + {meta?.runtime ? ` · ${meta.runtime} min` : ''} +

+ {meta?.genres && meta.genres.length > 0 && ( +
+ {meta.genres.map(g => ( + {g} + ))} +
+ )} + {meta?.overview ? ( +

+ {meta.overview} +

+ ) : ( +

+ Keine Metadaten zu diesem Job gespeichert — die Disc wurde ohne Erkennung gerippt. +

+ )} +
+
+ + {/* Nüchterne Job-Fakten */} +
+ Status + + {STATUS_LABEL[detail.status] || detail.status}{detail.progress > 0 && detail.progress < 100 ? ` · ${detail.progress} %` : ''} + + Laufwerk + {detail.device || '—'} + Gestartet + + {detail.startTime ? new Date(detail.startTime).toLocaleString('de-DE') : '—'} + + Beendet + + {detail.endTime ? new Date(detail.endTime).toLocaleString('de-DE') : '—'} + + {(detail.output_path || detail.target_dir) && ( + <> + Ablage + + {detail.output_path || detail.target_dir} + + + )} +
+ + {detail.error && ( +
+ + {detail.error} +
+ )} +
+ )} +
+
+ ) +} diff --git a/docker/ui/src/components/RipTargetModal.tsx b/docker/ui/src/components/RipTargetModal.tsx index 25d1f06..0b6139d 100644 --- a/docker/ui/src/components/RipTargetModal.tsx +++ b/docker/ui/src/components/RipTargetModal.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react' -import { Folder, FolderOpen, ArrowUp, CheckCircle } from 'lucide-react' +import { Folder, FolderOpen, File, ArrowUp, CheckCircle } from 'lucide-react' import { api } from '../lib/api' import { useDarkMode } from '../context/ThemeContext' @@ -34,6 +34,7 @@ export default function RipTargetModal({ isOpen, onClose, onSave }: RipTargetMod const [browsePath, setBrowsePath] = useState('/app/media') const [browseParent, setBrowseParent] = useState(null) const [browseDirs, setBrowseDirs] = useState([]) + const [browseFiles, setBrowseFiles] = useState<{ name: string, size_mb: number | null }[]>([]) const [customPath, setCustomPath] = useState('') const { theme } = useDarkMode() @@ -58,8 +59,10 @@ export default function RipTargetModal({ isOpen, onClose, onSave }: RipTargetMod setBrowsePath(r.data.path) setBrowseParent(r.data.parent) setBrowseDirs(r.data.dirs) + setBrowseFiles(r.data.files || []) } catch { setBrowseDirs([]) + setBrowseFiles([]) } } @@ -134,19 +137,32 @@ export default function RipTargetModal({ isOpen, onClose, onSave }: RipTargetMod
- {browseDirs.length === 0 ? ( -

Keine Unterordner

+ {browseDirs.length === 0 && browseFiles.length === 0 ? ( +

Ordner ist leer

) : ( - browseDirs.map(d => ( - - )) + <> + {browseDirs.map(d => ( + + ))} + {browseFiles.map(f => ( +
+ + {f.name} + {f.size_mb != null && ( + + {f.size_mb >= 1024 ? `${(f.size_mb / 1024).toFixed(1)} GB` : `${f.size_mb} MB`} + + )} +
+ ))} + )}
diff --git a/docker/ui/src/components/StorageMounts.tsx b/docker/ui/src/components/StorageMounts.tsx index 3b4a92d..0f65c04 100644 --- a/docker/ui/src/components/StorageMounts.tsx +++ b/docker/ui/src/components/StorageMounts.tsx @@ -1,7 +1,8 @@ import { useState, useEffect } from 'react' -import { HardDrive, Plus, Trash2, CheckCircle, AlertCircle, RefreshCw, Folder, ArrowUp, FolderPlus } from 'lucide-react' +import { HardDrive, Plus, Trash2, CheckCircle, AlertCircle, RefreshCw, Folder, File, ArrowUp, FolderPlus } from 'lucide-react' import { api } from '../lib/api' import { useDarkMode } from '../context/ThemeContext' +import { useToast } from '../context/ToastContext' // Netzwerk-Speicherziele (NAS/PC-Freigaben) direkt aus dem UI einhängen — // jedes Ziel erscheint danach in der Ziel-Auswahl beim Rippen. @@ -38,8 +39,10 @@ export default function StorageMounts() { const [browsePath, setBrowsePath] = useState('/app/media') const [browseParent, setBrowseParent] = useState(null) const [browseDirs, setBrowseDirs] = useState<{ name: string, path: string }[]>([]) + const [browseFiles, setBrowseFiles] = useState<{ name: string, size_mb: number | null }[]>([]) const [neuerOrdner, setNeuerOrdner] = useState('') const { theme } = useDarkMode() + const { toast } = useToast() const browsen = async (pfad: string) => { try { @@ -47,8 +50,10 @@ export default function StorageMounts() { setBrowsePath(r.data.path) setBrowseParent(r.data.parent) setBrowseDirs(r.data.dirs) + setBrowseFiles(r.data.files || []) } catch { setBrowseDirs([]) + setBrowseFiles([]) } } @@ -64,7 +69,7 @@ export default function StorageMounts() { } } - const laden = async () => { + const laden = async (manuell = false) => { try { const [m, t] = await Promise.all([ api.get('/storage-mounts'), @@ -72,8 +77,9 @@ export default function StorageMounts() { ]) setMounts(m.data) setTargets(t.data) + if (manuell) toast('success', 'Speicherziele aktualisiert') } catch { - /* API weg — Anzeige bleibt leer */ + if (manuell) toast('error', 'Speicherziele konnten nicht geladen werden') } } @@ -105,20 +111,25 @@ export default function StorageMounts() { setBusy(true) setFeedback(null) const quelle = typ === 'cifs' && host && selectedShare ? `//${host}/${selectedShare}` : source + const zielName = name try { const r = await api.post('/storage-mounts', { name, type: typ, source: quelle, ...(username ? { username, password } : {}), }) - setFeedback( - r.data.writable - ? `✓ „${name}" eingehängt — Schreibtest OK, ab sofort als Rip-Ziel wählbar` - : `⚠️ „${name}" eingehängt, aber NUR LESBAR — Freigabe-Rechte prüfen!` - ) + if (r.data.writable) { + setFeedback(`✓ „${zielName}" eingehängt — Schreibtest OK, ab sofort als Rip-Ziel wählbar`) + toast('success', `„${zielName}" eingehängt — Schreibtest OK`) + } else { + setFeedback(`⚠️ „${zielName}" eingehängt, aber NUR LESBAR — Freigabe-Rechte prüfen!`) + toast('error', `„${zielName}" ist nur lesbar — Freigabe-Rechte prüfen`) + } setName(''); setSource(''); setHost(''); setShares([]); setSelectedShare(''); setUsername(''); setPassword('') laden() } catch (e: any) { - setFeedback(`✗ ${e?.response?.data?.detail || 'Einhängen fehlgeschlagen'}`) + const detail = e?.response?.data?.detail || 'Einhängen fehlgeschlagen' + setFeedback(`✗ ${detail}`) + toast('error', `„${zielName}": ${detail}`) } finally { setBusy(false) } @@ -128,9 +139,12 @@ export default function StorageMounts() { setBusy(true) try { await api.delete(`/storage-mounts/${mountName}`) + toast('success', `„${mountName}" ausgehängt und entfernt`) laden() } catch (e: any) { - setFeedback(`✗ ${e?.response?.data?.detail || 'Entfernen fehlgeschlagen'}`) + const detail = e?.response?.data?.detail || 'Entfernen fehlgeschlagen' + setFeedback(`✗ ${detail}`) + toast('error', detail) } finally { setBusy(false) } @@ -144,7 +158,7 @@ export default function StorageMounts() {

Verfügbare Ziele

-
@@ -202,19 +216,37 @@ export default function StorageMounts() {
- {browseDirs.length === 0 ? ( -

Keine Unterordner

+ {browseDirs.length === 0 && browseFiles.length === 0 ? ( +

Ordner ist leer

) : ( - browseDirs.map(d => ( - - )) + <> + {browseDirs.map(d => ( + + ))} + {/* Dateien mit anzeigen (grau, nicht klickbar) — vorher wirkte + z. B. der bluray-Ordner „leer", obwohl die MKVs drinlagen */} + {browseFiles.map(f => ( +
+ + {f.name} + {f.size_mb != null && ( + + {f.size_mb >= 1024 ? `${(f.size_mb / 1024).toFixed(1)} GB` : `${f.size_mb} MB`} + + )} +
+ ))} + )}
@@ -245,13 +277,29 @@ export default function StorageMounts() { ) : ( <> -
+
setHost(e.target.value)} className={feld} placeholder="192.168.178.20 oder TOBIS-PC" />
-
+ {/* Zugangsdaten VOR der Freigaben-Abfrage (Befund 24.07.): + Windows/NAS lehnen Gast-Abfragen fast immer mit + ACCESS_DENIED ab — ohne Benutzer/Passwort scheiterte der + Knopf darunter scheinbar grundlos. */} +
+ + setUsername(e.target.value)} className={feld} autoComplete="off" placeholder="Freigabe-Benutzer" /> +
+
+ + setPassword(e.target.value)} className={feld} autoComplete="new-password" /> +
+

+ Windows-PCs und die meisten NAS verlangen Benutzer + Passwort — ohne Angaben + versucht Rippy einen Gast-Zugriff, der meist mit „Zugriff verweigert" endet. +

+
)} -
- - setUsername(e.target.value)} className={feld} autoComplete="off" /> -
-
- - setPassword(e.target.value)} className={feld} autoComplete="new-password" /> -
)}
diff --git a/docker/ui/src/components/WorkerVerwaltung.tsx b/docker/ui/src/components/WorkerVerwaltung.tsx index f5e0576..9b8086f 100644 --- a/docker/ui/src/components/WorkerVerwaltung.tsx +++ b/docker/ui/src/components/WorkerVerwaltung.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react' import { Server, RefreshCw, Copy, CheckCircle } from 'lucide-react' import { api } from '../lib/api' import { useDarkMode } from '../context/ThemeContext' +import { useToast } from '../context/ToastContext' // Worker-Verwaltung (Commander-Anforderung 23.07.): Welche Maschinen encoden, // sind sie erreichbar, und wie bindet man eine neue an? Ein Worker "installiert @@ -34,9 +35,15 @@ export default function WorkerVerwaltung() { const [workers, setWorkers] = useState([]) const [kopiert, setKopiert] = useState(false) const { theme } = useDarkMode() + const { toast } = useToast() - const laden = () => { - api.get('/capabilities').then(r => setWorkers(r.data.workers || [])).catch(() => {}) + const laden = (manuell = false) => { + api.get('/capabilities') + .then(r => { + setWorkers(r.data.workers || []) + if (manuell) toast('success', 'Worker-Liste aktualisiert') + }) + .catch(() => { if (manuell) toast('error', 'Worker-Liste konnte nicht geladen werden') }) } useEffect(() => { @@ -64,7 +71,7 @@ export default function WorkerVerwaltung() {

Verbundene Worker

-
diff --git a/docker/ui/src/context/ToastContext.tsx b/docker/ui/src/context/ToastContext.tsx new file mode 100644 index 0000000..9cdf1ee --- /dev/null +++ b/docker/ui/src/context/ToastContext.tsx @@ -0,0 +1,62 @@ +import { createContext, useCallback, useContext, useRef, useState, ReactNode } from 'react' +import { CheckCircle, AlertCircle, Info } from 'lucide-react' + +// Globales Feedback-System (Commander-Wunsch 24.07.): JEDE Aktualisierung im +// UI quittiert sichtbar — „Erfolgreich aktualisiert" ploppt oben rechts rein, +// statt dass man raten muss, ob der Klick etwas getan hat. + +type ToastTyp = 'success' | 'error' | 'info' + +interface Toast { + id: number + typ: ToastTyp + text: string +} + +const ToastContext = createContext<{ toast: (typ: ToastTyp, text: string) => void }>({ + toast: () => {}, +}) + +export function useToast() { + return useContext(ToastContext) +} + +const STIL: Record = { + success: 'bg-emerald-600 text-white', + error: 'bg-rose-600 text-white', + info: 'bg-slate-800 text-slate-100 dark:bg-slate-700', +} + +const ICON: Record = { + success: , + error: , + info: , +} + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]) + const naechsteId = useRef(1) + + const toast = useCallback((typ: ToastTyp, text: string) => { + const id = naechsteId.current++ + setToasts(t => [...t.slice(-3), { id, typ, text }]) + setTimeout(() => setToasts(t => t.filter(x => x.id !== id)), 4000) + }, []) + + return ( + + {children} +
+ {toasts.map(t => ( +
+ {ICON[t.typ]} + {t.text} +
+ ))} +
+
+ ) +} diff --git a/docker/ui/src/index.css b/docker/ui/src/index.css index 931a3ed..284e0ee 100644 --- a/docker/ui/src/index.css +++ b/docker/ui/src/index.css @@ -51,3 +51,19 @@ code { background-color: var(--bg-secondary); color: var(--text-primary); } + +/* Toast-Einflug (ToastContext): von rechts reinploppen */ +@keyframes toast-rein { + from { + opacity: 0; + transform: translateX(24px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +.toast-rein { + animation: toast-rein 0.25s ease-out; +} diff --git a/docker/ui/src/lib/mediaServer.ts b/docker/ui/src/lib/mediaServer.ts new file mode 100644 index 0000000..2ea08e2 --- /dev/null +++ b/docker/ui/src/lib/mediaServer.ts @@ -0,0 +1,16 @@ +// Media-Server-Auswahl (Wizard + Einstellungen → Ripping): Rippy bereitet die +// Ablage aufs Zielsystem vor — Ordner „Titel (Jahr)", NFO + Poster, wo der +// Server sie nativ liest (Jellyfin/Emby/Kodi; Plex nutzt nur die Benennung). +export interface MediaServerOption { + wert: string + label: string + hinweis: string +} + +export const MEDIA_SERVER_OPTIONEN: MediaServerOption[] = [ + { wert: 'jellyfin', label: 'Jellyfin', hinweis: 'Ordner „Titel (Jahr)" + movie.nfo + poster.jpg' }, + { wert: 'emby', label: 'Emby', hinweis: 'Ordner „Titel (Jahr)" + movie.nfo + poster.jpg' }, + { wert: 'kodi', label: 'Kodi', hinweis: 'Ordner „Titel (Jahr)" + movie.nfo + poster.jpg' }, + { wert: 'plex', label: 'Plex', hinweis: 'Ordner „Titel (Jahr)" — Plex liest keine NFO' }, + { wert: 'none', label: 'Keins', hinweis: 'nur Dateien ablegen, keine Extras' }, +] diff --git a/docker/ui/src/main.tsx b/docker/ui/src/main.tsx index 227e4e1..deaf876 100644 --- a/docker/ui/src/main.tsx +++ b/docker/ui/src/main.tsx @@ -2,12 +2,15 @@ import React from 'react' import ReactDOM from 'react-dom/client' import App from './App' import { ThemeProvider } from './context/ThemeContext' +import { ToastProvider } from './context/ToastContext' import './index.css' ReactDOM.createRoot(document.getElementById('root')!).render( - + + + , ) diff --git a/docker/ui/src/pages/Dashboard.tsx b/docker/ui/src/pages/Dashboard.tsx index 18d6932..4a23a56 100644 --- a/docker/ui/src/pages/Dashboard.tsx +++ b/docker/ui/src/pages/Dashboard.tsx @@ -3,6 +3,7 @@ import { Clock, Activity, AlertCircle, CheckCircle, Disc } from 'lucide-react' import { api } from '../lib/api' import { useDarkMode } from '../context/ThemeContext' import DeviceDiscovery from '../components/DeviceDiscovery' +import JobDetailModal from '../components/JobDetailModal' import LiveLogSection from '../components/LiveLogSection' interface Job { @@ -139,6 +140,7 @@ export default function Dashboard() { const [jobs, setJobs] = useState([]) const [workers, setWorkers] = useState([]) const [loading, setLoading] = useState(true) + const [detailJobId, setDetailJobId] = useState(null) const { theme } = useDarkMode() useEffect(() => { @@ -281,9 +283,13 @@ export default function Dashboard() { -
+
+ @@ -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 + +
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 => ( + + ))} +
+

+ {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 */} +
+ + 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"{'}'}. +

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

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

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

+ + System & Kern-Werkzeuge +

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

Installierte Werkzeuge

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

Noch kein Worker gemeldet.

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

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

+ )} +
+ + {/* MakeMKV-Beta-Key: der Monats-Key, OHNE Rebuild aktualisierbar */} +
+ handleChange('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).

}