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]