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.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
|
||||
|
||||
@@ -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?"""
|
||||
|
||||
+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.
|
||||
"""
|
||||
|
||||
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")
|
||||
# <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]:
|
||||
"""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,
|
||||
|
||||
Reference in New Issue
Block a user