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
+3 -4
View File
@@ -3,12 +3,13 @@ import { LayoutDashboard, Disc, Settings, Sun, Moon, Terminal } from 'lucide-rea
import { api } from './lib/api'
import { useDarkMode } from './context/ThemeContext'
import Dashboard from './pages/Dashboard'
import MetadataPreview from './pages/MetadataPreview'
import SettingsPage from './pages/Settings'
import LogsPage from './pages/Logs'
import FirstRunWizard from './components/FirstRunWizard'
type Page = 'dashboard' | 'metadata' | 'settings' | 'logs'
// Metadaten-Seite entfernt (24.07., Abnahme durch den Commander): die
// Korrektur läuft komplett über das „Nicht korrekt?"-Popup am Dashboard.
type Page = 'dashboard' | 'settings' | 'logs'
function App() {
const [page, setPage] = useState<Page>('dashboard')
@@ -27,7 +28,6 @@ function App() {
const navItems = [
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ id: 'metadata', label: 'Metadaten', icon: Disc },
{ id: 'logs', label: 'Logs', icon: Terminal },
{ id: 'settings', label: 'Einstellungen', icon: Settings },
]
@@ -86,7 +86,6 @@ function App() {
<main className="flex-1 overflow-auto bg-slate-50 dark:bg-slate-950">
<div className="max-w-7xl mx-auto p-6">
{page === 'dashboard' && <Dashboard />}
{page === 'metadata' && <MetadataPreview />}
{page === 'logs' && <LogsPage />}
{page === 'settings' && <SettingsPage />}
</div>
+22 -5
View File
@@ -3,7 +3,7 @@ import { HardDrive, Disc, AlertCircle, CheckCircle, RefreshCw } from 'lucide-rea
import { api } from '../lib/api'
import { useDarkMode } from '../context/ThemeContext'
import { useToast } from '../context/ToastContext'
import RipTargetModal from './RipTargetModal'
import RipTargetModal, { RipOptionen } from './RipTargetModal'
import MetadataKorrektur from './MetadataKorrektur'
interface DiscInfo {
@@ -17,6 +17,11 @@ interface DiscInfo {
type?: string
source?: string
}
bereits_gerippt?: {
job_id: string
title?: string
finished_at?: string
}
}
interface Device {
@@ -66,13 +71,15 @@ export default function DeviceDiscovery() {
}
}
const startRip = async (device: Device, targetDir?: string) => {
const startRip = async (device: Device, targetDir?: string, optionen?: RipOptionen) => {
setActionBusy(true)
setActionFeedback(null)
try {
const response = await api.post('/jobs', {
device_path: device.path,
...(targetDir ? { target_dir: targetDir } : {}),
...(optionen?.series ? { series: optionen.series, season: optionen.season } : {}),
...(optionen?.mainFeatureOnly !== undefined ? { main_feature_only: optionen.mainFeatureOnly } : {}),
})
setActionFeedback(`✓ Job angelegt (${response.data.id.slice(0, 8)}…) — Fortschritt im Dashboard`)
toast('success', 'Rip gestartet — Fortschritt unten bei „Neueste Jobs"')
@@ -203,6 +210,15 @@ export default function DeviceDiscovery() {
{device.disc!.metadata.overview}
</p>
)}
{device.disc!.bereits_gerippt && (
<p className={`text-xs mt-2 px-2.5 py-1.5 rounded-lg inline-flex items-center gap-1.5 ${theme === 'dark' ? 'bg-amber-900/30 text-amber-300' : 'bg-amber-50 text-amber-700'}`}>
<AlertCircle size={13} className="flex-shrink-0" />
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.
</p>
)}
<div className="flex items-center gap-3 mt-3">
<button
onClick={() => setModalDevice(device)}
@@ -341,11 +357,12 @@ export default function DeviceDiscovery() {
Pfad — dort eingehängte Shares sind direkt wählbar) */}
<RipTargetModal
isOpen={modalDevice !== null}
initialType={modalDevice?.type === 'cd' ? 'music' : 'movies'}
initialType={modalDevice?.type === 'cd' ? 'music' : (modalDevice?.disc?.metadata?.type === 'tv' ? 'series' : 'movies')}
discTitle={modalDevice?.disc?.title}
onClose={() => setModalDevice(null)}
onSave={(target) => {
onSave={(target, optionen) => {
if (modalDevice) {
startRip(modalDevice, target.path)
startRip(modalDevice, target.path, optionen)
}
}}
/>
+68 -4
View File
@@ -11,11 +11,18 @@ interface TargetConfig {
isActive: boolean
}
export interface RipOptionen {
series?: string
season?: number
mainFeatureOnly?: boolean
}
interface RipTargetModalProps {
isOpen: boolean
initialType?: TargetConfig['type'] // CD → Musik vorwählen (Medium-gerecht)
discTitle?: string // Vorschlag für den Serien-Namen
onClose: () => void
onSave: (target: TargetConfig) => void
onSave: (target: TargetConfig, optionen: RipOptionen) => void
}
interface BrowseDir {
@@ -25,8 +32,11 @@ 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, initialType, onClose, onSave }: RipTargetModalProps) {
export default function RipTargetModal({ isOpen, initialType, discTitle, onClose, onSave }: RipTargetModalProps) {
const [selectedType, setSelectedType] = useState<TargetConfig['type']>('movies')
const [serienName, setSerienName] = useState('')
const [staffel, setStaffel] = useState(1)
const [nurHauptfilm, setNurHauptfilm] = useState(false)
const [targets, setTargets] = useState<TargetConfig[]>([
{ id: '1', name: 'Filme', path: '/app/media/movies', type: 'movies', isActive: true },
{ id: '2', name: 'Serien', path: '/app/media/series', type: 'series', isActive: true },
@@ -44,8 +54,11 @@ export default function RipTargetModal({ isOpen, initialType, onClose, onSave }:
if (!isOpen) return
setSelectedType(initialType || 'movies')
setCustomPath('')
setSerienName(discTitle || '')
setStaffel(1)
api.get('/settings').then(r => {
const s = r.data || {}
setNurHauptfilm(!!s.mainFeatureOnly)
const basis = s.outputDir || '/app/media'
setTargets([
{ id: '1', name: 'Filme', path: `${basis}/${s.movieDir || 'movies'}`, type: 'movies', isActive: true },
@@ -70,11 +83,16 @@ export default function RipTargetModal({ isOpen, initialType, onClose, onSave }:
}
const handleSave = () => {
const optionen: RipOptionen = { mainFeatureOnly: nurHauptfilm }
if (selectedType === 'series' && serienName.trim()) {
optionen.series = serienName.trim()
optionen.season = Math.max(1, staffel || 1)
}
const target = targets.find(t => t.type === selectedType)
if (customPath) {
onSave({ id: 'custom', name: 'Eigener Ordner', path: customPath, type: selectedType, isActive: true })
onSave({ id: 'custom', name: 'Eigener Ordner', path: customPath, type: selectedType, isActive: true }, optionen)
} else if (target) {
onSave(target)
onSave(target, optionen)
}
onClose()
}
@@ -120,6 +138,52 @@ export default function RipTargetModal({ isOpen, initialType, onClose, onSave }:
))}
</div>
{/* Serien-Flow: Name + Staffel → Ablage <Serie>/Season NN, Episoden
werden nach dem Rip per Laufzeit-Abgleich (TMDB) benannt */}
{selectedType === 'series' && (
<div className={`rounded-lg p-3 space-y-2 ${theme === 'dark' ? 'bg-slate-900' : 'bg-slate-50'}`}>
<div className="grid grid-cols-[1fr_90px] gap-3">
<div>
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Serienname</label>
<input
value={serienName}
onChange={e => 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'}`}
/>
</div>
<div>
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Staffel</label>
<input
type="number"
min={1}
value={staffel}
onChange={e => 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'}`}
/>
</div>
</div>
<p className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
Ablage: {serienName.trim() || '<Serie>'}/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).
</p>
</div>
)}
{/* Hauptfilm-Wahl: pro Rip übersteuerbar (Standard = Einstellung) */}
{selectedType === 'movies' && (
<label className={`flex items-center gap-2.5 text-sm px-1 ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
<input
type="checkbox"
checked={nurHauptfilm}
onChange={e => setNurHauptfilm(e.target.checked)}
className="w-4 h-4 accent-indigo-600"
/>
Nur Hauptfilm rippen (längster Titel — Extras/Trailer bleiben weg)
</label>
)}
{/* Ordner-Browser */}
<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'}`}>
+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>}