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.