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>
688 lines
29 KiB
Python
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
|