Restefeger: Auth komplett raus, Serien-Flow + Episoden-Matching, Jellyfin-Refresh, Duplikat-Warnung, echtes Nur-Hauptfilm
Ampel / ampel (push) Successful in 55s
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:
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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'}`}>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>}
|
||||
|
||||
Reference in New Issue
Block a user