Praxis-Feedback-Runde: CIFS-Cap-Fix, TMDB v3-Keys, 4K-UHD-Typ, Vollautomatik, Job-Verwaltung, Worker-Namen
Ampel / ampel (push) Successful in 29s

Zwei bewiesene Bug-Fixes:
- SMB-Mount 'Unable to apply new capability set': mount.cifs hebt
  CAP_DAC_READ_SEARCH an, die in Dockers Default-Caps fehlt (auf der VM
  reproduziert: Bounding-Set a82425fb ohne Bit 2; mit der Capability
  verschwindet der Fehler). Fix: cap_add DAC_READ_SEARCH fuer den
  api-Container.
- TMDB fiel still aus: Client konnte nur v4-Bearer-Tokens, der uebliche
  32-Hex-v3-Key bekam 401 und die Suche lieferte nur OMDb. Jetzt beide
  Key-Arten (ist_v4_token + api_key-Query-Param lt.
  developer.themoviedb.org, mit Tests) — damit kommen auch die deutschen
  Texte an (language=de-DE war ueberall schon gesetzt). Neu:
  GET /metadata/status + 'Verbindung pruefen' in Einstellungen -> APIs
  (Live-Check am Cache vorbei).

Features aus dem Commander-Feedback:
- 4K UHD als eigener Disc-Typ: classify >= 55 GiB (BD-66/BD-100; BD-50
  bleibt bluray), beide detection.py + Tests, eigene Badge-Farbe in
  Dashboard/Laufwerken, Prescan-Label '4K UHD'. UHD-Rip-Fehler 'Failed to
  open disc' (Code 11) bekommt Klartext: LibreDrive-Firmware noetig.
- Vollautomatik (Setting autoRipStart): Disc erkannt -> Rip startet ohne
  Popup in den Schnellwahl-Ordner (Serie->Serien, sonst Filme, CD->Musik).
- Job-Verwaltung: 'Neu komprimieren' nur noch mit can_retry (Rohdaten
  liegen wirklich da), DELETE /jobs/{id} + 'Erledigte aufraeumen'
  (Dateien bleiben immer), 'Alle herunterladen' im Job-Detail
  (gestaffelte Einzel-Downloads statt Server-Zip von 40-GB-Dateien).
- Worker zuordenbar: WORKER_NAME-Env als stabiler Anzeigename (fixt auch
  die Offline-Leichen nach Rebuilds), info.hostname/ip gemeldet,
  Online-Abgleich ueber hostname, DELETE /workers/{name} + Papierkorb im
  UI, Quelle-Anzeige an der Disc-Karte ('Quelle: TMDB - 99 % sicher').
- Ripping-Tab nach Medium gegliedert (Video/Audio-CD/Allgemein) +
  Untertitel-Klartext (--all-audio/--all-subtitles bleiben komplett),
  CD -> Musik-Vorauswahl im Ziel-Dialog, Ordner-Verwaltung beschriftet
  und standardmaessig eingeklappt.
- Docs: SAVEPOINT v3.3, ROADMAP Etappe 14 + Ideen (nativer
  Windows-Worker, deutsche OMDb-Texte via TMDB-Find), README.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-24 13:29:10 +02:00
parent ab75134931
commit 5778ac4645
23 changed files with 691 additions and 81 deletions
+15 -4
View File
@@ -15,13 +15,14 @@ interface DiscInfo {
poster_path?: string
overview?: string
type?: string
source?: string
}
}
interface Device {
id: string
name: string
type: 'cd' | 'dvd' | 'bluray' | 'unknown'
type: 'cd' | 'dvd' | 'bluray' | 'uhd' | 'unknown'
path: string
status: 'empty' | 'ready' | 'ripping'
serial?: string
@@ -112,10 +113,18 @@ export default function DeviceDiscovery() {
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<string, string>)[type] || type.toUpperCase()
const QUELLEN_LABEL: Record<string, string> = {
tmdb: 'TMDB', omdb: 'OMDb', jikan: 'MyAnimeList', manuell: 'manuell gewählt',
}
const getStatusColor = (status: string) => {
switch (status) {
case 'empty': return theme === 'dark' ? 'bg-slate-700 text-slate-400' : 'bg-slate-100 text-slate-600'
@@ -185,7 +194,8 @@ export default function DeviceDiscovery() {
{device.disc!.disc_type?.toUpperCase()}
</span>
<span className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
Übereinstimmung {Math.round((device.disc!.confidence || 0) * 100)} %
Quelle: {QUELLEN_LABEL[device.disc!.metadata?.source || ''] || device.disc!.metadata?.source || 'Disc-Label'}
{' · '}{Math.round((device.disc!.confidence || 0) * 100)} % sicher
</span>
</div>
{device.disc!.metadata?.overview && (
@@ -266,7 +276,7 @@ export default function DeviceDiscovery() {
'Rippt'}
</span>
<span className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
Typ: {device.type.toUpperCase()}
Typ: {typLabel(device.type)}
</span>
</div>
</div>
@@ -293,7 +303,7 @@ export default function DeviceDiscovery() {
<span className={`font-mono text-xs ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>{device.serial || '—'}</span>
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>Disc</span>
<span className={theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}>
{device.status === 'ready' ? `eingelegt (${device.type.toUpperCase()})` : 'keine'}
{device.status === 'ready' ? `eingelegt (${typLabel(device.type)})` : 'keine'}
</span>
</div>
@@ -331,6 +341,7 @@ export default function DeviceDiscovery() {
Pfad — dort eingehängte Shares sind direkt wählbar) */}
<RipTargetModal
isOpen={modalDevice !== null}
initialType={modalDevice?.type === 'cd' ? 'music' : 'movies'}
onClose={() => setModalDevice(null)}
onSave={(target) => {
if (modalDevice) {
+27 -3
View File
@@ -177,9 +177,33 @@ export default function JobDetailModal({ jobId, onClose }: { jobId: string | nul
{/* Download der fertigen Dateien — vorher kam man nur per scp dran */}
{detail.status === 'completed' && dateien && dateien.length > 0 && (
<div className={`rounded-lg p-4 ${theme === 'dark' ? 'bg-slate-900' : 'bg-slate-50'}`}>
<p className={`text-sm font-medium mb-2 flex items-center gap-2 ${theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}`}>
<Download size={15} /> Dateien herunterladen
</p>
<div className="flex items-center justify-between mb-2">
<p className={`text-sm font-medium flex items-center gap-2 ${theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}`}>
<Download size={15} /> Dateien herunterladen
</p>
{dateien.length > 1 && (
<button
onClick={() => {
// Gestaffelte Einzel-Downloads statt Server-seitigem
// 40-GB-Zip — der Browser fragt ggf. einmal nach
// Erlaubnis für mehrere Downloads.
dateien.forEach((f, i) => {
setTimeout(() => {
const a = document.createElement('a')
a.href = `/api/jobs/${detail.id}/files/${encodeURIComponent(f.name)}`
a.download = f.name
document.body.appendChild(a)
a.click()
a.remove()
}, i * 400)
})
}}
className="text-xs font-medium px-3 py-1.5 rounded-lg bg-indigo-600 text-white hover:bg-indigo-700 transition-colors"
>
Alle herunterladen ({dateien.length})
</button>
)}
</div>
<div className="space-y-1">
{dateien.map(f => (
<a
+4 -1
View File
@@ -13,6 +13,7 @@ interface TargetConfig {
interface RipTargetModalProps {
isOpen: boolean
initialType?: TargetConfig['type'] // CD → Musik vorwählen (Medium-gerecht)
onClose: () => void
onSave: (target: TargetConfig) => void
}
@@ -24,7 +25,7 @@ interface BrowseDir {
// 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, onClose, onSave }: RipTargetModalProps) {
export default function RipTargetModal({ isOpen, initialType, onClose, onSave }: RipTargetModalProps) {
const [selectedType, setSelectedType] = useState<TargetConfig['type']>('movies')
const [targets, setTargets] = useState<TargetConfig[]>([
{ id: '1', name: 'Filme', path: '/app/media/movies', type: 'movies', isActive: true },
@@ -41,6 +42,8 @@ export default function RipTargetModal({ isOpen, onClose, onSave }: RipTargetMod
// Standard-Unterordner aus den Einstellungen ziehen (nicht hartkodiert)
useEffect(() => {
if (!isOpen) return
setSelectedType(initialType || 'movies')
setCustomPath('')
api.get('/settings').then(r => {
const s = r.data || {}
const basis = s.outputDir || '/app/media'
+25 -2
View File
@@ -41,6 +41,7 @@ export default function StorageMounts() {
const [browseDirs, setBrowseDirs] = useState<{ name: string, path: string }[]>([])
const [browseFiles, setBrowseFiles] = useState<{ name: string, size_mb: number | null }[]>([])
const [neuerOrdner, setNeuerOrdner] = useState('')
const [browserOffen, setBrowserOffen] = useState(false)
const { theme } = useDarkMode()
const { toast } = useToast()
@@ -188,9 +189,30 @@ export default function StorageMounts() {
)}
</div>
{/* Lokale Ordner durchsuchen + anlegen */}
{/* Ordner-Verwaltung — bewusst eingeklappt: fürs tägliche Rippen nicht
nötig (Ziel wählt man im „Rippen starten"-Dialog), war unbeschriftet
und verwirrte mehr als sie half (Commander-Feedback 24.07.). */}
<div className={`rounded-lg border overflow-hidden ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'}`}>
<div className={`flex items-center gap-2 px-3 py-2 border-b ${theme === 'dark' ? 'border-slate-700 bg-slate-900' : 'border-slate-200 bg-slate-50'}`}>
<button
onClick={() => setBrowserOffen(!browserOffen)}
className={`w-full flex items-center justify-between px-4 py-3 text-left transition-colors ${theme === 'dark' ? 'hover:bg-slate-800' : 'hover:bg-slate-50'}`}
>
<div>
<h3 className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
Ordner-Verwaltung (optional)
</h3>
<p className={`text-xs mt-0.5 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
Unterordner in der Ablage anschauen und neue anlegen z. B. anime" oder
„doku", die danach im Rippen starten"-Dialog wählbar sind. Fürs normale
Rippen brauchst du das hier nicht.
</p>
</div>
<span className={`text-xs flex-shrink-0 ml-3 ${theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'}`}>
{browserOffen ? 'zuklappen ▲' : 'aufklappen ▼'}
</span>
</button>
{browserOffen && (<>
<div className={`flex items-center gap-2 px-3 py-2 border-y ${theme === 'dark' ? 'border-slate-700 bg-slate-900' : 'border-slate-200 bg-slate-50'}`}>
<button
onClick={() => browseParent && browsen(browseParent)}
disabled={!browseParent}
@@ -249,6 +271,7 @@ export default function StorageMounts() {
</>
)}
</div>
</>)}
</div>
{/* Neues Netzwerk-Ziel */}
+41 -13
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'
import { Server, RefreshCw, Copy, CheckCircle } from 'lucide-react'
import { Server, RefreshCw, Copy, CheckCircle, Trash2 } from 'lucide-react'
import { api } from '../lib/api'
import { useDarkMode } from '../context/ThemeContext'
import { useToast } from '../context/ToastContext'
@@ -14,6 +14,12 @@ interface WorkerInfo {
encoders: string[]
last_seen?: string
online?: boolean
info?: {
hostname?: string
ip?: string
makemkv?: string
handbrake?: string
}
}
const ENCODER_LABELS: Record<string, string> = {
@@ -55,7 +61,8 @@ export default function WorkerVerwaltung() {
const rippyHost = window.location.hostname
const installBefehl = [
`git clone <rippy-repo-url> rippy && cd rippy`,
`RIPPY_HOST=${rippyHost} docker compose -f deploy/remote-transcode-worker.yml up -d --build`,
`RIPPY_HOST=${rippyHost} WORKER_NAME=mein-pc \\`,
` docker compose -f deploy/remote-transcode-worker.yml up -d --build`,
].join('\n')
const kopieren = () => {
@@ -88,18 +95,35 @@ export default function WorkerVerwaltung() {
/>
<Server size={18} className={theme === 'dark' ? 'text-slate-400' : 'text-slate-500'} />
<div className="min-w-0">
<p className={`text-sm font-mono truncate ${theme === 'dark' ? 'text-slate-200' : 'text-slate-800'}`}>{w.name}</p>
<p className={`text-sm font-medium truncate ${theme === 'dark' ? 'text-slate-200' : 'text-slate-800'}`}>{w.name}</p>
<p className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
{w.online ? 'online' : 'offline'} · Lebenszeichen {relativeZeit(w.last_seen)}
{w.info?.ip ? ` · IP ${w.info.ip}` : ''}
{w.info?.hostname && w.info.hostname !== w.name ? ` · ID ${w.info.hostname}` : ''}
</p>
</div>
</div>
<div className="flex flex-wrap gap-1 justify-end">
{w.encoders.map(e => (
<span key={e} className={`text-xs px-2 py-0.5 rounded ${e.startsWith('cpu') ? (theme === 'dark' ? 'bg-slate-700 text-slate-300' : 'bg-slate-200 text-slate-600') : 'bg-emerald-600/20 text-emerald-500 font-medium'}`}>
{ENCODER_LABELS[e] || e}
</span>
))}
<div className="flex items-center gap-2">
<div className="flex flex-wrap gap-1 justify-end">
{w.encoders.map(e => (
<span key={e} className={`text-xs px-2 py-0.5 rounded ${e.startsWith('cpu') ? (theme === 'dark' ? 'bg-slate-700 text-slate-300' : 'bg-slate-200 text-slate-600') : 'bg-emerald-600/20 text-emerald-500 font-medium'}`}>
{ENCODER_LABELS[e] || e}
</span>
))}
</div>
{!w.online && (
<button
onClick={() => {
api.delete(`/workers/${encodeURIComponent(w.name)}`)
.then(() => { toast('success', `${w.name}" entfernt — ein aktiver Worker meldet sich binnen 1 min zurück`); laden() })
.catch(() => toast('error', 'Entfernen fehlgeschlagen'))
}}
title="Verwaisten Eintrag entfernen (alte Container-ID nach Rebuild) — aktive Worker melden sich automatisch wieder an"
className="p-1.5 rounded text-rose-500 hover:bg-rose-500/10"
>
<Trash2 size={14} />
</button>
)}
</div>
</div>
))}
@@ -112,9 +136,10 @@ export default function WorkerVerwaltung() {
<h3 className={`font-medium mb-1 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Maschine als Encoding-Worker anbinden</h3>
<p className={`text-xs mb-3 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
Auf der Zielmaschine (Linux mit Docker, oder Windows mit Docker Desktop) diesen
Befehl ausführen der Worker verbindet sich, taucht oben in der Liste auf und
übernimmt ab dann automatisch Kompressions-Jobs. GPU vorhanden? Wird erkannt
und angezeigt. Voraussetzung: die Maschine erreicht diesen Rippy-Host im Netz.
Befehl ausführen WORKER_NAME frei wählen, unter dem Namen taucht die Maschine
oben auf und übernimmt ab dann automatisch Kompressions-Jobs. GPU vorhanden?
Wird erkannt und angezeigt. Voraussetzung: die Maschine erreicht diesen
Rippy-Host im Netz.
</p>
<div className={`relative rounded-lg p-3 font-mono text-xs whitespace-pre overflow-x-auto ${theme === 'dark' ? 'bg-slate-950 text-slate-300' : 'bg-slate-900 text-slate-200'}`}>
{installBefehl}
@@ -127,7 +152,10 @@ export default function WorkerVerwaltung() {
</button>
</div>
<p className={`text-xs mt-2 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
Eine geführte Ein-Klick-Installation (SSH) ist als Ausbaustufe geplant.
Ehrliche Antwort zu muss das Docker sein?": aktuell ja der Worker braucht
HandBrake + Celery, und Docker ist der einzige Weg, das auf jeder Maschine
identisch und ohne Installations-Roulette auszuliefern. Ein nativer
Windows-Dienst (Installer, ohne Docker) steht als Ausbaustufe in der ROADMAP.
</p>
</div>
</div>
+89 -17
View File
@@ -1,20 +1,23 @@
import { useEffect, useState } from 'react'
import { Clock, Activity, AlertCircle, CheckCircle, Disc, Download } from 'lucide-react'
import { Clock, Activity, AlertCircle, CheckCircle, Disc, Download, Trash2 } from 'lucide-react'
import { api } from '../lib/api'
import { useDarkMode } from '../context/ThemeContext'
import { useToast } from '../context/ToastContext'
import ConfirmDialog from '../components/ConfirmDialog'
import DeviceDiscovery from '../components/DeviceDiscovery'
import JobDetailModal from '../components/JobDetailModal'
import LiveLogSection from '../components/LiveLogSection'
interface Job {
id: string
type: 'cd' | 'dvd' | 'bluray'
type: 'cd' | 'dvd' | 'bluray' | 'uhd'
status: 'pending' | 'processing' | 'transcoding' | 'canceling' | 'completed' | 'failed'
device: string
startTime: string
endTime?: string
progress: number
title?: string
can_retry?: boolean
}
async function fetchJobs(): Promise<Job[]> {
@@ -64,20 +67,29 @@ function StatusBadge({ status }: { status: string }) {
function TypeBadge({ type }: { type: string }) {
const { theme } = useDarkMode()
const colors = theme === 'dark'
? 'bg-slate-800 text-slate-300'
: 'bg-slate-100 text-slate-700'
const icons = {
cd: Disc,
dvd: Disc,
bluray: Disc,
// 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<string, string> = 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<string, string> = {
cd: 'CD', dvd: 'DVD', bluray: 'BLU-RAY', uhd: '4K UHD',
}
const Icon = icons[type as keyof typeof icons] || Disc
const fallback = theme === 'dark' ? 'bg-slate-800 text-slate-300' : 'bg-slate-100 text-slate-700'
return (
<span className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium ${colors}`}>
<Icon size={14} />
{type.toUpperCase()}
<span className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium ${colors[type] || fallback}`}>
<Disc size={14} />
{labels[type] || type.toUpperCase()}
</span>
)
}
@@ -141,7 +153,30 @@ export default function Dashboard() {
const [workers, setWorkers] = useState<WorkerInfo[]>([])
const [loading, setLoading] = useState(true)
const [detailJobId, setDetailJobId] = useState<string | null>(null)
const [aufraeumenOffen, setAufraeumenOffen] = useState(false)
const { theme } = useDarkMode()
const { toast } = useToast()
const jobLoeschen = async (job: Job) => {
try {
await api.delete(`/jobs/${job.id}`)
toast('success', `${job.title || job.id.slice(0, 8)}" aus der Liste entfernt`)
setJobs(await fetchJobs())
} catch (e: any) {
toast('error', e?.response?.data?.detail || 'Entfernen fehlgeschlagen')
}
}
const erledigteAufraeumen = async () => {
setAufraeumenOffen(false)
try {
const r = await api.delete('/jobs')
toast('success', `${r.data.deleted} erledigte Jobs entfernt — Dateien bleiben liegen`)
setJobs(await fetchJobs())
} catch {
toast('error', 'Aufräumen fehlgeschlagen')
}
}
useEffect(() => {
const loadData = async () => {
@@ -253,7 +288,19 @@ export default function Dashboard() {
<div className={`rounded-xl shadow-sm border overflow-hidden transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
<div className={`px-6 py-4 border-b transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'} flex items-center justify-between`}>
<h2 className={`text-lg font-semibold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Neueste Jobs</h2>
<span className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>{jobs.length} Jobs insgesamt</span>
<div className="flex items-center gap-3">
<span className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>{jobs.length} Jobs insgesamt</span>
{jobs.some(j => j.status === 'completed' || j.status === 'failed') && (
<button
onClick={() => setAufraeumenOffen(true)}
title="Alle fertigen und fehlgeschlagenen Jobs aus der Liste entfernen (Dateien bleiben)"
className={`inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-lg transition-colors ${theme === 'dark' ? 'bg-slate-700 text-slate-300 hover:bg-slate-600' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}`}
>
<Trash2 size={13} />
Erledigte aufräumen
</button>
)}
</div>
</div>
<div className="overflow-x-auto transition-colors duration-300">
<table className="w-full transition-colors duration-300">
@@ -314,15 +361,30 @@ export default function Dashboard() {
Download
</button>
)}
{job.status === 'failed' && (
{/* Nur wenn Rohdaten wirklich daliegen — an einem Job,
der nie gerippt hat, war der Knopf Unsinn */}
{job.status === 'failed' && job.can_retry && (
<button
onClick={() => api.post(`/jobs/${job.id}/retry-transcode`).catch(() => {})}
onClick={() => {
api.post(`/jobs/${job.id}/retry-transcode`)
.then(() => toast('success', 'Kompression neu eingereiht'))
.catch((e: any) => toast('error', e?.response?.data?.detail || 'Neu anstoßen fehlgeschlagen'))
}}
title="Kompression neu anstoßen (Rohdatei bleibt nach Fehlschlägen erhalten)"
className={`text-xs font-medium px-3 py-1.5 rounded-lg transition-colors ${theme === 'dark' ? 'bg-slate-700 text-indigo-300 hover:bg-slate-600' : 'bg-slate-100 text-indigo-700 hover:bg-slate-200'}`}
>
Neu komprimieren
</button>
)}
{(job.status === 'completed' || job.status === 'failed') && (
<button
onClick={() => jobLoeschen(job)}
title="Job aus der Liste entfernen — fertige Dateien bleiben liegen"
className={`ml-2 p-1.5 rounded-lg transition-colors text-rose-500 ${theme === 'dark' ? 'hover:bg-slate-700' : 'hover:bg-slate-100'}`}
>
<Trash2 size={14} />
</button>
)}
{(job.status === 'pending' || job.status === 'processing' || job.status === 'transcoding') && (
<button
onClick={() => api.post(`/jobs/${job.id}/cancel`).catch(() => {})}
@@ -346,6 +408,16 @@ export default function Dashboard() {
{/* Klick auf einen Job-Titel: Film-/Serien-Info + Job-Fakten */}
<JobDetailModal jobId={detailJobId} onClose={() => setDetailJobId(null)} />
<ConfirmDialog
isOpen={aufraeumenOffen}
onClose={() => setAufraeumenOffen(false)}
onConfirm={erledigteAufraeumen}
title="Job-Liste aufräumen?"
message="Alle fertigen und fehlgeschlagenen Jobs verschwinden aus der Liste. Die gerippten Dateien bleiben selbstverständlich liegen."
confirmText="Aufräumen"
type="warning"
/>
</div>
)
}
+106 -11
View File
@@ -17,6 +17,7 @@ interface SettingsState {
ripAllTracks: boolean
mainFeatureOnly: boolean
autoEject: boolean
autoRipStart: boolean
notificationWebhook: string
transcodeEnabled: boolean
transcodePreset: string
@@ -37,6 +38,7 @@ const defaultSettings: SettingsState = {
ripAllTracks: true,
mainFeatureOnly: false,
autoEject: true,
autoRipStart: false,
notificationWebhook: '',
transcodeEnabled: true,
transcodePreset: 'H.265 MKV 1080p30',
@@ -80,9 +82,29 @@ export default function SettingsPage() {
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 { 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 || []))
@@ -182,20 +204,28 @@ export default function SettingsPage() {
Ripping-Einstellungen
</h2>
<div className="space-y-4 transition-colors duration-300">
<div className="flex items-center justify-between p-4 transition-colors duration-300">
<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'}`}>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 (nicht nur Main Feature)</p>
<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.ripAllTracks}
onChange={(v) => handleChange('ripAllTracks', v)}
<Toggle
checked={settings.autoRipStart}
onChange={(v) => handleChange('autoRipStart', v)}
darkMode={theme === 'dark'}
/>
</div>
@@ -210,13 +240,46 @@ export default function SettingsPage() {
<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)}
<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 &amp; 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'}`}>
@@ -408,7 +471,39 @@ export default function SettingsPage() {
<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'}`}>