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]