From 90eb17d407ed03fd5d579d2b1ff39dc246f76c86 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Thu, 23 Jul 2026 21:07:55 +0200 Subject: [PATCH] UX-Runde: echte Disc-Titel via BD-Metadaten, Preview-Crash, Dashboard, Ziele MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docker/api/clients/omdb.py | 16 ++ docker/api/main.py | 25 +++ docker/api/prescan/prescan.py | 138 +++++++++++--- docker/ui/src/components/RipTargetModal.tsx | 188 ++++++++++++-------- docker/ui/src/pages/Dashboard.tsx | 52 +++++- docker/ui/src/pages/MetadataPreview.tsx | 5 +- docker/ui/src/pages/Settings.tsx | 87 +++------ 7 files changed, 348 insertions(+), 163 deletions(-) diff --git a/docker/api/clients/omdb.py b/docker/api/clients/omdb.py index e90f66d..996c3ec 100644 --- a/docker/api/clients/omdb.py +++ b/docker/api/clients/omdb.py @@ -55,6 +55,22 @@ class OMDbClient: response = self.session.get(OMDB_BASE_URL, params=params, timeout=10) response.raise_for_status() 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: print(f"OMDb API Error: {e}") return None diff --git a/docker/api/main.py b/docker/api/main.py index a96c032..1c539f8 100644 --- a/docker/api/main.py +++ b/docker/api/main.py @@ -428,6 +428,31 @@ async def delete_storage_mount(name: str): 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") async def setup_status(): """First-Run-Erkennung: wurde der Einrichtungs-Assistent abgeschlossen?""" diff --git a/docker/api/prescan/prescan.py b/docker/api/prescan/prescan.py index 6af7523..852ffec 100644 --- a/docker/api/prescan/prescan.py +++ b/docker/api/prescan/prescan.py @@ -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. """ +import os import shutil import subprocess from typing import Dict, List, Optional @@ -73,6 +74,77 @@ def parse_udf_dstring(data: bytes) -> str: 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") + # Titel — bewusst per Regex statt XML-Parser + # (Namespaces variieren je Authoring-Werkzeug) + treffer = _re.search(r"([^<]{2,120})", 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]: """Liest das Volume-Label aus dem UDF Primary Volume Descriptor. @@ -193,14 +265,17 @@ class PreScan: "duration": 0 }) else: - # Titel-Quelle 1: Volume-Label direkt vom Medium — läuft - # überall. ISO-9660 für DVDs, UDF für Blu-rays (die haben - # meist keine ISO-Bridge). (makemkvcon gibt es NUR im - # Worker-Container; der alte Aufruf hier scheiterte in der - # API still und der Titel blieb ewig "Unknown Title".) - label = read_iso_volume_label(device_path) or read_udf_volume_label(device_path) - if label: - toc["title"] = normalize_disc_label(label) + # Titel-Quelle 1 (beste): Klartext-Titel aus den BD-Metadaten + # (BDMV/META — Labels wie BD_EVG_D2 taugen nicht für APIs) + klartext = read_disc_title_via_mount(device_path) + if klartext: + 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) + if label: + toc["title"] = normalize_disc_label(label) # Titel-Quelle 2 (optional, falls makemkvcon doch da ist): if shutil.which("makemkvcon"): @@ -282,15 +357,24 @@ class PreScan: if 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 metadata = {} matched = False - movies = self.tmdb.search_movie(title) - if movies: - for movie in movies: - if movie.get("title", "").lower() == title.lower(): + # Progressive Suchdegradation (ARM-Lehre): mehrere Titel-Varianten + # probieren — Disc-Titel sind selten API-freundlich formatiert. + kandidaten = titel_kandidaten(title) or [title] + 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 movie_details = self.tmdb.get_movie_details(movie["id"]) if movie_details: @@ -307,12 +391,14 @@ class PreScan: } matched = True break + if matched: + break if not matched: - tv_shows = self.tmdb.search_tv(title) - if tv_shows: + for kandidat in kandidaten: + tv_shows = self.tmdb.search_tv(kandidat) for show in tv_shows: - if show.get("name", "").lower() == title.lower(): + if show.get("name", "").lower() == kandidat.lower(): confidence = 0.9 tv_details = self.tmdb.get_tv_details(show["id"]) if tv_details: @@ -328,16 +414,20 @@ class PreScan: } matched = True break + if matched: + break # Fallback 1: OMDb (eigene Datenbasis — findet oft, was TMDB nicht # exakt trifft; braucht OMDB_API_KEY, sonst überspringt es sich selbst) if not matched: - omdb_treffer = self.omdb.lookup(title) - if omdb_treffer: - confidence = 0.8 - metadata = omdb_treffer - title = omdb_treffer.get("title", title) - matched = True + for kandidat in kandidaten: + omdb_treffer = self.omdb.lookup(kandidat) + if omdb_treffer: + confidence = 0.8 + metadata = omdb_treffer + title = omdb_treffer.get("title", title) + matched = True + break # Fallback 2: bester TMDB-Vorschlag ohne exakten Treffer — als # VORSCHLAG gekennzeichnet (niedrige Confidence, Nutzer korrigiert) @@ -366,6 +456,10 @@ class PreScan: "year": None } + # Bei Treffer den sauberen API-Titel anzeigen statt des Disc-Titels + if matched and metadata.get("title"): + title = metadata["title"] + result = PreScanResult( disc_type=toc.get("disc_type", "DVD"), title=title, diff --git a/docker/ui/src/components/RipTargetModal.tsx b/docker/ui/src/components/RipTargetModal.tsx index bc58d16..25d1f06 100644 --- a/docker/ui/src/components/RipTargetModal.tsx +++ b/docker/ui/src/components/RipTargetModal.tsx @@ -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' interface TargetConfig { @@ -15,24 +17,58 @@ interface RipTargetModalProps { 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) { const [selectedType, setSelectedType] = useState('movies') + const [targets, setTargets] = useState([ + { 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('/app/media') + const [browseParent, setBrowseParent] = useState(null) + const [browseDirs, setBrowseDirs] = useState([]) const [customPath, setCustomPath] = useState('') const { theme } = useDarkMode() - const defaultTargets: TargetConfig[] = [ - { id: '1', name: 'Movies', path: '/app/media/movies', type: 'movies', isActive: true }, - { id: '2', name: 'Series', path: '/app/media/series', type: 'series', isActive: true }, - { id: '3', name: 'Music', path: '/app/media/music', type: 'music', isActive: true }, - ] + // Standard-Unterordner aus den Einstellungen ziehen (nicht hartkodiert) + useEffect(() => { + if (!isOpen) return + 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 target = targets.find(t => t.type === selectedType) - if (target) { - // Eigener Pfad gewinnt — vorher wurde customPath stillschweigend ignoriert - onSave(customPath ? { ...target, path: customPath } : target) + if (customPath) { + onSave({ id: 'custom', name: 'Eigener Ordner', path: customPath, type: selectedType, isActive: true }) + } else if (target) { + onSave(target) } onClose() } @@ -46,75 +82,81 @@ export default function RipTargetModal({ isOpen, onClose, onSave }: RipTargetMod

Rip-Ziel auswählen

- Wohin sollen die gerippten Dateien gespeichert werden? + Schnellwahl oder eigenen Ordner wählen — Netzwerk-Ziele (NAS, PC) sind unter Einstellungen → Speicherziele einhängbar.

{/* Body */} -
- {/* Type Selection */} -
- -
- {['movies', 'series', 'music'].map((type) => ( - - ))} +
+ {/* Schnellwahl */} +
+ {targets.map((t) => ( + + ))} +
+ + {/* Ordner-Browser */} +
+
+ + {browsePath} + +
+
+ {browseDirs.length === 0 ? ( +

Keine Unterordner

+ ) : ( + browseDirs.map(d => ( + + )) + )}
- {/* Path Display */} -
- -
- 📁 - 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" - /> - -
-

- {customPath - ? `Verwende benutzerdefiniertes Verzeichnis: ${customPath}` - : `Verwende Standard-Verzeichnis: ${targets.find(t => t.type === selectedType)?.path}` - } -

+ {/* Gewähltes Ziel */} +
+ {customPath ? : } + + {customPath || targets.find(t => t.type === selectedType)?.path} +
@@ -130,7 +172,7 @@ export default function RipTargetModal({ isOpen, onClose, onSave }: RipTargetMod 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" > - Speichern + Rippen starten
diff --git a/docker/ui/src/pages/Dashboard.tsx b/docker/ui/src/pages/Dashboard.tsx index 5804d8d..21a1c83 100644 --- a/docker/ui/src/pages/Dashboard.tsx +++ b/docker/ui/src/pages/Dashboard.tsx @@ -120,8 +120,21 @@ function ProgressBar({ progress }: { progress: number }) { ) } +interface WorkerInfo { + name: string + encoders: string[] +} + +const ENCODER_KURZ: Record = { + 'cpu-x264': 'H.264 (CPU)', + 'cpu-x265': 'H.265 (CPU)', + 'vaapi': 'VAAPI ⚡', + 'nvenc': 'NVENC ⚡', +} + export default function Dashboard() { const [jobs, setJobs] = useState([]) + const [workers, setWorkers] = useState([]) const [loading, setLoading] = useState(true) const { theme } = useDarkMode() @@ -135,10 +148,13 @@ export default function Dashboard() { } loadData() + api.get('/capabilities').then(r => setWorkers(r.data.workers || [])).catch(() => {}) const interval = setInterval(loadData, 5000) return () => clearInterval(interval) }, []) + const aktiverJob = jobs.find(j => j.status === 'processing' || j.status === 'transcoding') + const stats = { pending: jobs.filter(j => j.status === 'pending').length, processing: jobs.filter(j => j.status === 'processing').length, @@ -162,11 +178,36 @@ export default function Dashboard() {

Übersicht über alle Ripping-Jobs und Geräte

- {/* Device Discovery Section */} - + {/* System-Leiste: Encoder + aktiver Job auf einen Blick */} +
+
+ + Encoder: + {workers.length === 0 ? ( + kein Worker + ) : ( + workers.flatMap(w => w.encoders).filter((e, i, a) => a.indexOf(e) === i).map(e => ( + + {ENCODER_KURZ[e] || e} + + )) + )} +
+
+ + Aktiv: + {aktiverJob ? ( + + {aktiverJob.status === 'transcoding' ? 'Komprimieren' : 'Rippen'} · {aktiverJob.progress} % + + ) : ( + kein Job + )} +
+
- {/* Live Log Section */} - + {/* Laufwerke + erkannte Disc */} + {/* Stats Cards */}
@@ -271,6 +312,9 @@ export default function Dashboard() {
+ + {/* Live-Log ganz unten — Detail-Ansicht, nicht Haupt-Info */} + ) } diff --git a/docker/ui/src/pages/MetadataPreview.tsx b/docker/ui/src/pages/MetadataPreview.tsx index e5c5283..5803b36 100644 --- a/docker/ui/src/pages/MetadataPreview.tsx +++ b/docker/ui/src/pages/MetadataPreview.tsx @@ -190,7 +190,10 @@ export default function MetadataPreview() { {/* Header */}
- {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 })()} {result.disc_type.toUpperCase()} diff --git a/docker/ui/src/pages/Settings.tsx b/docker/ui/src/pages/Settings.tsx index 087cd44..62f81e0 100644 --- a/docker/ui/src/pages/Settings.tsx +++ b/docker/ui/src/pages/Settings.tsx @@ -38,7 +38,7 @@ const defaultSettings: SettingsState = { keepOriginal: false, } -type SettingsTab = 'ripping' | 'verarbeitung' | 'speicherziele' | 'verzeichnisse' | 'apis' | 'benachrichtigungen' +type SettingsTab = 'ripping' | 'verarbeitung' | 'speicherziele' | 'apis' | 'benachrichtigungen' interface WorkerInfo { name: string @@ -126,7 +126,6 @@ export default function SettingsPage() { setActiveTab('ripping')} darkMode={theme === 'dark'} /> setActiveTab('verarbeitung')} darkMode={theme === 'dark'} /> setActiveTab('speicherziele')} darkMode={theme === 'dark'} /> - setActiveTab('verzeichnisse')} darkMode={theme === 'dark'} /> setActiveTab('apis')} darkMode={theme === 'dark'} /> setActiveTab('benachrichtigungen')} darkMode={theme === 'dark'} />
@@ -265,70 +264,32 @@ export default function SettingsPage() { Speicherziele - - } - {/* Output Directories */} - {activeTab === 'verzeichnisse' &&
-

- - Ausgabeverzeichnisse -

- -
-
- - 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" - /> -

Basisverzeichnis für alle Ausgaben

-
- -
- - 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" - /> -
- -
- - 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" - /> -
- -
- - 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" - /> + {/* Standard-Unterordner (ehemals eigener Tab "Verzeichnisse" — + gehört inhaltlich hierher) */} +
+

+ Standard-Unterordner +

+
+ {([['outputDir', 'Basis'], ['movieDir', 'Filme'], ['seriesDir', 'Serien'], ['musicDir', 'Musik']] as const).map(([feld, label]) => ( +
+ + 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'}`} + /> +
+ ))}
+

+ Diese Ordner bietet die Schnellwahl beim Rippen an. Speichern nicht vergessen (Knopf unten). +

+ +
} {/* API Keys */}