feat(drives): V2-4 (Teil 2) — main.py laedt unter Windows, Treiber gemessen
Ampel / ampel (push) Failing after 46s

WAS: rippy/drives/__init__.py waehlt den Treiber fuer DIESE Maschine.
main.py und prescan.py fragen ihn, statt fest den Linux-Treiber zu
importieren. Damit ist der Blocker fuer den nativen Windows-Betrieb weg.

GEMESSEN, vorher (frische Python-3.12-Umgebung unter Windows):
  >>> import main
  ModuleNotFoundError: No module named 'fcntl'

GEMESSEN, nachher:
  main.py laedt unter Windows: JA
    Treiber:   rippy.drives.windows
    Routen:    61
    Laufwerke: ['\\.\G:']

Der Import stand in rippy/drives/linux.py -> detection.py -> fcntl. Genau
dafuer gibt es den Port: Der Aufrufer sagt WAS, nicht WIE. test_treiberwahl.py
haelt ausserdem fest, dass BEIDE Treiber denselben Satz Funktionen anbieten —
sonst faellt eine fehlende erst im Betrieb als AttributeError auf, und zwar
auf der anderen Plattform.

DER TREIBER AN ECHTER HARDWARE (LG BU40N extern, 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 Beleg, dass wirklich etwas passiert ist: Ein
abgelehnter Steuercode kommt in rund 2 ms zurueck.

BEFUND, DER EIN PLACEBO VERHINDERT HAT: IOCTL_STORAGE_LOAD_MEDIA (Schublade
einziehen) antwortet auf diesem Laufwerk mit ERROR_INVALID_FUNCTION. Bei
flachen und externen Laufwerken ist das die Regel. Deshalb gibt es bewusst
KEINE einziehen()-Funktion — ein Knopf "Schublade schliessen", der auf der
Haelfte aller Laufwerke stillschweigend nichts tut, waere genau die Sorte
Placebo, die in Etappe 19 aufgeraeumt wurde.

Dazu eine DeprecationWarning behoben: '\G' im Docstring ist keine gueltige
Escape-Sequenz und waere in einer kuenftigen Python-Version ein harter Fehler.

NOCH NICHT gemessen: das Verhalten MIT eingelegter Disc (Typ-Erkennung ueber
die Groesse, und ein Auswurf, bei dem wirklich etwas drin ist).

GEMESSEN: ruff sauber, 429 Tests gruen + 13 uebersprungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-08-28 09:55:15 +02:00
co-authored by Claude Opus 5
parent b723fee3a2
commit 3d7f3b1680
5 changed files with 177 additions and 14 deletions
+16 -7
View File
@@ -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.
+7 -1
View File
@@ -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).
+47
View File
@@ -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")
+74
View File
@@ -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))
+33 -6
View File
@@ -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