Restefeger: Auth komplett raus, Serien-Flow + Episoden-Matching, Jellyfin-Refresh, Duplikat-Warnung, echtes Nur-Hauptfilm
Ampel / ampel (push) Successful in 55s

AUTH ENTFERNT (Commander-Entscheid 24.07., KONZEPT §10): /token- und
/api-keys-Endpoints, auth.py, test_auth.py, passlib/bcrypt/PyJWT/
python-multipart, JWT_SECRET_KEY-Pflicht. Heimnetz-only, das UI hatte nie
einen Login — die Auth-Oberflaeche war Placebo und die passlib/bcrypt-
Falle brach die Ampel. Rate-Limit pro IP bleibt. Schnellstart laeuft
jetzt ganz ohne .env-Pflichtwerte.

Serien-Flow (Etappe-12-Kern, ARM-Wunde #395):
- Rip-Dialog: Serienname + Staffel -> Ablage <Serie>/Season NN
  (jellyfin.org/docs Naming-Schema); tvshow.nfo + poster.jpg im
  Serien-Ordner, bei Staffel 2 nicht ueberschrieben.
- Episoden-Matching per Laufzeitabgleich: HandBrakeCLI --scan
  ('+ duration:', handbrake.fr/docs) je MKV gegen TMDB-Staffel-Laufzeiten
  (GET /metadata/tv/{id}/season/{n}; tv-season-details-API).
  Ordnungserhaltend; komplette Staffel auf einer Disc klappt auch bei
  uniformen Anime-Laufzeiten (Sequenz-Stufe). Umbenannt wird NUR bei
  eindeutiger Zuordnung — sonst ehrliches Log. Mit Tests.

Weitere Punkte:
- Jellyfin/Emby-Bibliotheks-Refresh nach jedem fertigen Rip
  (POST /Library/Refresh, X-Emby-Token lt. jellyfin.org/docs) —
  URL/Key + Test-Knopf in Einstellungen -> Ripping.
- Duplikat-Warnung: Disc-Fingerabdruck (jetzt Teil des Prescan-Ergebnisses
  + der Job-Metadaten) gegen die Historie; Karte zeigt 'bereits gerippt',
  Vollautomatik ueberspringt Duplikate.
- 'Nur Hauptfilm' ECHT: makemkvcon info -> TINFO-Attr-9-Laufzeiten
  (usage.txt) -> laengster Titel -> mkv dev:X <nr>. Vorher wirkungsloses
  Setting; pro Rip im Dialog uebersteuerbar. Mit Tests.
- OMDb-Treffer eingedeutscht via TMDB /find (external_source=imdb_id,
  de-DE; find-by-id-API).
- Dashboard: Speicherplatz-Anzeige (amber < 60 GB) + CSV-Export
  (GET /jobs/export, Semikolon+BOM fuer deutsches Excel).
- Metadaten-Seite entfernt (Abnahme durch Commander-Auftrag) inkl.
  Placebo-Endpoints /metadata/lookup (scannte Dummy-Device) und
  /metadata/confirm (schrieb nie gelesenen Cache-Key).
- Doppel-Jahr-Fix: 'X (2009) (2009)' in Log und Ordnernamen.
- Remote-Worker-Blocker: redis (6379) + postgres (5432) waren NIE
  veroeffentlicht — kein Remote-Worker konnte sich je verbinden. Ports
  jetzt offen (Heimnetz-Kompromiss, kommentiert) + API_URL fuer Worker.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-24 14:22:19 +02:00
parent e7a7d5fdb4
commit 8eb5653848
28 changed files with 934 additions and 892 deletions
+29 -1
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { Clock, Activity, AlertCircle, CheckCircle, Disc, Download, Trash2 } from 'lucide-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'
@@ -151,6 +151,7 @@ const ENCODER_KURZ: Record<string, string> = {
export default function Dashboard() {
const [jobs, setJobs] = useState<Job[]>([])
const [workers, setWorkers] = useState<WorkerInfo[]>([])
const [plaetze, setPlaetze] = useState<{ name: string, frei_gb: number, gesamt_gb: number }[]>([])
const [loading, setLoading] = useState(true)
const [detailJobId, setDetailJobId] = useState<string | null>(null)
const [aufraeumenOffen, setAufraeumenOffen] = useState(false)
@@ -189,6 +190,7 @@ export default function Dashboard() {
loadData()
api.get('/capabilities').then(r => setWorkers(r.data.workers || [])).catch(() => {})
api.get('/system/info').then(r => setPlaetze(r.data.plaetze || [])).catch(() => {})
const interval = setInterval(loadData, 5000)
return () => clearInterval(interval)
}, [])
@@ -244,6 +246,21 @@ export default function Dashboard() {
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>kein Job</span>
)}
</div>
{plaetze.length > 0 && (
<div className="flex items-center gap-2">
<HardDrive size={16} className="text-indigo-500" />
<span className={theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}>Frei:</span>
{plaetze.map(p => (
<span
key={p.name}
title={`${p.name}: ${p.frei_gb} von ${p.gesamt_gb} GB frei`}
className={`text-xs px-2 py-0.5 rounded ${p.frei_gb < 60 ? 'bg-amber-600/20 text-amber-500 font-medium' : theme === 'dark' ? 'bg-slate-700 text-slate-300' : 'bg-slate-100 text-slate-600'}`}
>
{p.name.startsWith('Media') ? 'Media' : 'Arbeit'} {p.frei_gb} GB
</span>
))}
</div>
)}
</div>
{/* Laufwerke + erkannte Disc */}
@@ -290,6 +307,17 @@ export default function Dashboard() {
<h2 className={`text-lg font-semibold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Neueste Jobs</h2>
<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.length > 0 && (
<a
href="/api/jobs/export"
download
title="Job-Historie als CSV herunterladen (öffnet in Excel)"
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'}`}
>
<FileSpreadsheet size={13} />
CSV
</a>
)}
{jobs.some(j => j.status === 'completed' || j.status === 'failed') && (
<button
onClick={() => setAufraeumenOffen(true)}
-420
View File
@@ -1,420 +0,0 @@
import { useState, useEffect } from 'react'
import { BookOpen, Music, Film, AlertCircle, CheckCircle, Search, Loader2 } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { api } from '../lib/api'
import { useDarkMode } from '../context/ThemeContext'
interface DeviceOption {
path: string
name: string
status: string
}
interface SuchKandidat {
title: string
year?: number
poster?: string
overview?: string
type: string
source: string
id?: string
}
function posterAbsolut(p?: string): string | null {
if (!p) return null
return p.startsWith('http') ? p : `https://image.tmdb.org/t/p/w342${p}`
}
interface Metadata {
type: 'movie' | 'tv' | 'music' | 'unknown'
id: number
title: string
year?: number
overview?: string
poster_path?: string
backdrop_path?: string
runtime?: number
genres?: string[]
}
interface Track {
track_number: number
title: string
duration?: number
}
interface PrescanResult {
disc_type: 'cd' | 'dvd' | 'bluray'
title: string
year?: number
confidence: number
metadata?: Metadata
tracks?: Track[]
}
export default function MetadataPreview() {
const [device, setDevice] = useState<string>('')
const [devices, setDevices] = useState<DeviceOption[]>([])
const [result, setResult] = useState<PrescanResult | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [confirming, setConfirming] = useState(false)
const [suchbegriff, setSuchbegriff] = useState('')
const [kandidaten, setKandidaten] = useState<SuchKandidat[]>([])
const [sucheLaeuft, setSucheLaeuft] = useState(false)
const { theme } = useDarkMode()
const titelSuchen = async () => {
if (!suchbegriff) return
setSucheLaeuft(true)
try {
const r = await api.get('/metadata/search', { params: { q: suchbegriff } })
setKandidaten(r.data)
} catch {
setKandidaten([])
} finally {
setSucheLaeuft(false)
}
}
const kandidatWaehlen = async (k: SuchKandidat) => {
try {
const r = await api.post('/metadata/override', {
device_path: device,
title: k.title,
year: k.year,
poster: k.poster,
overview: k.overview,
type: k.type,
source: k.source,
id: k.id,
})
setResult(r.data) // Karte + Dashboard zeigen ab jetzt DEINE Wahl
setKandidaten([])
setSuchbegriff('')
} catch {
setError('Übernehmen fehlgeschlagen.')
}
}
// Echte Laufwerke laden — vorher standen hier erfundene Festwerte
// (/dev/dvd, /dev/bluray), die es im Container nie gab.
useEffect(() => {
const loadDevices = async () => {
try {
const response = await api.get('/devices')
setDevices(response.data)
if (response.data.length === 1) {
setDevice(response.data[0].path)
}
} catch {
setDevices([])
}
}
loadDevices()
const interval = setInterval(loadDevices, 5000)
return () => clearInterval(interval)
}, [])
const handleScan = async () => {
if (!device) {
setError('Bitte wählen Sie ein Gerät aus.')
return
}
setLoading(true)
setError(null)
try {
const response = await api.post('/prescan', { device_path: device })
setResult(response.data)
} catch (err) {
setError('Pre-Scan fehlgeschlagen.')
} finally {
setLoading(false)
}
}
const handleConfirm = async () => {
if (!result) return
setConfirming(true)
try {
await api.post('/metadata/confirm', {
title: result.title,
year: result.year,
metadata: result.metadata
})
// Job erstellen — POST /jobs existiert seit 23.07. wirklich
await api.post('/jobs', {
device_path: device,
title: result.title
})
} catch (err) {
setError('Bestätigung fehlgeschlagen.')
} finally {
setConfirming(false)
}
}
const getIcon = (type: string): LucideIcon => {
switch (type) {
case 'cd':
return Music
case 'dvd':
case 'bluray':
return Film
default:
return BookOpen
}
}
const getConfidenceColor = (confidence: number): string => {
if (confidence >= 0.9) return theme === 'dark' ? 'text-green-400 bg-green-900/30' : 'text-green-600 bg-green-100'
if (confidence >= 0.7) return theme === 'dark' ? 'text-yellow-400 bg-yellow-900/30' : 'text-yellow-600 bg-yellow-100'
return theme === 'dark' ? 'text-red-400 bg-red-900/30' : 'text-red-600 bg-red-100'
}
return (
<div className="p-6 max-w-4xl mx-auto transition-colors duration-300">
<div className="mb-8 transition-colors duration-300">
<h1 className={`text-3xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Metadaten-Preview</h1>
<p className={`text-sm transition-colors duration-300 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
Wählen Sie ein Gerät aus, um die Metadaten vor dem Ripping zu lookupen
</p>
</div>
{/* Device Selection */}
<div className={`p-4 rounded-lg shadow mb-6 transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800' : 'bg-white'}`}>
<div className="flex gap-4 transition-colors duration-300">
<select
value={device}
onChange={(e) => setDevice(e.target.value)}
className={`flex-1 px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent ${theme === 'dark' ? 'bg-slate-700 border-slate-600 text-slate-100' : 'bg-white border-gray-300 text-slate-900'}`}
>
<option value="" className={theme === 'dark' ? 'bg-slate-800 text-slate-100' : 'bg-white text-slate-900'}>
{devices.length === 0 ? 'Kein Laufwerk gefunden' : 'Gerät auswählen...'}
</option>
{devices.map((d) => (
<option key={d.path} value={d.path} className={theme === 'dark' ? 'bg-slate-800 text-slate-100' : 'bg-white text-slate-900'}>
{d.name} ({d.path}){d.status === 'empty' ? ' — keine Disc' : ''}
</option>
))}
</select>
<button
onClick={handleScan}
disabled={!device || loading}
className={`flex items-center gap-2 px-6 py-2 rounded-lg text-white transition-colors ${
!device || loading
? 'bg-gray-400 cursor-not-allowed'
: 'bg-blue-600 hover:bg-blue-700'
}`}
>
{loading ? (
<>
<Loader2 className="animate-spin" size={20} />
Scannen...
</>
) : (
<>
<Search size={20} />
Scan starten
</>
)}
</button>
</div>
</div>
{error && (
<div className={`mb-6 p-4 rounded-lg flex items-center gap-3 transition-colors duration-300 ${theme === 'dark' ? 'bg-red-900/30 border border-red-700 text-red-400' : 'bg-red-50 border border-red-200 text-red-700'}`}>
<AlertCircle size={24} />
<span>{error}</span>
</div>
)}
{/* Manuelle Korrektur: wenn die Automatik danebenliegt, entscheidest DU */}
{device && (
<div className={`p-4 rounded-lg shadow mb-6 transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800' : 'bg-white'}`}>
<p className={`text-sm font-medium mb-2 ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
Erkennung falsch? Richtigen Titel suchen und übernehmen:
</p>
<div className="flex gap-3">
<input
value={suchbegriff}
onChange={e => setSuchbegriff(e.target.value)}
onKeyDown={e => e.key === 'Enter' && titelSuchen()}
placeholder="z. B. Evangelion 2.22"
className={`flex-1 px-4 py-2 border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent ${theme === 'dark' ? 'bg-slate-700 border-slate-600 text-slate-100' : 'bg-white border-gray-300 text-slate-900'}`}
/>
<button
onClick={titelSuchen}
disabled={!suchbegriff || sucheLaeuft}
className="flex items-center gap-2 px-5 py-2 rounded-lg bg-indigo-600 text-white hover:bg-indigo-700 transition-colors disabled:opacity-40"
>
{sucheLaeuft ? <Loader2 className="animate-spin" size={18} /> : <Search size={18} />}
Suchen
</button>
</div>
{kandidaten.length > 0 && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mt-4">
{kandidaten.map((k, i) => (
<button
key={i}
onClick={() => kandidatWaehlen(k)}
className={`text-left rounded-lg border overflow-hidden transition-all hover:border-indigo-500 hover:shadow-lg ${theme === 'dark' ? 'bg-slate-900 border-slate-700' : 'bg-slate-50 border-slate-200'}`}
>
{k.poster ? (
<img src={k.poster} alt={k.title} className="w-full h-40 object-cover" />
) : (
<div className={`w-full h-40 flex items-center justify-center ${theme === 'dark' ? 'bg-slate-800 text-slate-600' : 'bg-slate-100 text-slate-400'}`}>
<Film size={32} />
</div>
)}
<div className="p-2">
<p className={`text-xs font-semibold leading-tight line-clamp-2 ${theme === 'dark' ? 'text-slate-200' : 'text-slate-800'}`}>{k.title}</p>
<p className={`text-xs mt-0.5 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
{k.year || '—'} · {k.type === 'tv' ? 'Serie' : 'Film'} · {k.source}
</p>
</div>
</button>
))}
</div>
)}
</div>
)}
{/* Results */}
{result && (
<div className={`rounded-lg shadow overflow-hidden transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800' : 'bg-white'}`}>
{/* Header — mit Poster, wenn eine Quelle eins geliefert hat */}
<div className="bg-gradient-to-r from-blue-600 to-indigo-700 p-6 text-white flex gap-5">
{posterAbsolut(result.metadata?.poster_path) && (
<img
src={posterAbsolut(result.metadata?.poster_path)!}
alt={result.title}
className="w-24 rounded-lg shadow-lg object-cover flex-shrink-0"
/>
)}
<div className="min-w-0">
<div className="flex items-center gap-3 mb-2">
{/* Fix 23.07.: Lucide-Icons sind forwardRef-Komponenten — ein
Direkt-Aufruf getIcon(...)({size}) crasht die ganze Seite
(weißes Fenster). Als JSX rendern. */}
{(() => { const TypIcon = getIcon(result.disc_type); return <TypIcon size={28} /> })()}
<span className="text-sm font-medium opacity-90 uppercase tracking-wide">
{result.disc_type.toUpperCase()}
</span>
</div>
<h2 className="text-3xl font-bold">{result.title}</h2>
{result.year && (
<p className="text-lg opacity-90 mt-1">{result.year}</p>
)}
</div>
</div>
{/* Confidence Score */}
<div className={`p-6 transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-b border-gray-200'}`}>
<div className="flex items-center gap-2 transition-colors duration-300">
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-gray-700'}`}>Übereinstimmung:</span>
<span className={`px-3 py-1 rounded-full text-sm font-medium transition-colors duration-300 ${getConfidenceColor(result.confidence)}`}>
{Math.round(result.confidence * 100)}%
</span>
</div>
</div>
{/* Metadata */}
<div className="p-6 transition-colors duration-300">
{result.metadata && (
<div className="space-y-4 transition-colors duration-300">
{result.metadata.overview && (
<div>
<h3 className={`text-sm font-medium uppercase mb-2 ${theme === 'dark' ? 'text-slate-400' : 'text-gray-500'}`}>Beschreibung</h3>
<p className={`leading-relaxed ${theme === 'dark' ? 'text-slate-300' : 'text-gray-700'}`}>{result.metadata.overview}</p>
</div>
)}
{/* .length-Guards: {[] && …} rendert die leere Sektion,
{0 && …} sogar eine nackte "0" — klassische JSX-Falle */}
{result.metadata.genres && result.metadata.genres.length > 0 && (
<div>
<h3 className={`text-sm font-medium uppercase mb-2 ${theme === 'dark' ? 'text-slate-400' : 'text-gray-500'}`}>Genre</h3>
<div className="flex flex-wrap gap-2 transition-colors duration-300">
{result.metadata.genres.map((genre, index) => (
<span
key={index}
className={`px-3 py-1 rounded-full text-sm transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-700 text-slate-300' : 'bg-gray-100 text-gray-700'}`}
>
{genre}
</span>
))}
</div>
</div>
)}
{(result.metadata.runtime ?? 0) > 0 && (
<div>
<h3 className={`text-sm font-medium uppercase mb-2 ${theme === 'dark' ? 'text-slate-400' : 'text-gray-500'}`}>Laufzeit</h3>
<p className={`transition-colors duration-300 ${theme === 'dark' ? 'text-slate-300' : 'text-gray-700'}`}>{Math.floor(result.metadata.runtime / 60)}h {result.metadata.runtime % 60}m</p>
</div>
)}
</div>
)}
{/* Tracks */}
{result.tracks && result.tracks.length > 0 && (
<div className="mt-6 transition-colors duration-300">
<h3 className={`text-sm font-medium uppercase mb-3 ${theme === 'dark' ? 'text-slate-400' : 'text-gray-500'}`}>Tracks</h3>
<div className="space-y-2 transition-colors duration-300">
{result.tracks.map((track, index) => (
<div key={index} className="flex items-center justify-between p-3 transition-colors duration-300">
<div className="flex items-center gap-3 transition-colors duration-300">
<span className={`w-8 font-medium ${theme === 'dark' ? 'text-slate-400' : 'text-gray-500'}`}>{track.track_number}</span>
<span className={`transition-colors duration-300 ${theme === 'dark' ? 'text-slate-100' : 'text-gray-900'}`}>{track.title}</span>
</div>
{track.duration && (
<span className={`text-sm transition-colors duration-300 ${theme === 'dark' ? 'text-slate-400' : 'text-gray-500'}`}>
{Math.floor(track.duration / 60)}:{String(track.duration % 60).padStart(2, '0')}
</span>
)}
</div>
))}
</div>
</div>
)}
</div>
{/* Action */}
<div className={`p-6 transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-700/50 border-t border-slate-700' : 'bg-gray-50 border-t border-gray-200'}`}>
<div className="flex items-center justify-between transition-colors duration-300">
<div className="flex items-center gap-3 text-sm transition-colors duration-300">
<CheckCircle className={theme === 'dark' ? 'text-green-400' : 'text-green-600'} size={20} />
<span className={theme === 'dark' ? 'text-slate-300' : 'text-gray-600'}>Bereit zum Ripping</span>
</div>
<button
onClick={handleConfirm}
disabled={confirming}
className="flex items-center gap-2 px-6 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors"
>
{confirming ? (
<>
<Loader2 className="animate-spin" size={20} />
Bestätigen...
</>
) : (
<>
<CheckCircle size={20} />
Rippen starten
</>
)}
</button>
</div>
</div>
</div>
)}
</div>
)
}
+50
View File
@@ -23,6 +23,8 @@ interface SettingsState {
transcodePreset: string
keepOriginal: boolean
mediaServer: string
jellyfinUrl: string
jellyfinApiKey: string
workDir: string
makemkvAppKey: string
}
@@ -44,6 +46,8 @@ const defaultSettings: SettingsState = {
transcodePreset: 'H.265 MKV 1080p30',
keepOriginal: false,
mediaServer: 'none',
jellyfinUrl: '',
jellyfinApiKey: '',
workDir: '',
makemkvAppKey: '',
}
@@ -330,6 +334,52 @@ export default function SettingsPage() {
<p className={`text-xs mt-2 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
{MEDIA_SERVER_OPTIONEN.find(o => o.wert === settings.mediaServer)?.hinweis}
</p>
{/* Jellyfin/Emby: nach jedem fertigen Rip die Bibliothek scannen */}
{(settings.mediaServer === 'jellyfin' || settings.mediaServer === 'emby') && (
<div className={`mt-4 pt-4 border-t space-y-3 ${theme === 'dark' ? 'border-slate-600' : 'border-slate-200'}`}>
<p className={`text-sm ${theme === 'dark' ? 'text-slate-300' : 'text-slate-600'}`}>
<strong>Bibliotheks-Refresh</strong> (optional): Trag Server-URL + API-Key ein,
dann stößt Rippy nach jedem fertigen Rip sofort einen Scan an der Film
erscheint ohne Warten in {settings.mediaServer === 'jellyfin' ? 'Jellyfin' : 'Emby'}.
API-Key: Administration API-Schlüssel Neu.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<input
type="text"
value={settings.jellyfinUrl}
onChange={(e) => handleChange('jellyfinUrl', e.target.value)}
placeholder="http://192.168.178.50:8096"
className={`px-3 py-2 border rounded-lg text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'}`}
/>
<div className="flex gap-2">
<input
type="password"
value={settings.jellyfinApiKey}
onChange={(e) => handleChange('jellyfinApiKey', e.target.value)}
placeholder="API-Key"
className={`flex-1 px-3 py-2 border rounded-lg text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'}`}
/>
<button
onClick={async () => {
try {
await api.post('/mediaserver/refresh', {
url: settings.jellyfinUrl, api_key: settings.jellyfinApiKey,
})
toast('success', 'Verbunden — Bibliotheks-Scan wurde angestoßen!')
} catch (e: any) {
toast('error', e?.response?.data?.detail || 'Verbindung fehlgeschlagen')
}
}}
disabled={!settings.jellyfinUrl || !settings.jellyfinApiKey}
className="px-3 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 transition-colors disabled:opacity-40"
>
Testen
</button>
</div>
</div>
</div>
)}
</div>
</div>
</section>}