diff --git a/docker/ui/src/components/DeviceDiscovery.tsx b/docker/ui/src/components/DeviceDiscovery.tsx index 6853acd..4f2aaf7 100644 --- a/docker/ui/src/components/DeviceDiscovery.tsx +++ b/docker/ui/src/components/DeviceDiscovery.tsx @@ -1,8 +1,10 @@ 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 { Card, CardHeader, CardTitle, CardContent } from './ui/Card' +import { Button } from './ui/Button' +import { TypeBadge } from './ui/Badge' import RipTargetModal, { RipOptionen } from './RipTargetModal' import MetadataKorrektur from './MetadataKorrektur' @@ -35,7 +37,6 @@ interface Device { disc?: DiscInfo } -// TMDB liefert relative Poster-Pfade, OMDb absolute URLs — beides abdecken function posterUrl(disc?: DiscInfo): string | null { const p = disc?.metadata?.poster_path if (!p) return null @@ -51,12 +52,9 @@ export default function DeviceDiscovery() { const [actionBusy, setActionBusy] = useState(false) const [modalDevice, setModalDevice] = useState(null) const [korrekturDevice, setKorrekturDevice] = useState(null) - const { theme } = useDarkMode() const { toast } = useToast() 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') @@ -115,233 +113,194 @@ export default function DeviceDiscovery() { return () => clearInterval(interval) }, []) - const getTypeColor = (type: string) => { - switch (type) { - case 'cd': return theme === 'dark' ? 'bg-blue-900/30 text-blue-400' : 'bg-blue-100 text-blue-600' - case 'dvd': return theme === 'dark' ? 'bg-purple-900/30 text-purple-400' : 'bg-purple-100 text-purple-600' - case 'bluray': return theme === 'dark' ? 'bg-pink-900/30 text-pink-400' : 'bg-pink-100 text-pink-600' - case 'uhd': return theme === 'dark' ? 'bg-amber-900/30 text-amber-400' : 'bg-amber-100 text-amber-700' - default: return theme === 'dark' ? 'bg-slate-700 text-slate-400' : 'bg-slate-100 text-slate-600' - } - } - - const typLabel = (type: string) => - ({ cd: 'CD', dvd: 'DVD', bluray: 'BLU-RAY', uhd: '4K UHD' } as Record)[type] || type.toUpperCase() - const QUELLEN_LABEL: Record = { tmdb: 'TMDB', omdb: 'OMDb', jikan: 'MyAnimeList', manuell: 'manuell gewählt', } - const getStatusColor = (status: string) => { + const getStatusBadge = (status: string) => { switch (status) { - case 'empty': return theme === 'dark' ? 'bg-slate-700 text-slate-400' : 'bg-slate-100 text-slate-600' - case 'ready': return theme === 'dark' ? 'bg-emerald-900/30 text-emerald-400' : 'bg-emerald-100 text-emerald-700' - case 'ripping': return theme === 'dark' ? 'bg-blue-900/30 text-blue-400' : 'bg-blue-100 text-blue-700' - default: return theme === 'dark' ? 'bg-slate-700 text-slate-400' : 'bg-slate-100 text-slate-600' - } - } - - const getStatusIcon = (status: string) => { - switch (status) { - case 'empty': return - case 'ready': return - case 'ripping': return - default: return + case 'empty': + return Keine Disc + case 'ready': + return Bereit + case 'ripping': + return Rippt + default: + return Unbekannt } } if (loading) { return (
- +
) } return ( -
-
+ +
-

Laufwerke

-

Erkannte Geräte mit automatischer Typ-Erkennung

+ Laufwerke +

Erkannte Geräte mit automatischer Typ-Erkennung

- -
+ + -
+ {/* Disc-Karte: WAS liegt gerade im Laufwerk (Auto-Pre-Scan) */} {devices.filter(d => d.status === 'ready' && d.disc).map(device => (
{posterUrl(device.disc) && ( {device.disc!.title} )} -
-

- Im Laufwerk erkannt -

-

- {device.disc!.title} - {device.disc!.year ? ({device.disc!.year}) : null} -

-
- - {device.disc!.disc_type?.toUpperCase()} - - - Quelle: {QUELLEN_LABEL[device.disc!.metadata?.source || ''] || device.disc!.metadata?.source || 'Disc-Label'} - {' · '}{Math.round((device.disc!.confidence || 0) * 100)} % sicher - +
+
+

+ Im Laufwerk erkannt +

+

+ {device.disc!.title} + {device.disc!.year ? ({device.disc!.year}) : null} +

+
+ + + Quelle: {QUELLEN_LABEL[device.disc!.metadata?.source || ''] || device.disc!.metadata?.source || 'Disc-Label'} + {' · '}{Math.round((device.disc!.confidence || 0) * 100)} % sicher + +
+ {device.disc!.metadata?.overview && ( +

+ {device.disc!.metadata.overview} +

+ )} + {device.disc!.bereits_gerippt && ( +

+ + Diese Disc wurde bereits gerippt + {device.disc!.bereits_gerippt.finished_at + ? ` (${new Date(device.disc!.bereits_gerippt.finished_at).toLocaleDateString('de-DE')})` + : ''} — nochmal rippen ist möglich. +

+ )}
- {device.disc!.metadata?.overview && ( -

- {device.disc!.metadata.overview} -

- )} - {device.disc!.bereits_gerippt && ( -

- - Diese Disc wurde bereits gerippt - {device.disc!.bereits_gerippt.finished_at - ? ` (${new Date(device.disc!.bereits_gerippt.finished_at).toLocaleDateString('de-DE')})` - : ''} — nochmal rippen ist trotzdem möglich. -

- )} -
- - + +
))} {devices.length === 0 ? ( -
-
- +
+
+
-

Keine Laufwerke gefunden

-

+

Keine Laufwerke gefunden

+

Stelle sicher, dass ein Laufwerk angeschlossen ist

) : ( -
+
{devices.map(device => ( -
+
-
- +
+
-

+

{device.name || 'Unbekanntes Laufwerk'}

- {device.status === 'ripping' && ( - - - Rippt - - )} +
- + {device.path} {device.serial && ( - + {device.serial} )} {device.model && ( - + {device.model} )}
-
- - {getStatusIcon(device.status)} - {device.status === 'empty' ? 'Keine Disc' : - device.status === 'ready' ? 'Bereit' : - 'Rippt'} - - - Typ: {typLabel(device.type)} - +
+ {getStatusBadge(device.status)}
- +
{expandedId === device.id && ( -
+
- Gerätepfad - {device.path} - Modell - {device.model || 'unbekannt'} - Seriennummer - {device.serial || '—'} - Disc - + Gerätepfad + {device.path} + Modell + {device.model || 'unbekannt'} + Seriennummer + {device.serial || '—'} + Disc + {device.status === 'ready' ? `eingelegt (${typLabel(device.type)})` : 'keine'}
- - +
{actionFeedback && ( -

+

{actionFeedback}

)} @@ -351,10 +310,8 @@ export default function DeviceDiscovery() { ))}
)} -
+ - {/* Ziel-Auswahl vor dem Rip-Start (Filme/Serien/Musik oder eigener - Pfad — dort eingehängte Shares sind direkt wählbar) */} - {/* „Nicht korrekt?"-Popup: Korrektur ohne Seitenwechsel */} setKorrekturDevice(null)} onUebernommen={refreshDevices} /> -
+ ) } diff --git a/docker/ui/src/components/RipTargetModal.tsx b/docker/ui/src/components/RipTargetModal.tsx index f26626b..6e78db6 100644 --- a/docker/ui/src/components/RipTargetModal.tsx +++ b/docker/ui/src/components/RipTargetModal.tsx @@ -1,7 +1,9 @@ import { useState, useEffect } from 'react' -import { Folder, FolderOpen, File, ArrowUp, CheckCircle } from 'lucide-react' +import { Folder, FolderOpen, File, ArrowUp, CheckCircle, Film, Tv, Music } from 'lucide-react' import { api } from '../lib/api' -import { useDarkMode } from '../context/ThemeContext' +import { Modal } from './ui/Modal' +import { Button } from './ui/Button' +import { Input } from './ui/Input' interface TargetConfig { id: string @@ -15,7 +17,7 @@ export interface RipOptionen { series?: string season?: number mainFeatureOnly?: boolean - titles?: number[] // exakte Titel-Auswahl (Track-Tabelle) + titles?: number[] } interface TitelInfo { @@ -27,9 +29,9 @@ interface TitelInfo { interface RipTargetModalProps { isOpen: boolean - initialType?: TargetConfig['type'] // CD → Musik vorwählen (Medium-gerecht) - discTitle?: string // Vorschlag für den Serien-Namen - deviceId?: string // Laufwerks-ID (sr0) für den Titel-Scan + initialType?: TargetConfig['type'] + discTitle?: string + deviceId?: string onClose: () => void onSave: (target: TargetConfig, optionen: RipOptionen) => void } @@ -45,8 +47,6 @@ interface BrowseDir { path: string } -// Ziel-Auswahl vor dem Rip: Schnellwahl (Filme/Serien/Musik) ODER frei per -// Ordner-Browser — auch in eingehängte Netzwerk-Ziele (NAS, PC-Freigabe). export default function RipTargetModal({ isOpen, initialType, discTitle, deviceId, onClose, onSave }: RipTargetModalProps) { const [selectedType, setSelectedType] = useState('movies') const [serienName, setSerienName] = useState('') @@ -69,7 +69,6 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI } } - // Polling, solange der Titel-Scan läuft (Info-Lauf dauert 20–120 s) useEffect(() => { if (scanStatus !== 'running' || !deviceId) return const interval = setInterval(async () => { @@ -78,7 +77,6 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI if (r.data.status === 'done') { const tracks: TitelInfo[] = r.data.tracks || [] setTitelListe(tracks) - // Vorauswahl: alles ab 5 Minuten (Trailer/Menü-Schnipsel bleiben weg) setGewaehlt(new Set(tracks.filter(t => t.dauer_s >= 300).map(t => t.nr))) setScanStatus('done') } else if (r.data.status === 'error') { @@ -89,6 +87,7 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI }, 3000) return () => clearInterval(interval) }, [scanStatus, deviceId]) + const [targets, setTargets] = useState([ { id: '1', name: 'Filme', path: '/app/media/movies', type: 'movies', isActive: true }, { id: '2', name: 'Serien', path: '/app/media/series', type: 'series', isActive: true }, @@ -99,9 +98,7 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI const [browseDirs, setBrowseDirs] = useState([]) const [browseFiles, setBrowseFiles] = useState<{ name: string, size_mb: number | null }[]>([]) const [customPath, setCustomPath] = useState('') - const { theme } = useDarkMode() - // Standard-Unterordner aus den Einstellungen ziehen (nicht hartkodiert) useEffect(() => { if (!isOpen) return setSelectedType(initialType || 'movies') @@ -144,11 +141,8 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI optionen.series = serienName.trim() optionen.season = Math.max(1, staffel || 1) } - // Explizite Titel-Auswahl schlägt alles andere if (scanStatus === 'done' && gewaehlt.size > 0 && gewaehlt.size < titelListe.length) { optionen.titles = [...gewaehlt].sort((a, b) => a - b) - } else if (scanStatus === 'done' && gewaehlt.size > 0) { - // Alle gewählt = normales „all" — keine Einzelaufrufe nötig } const target = targets.find(t => t.type === selectedType) if (customPath) { @@ -159,243 +153,195 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI onClose() } - if (!isOpen) return null - return ( -
-
- {/* Header */} -
-

Rip-Ziel auswählen

-

- Schnellwahl oder eigenen Ordner wählen — Netzwerk-Ziele (NAS, PC) sind unter Einstellungen → Speicherziele einhängbar. -

-
+ +
+

+ Schnellwahl oder eigenen Ordner wählen — Netzwerk-Ziele (NAS, PC) sind unter Einstellungen → Speicherziele einhängbar. +

- {/* Body */} -
- {/* Schnellwahl */} -
- {targets.map((t) => ( + {/* Schnellwahl */} +
+ {targets.map((t) => { + const isSelected = selectedType === t.type && !customPath + return ( + ) + })} +
+ + {/* Serien-Flow */} + {selectedType === 'series' && ( +
+
+ setSerienName(e.target.value)} + placeholder="z. B. Neon Genesis Evangelion" + /> + setStaffel(parseInt(e.target.value) || 1)} + /> +
+

+ Ablage: {serienName.trim() || ''}/Season {String(Math.max(1, staffel || 1)).padStart(2, '0')} +

+
+ )} + + {/* Hauptfilm-Wahl */} + {selectedType === 'movies' && scanStatus !== 'done' && ( + + )} + + {/* Titel-Auswahl (Track-Tabelle) */} + {deviceId && selectedType !== 'music' && ( +
+
+
+ Titel-Auswahl (optional) + {scanStatus === 'idle' && ( +

+ Disc scannen und einzelne Titel an-/abwählen. +

+ )} +
+ {scanStatus === 'running' ? ( + + + Disc wird gelesen (20–120 s)… - {t.name} + ) : ( + + )} +
+ + {scanStatus === 'error' && ( +

{scanFehler}

+ )} + + {scanStatus === 'done' && titelListe.length > 0 && ( + <> +
+ {titelListe.map(t => ( + + ))} +
+

+ {gewaehlt.size} von {titelListe.length} Titeln gewählt +

+ + )} +
+ )} + + {/* Ordner-Browser */} +
+
+ + {browsePath} + +
+
+ {browseDirs.map(d => ( + ))}
+
- {/* Serien-Flow: Name + Staffel → Ablage /Season NN, Episoden - werden nach dem Rip per Laufzeit-Abgleich (TMDB) benannt */} - {selectedType === 'series' && ( -
-
-
- - setSerienName(e.target.value)} - placeholder="z. B. Neon Genesis Evangelion" - className={`w-full 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'}`} - /> -
-
- - setStaffel(parseInt(e.target.value) || 1)} - className={`w-full 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'}`} - /> -
-
-

- Ablage: {serienName.trim() || ''}/Season {String(Math.max(1, staffel || 1)).padStart(2, '0')} — - Episoden werden nach dem Rip per Laufzeit-Abgleich (TMDB) automatisch - zu „S{String(Math.max(1, staffel || 1)).padStart(2, '0')}E01…" benannt (nur bei eindeutiger Zuordnung). -

-
- )} - - {/* Hauptfilm-Wahl: pro Rip übersteuerbar (Standard = Einstellung); - eine explizite Titel-Auswahl unten schlägt diese Option */} - {selectedType === 'movies' && scanStatus !== 'done' && ( - - )} - - {/* Titel-Auswahl (Track-Tabelle): erst scannen, dann abwählen */} - {deviceId && selectedType !== 'music' && ( -
-
-
- Titel-Auswahl (optional) - {scanStatus === 'idle' && ( -

- Disc scannen und einzelne Titel an-/abwählen — statt alles oder nur Hauptfilm. -

- )} -
- {scanStatus === 'running' ? ( - - - Disc wird gelesen (20–120 s)… - - ) : ( - - )} -
- {scanStatus === 'error' && ( -

{scanFehler}

- )} - {scanStatus === 'done' && titelListe.length === 0 && ( -

- Keine Titel lesbar — vermutlich kann diese Disc nicht entschlüsselt - werden (Details stehen nach einem Rip-Versuch im Job-Fehler). -

- )} - {scanStatus === 'done' && titelListe.length > 0 && ( - <> -
- {titelListe.map(t => ( - - ))} -
-

- {gewaehlt.size} von {titelListe.length} Titeln gewählt - {' · '}~{([...gewaehlt].reduce((s, nr) => s + (titelListe.find(t => t.nr === nr)?.groesse_bytes || 0), 0) / 1024 ** 3).toFixed(1)} GB - {' · '}Vorauswahl: alles ab 5 Minuten -

- - )} -
- )} - - {/* Ordner-Browser */} -
-
- - {browsePath} - -
-
- {browseDirs.length === 0 && browseFiles.length === 0 ? ( -

Ordner ist leer

- ) : ( - <> - {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`} - - )} -
- ))} - - )} -
-
- - {/* Gewähltes Ziel */} -
- {customPath ? : } - - {customPath || targets.find(t => t.type === selectedType)?.path} - -
+ {/* Gewähltes Ziel Display */} +
+ {customPath ? : } + + {customPath || targets.find(t => t.type === selectedType)?.path} +
{/* Footer */} -
- - + +
-
+ ) } diff --git a/docker/ui/src/pages/Dashboard.tsx b/docker/ui/src/pages/Dashboard.tsx index 42db653..685b42a 100644 --- a/docker/ui/src/pages/Dashboard.tsx +++ b/docker/ui/src/pages/Dashboard.tsx @@ -1,12 +1,16 @@ import { useEffect, useState } from 'react' import { Clock, Activity, AlertCircle, CheckCircle, Disc, Download, FileSpreadsheet, HardDrive, Trash2 } from 'lucide-react' import { api } from '../lib/api' -import { useDarkMode } from '../context/ThemeContext' import { useToast } from '../context/ToastContext' +import { PageHeader } from '../components/ui/PageHeader' +import { Card, CardHeader, CardTitle, CardContent } from '../components/ui/Card' +import { Button } from '../components/ui/Button' +import { StatusBadge, TypeBadge } from '../components/ui/Badge' import ConfirmDialog from '../components/ConfirmDialog' import DeviceDiscovery from '../components/DeviceDiscovery' import JobDetailModal from '../components/JobDetailModal' import LiveLogSection from '../components/LiveLogSection' +import { ENCODER_BADGES } from '../lib/design' interface Job { id: string @@ -29,105 +33,19 @@ async function fetchJobs(): Promise { } } -function StatusBadge({ status }: { status: string }) { - const { theme } = useDarkMode() - const styles = theme === 'dark' - ? { - pending: 'bg-amber-900/30 text-amber-400 ring-amber-700/20', - processing: 'bg-blue-900/30 text-blue-400 ring-blue-700/20', - transcoding: 'bg-purple-900/30 text-purple-400 ring-purple-700/20', - canceling: 'bg-orange-900/30 text-orange-400 ring-orange-700/20', - completed: 'bg-emerald-900/30 text-emerald-400 ring-emerald-700/20', - failed: 'bg-rose-900/30 text-rose-400 ring-rose-700/20', - } - : { - pending: 'bg-amber-100 text-amber-700 ring-amber-600/20', - processing: 'bg-blue-100 text-blue-700 ring-blue-600/20', - transcoding: 'bg-purple-100 text-purple-700 ring-purple-600/20', - canceling: 'bg-orange-100 text-orange-700 ring-orange-600/20', - completed: 'bg-emerald-100 text-emerald-700 ring-emerald-600/20', - failed: 'bg-rose-100 text-rose-700 ring-rose-600/20', - } - - const labels = { - pending: 'Wartend', - processing: 'Rippen', - transcoding: 'Komprimieren', - canceling: 'Wird abgebrochen…', - completed: 'Fertig', - failed: 'Fehler', - } - - return ( - - {labels[status as keyof typeof labels] || status} - - ) -} - -function TypeBadge({ type }: { type: string }) { - const { theme } = useDarkMode() - // 4K UHD sichtbar abheben (Commander-Wunsch 24.07.) — es IST eine Blu-ray, - // aber der Unterschied (Laufwerk! Größe!) muss auf einen Blick erkennbar sein. - const colors: Record = theme === 'dark' - ? { - cd: 'bg-blue-900/30 text-blue-400', - dvd: 'bg-purple-900/30 text-purple-400', - bluray: 'bg-pink-900/30 text-pink-400', - uhd: 'bg-amber-900/30 text-amber-400', - } - : { - cd: 'bg-blue-100 text-blue-600', - dvd: 'bg-purple-100 text-purple-600', - bluray: 'bg-pink-100 text-pink-600', - uhd: 'bg-amber-100 text-amber-700', - } - const labels: Record = { - cd: 'CD', dvd: 'DVD', bluray: 'BLU-RAY', uhd: '4K UHD', - } - const fallback = theme === 'dark' ? 'bg-slate-800 text-slate-300' : 'bg-slate-100 text-slate-700' - return ( - - - {labels[type] || type.toUpperCase()} - - ) -} - function ProgressBar({ progress }: { progress: number }) { - const { theme } = useDarkMode() - const bgColors = theme === 'dark' - ? { - empty: 'bg-slate-700', - indicator: 'bg-gradient-to-r from-indigo-500 to-purple-600', - done: 'bg-emerald-500', - } - : { - empty: 'bg-slate-200', - indicator: 'bg-gradient-to-r from-indigo-500 to-purple-600', - done: 'bg-emerald-500', - } - - const textColors = theme === 'dark' - ? { - label: 'text-slate-400', - value: 'text-slate-200', - } - : { - label: 'text-slate-500', - value: 'text-slate-900', - } - return (
- Fortschritt - {progress}% + Fortschritt + {progress}%
-
+
@@ -141,13 +59,6 @@ interface WorkerInfo { encoders: string[] } -const ENCODER_KURZ: Record = { - 'cpu-x264': 'H.264 (CPU)', - 'cpu-x265': 'H.265 (CPU)', - 'vaapi': 'VAAPI ⚡', - 'nvenc': 'NVENC ⚡', -} - export default function Dashboard() { const [jobs, setJobs] = useState([]) const [workers, setWorkers] = useState([]) @@ -155,7 +66,6 @@ export default function Dashboard() { const [loading, setLoading] = useState(true) const [detailJobId, setDetailJobId] = useState(null) const [aufraeumenOffen, setAufraeumenOffen] = useState(false) - const { theme } = useDarkMode() const { toast } = useToast() const jobLoeschen = async (job: Job) => { @@ -207,153 +117,219 @@ export default function Dashboard() { if (loading) { return (
-
+
) } return ( -
- {/* Header */} -
-

Dashboard

-

Übersicht über alle Ripping-Jobs und Geräte

-
+
+ - {/* System-Leiste: Encoder + aktiver Job auf einen Blick */} -
-
- - Encoder: - {workers.length === 0 ? ( - kein Worker - ) : ( - workers.flatMap(w => w.encoders).filter((e, i, a) => a.indexOf(e) === i).map(e => ( - - {ENCODER_KURZ[e] || e} + {/* Cinematic Spotlight Card für aktiven Rip/Transcode */} + {aktiverJob && ( + +
+
+
+
+ +
+
+ + + - )) - )} -
-
- - Aktiv: - {aktiverJob ? ( - - {aktiverJob.status === 'transcoding' ? 'Komprimieren' : 'Rippen'} · {aktiverJob.progress} % - - ) : ( - kein Job - )} -
- {plaetze.length > 0 && ( -
- - Frei: - {plaetze.map(p => ( - + +
+
+ + {aktiverJob.status === 'transcoding' ? 'Komprimieren' : 'Rippen'} aktiv + + +
+

+ {aktiverJob.title || 'Aktiver Job'} +

+

+ Gerät: {aktiverJob.device} · Gestartet: {new Date(aktiverJob.startTime).toLocaleTimeString('de-DE')} +

+ +
+
+ {aktiverJob.status === 'transcoding' ? 'HandBrake Kompression' : 'MakeMKV Extraktion'} + {aktiverJob.progress}% +
+
+
+
+
+
+ +
+ + +
- )} -
+ + )} - {/* Laufwerke + erkannte Disc */} + {/* System-Telemetrie Leiste */} + + +
+ + Encoder: + {workers.length === 0 ? ( + kein Worker + ) : ( + workers.flatMap(w => w.encoders).filter((e, i, a) => a.indexOf(e) === i).map(e => ( + + {ENCODER_BADGES[e]?.label || e} + + )) + )} +
+ +
+ + Aktiv: + {aktiverJob ? ( + + {aktiverJob.status === 'transcoding' ? 'Komprimieren' : 'Rippen'} · {aktiverJob.progress} % + + ) : ( + kein Job + )} +
+ + {plaetze.length > 0 && ( +
+ + Frei: + {plaetze.map(p => ( + + {p.name.startsWith('Media') ? 'Media' : 'Arbeit'} {p.frei_gb} GB + + ))} +
+ )} +
+
+ + {/* Laufwerke & Erkennung */} {/* Stats Cards */} -
+
- {/* Recent Jobs */} - {/* Schnellaktionen + doppelte Geräte-Sektion sind raus (23.07.): alle - vier Knöpfe und der zweite Details-Button waren funktionslos — - die echte Geräte-Steuerung lebt in DeviceDiscovery oben. */} -
-
-

Neueste Jobs

+ {/* Recent Jobs Table */} + + + Neueste Jobs
- {jobs.length} Jobs insgesamt + {jobs.length} Jobs insgesamt {jobs.length > 0 && ( CSV )} {jobs.some(j => j.status === 'completed' || j.status === 'failed') && ( - + )}
-
-
- - + + +
+
+ - - - - - - - + + + + + + + - + {jobs.length === 0 ? ( - ) : ( jobs.slice(0, 10).map(job => ( - + @@ -361,7 +337,7 @@ export default function Dashboard() { @@ -369,58 +345,59 @@ export default function Dashboard() { - - @@ -429,12 +406,10 @@ export default function Dashboard() {
TypTitelStatusGerätFortschrittStartzeitAktionTypTitelStatusGerätFortschrittStartzeitAktion
-
Noch keine Jobs
-

Füge eine CD, DVD oder Blu-ray hinzu, um zu starten

+
+
Noch keine Jobs
+

Füge eine CD, DVD oder Blu-ray hinzu, um zu starten

+ {job.device} + {new Date(job.startTime).toLocaleString('de-DE')} {job.status === 'completed' && ( - + )} - {/* Nur wenn Rohdaten wirklich daliegen — an einem Job, - der nie gerippt hat, war der Knopf Unsinn */} {job.status === 'failed' && job.can_retry && ( - + )} {(job.status === 'completed' || job.status === 'failed') && ( )} {(job.status === 'pending' || job.status === 'processing' || job.status === 'transcoding') && ( - + )}
-
+ - {/* Live-Log ganz unten — Detail-Ansicht, nicht Haupt-Info */} - {/* Klick auf einen Job-Titel: Film-/Serien-Info + Job-Fakten */} setDetailJobId(null)} /> -
-
- + + +
+
-

{label}

-

{value}

+

{label}

+

{value}

-
-
+ + ) } diff --git a/docker/ui/src/pages/Settings.tsx b/docker/ui/src/pages/Settings.tsx index b2c2479..4931474 100644 --- a/docker/ui/src/pages/Settings.tsx +++ b/docker/ui/src/pages/Settings.tsx @@ -1,10 +1,15 @@ 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' +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 @@ -68,15 +73,6 @@ interface SystemInfo { webhook_gesetzt: boolean } -import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer' - -const ENCODER_LABELS: Record = { - '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(defaultSettings) const [activeTab, setActiveTab] = useState('ripping') @@ -91,6 +87,8 @@ export default function SettingsPage() { const [updates, setUpdates] = useState | null>(null) const [updatesBusy, setUpdatesBusy] = useState(false) + const { toast } = useToast() + const updatesPruefen = async () => { setUpdatesBusy(true) try { @@ -107,8 +105,6 @@ export default function SettingsPage() { setUpdatesBusy(false) } } - const { theme } = useDarkMode() - const { toast } = useToast() const quellenPruefen = async () => { setQuellenBusy(true) @@ -154,7 +150,6 @@ export default function SettingsPage() { 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) @@ -187,674 +182,516 @@ export default function SettingsPage() { if (loading) { return (
-
+
) } + 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 ( -
-
-

Einstellungen

-

Konfiguriere Rippy nach deinen Wünschen

-
+
+ {saved && ( -
- +
+ Einstellungen erfolgreich gespeichert!
)} -
- {/* Tabs — echte Navigation (vorher reine Dekoration ohne onClick) */} -
- setActiveTab('ripping')} darkMode={theme === 'dark'} /> - setActiveTab('verarbeitung')} darkMode={theme === 'dark'} /> - setActiveTab('worker')} darkMode={theme === 'dark'} /> - setActiveTab('speicherziele')} darkMode={theme === 'dark'} /> - setActiveTab('apis')} darkMode={theme === 'dark'} /> - setActiveTab('benachrichtigungen')} darkMode={theme === 'dark'} /> - setActiveTab('system')} darkMode={theme === 'dark'} /> + + {/* Navigation Tabs */} +
+ {tabs.map((tab) => { + const isActive = activeTab === tab.id + return ( + + ) + })}
- {/* Content */} -
+ {/* Tab Content */} + {/* Ripping Settings */} - {activeTab === 'ripping' &&
-

- - Ripping-Einstellungen -

- -
- {/* ── Video: DVD · Blu-ray · 4K UHD ─────────────────────── */} -

- 📀 Video — DVD · Blu-ray · 4K UHD -

- -
-
-
- -
-
-

Vollautomatik: sofort rippen

-

- Disc erkannt → Rip startet ohne Nachfrage in den passenden Schnellwahl-Ordner - (Serie → Serien, sonst Filme; CD → Musik). Aus = wie bisher per „Rippen starten"-Dialog. -

-
-
- handleChange('autoRipStart', v)} - darkMode={theme === 'dark'} - /> + {activeTab === 'ripping' && ( +
+
+

+ + Ripping-Einstellungen +

-
-
-
- -
-
-

Nur Hauptfeature

-

Rippe bei DVDs/Blu-rays nur das Hauptfeature

-
-
- handleChange('mainFeatureOnly', v)} - darkMode={theme === 'dark'} - /> -
- -

- 🌐 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. -

- - {/* ── Audio-CD ──────────────────────────────────────────── */} -

- 🎵 Audio-CD -

- -
-
-
- -
-
-

Alle Tracks rippen

-

Rippe alle Tracks einer CD (FLAC + MusicBrainz-Tags)

-
-
- handleChange('ripAllTracks', v)} - darkMode={theme === 'dark'} - /> -
- - {/* ── Allgemein ─────────────────────────────────────────── */} -

- Allgemein -

- -
-
-
- -
-
-

Automatischer Auswurf

-

Disk nach erfolgreichem Ripping auswerfen

-
-
- 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} +

+

+ 📀 Video — DVD · Blu-ray · 4K UHD

- {/* Jellyfin/Emby: nach jedem fertigen Rip die Bibliothek scannen */} - {(settings.mediaServer === 'jellyfin' || settings.mediaServer === 'emby') && ( -
-

- Bibliotheks-Refresh (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. -

-
- 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'}`} - /> -
- 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'}`} - /> - -
-
-
- )} -
-
-
} +
+ handleChange('autoRipStart', v)} + /> +
- {/* Verarbeitung: Kompressions-Stufe nach dem Rip */} - {activeTab === 'verarbeitung' &&
-

- - Verarbeitung (Kompression) -

-

- MakeMKV rippt verlustfrei (der einzige Weg durch den Kopierschutz), HandBrake - komprimiert danach auf Arbeitsgröße. Die Roh-Datei wird nach Erfolg gelöscht. -

+
+ handleChange('mainFeatureOnly', v)} + /> +
- {/* Ehrliche Selbstauskunft: was können die Worker WIRKLICH? */} -
-

Verfügbare Encoder

- {workers.length === 0 ? ( -

Kein Worker gemeldet.

- ) : ( - workers.map(w => ( -
- {w.name} - {w.encoders.map(e => ( - - {ENCODER_LABELS[e] || e} - +

+ 🌐 Sprachen & Untertitel: MakeMKV sichert grundsätzlich ALLE Ton-Spuren und Untertitel verlustfrei. +

+ +

+ 🎵 Audio-CD +

+ +
+ handleChange('ripAllTracks', v)} + /> +
+ +

+ Allgemein +

+ +
+ handleChange('autoEject', v)} + /> +
+ + {/* Media-Server Integration */} +
+

+ + Media-Server (Jellyfin & Co.) +

+

+ Rippy benennt fertige Rips als „Titel (Jahr)" und legt NFO + Poster dazu. +

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

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

-
-
-
-

Nach dem Rip komprimieren

-

x265 statt 40-GB-Rohdatei (empfohlen)

+ {(settings.mediaServer === 'jellyfin' || settings.mediaServer === 'emby') && ( +
+

+ Bibliotheks-Refresh: Server-URL + API-Key für automatischen Scan nach dem Rip. +

+
+ handleChange('jellyfinUrl', e.target.value)} + placeholder="http://192.168.178.50:8096" + /> +
+
+ handleChange('jellyfinApiKey', e.target.value)} + placeholder="API-Key" + /> +
+ +
+
+
+ )}
- handleChange('transcodeEnabled', v)} darkMode={theme === 'dark'} /> +
+
+ )} + + {/* Verarbeitung */} + {activeTab === 'verarbeitung' && ( +
+

+ + Verarbeitung (Kompression) +

+

+ MakeMKV rippt verlustfrei, HandBrake komprimiert danach auf Arbeitsgröße. +

+ +
+

Verfügbare Encoder

+ {workers.length === 0 ? ( +

Kein Worker gemeldet.

+ ) : ( + workers.map(w => ( +
+ {w.name}: + {w.encoders.map(e => ( + + {ENCODER_BADGES[e]?.label || 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'}`} > - -
+ -
-
-

Original behalten

-

Verlustfreie Roh-Datei zusätzlich aufheben (viel Speicher!)

+
+ handleChange('keepOriginal', v)} + />
- 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. +

+ 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}

-
-
} +
+ )} - {/* Worker: Encoding-Maschinen im Netz */} - {activeTab === 'worker' &&
-

- - Encoding-Worker -

- -
} + {/* Worker */} + {activeTab === 'worker' && ( +
+

+ + Encoding-Worker +

+ +
+ )} - {/* Speicherziele: Netzwerk-Mounts aus dem UI */} - {activeTab === 'speicherziele' &&
-

- - Speicherziele -

+ {/* Speicherziele */} + {activeTab === 'speicherziele' && ( +
+

+ + Speicherziele +

- {/* Schnellwahl-Ordner: die drei Knöpfe im "Rippen starten"-Dialog */} -
-

- Schnellwahl beim Rippen -

-

- Beim Start eines Rips fragt Rippy „Wohin?" — diese drei Ordner erscheinen - dort als 🎥/📺/🎵-Schnellwahl. Hier legst du nur fest, wie sie heißen. -

-
- {([['movieDir', '🎥 Filme'], ['seriesDir', '📺 Serien'], ['musicDir', '🎵 Musik']] as const).map(([feld, label]) => ( -
- - +

+ Schnellwahl beim Rippen +

+

+ Name der Ordner im „Rippen starten"-Dialog (🎥/📺/🎵). +

+
+ {([['movieDir', '🎥 Filme'], ['seriesDir', '📺 Serien'], ['musicDir', '🎵 Musik']] as const).map(([feld, label]) => ( + 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'}`} /> -
- ))} -
-
- - -
} - - {/* API Keys */} - {activeTab === 'apis' &&
-

- - API-Schlüssel -

- - {/* Live-Prüfung: funktioniert der eingetragene Key WIRKLICH? */} -
- - {quellenStatus ? ( -
- {Object.entries(quellenStatus).map(([quelle, status]) => ( - - {quelle.toUpperCase()}: {status === 'ok' ? '✓ funktioniert' : status === 'kein_key' ? 'kein Key' : '✗ Fehler'} - ))}
- ) : ( - - 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). - - )} -
+
-
-
- - + + )} + + {/* APIs */} + {activeTab === 'apis' && ( +
+

+ + API-Schlüssel +

+ +
+ + {quellenStatus ? ( +
+ {Object.entries(quellenStatus).map(([quelle, status]) => ( + + {quelle.toUpperCase()}: {status === 'ok' ? '✓ funktioniert' : status === 'kein_key' ? 'kein Key' : '✗ Fehler'} + + ))} +
+ ) : ( + + Prüft live gegen TMDB/OMDb — nach dem Speichern eines neuen Keys hier klicken. + + )} +
+ +
+ 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" /> -

- Hol dir einen Key auf themoviedb.org -

-
- -
- - 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" /> -
- -
- - 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" /> -

- Zweite Quelle, wenn TMDB nichts findet — kostenloser Key auf omdbapi.com -

-
- } + + )} - {/* 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. + {/* Benachrichtigungen */} + {activeTab === 'benachrichtigungen' && ( +

+

+ + Webhook-Benachrichtigungen +

+

+ Rippy meldet sich, sobald ein Job fertig ist, fehlschlägt oder abgebrochen wird.

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

- - System & Kern-Werkzeuge -

+
+
+

💬 Discord (einfachster Weg)

+

+ Kanal-Zahnrad → Integrationen → Webhooks → „Neuer Webhook" → URL kopieren. +

+
+
+

📱 ntfy (Push aufs Handy)

+

+ Thema abonnieren und https://ntfy.sh/mein-thema eintragen. +

+
+
- {/* 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)'} - - )} +
+
+
+ handleChange('notificationWebhook', e.target.value)} + placeholder="https://discord.com/api/webhooks/… oder https://ntfy.sh/mein-thema" + />
- )) - )} - {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 */} -
- - +

+ + System & Kern-Werkzeuge +

+ +
+

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} + + )} +
+ )) + )} +
+ + 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")'} + placeholder="T-… (Forum-Thread „MakeMKV is free while in beta”)" /> -

- 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). -

-
- {/* Update-Check: Rippy MELDET Updates ehrlich — selbst updaten - kann es nicht (die Versionen stecken im Worker-Image) */} -
-
-

Update-Check

- -
- {updates ? ( -
-
- MakeMKV: installiert {updates.makemkv?.installiert || '—'}, - aktuell {updates.makemkv?.verfuegbar || 'unbekannt'} - {updates.makemkv?.update && ( - - Update: in der .env MAKEMKV_VERSION={updates.makemkv.verfuegbar} setzen, - dann auf der Rippy-Maschine docker compose build worker && docker compose up -d worker. - Neue Version bringt auch die neueste Disc-Schlüssel-Datenbank mit. - - )} -
-
- HandBrake: Docker-Worker {updates.handbrake?.installiert || '—'} (Debian-Paket), - neueste offizielle Version {updates.handbrake?.verfuegbar || 'unbekannt'} - - 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. - -
+
+
+

Update-Check

+
- ) : ( -

- Prüft gegen makemkv.com und die HandBrake-GitHub-Releases (Ergebnis wird 12 h gemerkt). -

- )} -
- } - - {/* Save Button — nicht im Worker-Tab: dort gibt es nichts zu - speichern (Liste + Anbinde-Befehle), der Knopf verwirrte nur */} - {activeTab !== 'worker' && ( -
- -
+ {updates && ( +
+
+ MakeMKV: installiert {updates.makemkv?.installiert || '—'}, aktuell {updates.makemkv?.verfuegbar || 'unbekannt'} +
+
+ HandBrake: installiert {updates.handbrake?.installiert || '—'}, verfügbar {updates.handbrake?.verfuegbar || 'unbekannt'} +
+
+ )} +
+ )} -
-
+ + {/* Global Save Button */} + {activeTab !== 'worker' && ( +
+ +
+ )} + +
) } - -function TabButton({ icon: Icon, label, isActive, onClick, darkMode }: { icon: any, label: string, isActive: boolean, onClick: () => void, darkMode: boolean }) { - const { theme } = useDarkMode() - return ( - - ) -} - -function Toggle({ checked, onChange, darkMode }: { checked: boolean, onChange: (v: boolean) => void, darkMode: boolean }) { - const { theme } = useDarkMode() - return ( - - ) -}