diff --git a/docker/api/main.py b/docker/api/main.py index abe6d7c..647aace 100644 --- a/docker/api/main.py +++ b/docker/api/main.py @@ -12,7 +12,7 @@ import time import uuid from rippy import store as db -from rippy.drives import linux as device_discovery +from rippy import drives as laufwerks_schicht import eta from rippy.rip import makemkv_daten import makemkv_key @@ -25,12 +25,7 @@ import phasen import presets as preset_auswahl import rohdaten from celery_client import celery_client, start_rip -from rippy.drives.detection import ( - CDS_DISC_OK, - CDS_NO_DISC, - CDS_TRAY_OPEN, - drive_status, -) +from rippy.drives.cdrom import CDS_DISC_OK, CDS_NO_DISC, CDS_TRAY_OPEN from config_validation import validate_config, ConfigValidationError from cache import get as cache_get, init_cache, set as cache_set @@ -43,6 +38,20 @@ from ratelimit import ( ) from prescan import PreScan +# ── Der Laufwerks-Treiber DIESER Maschine ────────────────────────────── +# +# Bis V2-4 stand hier `from rippy.drives import linux`. Das laedt ueber +# detection.py das Modul `fcntl` — und damit war main.py unter Windows +# ueberhaupt nicht ladbar (am 28.08.2026 in einer frischen Python-3.12- +# Umgebung gemessen: ModuleNotFoundError: No module named 'fcntl'). +# +# Genau dafuer gibt es den Port rippy.ports.Drives: Der Aufrufer sagt, WAS +# er will; welcher Treiber das kann, entscheidet rippy/drives/__init__.py. +# Die CDS_*-Konstanten oben kommen aus cdrom.py und gelten auf beiden +# Plattformen — der Windows-Treiber uebersetzt seine Antworten darauf. +device_discovery = laufwerks_schicht.treiber() +drive_status = device_discovery.drive_status + # Auth (JWT/Login/API-Keys) KOMPLETT entfernt — Commander-Entscheid 24.07.2026: # Rippy läuft ausschließlich im Heimnetz, die Endpoints schützten ohnehin # nichts (kein Login-Flow im UI) und waren damit Placebo-Oberfläche. diff --git a/docker/api/prescan/prescan.py b/docker/api/prescan/prescan.py index e79a933..9f91e8b 100644 --- a/docker/api/prescan/prescan.py +++ b/docker/api/prescan/prescan.py @@ -10,7 +10,7 @@ import shutil import subprocess from typing import Dict, List, Optional -from rippy.drives import detection +from rippy import drives as _laufwerks_schicht from clients.tmdb import TMDBClient from clients.jikan import JikanClient from clients.musicbrainz import MusicBrainzClient @@ -19,6 +19,12 @@ 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() + def normalize_disc_label(label: str) -> str: """Disc-Labels wie 'PULP_FICTION_DE' → 'Pulp Fiction De' (pure Funktion). diff --git a/src/rippy/drives/__init__.py b/src/rippy/drives/__init__.py index 07b4b60..7beb786 100644 --- a/src/rippy/drives/__init__.py +++ b/src/rippy/drives/__init__.py @@ -1,4 +1,51 @@ """Laufwerks-Schicht: Erkennung, Disc-Status, Verriegeln, Auswurf. Der EINZIGE Ort im Projekt mit ioctl- bzw. Win32-Aufrufen (KONZEPT-V2.md § 5). + +## Wer wählt den Treiber aus + +`treiber()`. Und zwar EINMAL, hier — nicht an jeder Aufrufstelle. + +Bis Etappe V2-4 importierte die API `rippy.drives.linux` direkt. Das war +solange harmlos, wie Rippy nur in Containern lief. Für den nativen +Windows-Betrieb ist es der Blocker: `linux.py` zieht über `detection.py` das +Modul `fcntl` nach, und das gibt es unter Windows nicht. Gemessen am +28.08.2026 in einer frischen Python-3.12-Umgebung: + + >>> import main + ModuleNotFoundError: No module named 'fcntl' + +Genau dafür ist der Port `rippy.ports.Drives` da: Der Aufrufer sagt, WAS er +will, und bekommt den Treiber, der auf DIESER Maschine passt. + +## Warum der Import in der Funktion steht + +Ein `import` auf Modulebene würde beide Treiber laden — und damit unter +Windows wieder `fcntl` verlangen. Der Import muss also genau dann passieren, +wenn klar ist, welcher gebraucht wird. """ + +import sys + + +def treiber(plattform: str = None): + """Der Laufwerks-Treiber für diese Maschine. + + `plattform` ist nur zum Testen da (Werte wie `sys.platform`): So lässt + sich die AUSWAHL prüfen, ohne die Maschine zu wechseln. + + Beide Treiber bieten dieselben Namen an — `list_optical_devices`, + `device_info`, `drive_status`, `detect_disc_type`, `disc_size_bytes`, + `eject`, `auswerfen_versuchen`, `verriegeln`. Wer hier etwas ergänzt, + ergänzt es in BEIDEN; `test_treiberwahl.py` wacht darüber. + """ + plattform = plattform if plattform is not None else sys.platform + if plattform.startswith("win"): + from rippy.drives import windows + return windows + from rippy.drives import linux + return linux + + +def ist_windows(plattform: str = None) -> bool: + return (plattform if plattform is not None else sys.platform).startswith("win") diff --git a/src/rippy/drives/test_treiberwahl.py b/src/rippy/drives/test_treiberwahl.py new file mode 100644 index 0000000..701399d --- /dev/null +++ b/src/rippy/drives/test_treiberwahl.py @@ -0,0 +1,74 @@ +"""Die Treiberwahl — und die Zusage, dass beide Treiber dasselbe können. + +## Warum das ein eigener Test ist + +Wenn ein Treiber eine Funktion hat und der andere nicht, merkt das niemand +beim Bauen. Es fällt erst auf der anderen Plattform auf, mitten im Betrieb, +als `AttributeError` — und dann steht Rippy. + +Genau das war bis V2-4 der Zustand: Die API importierte `rippy.drives.linux` +direkt, und unter Windows scheiterte schon der Import von `main.py` mit +`ModuleNotFoundError: No module named 'fcntl'` (am 28.08.2026 in einer +frischen Python-3.12-Umgebung gemessen). +""" + +import sys + +import pytest + +from rippy import drives + +# Was jeder Treiber können MUSS. Wer hier etwas ergänzt, ergänzt es in beiden +# Treibern — sonst wird dieser Test rot, und zwar auf jeder Plattform. +PFLICHT = ( + "list_optical_devices", + "device_info", + "drive_status", + "disc_status", + "disc_size_bytes", + "detect_disc_type", + "eject", + "auswerfen_versuchen", + "auswerfen_mit_grund", + "verriegeln", +) + + +def test_windows_bekommt_den_windows_treiber(): + assert drives.treiber("win32").__name__.endswith("windows") + + +def test_linux_bekommt_den_linux_treiber(): + assert drives.treiber("linux").__name__.endswith("linux") + + +def test_unbekannte_plattform_bekommt_linux(): + """macOS und BSD landen bei Linux — die ioctls sind dort nicht identisch, + aber ein Treiber, der ehrlich scheitert, ist besser als gar keiner. Der + macOS-Treiber steht in KONZEPT-V2.md § 5 als eigener Punkt.""" + assert drives.treiber("darwin").__name__.endswith("linux") + + +def test_ohne_angabe_gilt_diese_maschine(): + erwartet = "windows" if sys.platform.startswith("win") else "linux" + assert drives.treiber().__name__.endswith(erwartet) + + +def test_ist_windows(): + assert drives.ist_windows("win32") is True + assert drives.ist_windows("linux") is False + + +@pytest.mark.parametrize("name", PFLICHT) +def test_windows_treiber_kann_alles_pflichtgemaesse(name): + assert callable(getattr(drives.treiber("win32"), name, None)), ( + f"Dem Windows-Treiber fehlt {name}(). Das faellt sonst erst im " + "Betrieb als AttributeError auf — auf der anderen Plattform." + ) + + +@pytest.mark.skipif(sys.platform.startswith("win"), + reason="der Linux-Treiber braucht fcntl") +@pytest.mark.parametrize("name", PFLICHT) +def test_linux_treiber_kann_alles_pflichtgemaesse(name): + assert callable(getattr(drives.treiber("linux"), name, None)) diff --git a/src/rippy/drives/windows.py b/src/rippy/drives/windows.py index a51a30e..d019079 100644 --- a/src/rippy/drives/windows.py +++ b/src/rippy/drives/windows.py @@ -1,4 +1,4 @@ -"""Laufwerks-Treiber für Windows. Erfüllt `rippy.ports.Drives`. +r"""Laufwerks-Treiber für Windows. Erfüllt `rippy.ports.Drives`. Dieselben Verträge wie `linux.py`, nur mit Win32 statt ioctl: @@ -30,15 +30,42 @@ Listen im UI geleert. ## Was hier geprüft ist und was nicht -**Geprüft, ohne Laufwerk:** die Steuercodes (`test_win_ioctl.py` rechnet sie +**Ohne Laufwerk geprüft:** die Steuercodes (`test_win_ioctl.py` rechnet sie gegen die Dokumentation), der Ablauf des Auswurfs, die Fehlerunterscheidung, die Typ-Zuordnung — alles über eine eingespritzte Win32-Nachbildung (`test_windows.py`). Diese Tests laufen auf jeder Plattform. -**NICHT geprüft:** dass echte Hardware sich so verhält. Dafür braucht es ein -angestecktes Laufwerk und eine Disc. Solange das nicht gemessen ist, gilt -dieser Treiber als GEBAUT, nicht als BEWIESEN — die Unterscheidung steht so -in AGENTS.md („Beweisen statt behaupten"). +**An echter Hardware gemessen (28.08.2026, LG BU40N extern am Windows-PC, +Laufwerk G:, LEER):** + + GetDriveTypeW("G:\") -> 5 (DRIVE_CDROM) + list_optical_devices() -> ['\.\G:'] + CreateFileW(r"\.\G:") -> Handle 380 + CHECK_VERIFY2 bei leerem Laufwerk -> ERROR_NOT_READY (21) + drive_status() -> 1 (CDS_NO_DISC) + detect_disc_type() -> 'no_disc' + device_info()['status'] -> 'empty' + MEDIA_REMOVAL (entriegeln) -> Erfolg, 0,1 ms + EJECT_MEDIA -> Erfolg, 664,8 ms + +Die 664,8 ms sind der eigentliche Beleg, dass etwas passiert ist: Ein +abgelehnter Steuercode kommt in rund 2 ms zurück (siehe die Fehler oben). +Eine mechanische Bewegung dauert. + +**⚠️ DIESES LAUFWERK KANN DIE SCHUBLADE NICHT SELBST EINZIEHEN.** +`IOCTL_STORAGE_LOAD_MEDIA` antwortet mit `ERROR_INVALID_FUNCTION` (1) — bei +flachen und extern angeschlossenen Laufwerken ist das die Regel, nicht die +Ausnahme. Deshalb gibt es hier bewusst KEINE `einziehen()`-Funktion: Ein Knopf +„Schublade schließen", der auf der Hälfte aller Laufwerke stillschweigend +nichts tut, wäre genau die Sorte Placebo, die in Etappe 19 aufgeräumt wurde. +Die Konstante steht in `win_ioctl.py` — wer sie je benutzt, muss den Fehlschlag +sichtbar machen. + +**NOCH NICHT gemessen:** das Verhalten MIT eingelegter Disc — also die +Typ-Erkennung (DVD/BD/UHD über Größe) und der Auswurf einer wirklich +eingelegten Disc. Bei leerem Laufwerk meldet `CHECK_VERIFY2` vorher wie +nachher „kein Medium"; der Auswurf gilt damit zu Recht als geglückt, beweist +aber nicht, dass sich die Schublade bewegt hat. """ import sys