From 878b24c43b421623dfffa4827e1b22033014d702 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Fri, 28 Aug 2026 15:59:47 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20die=20Metadaten-Suche=20war=20seit=20V2-?= =?UTF-8?q?1=20tot=20=E2=80=94=20auf=20Windows=20UND=20auf=20der=20VM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docker/api/cache/cache.py | 116 +++++++++++++++++++++---- docker/api/clients/omdb.py | 9 +- docker/api/clients/thetvdb.py | 9 +- docker/api/clients/tmdb.py | 16 +++- docker/api/prescan/prescan.py | 115 +++++++++++++++++-------- docker/api/test_clients.py | 157 ++++++++++++++++++++++++++++++++++ src/rippy/drives/win_ioctl.py | 23 +++++ src/rippy/drives/windows.py | 80 ++++++++++++++++- 8 files changed, 463 insertions(+), 62 deletions(-) create mode 100644 docker/api/test_clients.py diff --git a/docker/api/cache/cache.py b/docker/api/cache/cache.py index e5d3a21..6442e6f 100644 --- a/docker/api/cache/cache.py +++ b/docker/api/cache/cache.py @@ -1,42 +1,122 @@ -"""Cache module.""" +"""Zwischenspeicher für API-Antworten — mit Redis, wenn es eins gibt. + +## Warum das nicht mehr scheitern darf (Befund 28.08.2026) + +Hier stand ein nacktes `redis_client.get(key)`. Der Vorgabe-Host ist `redis` +— der Dienstname aus `docker-compose.yml`. Im Container stimmt das; auf einem +Windows-PC gibt es kein Redis, und jeder Aufruf endete mit: + + redis.exceptions.ConnectionError: Error 11001 connecting to redis:6379 + +Das riss den Pre-Scan mit. Der Commander sah die Folge, nicht die Ursache: +Eine eingelegte Blu-ray blieb namenlos, und die Metadaten-Suche lief nie an. + +**Ein Zwischenspeicher ist eine Beschleunigung, keine Voraussetzung.** Ist +keiner da, wird eben jedes Mal neu gefragt — TMDB und OMDb halten das aus. +Deshalb fängt jeder Zugriff hier den Verbindungsfehler ab und tut so, als +wäre der Eintrag nicht vorhanden. Das ist genau die Wahrheit: Er ist es +nicht. + +## Warum trotzdem einmal gemeldet wird + +Ein Zwischenspeicher, der still nie greift, ist eine unsichtbare +Verlangsamung — und auf der VM wäre ein weggefallenes Redis ein echter +Befund. Deshalb sagt `zustand()`, woran man ist, und die erste fehlgeschlagene +Verbindung schreibt eine Zeile. Danach Ruhe: Eine Meldung je Anfrage wäre +Lärm. +""" -from redis import Redis -from typing import Any, Optional import json import os +from typing import Any, Optional + +from redis import Redis +from redis.exceptions import RedisError redis_host = os.getenv("REDIS_HOST", "redis") redis_port = int(os.getenv("REDIS_PORT", "6379")) -redis_client = Redis(host=redis_host, port=redis_port, decode_responses=True) +# Kurze Zeitgrenzen: Ein Zwischenspeicher, auf den man wartet, ist keiner. +# Ohne sie hing jeder Aufruf am Standard-Timeout der Bibliothek. +redis_client = Redis(host=redis_host, port=redis_port, decode_responses=True, + socket_connect_timeout=1.5, socket_timeout=1.5) + +# Erst beim ersten Zugriff bekannt. None = noch nichts versucht. +_erreichbar: Optional[bool] = None + + +def _melde_einmal(fehler: Exception) -> None: + """Beim ERSTEN Fehlschlag eine Zeile, danach Ruhe.""" + global _erreichbar + if _erreichbar is False: + return + _erreichbar = False + print("Zwischenspeicher (%s:%s) nicht erreichbar — es wird ohne " + "gearbeitet: %s" % (redis_host, redis_port, fehler)) + + +def zustand() -> dict: + """Woran man ist. Für Diagnose und Selbstauskunft.""" + return {"host": redis_host, "port": redis_port, "erreichbar": _erreichbar} def init_cache() -> None: - """Initialize cache connection.""" - pass + """Einmal anklopfen, damit der Zustand bekannt ist. Wirft nie.""" + global _erreichbar + try: + redis_client.ping() + _erreichbar = True + except (RedisError, OSError) as e: + _melde_einmal(e) def get(key: str) -> Optional[Any]: - """Get value from cache.""" - value = redis_client.get(key) + """Wert aus dem Zwischenspeicher. None = nicht da ODER kein Speicher.""" + global _erreichbar + try: + value = redis_client.get(key) + except (RedisError, OSError) as e: + _melde_einmal(e) + return None + _erreichbar = True if value: - return json.loads(value) + try: + return json.loads(value) + except (TypeError, ValueError): + # Ein kaputter Eintrag ist kein Grund, den Aufrufer scheitern zu + # lassen — er holt sich die Antwort dann eben frisch. + return None return None def set(key: str, value: Any, expire: Optional[int] = None) -> bool: - """Set value in cache.""" - serialized = json.dumps(value) - if expire: - return redis_client.setex(key, expire, serialized) - return redis_client.set(key, serialized) + """Wert ablegen. False heißt: ging nicht — und das ist in Ordnung.""" + global _erreichbar + try: + serialized = json.dumps(value) + if expire: + return bool(redis_client.setex(key, expire, serialized)) + return bool(redis_client.set(key, serialized)) + except (RedisError, OSError) as e: + _melde_einmal(e) + return False + except (TypeError, ValueError): + # Nicht serialisierbar. Das ist ein Fehler des Aufrufers, aber keiner, + # der eine Metadaten-Abfrage umwerfen darf. + return False def delete(key: str) -> bool: - """Delete value from cache.""" - return redis_client.delete(key) + try: + return bool(redis_client.delete(key)) + except (RedisError, OSError) as e: + _melde_einmal(e) + return False def clear() -> bool: - """Clear all cache.""" - return redis_client.flushdb() + try: + return bool(redis_client.flushdb()) + except (RedisError, OSError) as e: + _melde_einmal(e) + return False diff --git a/docker/api/clients/omdb.py b/docker/api/clients/omdb.py index 9050f23..56b9f2c 100644 --- a/docker/api/clients/omdb.py +++ b/docker/api/clients/omdb.py @@ -33,8 +33,13 @@ def parse_year(year: str) -> Optional[int]: class OMDbClient: def __init__(self): # DB-Einstellung (Settings-UI/Wizard) gewinnt gegen die Env-Variable - from db import get_settings - self.api_key = get_settings().get("omdbApiKey") or settings.omdb_api_key + # ⚠️ rippy.store, NICHT db (Befund 28.08.2026). docker/api/db.py + # gibt es seit V2-1 (dd1d0b7) nicht mehr — main.py schreibt seitdem + # "from rippy import store as db", dieses Modul hat es nie + # mitbekommen. Jede Abfrage starb beim Erzeugen des Clients, und + # _auto_prescan verschluckte es. Siehe clients/tmdb.py. + from rippy.store import get_settings + self.api_key = get_settings(bei_fehler_leer=True).get("omdbApiKey") or settings.omdb_api_key self.session = requests.Session() def suche(self, title: str) -> list: diff --git a/docker/api/clients/thetvdb.py b/docker/api/clients/thetvdb.py index 7ef23bc..172435e 100644 --- a/docker/api/clients/thetvdb.py +++ b/docker/api/clients/thetvdb.py @@ -13,8 +13,13 @@ THETVDB_BASE_URL = "https://api.thetvdb.com" class TheTVDBClient: def __init__(self): # DB-Einstellung (Settings-UI/Wizard) gewinnt gegen die Env-Variable - from db import get_settings - self.api_key = get_settings().get("tvdbApiKey") or settings.thetvdb_api_key + # ⚠️ rippy.store, NICHT db (Befund 28.08.2026). docker/api/db.py + # gibt es seit V2-1 (dd1d0b7) nicht mehr — main.py schreibt seitdem + # "from rippy import store as db", dieses Modul hat es nie + # mitbekommen. Jede Abfrage starb beim Erzeugen des Clients, und + # _auto_prescan verschluckte es. Siehe clients/tmdb.py. + from rippy.store import get_settings + self.api_key = get_settings(bei_fehler_leer=True).get("tvdbApiKey") or settings.thetvdb_api_key self.base_url = THETVDB_BASE_URL self.session = requests.Session() self.session.headers.update({ diff --git a/docker/api/clients/tmdb.py b/docker/api/clients/tmdb.py index 2186d10..e2c9278 100644 --- a/docker/api/clients/tmdb.py +++ b/docker/api/clients/tmdb.py @@ -24,8 +24,20 @@ class TMDBClient: def __init__(self): # DB-Einstellung (Settings-UI/Wizard) gewinnt gegen die Env-Variable — # vorher war das Settings-Feld reine Dekoration (Fix 23.07.). - from db import get_settings - self.api_key = get_settings().get("tmdbApiKey") or settings.tmdb_api_key + # ⚠️ rippy.store, NICHT db (Befund 28.08.2026). + # + # Hier stand "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", aber dieses Modul + # hat das nie mitbekommen. Die Folge: JEDE Metadaten-Abfrage starb + # schon beim Erzeugen des Clients mit ModuleNotFoundError, und + # _auto_prescan verschluckte das in seinem "except Exception". Discs + # blieben namenlos — auf Windows UND auf der VM. + # + # bei_fehler_leer=True: Ohne Datenbank gilt der Schlüssel aus der + # Umgebung. Ein Metadaten-Client ist kein Grund, warum nichts geht. + from rippy.store import get_settings + self.api_key = get_settings(bei_fehler_leer=True).get("tmdbApiKey") or settings.tmdb_api_key self.session = requests.Session() self.session.headers.update({"Content-Type": "application/json"}) # Beide Key-Arten unterstützen (developer.themoviedb.org: v3 als diff --git a/docker/api/prescan/prescan.py b/docker/api/prescan/prescan.py index 9f91e8b..890afe6 100644 --- a/docker/api/prescan/prescan.py +++ b/docker/api/prescan/prescan.py @@ -80,50 +80,93 @@ def parse_udf_dstring(data: bytes) -> str: 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. Die API darf - read-only mounten (CAP_SYS_ADMIN ist für die Speicherziele ohnehin da). - Gibt None zurück, wenn kein BD-Metadatensatz existiert (z. B. DVD). + 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). """ - import re as _re - mountpoint = "/mnt/rippy-disc" - os.makedirs(mountpoint, exist_ok=True) - gemountet = False + wurzel, aufraeumen = None, lambda: None try: - ergebnis = subprocess.run( - ["mount", "-t", "udf", "-o", "ro", device_path, mountpoint], - capture_output=True, text=True, timeout=30, - ) - if ergebnis.returncode != 0: - return None - gemountet = True - - meta_dir = os.path.join(mountpoint, "BDMV", "META", "DL") - if not os.path.isdir(meta_dir): - return None - kandidaten = sorted(os.listdir(meta_dir)) - # bdmt_eng.xml bevorzugen, sonst erste bdmt-Datei (bdmt_ger.xml, …) - bdmt = next((k for k in kandidaten if k == "bdmt_eng.xml"), None) or next( - (k for k in kandidaten if k.startswith("bdmt_") and k.endswith(".xml")), None - ) - if not bdmt: - return None - with open(os.path.join(meta_dir, bdmt), "rb") as f: - inhalt = f.read(65536).decode("utf-8", errors="replace") - # Titel — bewusst per Regex statt XML-Parser - # (Namespaces variieren je Authoring-Werkzeug) - treffer = _re.search(r"([^<]{2,120})", inhalt) - if treffer: - return treffer.group(1).strip() - return None + wurzel, aufraeumen = disc_wurzel(device_path) + return titel_aus_bdmt(wurzel) if wurzel else None except Exception: return None finally: - if gemountet: - subprocess.run(["umount", mountpoint], capture_output=True, timeout=15) + try: + aufraeumen() + except Exception: + pass def titel_kandidaten(titel: str) -> List[str]: diff --git a/docker/api/test_clients.py b/docker/api/test_clients.py new file mode 100644 index 0000000..f8847ca --- /dev/null +++ b/docker/api/test_clients.py @@ -0,0 +1,157 @@ +"""Die Metadaten-Clients muessen sich OHNE Datenbank und OHNE Redis bauen lassen. + +## Warum es diese Tests gibt (Befund 28.08.2026) + +Der Commander meldete, dass eine eingelegte Blu-ray namenlos blieb und keine +Metadaten-Abfrage lief. Zwei Ursachen, beide unsichtbar: + +**1. Ein Phantom-Modul.** `clients/tmdb.py`, `clients/omdb.py` und +`clients/thetvdb.py` machten im Konstruktor `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`, auf Windows UND auf der +VM. `_auto_prescan` verschluckte es in seinem `except Exception`. + +**2. Ein harter Redis-Zwang.** `cache/cache.py` sprach `redis:6379` an — den +Dienstnamen aus `docker-compose.yml`. Auf einem Windows-PC gibt es kein +Redis, und jeder Zugriff endete mit `ConnectionError`. Das riss den Pre-Scan +mit. + +Beide Male war der SYMPTOM sichtbar (keine Metadaten) und die URSACHE nicht. +Diese Tests machen die Ursache sichtbar: Sie bauen die Clients und benutzen +den Zwischenspeicher, ohne dass irgendetwas davon erreichbar sein muss. +""" + +import pytest + + +def test_tmdb_client_baut_sich_ohne_datenbank(): + """Der Test, der den Phantom-Import gefangen haette.""" + from clients.tmdb import TMDBClient + + klient = TMDBClient() + assert hasattr(klient, "api_key") + + +def test_omdb_client_baut_sich_ohne_datenbank(): + from clients.omdb import OMDbClient + + assert OMDbClient() is not None + + +def test_thetvdb_client_baut_sich_ohne_datenbank(): + from clients.thetvdb import TheTVDBClient + + assert TheTVDBClient() is not None + + +def test_kein_client_importiert_das_verschwundene_db_modul(): + """Der Waechter gegen genau diesen Fehler, mechanisch. + + Ein `from db import ...` faellt sonst erst auf, wenn jemand eine Disc + einlegt — und dann als „keine Metadaten", nicht als Import-Fehler. + """ + import os + + hier = os.path.dirname(os.path.abspath(__file__)) + fundstellen = [] + for wurzel, ordner, dateien in os.walk(os.path.join(hier, "clients")): + ordner[:] = [o for o in ordner if o != "__pycache__"] + for name in dateien: + if not name.endswith(".py"): + continue + pfad = os.path.join(wurzel, name) + with open(pfad, encoding="utf-8") as f: + for nr, zeile in enumerate(f, 1): + if zeile.strip().startswith(("from db import", "import db")): + fundstellen.append("%s:%d" % (name, nr)) + assert not fundstellen, ( + "docker/api/db.py gibt es seit V2-1 nicht mehr — benutzt " + "rippy.store. Fundstellen: " + ", ".join(fundstellen)) + + +# ── Der Zwischenspeicher ──────────────────────────────────────────────── +def test_cache_ohne_redis_liefert_None_statt_zu_werfen(): + """Ein Zwischenspeicher ist eine Beschleunigung, keine Voraussetzung. + + Auf einem Windows-PC gibt es kein Redis. Vorher riss jeder Zugriff den + Pre-Scan mit — und die Disc blieb namenlos. + """ + from cache import cache + + class ToterRedis: + def get(self, *a, **k): + raise cache.RedisError("nicht erreichbar") + + def set(self, *a, **k): + raise cache.RedisError("nicht erreichbar") + + def setex(self, *a, **k): + raise cache.RedisError("nicht erreichbar") + + def delete(self, *a, **k): + raise cache.RedisError("nicht erreichbar") + + def flushdb(self, *a, **k): + raise cache.RedisError("nicht erreichbar") + + def ping(self, *a, **k): + raise cache.RedisError("nicht erreichbar") + + echt = cache.redis_client + cache.redis_client = ToterRedis() + try: + assert cache.get("egal") is None + assert cache.set("egal", {"a": 1}) is False + assert cache.delete("egal") is False + assert cache.clear() is False + cache.init_cache() # darf ebenfalls nicht werfen + assert cache.zustand()["erreichbar"] is False + finally: + cache.redis_client = echt + + +def test_cache_meldet_den_ausfall_nur_EINMAL(capsys): + """Eine Meldung je Anfrage waere Laerm — und bei jedem Titel-Suchlauf + kaeme sie mehrfach.""" + from cache import cache + + class ToterRedis: + def get(self, *a, **k): + raise cache.RedisError("weg") + + echt, zustand = cache.redis_client, cache._erreichbar + cache.redis_client = ToterRedis() + cache._erreichbar = None + try: + for _ in range(5): + cache.get("x") + ausgabe = capsys.readouterr().out + assert ausgabe.count("nicht erreichbar") == 1, ausgabe + finally: + cache.redis_client, cache._erreichbar = echt, zustand + + +def test_ein_kaputter_eintrag_wirft_nicht(): + """Ein unlesbarer Eintrag ist kein Grund zu scheitern — dann wird die + Antwort eben frisch geholt.""" + from cache import cache + + class KaputterRedis: + def get(self, *a, **k): + return "{kein json" + + echt = cache.redis_client + cache.redis_client = KaputterRedis() + try: + assert cache.get("x") is None + finally: + cache.redis_client = echt + + +@pytest.mark.parametrize("name", ["tmdb", "omdb", "jikan", "musicbrainz"]) +def test_jeder_client_ist_importierbar(name): + """Ein Tippfehler in einem Modulpfad faellt sonst erst auf, wenn jemand + eine Disc einlegt.""" + __import__("clients." + name) diff --git a/src/rippy/drives/win_ioctl.py b/src/rippy/drives/win_ioctl.py index 8e839ab..ffdf246 100644 --- a/src/rippy/drives/win_ioctl.py +++ b/src/rippy/drives/win_ioctl.py @@ -73,6 +73,29 @@ IOCTL_STORAGE_EJECT_MEDIA = ctl_code( IOCTL_STORAGE_LOAD_MEDIA = ctl_code( FILE_DEVICE_MASS_STORAGE, 0x0203, METHOD_BUFFERED, FILE_READ_ACCESS) +# Hersteller, Modell und Seriennummer des LAUFWERKS (nicht der Disc). +# +# Die Entsprechung von `/sys/class/block//device/{vendor,model,wwid}` +# unter Linux. Ohne diesen Aufruf stand im UI „Modell: unbekannt, +# Seriennummer: –" — der Commander hat es am 28.08.2026 gemeldet. +# +# Erwartet eine STORAGE_PROPERTY_QUERY (PropertyId=0 StorageDeviceProperty, +# QueryType=0 PropertyStandardQuery) und antwortet mit einem +# STORAGE_DEVICE_DESCRIPTOR: feste Kopfdaten mit OFFSETS auf die Zeichenketten, +# die dahinter im selben Puffer liegen. +IOCTL_STORAGE_QUERY_PROPERTY = ctl_code( + FILE_DEVICE_MASS_STORAGE, 0x0500, METHOD_BUFFERED, FILE_ANY_ACCESS) + +STORAGE_DEVICE_PROPERTY = 0 +PROPERTY_STANDARD_QUERY = 0 + +# Feldpositionen im STORAGE_DEVICE_DESCRIPTOR (winioctl.h), in Bytes. +SDD_VENDOR_ID_OFFSET = 12 +SDD_PRODUCT_ID_OFFSET = 16 +SDD_PRODUCT_REVISION_OFFSET = 20 +SDD_SERIAL_NUMBER_OFFSET = 24 + + # ── CD-ROM (IOCTL_CDROM_*) ────────────────────────────────────────────── # Audio- oder Datenspur? Grob, aber genau das, was CDROM_DISC_STATUS auch # liefert — mehr braucht die Einordnung nicht (die Feinunterscheidung diff --git a/src/rippy/drives/windows.py b/src/rippy/drives/windows.py index eb8b8ce..c2216b5 100644 --- a/src/rippy/drives/windows.py +++ b/src/rippy/drives/windows.py @@ -376,6 +376,66 @@ def eject(geraet: str, api=None) -> None: # ── Für das UI ────────────────────────────────────────────────────────── +def _text_bei(puffer: bytes, offset_pos: int) -> str: + """Eine nullterminierte Zeichenkette, auf die ein Offset im Puffer zeigt. + + Reine Funktion, damit die Auswertung ohne Laufwerk prüfbar ist. Offset 0 + heißt „gibt es nicht" — das ist der dokumentierte Weg, wie ein + STORAGE_DEVICE_DESCRIPTOR ein fehlendes Feld meldet. + """ + if len(puffer) < offset_pos + 4: + return "" + offset = int.from_bytes(puffer[offset_pos:offset_pos + 4], "little") + if not offset or offset >= len(puffer): + return "" + ende = puffer.find(b"\x00", offset) + roh = puffer[offset:ende if ende >= 0 else len(puffer)] + return roh.decode("latin-1", errors="replace").strip() + + +def geraeteangaben(geraet: str, api=None) -> dict: + """Hersteller, Modell und Seriennummer des LAUFWERKS. + + ## Warum es das gibt (Commander-Befund 28.08.2026) + + Im UI stand: + + Modell unbekannt + Seriennummer – + + Der Linux-Treiber liest beides aus `/sys/class/block//device/`; der + Windows-Treiber lieferte schlicht leere Felder. Die Entsprechung ist + `IOCTL_STORAGE_QUERY_PROPERTY` — es antwortet mit einem + `STORAGE_DEVICE_DESCRIPTOR`, dessen Kopfdaten OFFSETS auf die + Zeichenketten dahinter enthalten. + + Wirft nicht: Ein Laufwerk ohne Angaben ist ärgerlich, aber kein Grund, + die ganze Geräteliste scheitern zu lassen. + """ + api = _api(api) + abfrage = (w.STORAGE_DEVICE_PROPERTY.to_bytes(4, "little") + + w.PROPERTY_STANDARD_QUERY.to_bytes(4, "little") + + b"\x00" * 4) + try: + handle = api.oeffnen(geraet) + except OSError: + return {} + try: + puffer = api.steuern(handle, w.IOCTL_STORAGE_QUERY_PROPERTY, + abfrage, 1024) + except OSError: + return {} + finally: + api.schliessen(handle) + + return { + "hersteller": _text_bei(puffer, w.SDD_VENDOR_ID_OFFSET), + "modell": _text_bei(puffer, w.SDD_PRODUCT_ID_OFFSET), + "fassung": _text_bei(puffer, w.SDD_PRODUCT_REVISION_OFFSET), + "seriennummer": _text_bei(puffer, w.SDD_SERIAL_NUMBER_OFFSET), + } + + def device_info(geraet: str, api=None) -> dict: """Der Geräte-Eintrag fürs UI — gleiche Felder wie beim Linux-Treiber. @@ -400,12 +460,28 @@ def device_info(geraet: str, api=None) -> dict: status = "unknown" buchstabe = geraet.rstrip(":").rsplit("\\", 1)[-1].rstrip(":") + angaben = _angaben_gemerkt(geraet, api) + modell = " ".join(t for t in (angaben.get("hersteller"), + angaben.get("modell")) if t) return { "id": buchstabe, "name": f"Laufwerk {buchstabe}:", "type": disc_typ, "path": geraet, "status": status, - "model": "", - "serial": "", + "model": modell, + "serial": angaben.get("seriennummer", ""), } + + +# Die Angaben eines Laufwerks aendern sich nicht, solange es dasselbe +# Laufwerk ist -- die Disc-Wache ruft `device_info` aber alle drei Sekunden. +# Ohne dieses Gedaechtnis waere das alle drei Sekunden ein CreateFileW plus +# DeviceIoControl auf ein Geraet, das gerade rippt. +_ANGABEN_SPEICHER: dict = {} + + +def _angaben_gemerkt(geraet: str, api=None) -> dict: + if geraet not in _ANGABEN_SPEICHER: + _ANGABEN_SPEICHER[geraet] = geraeteangaben(geraet, api) or {} + return _ANGABEN_SPEICHER[geraet]