UX-Runde: echte Disc-Titel via BD-Metadaten, Preview-Crash, Dashboard, Ziele
Ampel / ampel (push) Failing after 38s

Commander-Befunde 23.07. abends:
1. Metadaten "klappen nicht": Wurzel = kryptisches Volume-Label + kein
   TMDB-Key. Jetzt: API mountet die Disc read-only (CAP_SYS_ADMIN ist da)
   und liest den KLARTEXT-Titel aus BDMV/META/DL/bdmt_*.xml; dazu
   progressive Suchdegradation (Titel-Varianten) und OMDb-Unscharf-Suche
   (s= + imdbID-Nachladen) — mit dem vorhandenen OMDb-Key gibt es damit
   Titel/Jahr/Poster auch ohne TMDB
2. Weisses Fenster beim Pre-Scan: Lucide-Icons sind forwardRef —
   Direktaufruf getIcon(...)({size}) crashte die Seite; als JSX gerendert
3. Dashboard sortiert: System-Leiste (Encoder + aktiver Job) oben,
   Disc/Geraete -> Stats -> Jobs, Live-Log ans Ende
4. Ziel-Dialog mit ORDNER-BROWSER (GET /browse, nur unter /app/media);
   Schnellwahl nutzt die Settings-Unterordner statt Hartkodierung;
   Tab "Verzeichnisse" in "Speicherziele" aufgegangen (redundant)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-23 21:07:55 +02:00
parent 3d384c1e86
commit 90eb17d407
7 changed files with 348 additions and 163 deletions
+16
View File
@@ -55,6 +55,22 @@ class OMDbClient:
response = self.session.get(OMDB_BASE_URL, params=params, timeout=10) response = self.session.get(OMDB_BASE_URL, params=params, timeout=10)
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
# t= ist exakt — wenn leer, unscharfe Suche (s=) und den besten
# Treffer per imdbID nachladen
if data.get("Response") != "True":
suche = self.session.get(
OMDB_BASE_URL,
params={"apikey": self.api_key, "s": title, "r": "json"},
timeout=10,
).json()
treffer = (suche.get("Search") or []) if suche.get("Response") == "True" else []
if treffer:
data = self.session.get(
OMDB_BASE_URL,
params={"apikey": self.api_key, "i": treffer[0]["imdbID"], "r": "json"},
timeout=10,
).json()
except Exception as e: except Exception as e:
print(f"OMDb API Error: {e}") print(f"OMDb API Error: {e}")
return None return None
+25
View File
@@ -428,6 +428,31 @@ async def delete_storage_mount(name: str):
return {"status": "removed"} return {"status": "removed"}
@app.get("/browse")
async def browse(path: str = MEDIA_ROOT):
"""Server-seitiger Ordner-Browser für die Ziel-Auswahl (nur unter /app/media)."""
normalisiert = os.path.normpath(path)
if not normalisiert.startswith(MEDIA_ROOT):
raise HTTPException(status_code=422, detail=f"Nur Pfade unter {MEDIA_ROOT}")
def liste():
try:
eintraege = sorted(os.listdir(normalisiert))
except OSError:
return None
return [
{"name": name, "path": os.path.join(normalisiert, name)}
for name in eintraege
if os.path.isdir(os.path.join(normalisiert, name))
]
ordner = await asyncio.to_thread(liste)
if ordner is None:
raise HTTPException(status_code=404, detail="Ordner nicht lesbar")
eltern = os.path.dirname(normalisiert) if normalisiert != MEDIA_ROOT else None
return {"path": normalisiert, "parent": eltern, "dirs": ordner}
@app.get("/setup") @app.get("/setup")
async def setup_status(): async def setup_status():
"""First-Run-Erkennung: wurde der Einrichtungs-Assistent abgeschlossen?""" """First-Run-Erkennung: wurde der Einrichtungs-Assistent abgeschlossen?"""
+108 -14
View File
@@ -5,6 +5,7 @@ WIEDERHERGESTELLT 22.07.2026: Beim SoC-Refactoring wurde die echte Implementieru
lieferte seitdem immer „Unknown Disc". Dies ist die echte Logik, bereinigt. lieferte seitdem immer „Unknown Disc". Dies ist die echte Logik, bereinigt.
""" """
import os
import shutil import shutil
import subprocess import subprocess
from typing import Dict, List, Optional from typing import Dict, List, Optional
@@ -73,6 +74,77 @@ def parse_udf_dstring(data: bytes) -> str:
return "" return ""
def read_disc_title_via_mount(device_path: str) -> Optional[str]:
"""Liest den KLARTEXT-Titel einer Blu-ray aus BDMV/META/DL/bdmt_*.xml.
Das Volume-Label ist oft kryptisch (BD_EVG_D2) — der echte Titel
(„Evangelion: 2.22 …") steht in den Disc-Metadaten. Die API darf
read-only mounten (CAP_SYS_ADMIN ist für die Speicherziele ohnehin da).
Gibt None zurück, wenn kein BD-Metadatensatz existiert (z. B. DVD).
"""
import re as _re
mountpoint = "/mnt/rippy-disc"
os.makedirs(mountpoint, exist_ok=True)
gemountet = False
try:
ergebnis = subprocess.run(
["mount", "-t", "udf", "-o", "ro", device_path, mountpoint],
capture_output=True, text=True, timeout=30,
)
if ergebnis.returncode != 0:
return None
gemountet = True
meta_dir = os.path.join(mountpoint, "BDMV", "META", "DL")
if not os.path.isdir(meta_dir):
return None
kandidaten = sorted(os.listdir(meta_dir))
# bdmt_eng.xml bevorzugen, sonst erste bdmt-Datei (bdmt_ger.xml, …)
bdmt = next((k for k in kandidaten if k == "bdmt_eng.xml"), None) or next(
(k for k in kandidaten if k.startswith("bdmt_") and k.endswith(".xml")), None
)
if not bdmt:
return None
with open(os.path.join(meta_dir, bdmt), "rb") as f:
inhalt = f.read(65536).decode("utf-8", errors="replace")
# <di:name>Titel</di:name> — bewusst per Regex statt XML-Parser
# (Namespaces variieren je Authoring-Werkzeug)
treffer = _re.search(r"<di:name>([^<]{2,120})</di:name>", inhalt)
if treffer:
return treffer.group(1).strip()
return None
except Exception:
return None
finally:
if gemountet:
subprocess.run(["umount", mountpoint], capture_output=True, timeout=15)
def titel_kandidaten(titel: str) -> List[str]:
"""Suchvarianten für die Metadaten-APIs (progressive Degradation, ARM-Lehre).
'Evangelion: 2.22 You Can (Not) Advance.' → auch 'Evangelion: 2.22 …' ohne
Zusatz, ohne Doppelpunkt-Teil usw. — Disc-Titel sind selten API-freundlich.
"""
kandidaten = []
def merke(t: str):
t = " ".join(t.split()).strip(" .")
if t and t not in kandidaten:
kandidaten.append(t)
merke(titel)
if ":" in titel:
merke(titel.split(":", 1)[0]) # Haupttitel vor dem Doppelpunkt
merke(titel.replace(":", ""))
import re as _re
merke(_re.sub(r"\([^)]*\)", "", titel)) # Klammer-Zusätze raus
worte = titel.split()
if len(worte) > 2:
merke(" ".join(worte[:-1])) # letztes Wort strippen
return kandidaten
def read_udf_volume_label(device_path: str) -> Optional[str]: def read_udf_volume_label(device_path: str) -> Optional[str]:
"""Liest das Volume-Label aus dem UDF Primary Volume Descriptor. """Liest das Volume-Label aus dem UDF Primary Volume Descriptor.
@@ -193,11 +265,14 @@ class PreScan:
"duration": 0 "duration": 0
}) })
else: else:
# Titel-Quelle 1: Volume-Label direkt vom Medium — läuft # Titel-Quelle 1 (beste): Klartext-Titel aus den BD-Metadaten
# überall. ISO-9660 für DVDs, UDF für Blu-rays (die haben # (BDMV/META — Labels wie BD_EVG_D2 taugen nicht für APIs)
# meist keine ISO-Bridge). (makemkvcon gibt es NUR im klartext = read_disc_title_via_mount(device_path)
# Worker-Container; der alte Aufruf hier scheiterte in der if klartext:
# API still und der Titel blieb ewig "Unknown Title".) toc["title"] = klartext
else:
# Titel-Quelle 2: Volume-Label direkt vom Medium.
# ISO-9660 für DVDs, UDF für Blu-rays (keine ISO-Bridge).
label = read_iso_volume_label(device_path) or read_udf_volume_label(device_path) label = read_iso_volume_label(device_path) or read_udf_volume_label(device_path)
if label: if label:
toc["title"] = normalize_disc_label(label) toc["title"] = normalize_disc_label(label)
@@ -282,15 +357,24 @@ class PreScan:
if cached: if cached:
return PreScanResult(**cached) return PreScanResult(**cached)
title = normalize_disc_label(toc["title"] or "") or "Unknown Title" # Titel NICHT nochmal normalisieren: aus bdmt_*.xml kommt er sauber
# („2.22" würde die Label-Normalisierung zu „2 22" zerlegen)
title = (toc.get("title") or "").strip() or "Unknown Title"
confidence = 0.0 confidence = 0.0
metadata = {} metadata = {}
matched = False matched = False
movies = self.tmdb.search_movie(title) # Progressive Suchdegradation (ARM-Lehre): mehrere Titel-Varianten
if movies: # probieren — Disc-Titel sind selten API-freundlich formatiert.
for movie in movies: kandidaten = titel_kandidaten(title) or [title]
if movie.get("title", "").lower() == title.lower(): movies = []
for kandidat in kandidaten:
treffer = self.tmdb.search_movie(kandidat)
if treffer and not movies:
movies = treffer # bester Rohtreffer für den Vorschlags-Fallback
for movie in treffer:
if movie.get("title", "").lower() == kandidat.lower():
confidence = 0.95 confidence = 0.95
movie_details = self.tmdb.get_movie_details(movie["id"]) movie_details = self.tmdb.get_movie_details(movie["id"])
if movie_details: if movie_details:
@@ -307,12 +391,14 @@ class PreScan:
} }
matched = True matched = True
break break
if matched:
break
if not matched: if not matched:
tv_shows = self.tmdb.search_tv(title) for kandidat in kandidaten:
if tv_shows: tv_shows = self.tmdb.search_tv(kandidat)
for show in tv_shows: for show in tv_shows:
if show.get("name", "").lower() == title.lower(): if show.get("name", "").lower() == kandidat.lower():
confidence = 0.9 confidence = 0.9
tv_details = self.tmdb.get_tv_details(show["id"]) tv_details = self.tmdb.get_tv_details(show["id"])
if tv_details: if tv_details:
@@ -328,16 +414,20 @@ class PreScan:
} }
matched = True matched = True
break break
if matched:
break
# Fallback 1: OMDb (eigene Datenbasis — findet oft, was TMDB nicht # Fallback 1: OMDb (eigene Datenbasis — findet oft, was TMDB nicht
# exakt trifft; braucht OMDB_API_KEY, sonst überspringt es sich selbst) # exakt trifft; braucht OMDB_API_KEY, sonst überspringt es sich selbst)
if not matched: if not matched:
omdb_treffer = self.omdb.lookup(title) for kandidat in kandidaten:
omdb_treffer = self.omdb.lookup(kandidat)
if omdb_treffer: if omdb_treffer:
confidence = 0.8 confidence = 0.8
metadata = omdb_treffer metadata = omdb_treffer
title = omdb_treffer.get("title", title) title = omdb_treffer.get("title", title)
matched = True matched = True
break
# Fallback 2: bester TMDB-Vorschlag ohne exakten Treffer — als # Fallback 2: bester TMDB-Vorschlag ohne exakten Treffer — als
# VORSCHLAG gekennzeichnet (niedrige Confidence, Nutzer korrigiert) # VORSCHLAG gekennzeichnet (niedrige Confidence, Nutzer korrigiert)
@@ -366,6 +456,10 @@ class PreScan:
"year": None "year": None
} }
# Bei Treffer den sauberen API-Titel anzeigen statt des Disc-Titels
if matched and metadata.get("title"):
title = metadata["title"]
result = PreScanResult( result = PreScanResult(
disc_type=toc.get("disc_type", "DVD"), disc_type=toc.get("disc_type", "DVD"),
title=title, title=title,
+99 -57
View File
@@ -1,4 +1,6 @@
import { useState } from 'react' import { useState, useEffect } from 'react'
import { Folder, FolderOpen, ArrowUp, CheckCircle } from 'lucide-react'
import { api } from '../lib/api'
import { useDarkMode } from '../context/ThemeContext' import { useDarkMode } from '../context/ThemeContext'
interface TargetConfig { interface TargetConfig {
@@ -15,24 +17,58 @@ interface RipTargetModalProps {
onSave: (target: TargetConfig) => void onSave: (target: TargetConfig) => void
} }
interface BrowseDir {
name: string
path: string
}
// Ziel-Auswahl vor dem Rip: Schnellwahl (Filme/Serien/Musik) ODER frei per
// Ordner-Browser — auch in eingehängte Netzwerk-Ziele (NAS, PC-Freigabe).
export default function RipTargetModal({ isOpen, onClose, onSave }: RipTargetModalProps) { export default function RipTargetModal({ isOpen, onClose, onSave }: RipTargetModalProps) {
const [selectedType, setSelectedType] = useState<TargetConfig['type']>('movies') const [selectedType, setSelectedType] = useState<TargetConfig['type']>('movies')
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 },
{ id: '3', name: 'Musik', path: '/app/media/music', type: 'music', isActive: true },
])
const [browsePath, setBrowsePath] = useState<string>('/app/media')
const [browseParent, setBrowseParent] = useState<string | null>(null)
const [browseDirs, setBrowseDirs] = useState<BrowseDir[]>([])
const [customPath, setCustomPath] = useState('') const [customPath, setCustomPath] = useState('')
const { theme } = useDarkMode() const { theme } = useDarkMode()
const defaultTargets: TargetConfig[] = [ // Standard-Unterordner aus den Einstellungen ziehen (nicht hartkodiert)
{ id: '1', name: 'Movies', path: '/app/media/movies', type: 'movies', isActive: true }, useEffect(() => {
{ id: '2', name: 'Series', path: '/app/media/series', type: 'series', isActive: true }, if (!isOpen) return
{ id: '3', name: 'Music', path: '/app/media/music', type: 'music', isActive: true }, api.get('/settings').then(r => {
] const s = r.data || {}
const basis = s.outputDir || '/app/media'
setTargets([
{ id: '1', name: 'Filme', path: `${basis}/${s.movieDir || 'movies'}`, type: 'movies', isActive: true },
{ id: '2', name: 'Serien', path: `${basis}/${s.seriesDir || 'series'}`, type: 'series', isActive: true },
{ id: '3', name: 'Musik', path: `${basis}/${s.musicDir || 'music'}`, type: 'music', isActive: true },
])
}).catch(() => {})
laden('/app/media')
}, [isOpen])
const [targets, setTargets] = useState(defaultTargets) const laden = async (pfad: string) => {
try {
const r = await api.get('/browse', { params: { path: pfad } })
setBrowsePath(r.data.path)
setBrowseParent(r.data.parent)
setBrowseDirs(r.data.dirs)
} catch {
setBrowseDirs([])
}
}
const handleSave = () => { const handleSave = () => {
const target = targets.find(t => t.type === selectedType) const target = targets.find(t => t.type === selectedType)
if (target) { if (customPath) {
// Eigener Pfad gewinnt — vorher wurde customPath stillschweigend ignoriert onSave({ id: 'custom', name: 'Eigener Ordner', path: customPath, type: selectedType, isActive: true })
onSave(customPath ? { ...target, path: customPath } : target) } else if (target) {
onSave(target)
} }
onClose() onClose()
} }
@@ -46,24 +82,20 @@ export default function RipTargetModal({ isOpen, onClose, onSave }: RipTargetMod
<div className={`px-6 py-4 border-b transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'}`}> <div className={`px-6 py-4 border-b transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'}`}>
<h2 className={`text-xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Rip-Ziel auswählen</h2> <h2 className={`text-xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Rip-Ziel auswählen</h2>
<p className={`text-sm mt-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}> <p className={`text-sm mt-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
Wohin sollen die gerippten Dateien gespeichert werden? Schnellwahl oder eigenen Ordner wählen Netzwerk-Ziele (NAS, PC) sind unter Einstellungen Speicherziele einhängbar.
</p> </p>
</div> </div>
{/* Body */} {/* Body */}
<div className="p-6 space-y-6"> <div className="p-6 space-y-5">
{/* Type Selection */} {/* Schnellwahl */}
<div>
<label className={`block text-sm font-medium mb-3 ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
Dateityp
</label>
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
{['movies', 'series', 'music'].map((type) => ( {targets.map((t) => (
<button <button
key={type} key={t.type}
onClick={() => setSelectedType(type as TargetConfig['type'])} onClick={() => { setSelectedType(t.type); setCustomPath('') }}
className={`flex flex-col items-center justify-center gap-2 p-4 rounded-lg border transition-all ${ className={`flex flex-col items-center justify-center gap-1.5 p-3 rounded-lg border transition-all ${
selectedType === type selectedType === t.type && !customPath
? theme === 'dark' ? theme === 'dark'
? 'bg-indigo-900/30 border-indigo-500 text-indigo-400' ? 'bg-indigo-900/30 border-indigo-500 text-indigo-400'
: 'bg-indigo-50 border-indigo-600 text-indigo-700' : 'bg-indigo-50 border-indigo-600 text-indigo-700'
@@ -72,49 +104,59 @@ export default function RipTargetModal({ isOpen, onClose, onSave }: RipTargetMod
: 'bg-slate-50 border-slate-200 text-slate-600 hover:border-slate-300' : 'bg-slate-50 border-slate-200 text-slate-600 hover:border-slate-300'
}`} }`}
> >
<span className="text-2xl"> <span className="text-xl">
{type === 'movies' && '🎥'} {t.type === 'movies' && '🎥'}
{type === 'series' && '📺'} {t.type === 'series' && '📺'}
{type === 'music' && '🎵'} {t.type === 'music' && '🎵'}
</span> </span>
<span className="text-sm font-medium capitalize">{type}</span> <span className="text-sm font-medium">{t.name}</span>
</button> </button>
))} ))}
</div> </div>
</div>
{/* Path Display */} {/* Ordner-Browser */}
<div> <div className={`rounded-lg border overflow-hidden ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'}`}>
<label className={`block text-sm font-medium mb-2 ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}> <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'}`}>
Zielverzeichnis
</label>
<div className={`flex items-center gap-3 p-4 rounded-lg border ${theme === 'dark' ? 'bg-slate-900 border-slate-700' : 'bg-slate-50 border-slate-200'}`}>
<span className="text-xl">📁</span>
<input
type="text"
value={customPath || targets.find(t => t.type === selectedType)?.path || ''}
onChange={(e) => setCustomPath(e.target.value)}
className={`flex-1 bg-transparent outline-none text-sm font-mono ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}
placeholder="/path/to/target"
/>
<button <button
onClick={() => { onClick={() => browseParent && laden(browseParent)}
const target = targets.find(t => t.type === selectedType) disabled={!browseParent}
if (target) { className={`p-1 rounded disabled:opacity-30 ${theme === 'dark' ? 'hover:bg-slate-700 text-slate-400' : 'hover:bg-slate-200 text-slate-500'}`}
setCustomPath(target.path) title="Ordner hoch"
}
}}
className={`px-3 py-1.5 rounded text-xs font-medium transition-colors ${theme === 'dark' ? 'bg-slate-700 text-slate-300 hover:bg-slate-600' : 'bg-slate-200 text-slate-700 hover:bg-slate-300'}`}
> >
Standard <ArrowUp size={16} />
</button>
<span className={`text-xs font-mono truncate flex-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>{browsePath}</span>
<button
onClick={() => setCustomPath(browsePath)}
className={`text-xs font-medium px-2.5 py-1 rounded transition-colors ${customPath === browsePath ? 'bg-indigo-600 text-white' : theme === 'dark' ? 'bg-slate-700 text-indigo-300 hover:bg-slate-600' : 'bg-slate-200 text-indigo-700 hover:bg-slate-300'}`}
>
{customPath === browsePath ? '✓ gewählt' : 'Diesen Ordner nutzen'}
</button> </button>
</div> </div>
<p className={`text-xs mt-2 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-500'}`}> <div className="max-h-40 overflow-y-auto">
{customPath {browseDirs.length === 0 ? (
? `Verwende benutzerdefiniertes Verzeichnis: ${customPath}` <p className={`text-xs px-3 py-3 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Keine Unterordner</p>
: `Verwende Standard-Verzeichnis: ${targets.find(t => t.type === selectedType)?.path}` ) : (
} browseDirs.map(d => (
</p> <button
key={d.path}
onClick={() => laden(d.path)}
className={`w-full flex items-center gap-2 px-3 py-2 text-sm text-left transition-colors ${theme === 'dark' ? 'text-slate-300 hover:bg-slate-700' : 'text-slate-700 hover:bg-slate-100'}`}
>
<Folder size={15} className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'} />
{d.name}
</button>
))
)}
</div>
</div>
{/* Gewähltes Ziel */}
<div className={`flex items-center gap-2 text-sm px-3 py-2 rounded-lg ${theme === 'dark' ? 'bg-slate-900' : 'bg-slate-50'}`}>
{customPath ? <FolderOpen size={16} className="text-indigo-500" /> : <CheckCircle size={16} className="text-emerald-500" />}
<span className={`font-mono text-xs truncate ${theme === 'dark' ? 'text-slate-300' : 'text-slate-600'}`}>
{customPath || targets.find(t => t.type === selectedType)?.path}
</span>
</div> </div>
</div> </div>
@@ -130,7 +172,7 @@ export default function RipTargetModal({ isOpen, onClose, onSave }: RipTargetMod
onClick={handleSave} onClick={handleSave}
className="px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 transition-colors shadow-lg shadow-indigo-500/30" className="px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 transition-colors shadow-lg shadow-indigo-500/30"
> >
Speichern Rippen starten
</button> </button>
</div> </div>
</div> </div>
+48 -4
View File
@@ -120,8 +120,21 @@ function ProgressBar({ progress }: { progress: number }) {
) )
} }
interface WorkerInfo {
name: string
encoders: string[]
}
const ENCODER_KURZ: Record<string, string> = {
'cpu-x264': 'H.264 (CPU)',
'cpu-x265': 'H.265 (CPU)',
'vaapi': 'VAAPI ⚡',
'nvenc': 'NVENC ⚡',
}
export default function Dashboard() { export default function Dashboard() {
const [jobs, setJobs] = useState<Job[]>([]) const [jobs, setJobs] = useState<Job[]>([])
const [workers, setWorkers] = useState<WorkerInfo[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const { theme } = useDarkMode() const { theme } = useDarkMode()
@@ -135,10 +148,13 @@ export default function Dashboard() {
} }
loadData() loadData()
api.get('/capabilities').then(r => setWorkers(r.data.workers || [])).catch(() => {})
const interval = setInterval(loadData, 5000) const interval = setInterval(loadData, 5000)
return () => clearInterval(interval) return () => clearInterval(interval)
}, []) }, [])
const aktiverJob = jobs.find(j => j.status === 'processing' || j.status === 'transcoding')
const stats = { const stats = {
pending: jobs.filter(j => j.status === 'pending').length, pending: jobs.filter(j => j.status === 'pending').length,
processing: jobs.filter(j => j.status === 'processing').length, processing: jobs.filter(j => j.status === 'processing').length,
@@ -162,11 +178,36 @@ export default function Dashboard() {
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Übersicht über alle Ripping-Jobs und Geräte</p> <p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Übersicht über alle Ripping-Jobs und Geräte</p>
</div> </div>
{/* Device Discovery Section */} {/* System-Leiste: Encoder + aktiver Job auf einen Blick */}
<DeviceDiscovery /> <div className={`flex flex-wrap items-center gap-x-6 gap-y-2 rounded-xl border px-5 py-3 text-sm ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
<div className="flex items-center gap-2">
<Activity size={16} className="text-indigo-500" />
<span className={theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}>Encoder:</span>
{workers.length === 0 ? (
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>kein Worker</span>
) : (
workers.flatMap(w => w.encoders).filter((e, i, a) => a.indexOf(e) === i).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-100 text-slate-600') : 'bg-emerald-600/20 text-emerald-500 font-medium'}`}>
{ENCODER_KURZ[e] || e}
</span>
))
)}
</div>
<div className="flex items-center gap-2">
<Clock size={16} className="text-indigo-500" />
<span className={theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}>Aktiv:</span>
{aktiverJob ? (
<span className={theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}>
{aktiverJob.status === 'transcoding' ? 'Komprimieren' : 'Rippen'} · {aktiverJob.progress} %
</span>
) : (
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>kein Job</span>
)}
</div>
</div>
{/* Live Log Section */} {/* Laufwerke + erkannte Disc */}
<LiveLogSection /> <DeviceDiscovery />
{/* Stats Cards */} {/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 transition-colors duration-300"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 transition-colors duration-300">
@@ -271,6 +312,9 @@ export default function Dashboard() {
</table> </table>
</div> </div>
</div> </div>
{/* Live-Log ganz unten — Detail-Ansicht, nicht Haupt-Info */}
<LiveLogSection />
</div> </div>
) )
} }
+4 -1
View File
@@ -190,7 +190,10 @@ export default function MetadataPreview() {
{/* Header */} {/* Header */}
<div className="bg-gradient-to-r from-blue-600 to-indigo-700 p-6 text-white"> <div className="bg-gradient-to-r from-blue-600 to-indigo-700 p-6 text-white">
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
{getIcon(result.disc_type)({ size: 32 })} {/* 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={32} /> })()}
<span className="text-sm font-medium opacity-90 uppercase tracking-wide"> <span className="text-sm font-medium opacity-90 uppercase tracking-wide">
{result.disc_type.toUpperCase()} {result.disc_type.toUpperCase()}
</span> </span>
+26 -65
View File
@@ -38,7 +38,7 @@ const defaultSettings: SettingsState = {
keepOriginal: false, keepOriginal: false,
} }
type SettingsTab = 'ripping' | 'verarbeitung' | 'speicherziele' | 'verzeichnisse' | 'apis' | 'benachrichtigungen' type SettingsTab = 'ripping' | 'verarbeitung' | 'speicherziele' | 'apis' | 'benachrichtigungen'
interface WorkerInfo { interface WorkerInfo {
name: string name: string
@@ -126,7 +126,6 @@ export default function SettingsPage() {
<TabButton icon={Disc} label="Ripping" isActive={activeTab === 'ripping'} onClick={() => setActiveTab('ripping')} darkMode={theme === 'dark'} /> <TabButton icon={Disc} label="Ripping" isActive={activeTab === 'ripping'} onClick={() => setActiveTab('ripping')} darkMode={theme === 'dark'} />
<TabButton icon={Cpu} label="Verarbeitung" isActive={activeTab === 'verarbeitung'} onClick={() => setActiveTab('verarbeitung')} darkMode={theme === 'dark'} /> <TabButton icon={Cpu} label="Verarbeitung" isActive={activeTab === 'verarbeitung'} onClick={() => setActiveTab('verarbeitung')} darkMode={theme === 'dark'} />
<TabButton icon={HardDrive} label="Speicherziele" isActive={activeTab === 'speicherziele'} onClick={() => setActiveTab('speicherziele')} darkMode={theme === 'dark'} /> <TabButton icon={HardDrive} label="Speicherziele" isActive={activeTab === 'speicherziele'} onClick={() => setActiveTab('speicherziele')} darkMode={theme === 'dark'} />
<TabButton icon={Database} label="Verzeichnisse" isActive={activeTab === 'verzeichnisse'} onClick={() => setActiveTab('verzeichnisse')} darkMode={theme === 'dark'} />
<TabButton icon={Globe} label="APIs" isActive={activeTab === 'apis'} onClick={() => setActiveTab('apis')} darkMode={theme === 'dark'} /> <TabButton icon={Globe} label="APIs" isActive={activeTab === 'apis'} onClick={() => setActiveTab('apis')} darkMode={theme === 'dark'} />
<TabButton icon={AlertCircle} label="Benachrichtigungen" isActive={activeTab === 'benachrichtigungen'} onClick={() => setActiveTab('benachrichtigungen')} darkMode={theme === 'dark'} /> <TabButton icon={AlertCircle} label="Benachrichtigungen" isActive={activeTab === 'benachrichtigungen'} onClick={() => setActiveTab('benachrichtigungen')} darkMode={theme === 'dark'} />
</div> </div>
@@ -265,72 +264,34 @@ export default function SettingsPage() {
<HardDrive size={20} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} /> <HardDrive size={20} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} />
Speicherziele Speicherziele
</h2> </h2>
{/* Standard-Unterordner (ehemals eigener Tab "Verzeichnisse" —
gehört inhaltlich hierher) */}
<div className={`rounded-lg p-4 mb-6 border ${theme === 'dark' ? 'border-slate-700 bg-slate-800/50' : 'border-slate-200 bg-slate-50'}`}>
<h3 className={`font-medium mb-3 flex items-center gap-2 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
<Database size={16} className="text-indigo-500" /> Standard-Unterordner
</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{([['outputDir', 'Basis'], ['movieDir', 'Filme'], ['seriesDir', 'Serien'], ['musicDir', 'Musik']] as const).map(([feld, label]) => (
<div key={feld}>
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>{label}</label>
<input
type="text"
value={settings[feld]}
onChange={(e) => handleChange(feld, e.target.value)}
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'}`}
/>
</div>
))}
</div>
<p className={`text-xs mt-2 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
Diese Ordner bietet die Schnellwahl beim Rippen an. Speichern nicht vergessen (Knopf unten).
</p>
</div>
<StorageMounts /> <StorageMounts />
</section>} </section>}
{/* Output Directories */}
{activeTab === 'verzeichnisse' && <section>
<h2 className={`text-lg font-semibold mb-4 flex items-center gap-2 transition-colors duration-300 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
<Database size={20} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} />
Ausgabeverzeichnisse
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 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'}`}>
Hauptverzeichnis
</label>
<input
type="text"
value={settings.outputDir}
onChange={(e) => handleChange('outputDir', e.target.value)}
className={`w-full px-3 py-2 ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200 focus:ring-indigo-500' : 'bg-white border-slate-300 text-slate-900 focus:ring-indigo-600'} border rounded-lg focus:ring-2 focus:border-transparent transition-all`}
placeholder="/app/media"
/>
<p className={`text-xs ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Basisverzeichnis für alle Ausgaben</p>
</div>
<div className="space-y-2 transition-colors duration-300">
<label className={`block text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
Filme
</label>
<input
type="text"
value={settings.movieDir}
onChange={(e) => handleChange('movieDir', e.target.value)}
className={`w-full px-3 py-2 ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'} border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all`}
placeholder="movies"
/>
</div>
<div className="space-y-2 transition-colors duration-300">
<label className={`block text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
Serien
</label>
<input
type="text"
value={settings.seriesDir}
onChange={(e) => handleChange('seriesDir', e.target.value)}
className={`w-full px-3 py-2 ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'} border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all`}
placeholder="series"
/>
</div>
<div className="space-y-2 transition-colors duration-300">
<label className={`block text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
Musik
</label>
<input
type="text"
value={settings.musicDir}
onChange={(e) => handleChange('musicDir', e.target.value)}
className={`w-full px-3 py-2 ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'} border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all`}
placeholder="music"
/>
</div>
</div>
</section>}
{/* API Keys */} {/* API Keys */}
{activeTab === 'apis' && <section> {activeTab === 'apis' && <section>
<h2 className={`text-lg font-semibold mb-4 flex items-center gap-2 transition-colors duration-300 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}> <h2 className={`text-lg font-semibold mb-4 flex items-center gap-2 transition-colors duration-300 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>