"""Pre-Scan-Modul: Liest Disc-TOC ohne Ripping und schlägt Metadaten vor.
WIEDERHERGESTELLT 22.07.2026: Beim SoC-Refactoring wurde die echte Implementierung
(flache prescan.py) durch einen Stub überschattet — die Metadaten-Preview (Muss-Feature)
lieferte seitdem immer „Unknown Disc". Dies ist die echte Logik, bereinigt.
"""
import os
import subprocess
from typing import Dict, List, Optional
from rippy import drives as _laufwerks_schicht
from rippy.tools import katalog as werkzeug_katalog
from clients.tmdb import TMDBClient
from clients.jikan import JikanClient
from clients.musicbrainz import MusicBrainzClient
from clients.omdb import OMDbClient
from clients.thetvdb import TheTVDBClient
from cache import get, set as cache_set
from cache.keys import generate_prescan_key
# Der Treiber DIESER Maschine statt fest der Linux-Fassung — sonst waere
# dieses Modul (und ueber es main.py) unter Windows nicht ladbar. Beide
# Treiber bieten disc_size_bytes() und detect_disc_type() an; darauf achtet
# test_treiberwahl.py.
detection = _laufwerks_schicht.treiber()
# Wie viele Treffer eine Suche hoechstens liefern darf, damit sie als
# SPEZIFISCH gilt. Gemessen am 28.08.2026: Der volle Disc-Titel
# „Evangelion 2.22" ergab bei TMDB 1 Treffer (den richtigen), die gekuerzte
# Variante „Evangelion" ergab 20 (beliebige). Drei ist grosszuegig genug fuer
# Neuauflagen und Regie-Fassungen desselben Films.
WENIGE_TREFFER = 3
def normalize_disc_label(label: str) -> str:
"""Disc-Labels wie 'PULP_FICTION_DE' → 'Pulp Fiction De' (pure Funktion).
Volume-Labels sind fast immer GROSS_MIT_UNTERSTRICHEN — so findet keine
Metadaten-API etwas. Unterstriche zu Leerzeichen, Titel-Schreibung.
"""
if not label:
return ""
bereinigt = " ".join(label.replace("_", " ").replace(".", " ").split())
if bereinigt.isupper():
bereinigt = bereinigt.title()
return bereinigt
def read_iso_volume_label(device_path: str) -> Optional[str]:
"""Liest das Volume-Label aus dem ISO-9660 Primary Volume Descriptor.
Sektor 16 (Offset 32768), Bytes 40-71 = Volume Identifier. Funktioniert für
DVDs — Blu-rays sind oft reines UDF ohne ISO-Bridge (dann greift
read_udf_volume_label).
"""
try:
with open(device_path, "rb") as f:
f.seek(32768)
pvd = f.read(2048)
if len(pvd) < 72 or pvd[1:6] != b"CD001":
return None
label = pvd[40:72].decode("ascii", errors="replace").strip()
return label or None
except OSError:
return None
def parse_udf_dstring(data: bytes) -> str:
"""Dekodiert einen UDF d-string (ECMA-167 1/7.2.12; pure Funktion, testbar).
Letztes Byte = genutzte Länge, erstes Byte = Kompressions-ID
(8 = Latin-1, 16 = UTF-16BE), dazwischen der Text.
"""
if not data:
return ""
laenge = data[-1]
if laenge < 2 or laenge > len(data) - 1:
return ""
comp_id, text = data[0], data[1:laenge]
try:
if comp_id == 8:
return text.decode("latin-1").strip()
if comp_id == 16:
return text.decode("utf-16-be").strip()
except UnicodeDecodeError:
pass
return ""
def disc_wurzel(device_path: str):
r"""Ein lesbarer Einstieg in die Disc — Windows braucht dafuer kein Mounten.
Gibt `(wurzel, aufraeumen)` zurueck. `aufraeumen` ist eine Funktion, die
nach dem Lesen aufgerufen wird; unter Windows tut sie nichts.
## Warum das getrennt ist (28.08.2026)
Der Weg hier war fest `mount -t udf` — also Linux. Unter Windows haengt
Windows die Disc selbst ein: `\.\G:` entspricht `G:\`, und dort liegen
die Dateien einfach da. Ohne diese Unterscheidung fand Rippy unter
Windows NIE den Klartext-Titel und blieb beim Volume-Label haengen
(gemessen an einer echten Disc: `BD_EVG_D2` statt `Evangelion 2.22`) --
und mit so einem Label findet keine Metadaten-API etwas.
"""
if os.name == "nt":
# \.\G: -> G:\ (der Buchstabe ist alles, was wir brauchen)
buchstabe = device_path.rstrip(":").rsplit("\\", 1)[-1].rstrip(":")
wurzel = buchstabe + ":" + "\\"
return (wurzel, lambda: None) if os.path.isdir(wurzel) else (None, lambda: None)
mountpoint = "/mnt/rippy-disc"
os.makedirs(mountpoint, exist_ok=True)
ergebnis = subprocess.run(
["mount", "-t", "udf", "-o", "ro", device_path, mountpoint],
capture_output=True, text=True, timeout=30,
)
if ergebnis.returncode != 0:
return None, lambda: None
def abhaengen():
subprocess.run(["umount", mountpoint], capture_output=True, timeout=15)
return mountpoint, abhaengen
def titel_aus_bdmt(wurzel: str) -> Optional[str]:
"""Der Klartext-Titel aus BDMV/META/DL/bdmt_*.xml. (ohne Mounten)
Getrennt von der Beschaffung der Wurzel, damit er ohne Disc pruefbar ist:
ein Ordner mit einer bdmt-Datei reicht.
"""
import re as _re
meta_dir = os.path.join(wurzel, "BDMV", "META", "DL")
if not os.path.isdir(meta_dir):
return None
try:
kandidaten = sorted(os.listdir(meta_dir))
except OSError:
return None
# bdmt_eng.xml bevorzugen, sonst die erste bdmt-Datei (bdmt_deu.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
try:
with open(os.path.join(meta_dir, bdmt), "rb") as f:
inhalt = f.read(65536).decode("utf-8", errors="replace")
except OSError:
return None
# Titel - bewusst per Regex statt XML-Parser
# (Namespaces variieren je Authoring-Werkzeug)
treffer = _re.search(r"([^<]{2,120})", inhalt)
return treffer.group(1).strip() if treffer else None
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. Unter Linux wird dafuer
read-only gemountet, unter Windows haengt Windows die Disc selbst ein.
Gibt None zurueck, wenn kein BD-Metadatensatz existiert (z. B. DVD).
"""
wurzel, aufraeumen = None, lambda: None
try:
wurzel, aufraeumen = disc_wurzel(device_path)
return titel_aus_bdmt(wurzel) if wurzel else None
except Exception:
return None
finally:
try:
aufraeumen()
except Exception:
pass
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
# Iterativ hintere Worte strippen (ARM-Lehre): "Evangelion 2.22" muss
# auch als "Evangelion" gesucht werden — Versions-/Zusatz-Tokens stehen
# fast immer hinten. Begrenzt, damit die API-Anfragen überschaubar bleiben.
worte = titel.split()
while len(worte) > 1 and len(kandidaten) < 6:
worte = worte[:-1]
merke(" ".join(worte))
return kandidaten
def disc_fingerprint(device_path: str) -> str:
"""Billiger, stabiler Disc-Fingerabdruck (Volume-Label + Größe).
Für Cache-Keys und das Merken manueller Korrekturen — bewusst OHNE den
teuren Mount-Titel, damit ihn jeder Endpunkt schnell berechnen kann.
"""
label = read_iso_volume_label(device_path) or read_udf_volume_label(device_path) or "unbekannt"
try:
groesse = detection.disc_size_bytes(device_path)
except OSError:
groesse = 0
return f"{label}|{groesse}"
def read_udf_volume_label(device_path: str) -> Optional[str]:
"""Liest das Volume-Label aus dem UDF Primary Volume Descriptor.
Weg laut ECMA-167: Anchor Volume Descriptor Pointer bei Sektor 256 →
zeigt auf die Main Volume Descriptor Sequence → darin der PVD (Tag-ID 1)
mit dem Volume Identifier (d-string, 32 Bytes ab Offset 24).
"""
sektor = 2048
try:
with open(device_path, "rb") as f:
f.seek(256 * sektor)
avdp = f.read(sektor)
if len(avdp) < 24 or int.from_bytes(avdp[0:2], "little") != 2:
return None
vds_ort = int.from_bytes(avdp[20:24], "little")
f.seek(vds_ort * sektor)
# VDS-Deskriptoren durchgehen, bis der PVD (Tag-ID 1) kommt
for _ in range(32):
block = f.read(sektor)
if len(block) < 56:
return None
tag_id = int.from_bytes(block[0:2], "little")
if tag_id == 1:
return parse_udf_dstring(block[24:56]) or None
if tag_id == 8: # Terminating Descriptor — Sequenz zu Ende
return None
except OSError:
return None
return None
class PreScanResult:
def __init__(
self,
disc_type: str = "DVD",
title: str = "Unknown",
year: int = None,
confidence: float = 0.0,
metadata: Dict = None,
tracks: List[Dict] = None,
fingerprint: str = ""
):
self.disc_type = disc_type
self.title = title
self.year = year
self.confidence = confidence
self.metadata = metadata or {}
self.tracks = tracks or []
# Fingerabdruck (Label|Größe) wandert mit ins Ergebnis — Basis der
# Duplikat-Warnung („diese Disc wurde schon gerippt")
self.fingerprint = fingerprint
def to_dict(self) -> Dict:
return {
"disc_type": self.disc_type,
"title": self.title,
"year": self.year,
"confidence": self.confidence,
"metadata": self.metadata,
"tracks": self.tracks,
"fingerprint": self.fingerprint
}
class PreScan:
def __init__(self):
self.tmdb = TMDBClient()
self.musicbrainz = MusicBrainzClient()
self.thetvdb = TheTVDBClient()
self.omdb = OMDbClient()
self.jikan = JikanClient()
def scan(self, device_path: str) -> PreScanResult:
"""Führe Pre-Scan durch."""
disc_type = self._detect_disc_type(device_path)
toc = self._read_toc(device_path, disc_type)
# Typ + Disc-Fingerabdruck (Label|Größe) an die Zweige durchreichen —
# der Typ ging vorher verloren (BDs hießen immer "DVD"), und der
# Cache-Key braucht den Fingerabdruck (sonst erbt die nächste Disc
# die Metadaten der vorherigen).
toc["disc_type"] = disc_type
toc["fingerprint"] = disc_fingerprint(device_path)
if disc_type == "CD":
return self._scan_audio(device_path, toc)
return self._scan_video(device_path, toc)
def _detect_disc_type(self, device_path: str) -> str:
"""Erkenne Disc-Typ über die zentrale ioctl-Erkennung (detection.py).
Vorher lief hier `isosize` — das Werkzeug war im Container nicht
installiert, die Erkennung fiel still immer auf "DVD" zurück.
"""
typ = detection.detect_disc_type(device_path)
return {"cd": "CD", "dvd": "DVD", "bluray": "Blu-ray", "uhd": "4K UHD"}.get(typ, "DVD")
def _read_toc(self, device_path: str, disc_type: str) -> Dict:
"""Lese TOC (Table of Contents)."""
toc = {
"title": None,
"year": None,
"tracks": [],
"duration": 0
}
try:
if disc_type == "CD":
result = subprocess.run(
["cdparanoia", "-Q", device_path],
capture_output=True,
text=True,
timeout=10
)
for line in result.stdout.split('\n'):
if 'track' in line.lower():
parts = line.split()
if len(parts) >= 4:
toc["tracks"].append({
"track_number": parts[1],
"title": " ".join(parts[3:]) if len(parts) > 3 else "Track",
"duration": 0
})
else:
# 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 3: die Titelliste von makemkvcon (optional).
#
# ⚠️ Über den Werkzeug-Katalog, NICHT über `shutil.which`
# (Befund 28.08.2026). `which` sucht nur im PATH — unter
# Windows liegen Programme in „Programme", nicht im PATH.
# Gemessen: `shutil.which("makemkvcon")` gibt dort auch bei
# installiertem MakeMKV None zurück, und dieser Zweig lief
# NIE. Genau derselbe Fehler, für den es `tools/katalog.py`
# gibt.
makemkv = werkzeug_katalog.finden("makemkv")
if makemkv:
result = subprocess.run(
[makemkv, "-r", "--noscan", "--minlength=300", "info", f"dev:{device_path}"],
capture_output=True,
text=True,
timeout=120
)
for line in result.stdout.split('\n'):
if line.startswith('TINFO:'):
parts = line.split(',')
if len(parts) >= 5:
toc["tracks"].append({
"title": parts[4].strip().strip('"') if len(parts) > 4 else "Title",
"duration": int(parts[2]) if len(parts) > 2 else 0
})
except Exception as e:
print(f"Pre-Scan TOC Error: {e}")
return toc
def _scan_audio(self, device_path: str, toc: Dict) -> PreScanResult:
"""Pre-Scan für Audio-CD."""
cache_key = generate_prescan_key(device_path, is_audio=True, fingerprint=toc.get("fingerprint", ""))
cached = get(cache_key)
if cached:
return PreScanResult(**cached)
confidence = 0.5
artist = None
album = toc["title"] or "Unknown Album"
if " - " in album:
parts = album.split(" - ", 1)
if len(parts) == 2:
artist = parts[0]
album = parts[1]
if artist:
artists = self.musicbrainz.search_artist(artist)
if artists:
confidence = 0.8
releases = self.musicbrainz.search_release(album, artist)
if releases:
confidence = 0.9
release_id = releases[0]["id"]
release_info = self.musicbrainz.get_release_info(release_id)
if release_info:
for medium in release_info.get("media", []):
for track in medium.get("tracks", []):
toc["tracks"].append({
"track_number": track.get("position", 0),
"title": track.get("title", "Unknown"),
"duration": track.get("duration", 0) // 1000
})
if not artist:
movies = self.tmdb.search_movie(album)
if movies:
confidence = 0.7
movie_details = self.tmdb.get_movie_details(movies[0]["id"])
if movie_details:
album = f"Soundtrack - {movie_details.get('title', album)}"
toc["title"] = album
result = PreScanResult(
disc_type="CD",
title=album,
tracks=toc["tracks"],
confidence=confidence,
fingerprint=toc.get("fingerprint", "")
)
cache_set(cache_key, result.to_dict())
return result
def _scan_video(self, device_path: str, toc: Dict) -> PreScanResult:
"""Pre-Scan für DVD/Blu-ray."""
cache_key = generate_prescan_key(device_path, is_audio=False, fingerprint=toc.get("fingerprint", ""))
cached = get(cache_key)
if cached:
return PreScanResult(**cached)
# 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
# Progressive Suchdegradation (ARM-Lehre): mehrere Titel-Varianten
# probieren — Disc-Titel sind selten API-freundlich formatiert.
kandidaten = titel_kandidaten(title) or [title]
movies = []
movies_kandidat = "" # WELCHE Variante hat sie geliefert?
for kandidat in kandidaten:
treffer = self.tmdb.search_movie(kandidat)
if treffer and not movies:
movies = treffer # bester Rohtreffer für den Vorschlags-Fallback
movies_kandidat = kandidat
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:
metadata = {
"type": "movie",
"id": movie["id"],
"title": movie_details.get("title", title),
"year": int(movie_details.get("release_date", "0")[:4]) if movie_details.get("release_date") else None,
"overview": movie_details.get("overview", ""),
"poster_path": movie_details.get("poster_path", ""),
"backdrop_path": movie_details.get("backdrop_path", ""),
"runtime": movie_details.get("runtime", 0),
"genres": [g["name"] for g in movie_details.get("genres", [])],
"source": "tmdb",
}
matched = True
break
if matched:
break
if not matched:
for kandidat in kandidaten:
tv_shows = self.tmdb.search_tv(kandidat)
for show in tv_shows:
if show.get("name", "").lower() == kandidat.lower():
confidence = 0.9
tv_details = self.tmdb.get_tv_details(show["id"])
if tv_details:
metadata = {
"type": "tv",
"id": show["id"],
"title": tv_details.get("name", title),
"year": int(tv_details.get("first_air_date", "0")[:4]) if tv_details.get("first_air_date") else None,
"overview": tv_details.get("overview", ""),
"poster_path": tv_details.get("poster_path", ""),
"backdrop_path": tv_details.get("backdrop_path", ""),
"genres": [g["name"] for g in tv_details.get("genres", [])],
"source": "tmdb",
}
matched = True
break
if matched:
break
# ── Ein spezifischer Treffer zählt, auch ohne exakten Titel ─────────
#
# ## Der Befund des Commanders (28.08.2026)
#
# > „Was ist mit dem Cover auf Windows Rippy? In der Web Version haben
# > wir ein cover."
#
# Es gab keins, weil es gar keinen Treffer gab — und der Grund war
# nicht Windows, sondern die Bedingung oben: `movie["title"].lower()
# == kandidat.lower()`. An seiner Disc gemessen:
#
# 'Evangelion 2.22' 1 Treffer Evangelion: 2.0 You Can (Not) Advance
# 'Evangelion' 20 Treffer irgendein Evangelion
#
# Der EINZIGE Treffer auf den vollen Disc-Titel war der richtige Film,
# mit Poster — und wurde verworfen, weil der Titel nicht wörtlich
# gleich war. Danach gewann eine schlechtere Quelle oder gar keine.
#
# ## Warum die Trefferzahl das bessere Kriterium ist als Ähnlichkeit
#
# Die Ähnlichkeit hilft hier nicht: „Evangelion 2.22" gegen
# „Evangelion: 2.0 You Can (Not) Advance" ergibt 0,51 — das steht als
# Messung schon im Jikan-Absatz unten und fällt durch jedes sinnvolle
# Gatter. Die SPEZIFITÄT der Anfrage sagt mehr: Wer auf den vollen,
# ungekürzten Disc-Titel eine Handvoll Treffer bekommt, hat gefragt
# wie jemand, der weiß, was er sucht. Wer 20 bekommt, hat geraten.
#
# Deshalb: nur der UNGEKÜRZTE Titel (kandidaten[0]) und nur wenige
# Treffer. Confidence 0,8 — sicherer als ein Vorschlag (0,6), aber
# ehrlich unter einem wörtlichen Treffer (0,95).
if not matched and movies and movies_kandidat == kandidaten[0] \
and len(movies) <= WENIGE_TREFFER:
details = self.tmdb.get_movie_details(movies[0]["id"])
if details:
confidence = 0.8
metadata = {
"type": "movie",
"id": movies[0]["id"],
"title": details.get("title", title),
"year": int(details.get("release_date", "0")[:4]) if details.get("release_date") else None,
"overview": details.get("overview", ""),
"poster_path": details.get("poster_path", ""),
"backdrop_path": details.get("backdrop_path", ""),
"runtime": details.get("runtime", 0),
"genres": [g["name"] for g in details.get("genres", [])],
"source": "tmdb",
}
matched = True
# Fallback 1: Jikan/MyAnimeList (kostenlos, KEIN Key) — für Anime die
# präziseste Quelle; wählt per Titel-Ähnlichkeit, nicht Treffer #1.
#
# NEU 26.07.2026 — „exakt schlägt unscharf", unabhängig von der
# Reihenfolge. Vorher galt der erste Jikan-Treffer über dem Gate 0,55
# sofort als sicherer Fund (confidence 0,85) und OMDb kam nie dran.
# Das ist zu großzügig, und zwar messbar (offline gerechnet mit
# titel_aehnlichkeit, echte MyAnimeList-Titel):
#
# „Alien" vs. „Alien 9" → 0,83 TRIFFT
# „Inception" vs. „Deception" → 0,78 TRIFFT
# „Hero" vs. „Heroman" → 0,73 TRIFFT
# „The Dark Knight" vs. „Dark Knight Rises" → 0,69 TRIFFT
#
# Ein Kinofilm, den TMDB verpasst, bekam damit Anime-Metadaten — und
# OMDb, das ihn kennt, wurde nie gefragt. Bemerkenswert dabei, und das
# widerlegt die Vermutung im SAVEPOINT v3.16, das Gate sei einfach zu
# niedrig: Für den Fall, für den Jikan überhaupt eingebaut wurde, ist
# es sogar zu HOCH — „Evangelion 2.22" vs. „Evangelion: 2.0 You Can
# (Not) Advance" ergibt 0,51 und fällt durch. Eine einzelne Zahl kann
# beides nicht leisten.
#
# Deshalb: Nur ein praktisch exakter Titel gilt sofort. Ein unscharfer
# Treffer wird GEMERKT, dann OMDb gefragt — und erst wenn OMDb nichts
# hat, kommt er als VORSCHLAG (niedrige Confidence) zum Zug.
jikan_unscharf = None
if not matched:
for kandidat in kandidaten:
jikan_treffer = self.jikan.lookup(kandidat)
if not jikan_treffer:
continue
if jikan_treffer.get("match_score", 0) >= JikanClient.SICHER_AEHNLICHKEIT:
confidence = 0.85
metadata = jikan_treffer
matched = True
else:
jikan_unscharf = jikan_treffer
break
# Fallback 2: OMDb (eigene Datenbasis — findet oft, was TMDB nicht
# exakt trifft; braucht OMDB_API_KEY, sonst überspringt es sich selbst)
if not matched:
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 2b: der unscharfe Jikan-Treffer — besser als nichts, aber
# ehrlich als Vorschlag ausgewiesen (0,6 = dieselbe Stufe wie ein
# TMDB-Vorschlag ohne exakten Treffer; das UI zeigt die Prozentzahl an
# und der Nutzer kann korrigieren).
if not matched and jikan_unscharf:
confidence = 0.6
metadata = jikan_unscharf
matched = True
# Fallback 2: bester TMDB-Vorschlag ohne exakten Treffer — als
# VORSCHLAG gekennzeichnet (niedrige Confidence, Nutzer korrigiert)
if not matched and movies:
vorschlag = self.tmdb.get_movie_details(movies[0]["id"])
if vorschlag:
confidence = 0.6
metadata = {
"type": "movie",
"id": movies[0]["id"],
"title": vorschlag.get("title", title),
"year": int(vorschlag.get("release_date", "0")[:4]) if vorschlag.get("release_date") else None,
"overview": vorschlag.get("overview", ""),
"poster_path": vorschlag.get("poster_path", ""),
"backdrop_path": vorschlag.get("backdrop_path", ""),
"runtime": vorschlag.get("runtime", 0),
"genres": [g["name"] for g in vorschlag.get("genres", [])],
"source": "tmdb",
}
matched = True
if not matched:
confidence = 0.3
metadata = {
"type": "unknown",
"title": title,
"year": None
}
# Deutsche Texte für OMDb-Treffer nachladen (Commander-Wunsch 24.07.):
# OMDb kann nur Englisch — über die IMDb-ID liefert TMDB /find den
# deutschen Titel, die Beschreibung und oft ein besseres Poster.
if matched and metadata.get("source") == "omdb" and str(metadata.get("id", "")).startswith("tt"):
deutsch = self.tmdb.find_by_imdb(metadata["id"])
if deutsch:
for feld in ("title", "overview", "poster_path", "year", "type"):
if deutsch.get(feld):
metadata[feld] = deutsch[feld]
# 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,
year=metadata.get("year"),
confidence=confidence,
metadata=metadata,
tracks=toc.get("tracks", []),
fingerprint=toc.get("fingerprint", "")
)
# Nur ECHTE Treffer cachen: ein gecachtes "unknown" würde sonst auch
# nach Key-Eintrag/Fix ewig wieder serviert (Redis ist persistent).
# Mittelgute Treffer verfallen nach einem Tag — falls eine bessere
# Quelle (Jikan-504 vorbei, TMDB-Key neu) später mehr weiß.
if confidence >= 0.9:
cache_set(cache_key, result.to_dict(), expire=7 * 86400)
elif confidence >= 0.6:
cache_set(cache_key, result.to_dict(), expire=86400)
return result