Files
rippy/docker/api/prescan/prescan.py
T
HitonabiandClaude Opus 5 d3e86d2641 fix(windows): Der Schalter, den es nicht gibt, und vierzehn weitere Funde
Commander: „Kompression fehlgeschlagen bei Spartacus … _t01.mkv: HandBrake
endete mit Code 0" — 16,5 GB fertiger Rohschnitt, und die Kompression war in
derselben Sekunde vorbei, in der sie begann.

Nachgestellt mit genau der Befehlszeile, die Rippy baute:

    unknown option (--audio-codec)
    HandBrake has exited.        $? = 0

Den Schalter `--audio-codec` gibt es bei HandBrakeCLI nicht; er heisst
`-E` / `--aencoder`. Ein unbekannter Schalter ist fuer HandBrake kein Fehler,
der Rueckgabewert ist 0. Der Test dazu forderte den falschen Namen sogar ein.

Daraus wurde ein Rundgang durch den Windows-Pfad. Alles unten ist gemessen,
nichts vermutet (Regel D).

## Die Kompression

1. `--aencoder` statt `--audio-codec`. Am mitgelieferten HandBrakeCLI 1.11.2
   gemessen, mit einem 5-Sekunden-Encode auf der echten Roh-Datei bestaetigt.

2. HandBrakes letzte Zeilen werden aufgehoben (12 gepuffert, 4 in der
   Meldung) und `unknown option (...)` wird als eigener Fall erkannt, VOR
   allen anderen. Vorher wurde jede Zeile weggeworfen, die kein Fortschritt
   war — bei Rueckgabewert 0 blieb damit keine Auskunft uebrig. Die geratene
   Zeile „Meist ist der Zielordner nicht beschreibbar" ist raus; sie war
   falsch und hat die Suche in die falsche Richtung geschickt.

3. Ein `ue`-Umlaut im Pfad toetete die Kompression. HandBrake schreibt zwei
   Kodierungen in denselben Strom (derselbe Pfad einmal UTF-8, einmal CP850).
   In CP850 ist das Byte 0x81, und das ist in cp1252 — was `text=True` auf
   deutschem Windows waehlt — undefiniert:

       UnicodeDecodeError: charmap codec can't decode byte 0x81

   Neu: `rip/handbrake_aufruf.py` mit `HB_LESEN`, benutzt von ripping.py und
   caps.py. Bewusst nicht binaer wie bei makemkvcon: HandBrake trennt
   Fortschrittszeilen mit CR, im Binaermodus waere der Balken weg.

## Die Rohdaten

4. `rohdaten.kandidaten` machte aus dem Arbeitsordner `F:\` ein `F:` und
   verband damit weiter. Das ist unter Windows der aktuelle Ordner auf
   Laufwerk F, nicht dessen Wurzel — 16,5 GB waren unsichtbar, und der
   Wiederholen-Dialog bot nur „Neu rippen" an. Die Falle steht woertlich im
   Kopf von `pfade.verbinden`.

5. Gesucht wurde unter der heutigen Einstellung statt unter der Wahl DIESES
   Rips (`meta["work_dir"]`). Genau dafuer wurde rohdaten.py am 26.07.
   gebaut; repariert wurde damals die Kandidatenliste, nicht der Aufrufer.
   Neu: `_arbeitsverzeichnis_des_jobs`, benutzt an vier Stellen.

6. Zwei Speicher fuer dieselben Ordner: Die Oberflaeche schreibt
   `outputDir`/`workDir` in die Datenbank, `betrieb` liest `storage.*` aus
   der Konfigurationsdatei, und die schreibt niemand. Gemessen: eingestellt
   `E:\Rippy`, angezeigt `C:\Users\...\Videos\Rippy`. Neu:
   `betrieb.mit_einstellungen`.

## Das Laufwerk

7. `device_info` fing den OSError ab und lieferte „unknown" ohne den Grund.
   Nach einem Rip mit Lesefehlern beantwortete das Laufwerk keine
   Medien-Abfragen mehr (Win32-Fehler 1), die Geraete-Auskunft aber schon —
   im UI stand eine volle Laufwerkskarte, kein Rip startbar, und im
   Protokoll das laengst veraltete „Disc erkannt". Neu: `ZUGRIFFS_GRUENDE`,
   ein Feld `grund` im Laufwerks-Eintrag und eine Protokollzeile je Wechsel.
   Eine fehlgeschlagene Disc-Erkennung wird ebenfalls protokolliert.

8. Der Linux-Treiber nannte ein unzugaengliches Laufwerk „empty", waehrend
   Windows richtig „unknown" sagt. Angeglichen, samt Feld-Paritaet.

9. `CreateFileW`, `DeviceIoControl` und `CloseHandle` hatten weder `restype`
   noch `argtypes` — 32-Bit-`c_int` fuer einen 64-Bit-HANDLE, in beide
   Richtungen. Mit `restype` aendert sich der Fehlerwert von -1 auf
   0xFFFFFFFFFFFFFFFF; die Pruefung deckt jetzt beides ab. Am echten
   Laufwerk gegengeprueft, Fehlerpfad eingeschlossen.

10. Der Vor-Scan lief bei JEDER eingelegten Disc ein `makemkvcon info` mit
    120 s Zeitgrenze — 20 bis 120 Sekunden „Disc wird gelesen". Frueher war
    das schnell, weil der Zweig unter Windows nie lief (`shutil.which`,
    repariert am 28.08.). Das Ergebnis landete allein in `toc["tracks"]`,
    das niemand liest: Der Rip-Dialog holt seine Liste ueber
    `/devices/{id}/scan-tracks`, wenn sie gebraucht wird. Entfernt.

## Notbremsen

11. `_frei_bytes` suchte den naechsten vorhandenen Ordner selbst.
    `os.path.dirname("Q:\\")` gibt sich selbst zurueck — ein
    Arbeitsverzeichnis auf einer abgezogenen Platte haette den Job vor dem
    Rip stumm haengen lassen. Benutzt jetzt
    `pfade.naechster_vorhandener`, das den Abbruch seit V2-1 hat.

12. `naechster_vorhandener` haelt Laufwerks- und UNC-Wurzeln jetzt absolut.

13. `aufraeum_skript` baut sein `rmdir /s /q` aus `InstallLocation` in der
    Registry. Waere das eine Laufwerks-Wurzel, loeschte die Deinstallation
    das Laufwerk. Nicht beobachtet, aber nicht wiedergutzumachen — der
    Loeschbefehl bleibt in dem Fall weg.

## Lesefehler

MakeMKV sicherte 1 von 2 Titeln, endete mit 0, und Rippy schrieb „Rip
fertig". Jetzt gibt es eine Warnung, auch wenn der Rip als Erfolg endet, und
die MSG-Nummer steht im Protokoll: MakeMKVs Texte sind uebersetzt, die
Nummern nicht.

## Aus der Gegenprobe am laufenden Rippy

Die erste Fassung dieses Standes war installiert, als der Commander meldete:
„nun oeffnen sich diverse fenster im hintergrund, gehen ganz kurz auf und dann
wieder zu. Das laufwerk hoert auch einfach auf zu lesen." Beides Altlasten,
die erst durch die neue Protokollzeile sichtbar wurden.

14. Prozesserzeugung mitgeschnitten:

        14:54:40  timeout.exe          timeout 4 ls -d C:\Users\...\d7ee6c06-...
        14:54:40  WindowsTerminal.exe

    `rohdaten.pruefen` fragt mit `timeout N ls -d`, ob es ein Verzeichnis
    gibt. Unter Linux ist das richtig (os.path.isdir kann an einem toten
    CIFS-Mount im Kernel haengen, ein Kindprozess laesst sich abbrechen).
    Unter Windows ist es dreifach falsch: timeout.exe gibt es dort, kennt
    aber weder `ls` noch `-d`; sie braucht eine Konsole, und die reisst
    Windows auf; und ihr Rueckgabewert ist nie 0, die Antwort lautete also
    „weg" fuer JEDES Verzeichnis. Rohdaten waren unter Windows
    grundsaetzlich unsichtbar. Neu: `nativ_nachsehen()`. Die zwei
    gleichartigen Aufrufe in mounts.py bekommen dieselbe Absicherung.

15. Der Waechter fragte das Laufwerk alle drei Sekunden ab — auch mitten im
    Rip, also drei CreateFileW plus IOCTLs auf ein Geraet, das makemkvcon
    gerade liest:

        12:49:52  bluray-Rip gestartet
        12:50:09  [watcher] Laufwerk G: beantwortet keine Medien-Abfragen
        12:50:12  MSG 2003 SCSI-Fehler ILLEGAL REQUEST:INVALID FIELD IN CDB
        12:50:12  makemkvcon endete mit Code 11

    `_auto_prescan` haelt sich seit dem 29.08.2026 an die Regel „waehrend
    eines Rips wird nicht gescannt"; die Laufwerksabfrage tat es nicht.
    Jetzt gilt in der Zeit der letzte bekannte Stand.

## Zwei Tests, die gelogen haben

* `assert "--audio-codec" in cmd` schrieb den Fehler fest.
* `lambda: {}` als Doppelgaenger fuer `get_settings(key, bei_fehler_leer)`
  brach, sobald ein Aufrufer einen Parameter benutzte — und zeigte dann auf
  den Code statt auf sich selbst.

977 Tests gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 15:11:00 +02:00

688 lines
29 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 subprocess
from typing import Dict, List, Optional
from rippy import drives as _laufwerks_schicht
from rippy.platform.winlauf import OHNE_FENSTER
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,
creationflags=OHNE_FENSTER,
)
if ergebnis.returncode != 0:
return None, lambda: None
def abhaengen():
subprocess.run(["umount", mountpoint], capture_output=True, timeout=15,
creationflags=OHNE_FENSTER)
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,
creationflags=OHNE_FENSTER,
)
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 (makemkvcon) gibt es hier NICHT mehr.
#
# ## Warum sie weg ist (Befund 30.08.2026)
#
# Der Commander: „das erkennen der disk dauert sehr sehr
# lange. Das ging mal viel schneller."
#
# Hier stand ein `makemkvcon -r --noscan --minlength=300
# info` mit 120 s Zeitgrenze — bei jeder eingelegten Disc,
# vor jeder Anzeige. Auf einer Blu-ray dauert dieser Aufruf
# 20 bis 120 Sekunden; solange steht „Disc wird gelesen".
#
# Dass es frueher schnell war, hat einen unschoenen Grund:
# Der Zweig lief unter Windows NIE. Er suchte makemkvcon mit
# `shutil.which`, und das findet unter Windows nichts
# (Programme liegen nicht im PATH). `be3fac5` hat das am
# 28.08.2026 richtig repariert — und damit erst die Kosten
# sichtbar gemacht, die hier immer schon standen.
#
# Der Aufwand war umsonst: Das Ergebnis landete allein in
# `toc["tracks"]`, und **die liest niemand**. Der Rip-Dialog
# holt seine Titelliste ueber `POST /devices/{id}/scan-tracks`
# und `GET /devices/{id}/tracks` — also dann, wenn sie
# gebraucht wird, statt bei jedem Einlegen auf Verdacht.
# Der Weg fuer eine Disc, die man gar nicht rippen will,
# sind so zwei Minuten Warten fuer nichts.
#
# Der Titel kommt aus Quelle 1 und 2 darueber; die sind
# billig. Wer den Aufruf je wieder braucht, braucht dazu
# `makemkv_aufruf.quelle`, `makemkv_aufruf.text_von` und
# `tools.katalog` — die Importe sind mit ihm gegangen.
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