Ampel / ampel (push) Successful in 54s
Der Commander: "TMDB und OMDB Key sind hinterlegt, das laufwerk wird aber
auch nicht korrekt ausgelesen. Eigentlich sollte er direkt bei TMDB oder OMDB
oder JIKAN anfragen nach metadaten."
Er hat das Symptom gemeldet. Darunter lagen VIER Fehler.
## 1. Ein Phantom-Modul (der schwerste)
clients/tmdb.py:27 from db import get_settings
clients/omdb.py:36 from db import get_settings
clients/thetvdb.py:16 from db import get_settings
`docker/api/db.py` gibt es seit der Zusammenlegung in V2-1 (dd1d0b7) nicht
mehr; main.py schreibt seitdem `from rippy import store as db`. Diese drei
Module haben es nie mitbekommen.
Damit starb JEDE Metadaten-Abfrage schon beim Erzeugen des Clients mit
ModuleNotFoundError -- und `_auto_prescan` verschluckte das in seinem
`except Exception`. Die Disc blieb namenlos, und niemand sah warum.
**Das betrifft die VM genauso.** Sie steht auf 05ab655, also NACH dd1d0b7 --
dort laeuft seit V2-1 dieselbe tote Metadaten-Suche.
## 2. Redis war Pflicht statt Beschleunigung
`cache/cache.py` sprach `redis:6379` an -- den Dienstnamen aus
docker-compose.yml. Auf einem Windows-PC gibt es kein Redis:
redis.exceptions.ConnectionError: Error 11001 connecting to redis:6379
Das riss den Pre-Scan mit. Ein Zwischenspeicher ist eine Beschleunigung,
keine Voraussetzung: Jetzt faengt jeder Zugriff den Verbindungsfehler ab und
meldet "nicht vorhanden" -- was der Wahrheit entspricht. Gemeldet wird der
Ausfall EINMAL, nicht bei jeder Anfrage.
## 3. Der Klartext-Titel war unter Windows nicht erreichbar
`read_disc_title_via_mount` machte fest `mount -t udf` -- also Linux. Unter
Windows haengt Windows die Disc selbst ein. An der echten Disc gemessen:
Volume-Label (UDF) BD_EVG_D2 <- damit findet keine API etwas
BDMV/META/DL/bdmt_deu.xml Evangelion 2.22
`disc_wurzel()` liefert jetzt je Plattform einen lesbaren Einstieg, und
`titel_aus_bdmt()` ist davon getrennt (und damit ohne Disc pruefbar).
## 4. Modell und Seriennummer fehlten
Im UI stand "Modell: unbekannt, Seriennummer: -". Der Linux-Treiber liest
beides aus /sys; der Windows-Treiber lieferte leere Felder. Die Entsprechung
ist IOCTL_STORAGE_QUERY_PROPERTY. An echter Hardware gemessen:
hersteller 'HL-DT-ST'
modell 'BD-RE BU40N'
fassung '1.03'
seriennummer '0025114C0149'
Gemerkt statt jedes Mal abgefragt: Die Disc-Wache ruft device_info alle drei
Sekunden, und die Angaben eines Laufwerks aendern sich nicht.
## Ergebnis, an der eingelegten Disc gemessen
title 'Neon Genesis Evangelion'
year 1995
disc_type 'Blu-ray'
confidence 0.8
fingerprint 'BD_EVG_D2|48149364736'
(Jikan antwortete waehrend der Messung mit 504 -- deren Ausfall, nicht
unserer. Der Treffer kam von TMDB.)
Zwei Waechter, beide beim Zurueckdrehen rot gesehen: Die Clients muessen sich
OHNE Datenbank und OHNE Redis bauen lassen, und ein `from db import` in
clients/ faellt mechanisch auf.
Ampel lokal: 769 gruen, ruff sauber.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
612 lines
25 KiB
Python
612 lines
25 KiB
Python
"""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 shutil
|
|
import subprocess
|
|
from typing import Dict, List, Optional
|
|
|
|
from rippy import drives as _laufwerks_schicht
|
|
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()
|
|
|
|
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
|
|
# <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)
|
|
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 2 (optional, falls makemkvcon doch da ist):
|
|
if shutil.which("makemkvcon"):
|
|
result = subprocess.run(
|
|
["makemkvcon", "-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 = []
|
|
|
|
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:
|
|
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
|
|
|
|
# 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
|