UX-Runde: echte Disc-Titel via BD-Metadaten, Preview-Crash, Dashboard, Ziele
Ampel / ampel (push) Failing after 38s
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:
@@ -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
|
||||||
|
|||||||
@@ -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?"""
|
||||||
|
|||||||
+116
-22
@@ -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,14 +265,17 @@ 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
|
||||||
label = read_iso_volume_label(device_path) or read_udf_volume_label(device_path)
|
else:
|
||||||
if label:
|
# Titel-Quelle 2: Volume-Label direkt vom Medium.
|
||||||
toc["title"] = normalize_disc_label(label)
|
# 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)
|
||||||
|
if label:
|
||||||
|
toc["title"] = normalize_disc_label(label)
|
||||||
|
|
||||||
# Titel-Quelle 2 (optional, falls makemkvcon doch da ist):
|
# Titel-Quelle 2 (optional, falls makemkvcon doch da ist):
|
||||||
if shutil.which("makemkvcon"):
|
if shutil.which("makemkvcon"):
|
||||||
@@ -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:
|
||||||
if omdb_treffer:
|
omdb_treffer = self.omdb.lookup(kandidat)
|
||||||
confidence = 0.8
|
if omdb_treffer:
|
||||||
metadata = omdb_treffer
|
confidence = 0.8
|
||||||
title = omdb_treffer.get("title", title)
|
metadata = omdb_treffer
|
||||||
matched = True
|
title = omdb_treffer.get("title", title)
|
||||||
|
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,
|
||||||
|
|||||||
@@ -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,75 +82,81 @@ 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>
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<label className={`block text-sm font-medium mb-3 ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
|
{targets.map((t) => (
|
||||||
Dateityp
|
<button
|
||||||
</label>
|
key={t.type}
|
||||||
<div className="grid grid-cols-3 gap-3">
|
onClick={() => { setSelectedType(t.type); setCustomPath('') }}
|
||||||
{['movies', 'series', 'music'].map((type) => (
|
className={`flex flex-col items-center justify-center gap-1.5 p-3 rounded-lg border transition-all ${
|
||||||
<button
|
selectedType === t.type && !customPath
|
||||||
key={type}
|
? theme === 'dark'
|
||||||
onClick={() => setSelectedType(type as TargetConfig['type'])}
|
? 'bg-indigo-900/30 border-indigo-500 text-indigo-400'
|
||||||
className={`flex flex-col items-center justify-center gap-2 p-4 rounded-lg border transition-all ${
|
: 'bg-indigo-50 border-indigo-600 text-indigo-700'
|
||||||
selectedType === type
|
: theme === 'dark'
|
||||||
? theme === 'dark'
|
? 'bg-slate-900 border-slate-700 text-slate-400 hover:border-slate-600'
|
||||||
? 'bg-indigo-900/30 border-indigo-500 text-indigo-400'
|
: 'bg-slate-50 border-slate-200 text-slate-600 hover:border-slate-300'
|
||||||
: 'bg-indigo-50 border-indigo-600 text-indigo-700'
|
}`}
|
||||||
: theme === 'dark'
|
>
|
||||||
? 'bg-slate-900 border-slate-700 text-slate-400 hover:border-slate-600'
|
<span className="text-xl">
|
||||||
: 'bg-slate-50 border-slate-200 text-slate-600 hover:border-slate-300'
|
{t.type === 'movies' && '🎥'}
|
||||||
}`}
|
{t.type === 'series' && '📺'}
|
||||||
>
|
{t.type === 'music' && '🎵'}
|
||||||
<span className="text-2xl">
|
</span>
|
||||||
{type === 'movies' && '🎥'}
|
<span className="text-sm font-medium">{t.name}</span>
|
||||||
{type === 'series' && '📺'}
|
</button>
|
||||||
{type === 'music' && '🎵'}
|
))}
|
||||||
</span>
|
</div>
|
||||||
<span className="text-sm font-medium capitalize">{type}</span>
|
|
||||||
</button>
|
{/* 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'}`}>
|
||||||
|
<button
|
||||||
|
onClick={() => browseParent && laden(browseParent)}
|
||||||
|
disabled={!browseParent}
|
||||||
|
className={`p-1 rounded disabled:opacity-30 ${theme === 'dark' ? 'hover:bg-slate-700 text-slate-400' : 'hover:bg-slate-200 text-slate-500'}`}
|
||||||
|
title="Ordner hoch"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-40 overflow-y-auto">
|
||||||
|
{browseDirs.length === 0 ? (
|
||||||
|
<p className={`text-xs px-3 py-3 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Keine Unterordner</p>
|
||||||
|
) : (
|
||||||
|
browseDirs.map(d => (
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Path Display */}
|
{/* Gewähltes Ziel */}
|
||||||
<div>
|
<div className={`flex items-center gap-2 text-sm px-3 py-2 rounded-lg ${theme === 'dark' ? 'bg-slate-900' : 'bg-slate-50'}`}>
|
||||||
<label className={`block text-sm font-medium mb-2 ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
|
{customPath ? <FolderOpen size={16} className="text-indigo-500" /> : <CheckCircle size={16} className="text-emerald-500" />}
|
||||||
Zielverzeichnis
|
<span className={`font-mono text-xs truncate ${theme === 'dark' ? 'text-slate-300' : 'text-slate-600'}`}>
|
||||||
</label>
|
{customPath || targets.find(t => t.type === selectedType)?.path}
|
||||||
<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>
|
||||||
<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
|
|
||||||
onClick={() => {
|
|
||||||
const target = targets.find(t => t.type === selectedType)
|
|
||||||
if (target) {
|
|
||||||
setCustomPath(target.path)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
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
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className={`text-xs mt-2 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-500'}`}>
|
|
||||||
{customPath
|
|
||||||
? `Verwende benutzerdefiniertes Verzeichnis: ${customPath}`
|
|
||||||
: `Verwende Standard-Verzeichnis: ${targets.find(t => t.type === selectedType)?.path}`
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
</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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,70 +264,32 @@ 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>
|
||||||
<StorageMounts />
|
|
||||||
</section>}
|
|
||||||
|
|
||||||
{/* Output Directories */}
|
{/* Standard-Unterordner (ehemals eigener Tab "Verzeichnisse" —
|
||||||
{activeTab === 'verzeichnisse' && <section>
|
gehört inhaltlich hierher) */}
|
||||||
<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'}`}>
|
<div className={`rounded-lg p-4 mb-6 border ${theme === 'dark' ? 'border-slate-700 bg-slate-800/50' : 'border-slate-200 bg-slate-50'}`}>
|
||||||
<Database size={20} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} />
|
<h3 className={`font-medium mb-3 flex items-center gap-2 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
||||||
Ausgabeverzeichnisse
|
<Database size={16} className="text-indigo-500" /> Standard-Unterordner
|
||||||
</h2>
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 transition-colors duration-300">
|
{([['outputDir', 'Basis'], ['movieDir', 'Filme'], ['seriesDir', 'Serien'], ['musicDir', 'Musik']] as const).map(([feld, label]) => (
|
||||||
<div className="space-y-2 transition-colors duration-300">
|
<div key={feld}>
|
||||||
<label className={`block text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
|
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>{label}</label>
|
||||||
Hauptverzeichnis
|
<input
|
||||||
</label>
|
type="text"
|
||||||
<input
|
value={settings[feld]}
|
||||||
type="text"
|
onChange={(e) => handleChange(feld, e.target.value)}
|
||||||
value={settings.outputDir}
|
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'}`}
|
||||||
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`}
|
</div>
|
||||||
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>
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
|
<StorageMounts />
|
||||||
</section>}
|
</section>}
|
||||||
|
|
||||||
{/* API Keys */}
|
{/* API Keys */}
|
||||||
|
|||||||
Reference in New Issue
Block a user