Files
rippy/docker/api/test_rohdaten.py
HitonabiandClaude Opus 5 d3e86d2641 fix(windows): Der Schalter, den es nicht gibt, und vierzehn weitere Funde
Commander: „Kompression fehlgeschlagen bei Spartacus … _t01.mkv: HandBrake
endete mit Code 0" — 16,5 GB fertiger Rohschnitt, und die Kompression war in
derselben Sekunde vorbei, in der sie begann.

Nachgestellt mit genau der Befehlszeile, die Rippy baute:

    unknown option (--audio-codec)
    HandBrake has exited.        $? = 0

Den Schalter `--audio-codec` gibt es bei HandBrakeCLI nicht; er heisst
`-E` / `--aencoder`. Ein unbekannter Schalter ist fuer HandBrake kein Fehler,
der Rueckgabewert ist 0. Der Test dazu forderte den falschen Namen sogar ein.

Daraus wurde ein Rundgang durch den Windows-Pfad. Alles unten ist gemessen,
nichts vermutet (Regel D).

## Die Kompression

1. `--aencoder` statt `--audio-codec`. Am mitgelieferten HandBrakeCLI 1.11.2
   gemessen, mit einem 5-Sekunden-Encode auf der echten Roh-Datei bestaetigt.

2. HandBrakes letzte Zeilen werden aufgehoben (12 gepuffert, 4 in der
   Meldung) und `unknown option (...)` wird als eigener Fall erkannt, VOR
   allen anderen. Vorher wurde jede Zeile weggeworfen, die kein Fortschritt
   war — bei Rueckgabewert 0 blieb damit keine Auskunft uebrig. Die geratene
   Zeile „Meist ist der Zielordner nicht beschreibbar" ist raus; sie war
   falsch und hat die Suche in die falsche Richtung geschickt.

3. Ein `ue`-Umlaut im Pfad toetete die Kompression. HandBrake schreibt zwei
   Kodierungen in denselben Strom (derselbe Pfad einmal UTF-8, einmal CP850).
   In CP850 ist das Byte 0x81, und das ist in cp1252 — was `text=True` auf
   deutschem Windows waehlt — undefiniert:

       UnicodeDecodeError: charmap codec can't decode byte 0x81

   Neu: `rip/handbrake_aufruf.py` mit `HB_LESEN`, benutzt von ripping.py und
   caps.py. Bewusst nicht binaer wie bei makemkvcon: HandBrake trennt
   Fortschrittszeilen mit CR, im Binaermodus waere der Balken weg.

## Die Rohdaten

4. `rohdaten.kandidaten` machte aus dem Arbeitsordner `F:\` ein `F:` und
   verband damit weiter. Das ist unter Windows der aktuelle Ordner auf
   Laufwerk F, nicht dessen Wurzel — 16,5 GB waren unsichtbar, und der
   Wiederholen-Dialog bot nur „Neu rippen" an. Die Falle steht woertlich im
   Kopf von `pfade.verbinden`.

5. Gesucht wurde unter der heutigen Einstellung statt unter der Wahl DIESES
   Rips (`meta["work_dir"]`). Genau dafuer wurde rohdaten.py am 26.07.
   gebaut; repariert wurde damals die Kandidatenliste, nicht der Aufrufer.
   Neu: `_arbeitsverzeichnis_des_jobs`, benutzt an vier Stellen.

6. Zwei Speicher fuer dieselben Ordner: Die Oberflaeche schreibt
   `outputDir`/`workDir` in die Datenbank, `betrieb` liest `storage.*` aus
   der Konfigurationsdatei, und die schreibt niemand. Gemessen: eingestellt
   `E:\Rippy`, angezeigt `C:\Users\...\Videos\Rippy`. Neu:
   `betrieb.mit_einstellungen`.

## Das Laufwerk

7. `device_info` fing den OSError ab und lieferte „unknown" ohne den Grund.
   Nach einem Rip mit Lesefehlern beantwortete das Laufwerk keine
   Medien-Abfragen mehr (Win32-Fehler 1), die Geraete-Auskunft aber schon —
   im UI stand eine volle Laufwerkskarte, kein Rip startbar, und im
   Protokoll das laengst veraltete „Disc erkannt". Neu: `ZUGRIFFS_GRUENDE`,
   ein Feld `grund` im Laufwerks-Eintrag und eine Protokollzeile je Wechsel.
   Eine fehlgeschlagene Disc-Erkennung wird ebenfalls protokolliert.

8. Der Linux-Treiber nannte ein unzugaengliches Laufwerk „empty", waehrend
   Windows richtig „unknown" sagt. Angeglichen, samt Feld-Paritaet.

9. `CreateFileW`, `DeviceIoControl` und `CloseHandle` hatten weder `restype`
   noch `argtypes` — 32-Bit-`c_int` fuer einen 64-Bit-HANDLE, in beide
   Richtungen. Mit `restype` aendert sich der Fehlerwert von -1 auf
   0xFFFFFFFFFFFFFFFF; die Pruefung deckt jetzt beides ab. Am echten
   Laufwerk gegengeprueft, Fehlerpfad eingeschlossen.

10. Der Vor-Scan lief bei JEDER eingelegten Disc ein `makemkvcon info` mit
    120 s Zeitgrenze — 20 bis 120 Sekunden „Disc wird gelesen". Frueher war
    das schnell, weil der Zweig unter Windows nie lief (`shutil.which`,
    repariert am 28.08.). Das Ergebnis landete allein in `toc["tracks"]`,
    das niemand liest: Der Rip-Dialog holt seine Liste ueber
    `/devices/{id}/scan-tracks`, wenn sie gebraucht wird. Entfernt.

## Notbremsen

11. `_frei_bytes` suchte den naechsten vorhandenen Ordner selbst.
    `os.path.dirname("Q:\\")` gibt sich selbst zurueck — ein
    Arbeitsverzeichnis auf einer abgezogenen Platte haette den Job vor dem
    Rip stumm haengen lassen. Benutzt jetzt
    `pfade.naechster_vorhandener`, das den Abbruch seit V2-1 hat.

12. `naechster_vorhandener` haelt Laufwerks- und UNC-Wurzeln jetzt absolut.

13. `aufraeum_skript` baut sein `rmdir /s /q` aus `InstallLocation` in der
    Registry. Waere das eine Laufwerks-Wurzel, loeschte die Deinstallation
    das Laufwerk. Nicht beobachtet, aber nicht wiedergutzumachen — der
    Loeschbefehl bleibt in dem Fall weg.

## Lesefehler

MakeMKV sicherte 1 von 2 Titeln, endete mit 0, und Rippy schrieb „Rip
fertig". Jetzt gibt es eine Warnung, auch wenn der Rip als Erfolg endet, und
die MSG-Nummer steht im Protokoll: MakeMKVs Texte sind uebersetzt, die
Nummern nicht.

## Aus der Gegenprobe am laufenden Rippy

Die erste Fassung dieses Standes war installiert, als der Commander meldete:
„nun oeffnen sich diverse fenster im hintergrund, gehen ganz kurz auf und dann
wieder zu. Das laufwerk hoert auch einfach auf zu lesen." Beides Altlasten,
die erst durch die neue Protokollzeile sichtbar wurden.

14. Prozesserzeugung mitgeschnitten:

        14:54:40  timeout.exe          timeout 4 ls -d C:\Users\...\d7ee6c06-...
        14:54:40  WindowsTerminal.exe

    `rohdaten.pruefen` fragt mit `timeout N ls -d`, ob es ein Verzeichnis
    gibt. Unter Linux ist das richtig (os.path.isdir kann an einem toten
    CIFS-Mount im Kernel haengen, ein Kindprozess laesst sich abbrechen).
    Unter Windows ist es dreifach falsch: timeout.exe gibt es dort, kennt
    aber weder `ls` noch `-d`; sie braucht eine Konsole, und die reisst
    Windows auf; und ihr Rueckgabewert ist nie 0, die Antwort lautete also
    „weg" fuer JEDES Verzeichnis. Rohdaten waren unter Windows
    grundsaetzlich unsichtbar. Neu: `nativ_nachsehen()`. Die zwei
    gleichartigen Aufrufe in mounts.py bekommen dieselbe Absicherung.

15. Der Waechter fragte das Laufwerk alle drei Sekunden ab — auch mitten im
    Rip, also drei CreateFileW plus IOCTLs auf ein Geraet, das makemkvcon
    gerade liest:

        12:49:52  bluray-Rip gestartet
        12:50:09  [watcher] Laufwerk G: beantwortet keine Medien-Abfragen
        12:50:12  MSG 2003 SCSI-Fehler ILLEGAL REQUEST:INVALID FIELD IN CDB
        12:50:12  makemkvcon endete mit Code 11

    `_auto_prescan` haelt sich seit dem 29.08.2026 an die Regel „waehrend
    eines Rips wird nicht gescannt"; die Laufwerksabfrage tat es nicht.
    Jetzt gilt in der Zeit der letzte bekannte Stand.

## Zwei Tests, die gelogen haben

* `assert "--audio-codec" in cmd` schrieb den Fehler fest.
* `lambda: {}` als Doppelgaenger fuer `get_settings(key, bei_fehler_leer)`
  brach, sobald ein Aufrufer einen Parameter benutzte — und zeigte dann auf
  den Code statt auf sich selbst.

977 Tests gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 15:11:00 +02:00

307 lines
12 KiB
Python

"""Tests der Rohdaten-Suche — mit dem echten Fall, der sie nötig gemacht hat.
Job `95afdc89` (26.07.2026, an der laufenden Instanz gemessen): 79,6 GB
Rohschnitt unter /app/media/rippy/<id>, Einstellung `workDir` leer, `can_retry`
= false. Der Knopf „Neu komprimieren" fehlte, obwohl die Datei intakt war.
"""
import rohdaten
JOB = "95afdc89-2426-4d44-829e-ad6ce1411905"
def test_der_echte_fall_wird_gefunden():
"""workDir ist LEER (so stand es live) und der Rohschnitt liegt trotzdem auf
der NAS — weil beim Start eine Wahl nur für diesen Rip getroffen wurde."""
orte = rohdaten.kandidaten(JOB, "", ["movies", "music", "rippy", "series"])
assert f"/app/media/rippy/{JOB}" in orte
# Der Container-Standard bleibt der erste Kandidat (schnellster Treffer)
assert orte[0] == f"/app/temp/raw/{JOB}"
# Die Wurzeln des CONTAINER-Betriebs, eingespritzt. Ohne sie hingen diese
# Tests am laufenden Rechner: Unter Windows liefert `wurzeln()` echte
# Windows-Pfade, und die Erwartungen hier gelten dort nicht (am 29.08.2026
# prompt sieben Tests rot geworden).
CONTAINER = ("/app/temp/raw", "/app/media", False)
def test_suche_liefert_nur_was_existiert():
vorhanden = {f"/app/media/rippy/{JOB}"}
gefunden = rohdaten.suche(
JOB, "",
listdir=lambda p: ["movies", "rippy"],
isdir=lambda p: p in vorhanden,
orte_wurzeln=CONTAINER)
assert gefunden == [f"/app/media/rippy/{JOB}"]
def test_eingestelltes_arbeitsverzeichnis_kommt_vor_den_zielen():
orte = rohdaten.kandidaten(JOB, "/app/media/rippy", ["movies", "rippy"])
assert orte[1] == f"/app/media/rippy/{JOB}"
# ... und taucht nicht doppelt auf, obwohl „rippy" auch Ablageziel ist
assert orte.count(f"/app/media/rippy/{JOB}") == 1
def test_arbeitsverzeichnis_ausserhalb_media_wird_ignoriert():
"""Nur /app/media ist erlaubt (so entscheidet _arbeitsverzeichnis im
Worker) — ein Pfad daneben darf hier nicht durchrutschen."""
orte = rohdaten.kandidaten(JOB, "/etc", [])
assert orte == [f"/app/temp/raw/{JOB}"]
# Der Klassiker: ein Pfad, der nur mit dem Präfix ANFÄNGT
orte = rohdaten.kandidaten(JOB, "/app/media-boese", [])
assert orte == [f"/app/temp/raw/{JOB}"]
def test_media_root_selbst_ist_erlaubt():
orte = rohdaten.kandidaten(JOB, "/app/media", [])
assert f"/app/media/{JOB}" in orte
#: Die Wurzeln eines NATIVEN Betriebs (Windows, freies Blättern) —
#: Gegenstück zu CONTAINER weiter oben.
NATIV = ("C:\\Rippy\\_arbeit", "C:\\Rippy", True)
def test_laufwerks_wurzel_bleibt_absolut():
"""Der Fall des Commanders (30.08.2026): Arbeitsordner F: — die Wurzel.
Hier wurde der Schluss-Trenner abgestreift und mit dem Rest dann auch
VERBUNDEN. Ein blosses "F:" ist unter Windows aber der AKTUELLE Ordner
auf Laufwerk F, nicht dessen Wurzel — die Suche sah damit an einer
ganz anderen Stelle nach. Ergebnis: 16,5 GB Rohschnitt unsichtbar, und
der Wiederholen-Dialog bot nur "Neu rippen" an: Stunden am
beschädigten Datenträger für etwas, das schon dalag.
"""
orte = rohdaten.kandidaten(JOB, "F:\\", [], NATIV)
assert "F:" + chr(92) + JOB in orte
assert "F:" + JOB not in orte
# Ohne Schluss-Trenner muss dasselbe herauskommen
assert rohdaten.kandidaten(JOB, "F:\\Roh\\", [], NATIV)[-1] == (
"F:" + chr(92) + "Roh" + chr(92) + JOB)
def test_media_root_mit_schluss_trenner_zaehlt_auch():
"""/app/media/ und /app/media sind derselbe Ort — der Vergleich darf
nicht am Trenner scheitern."""
orte = rohdaten.kandidaten(JOB, "/app/media/", [], CONTAINER)
assert f"/app/media/{JOB}" in orte
def test_ohne_job_id_nichts():
assert rohdaten.kandidaten("", "/app/media", ["x"]) == []
def test_kaputter_mount_reisst_die_suche_nicht_mit():
"""Ein toter CIFS-Mount lässt isdir mit OSError fliegen. Das darf die
Job-Liste nicht mitnehmen (Befund 24.07. bei /storage-targets)."""
def isdir_kaputt(p):
if "totes-nas" in p:
raise OSError("Stale file handle")
return p == f"/app/temp/raw/{JOB}"
gefunden = rohdaten.suche(
JOB, "", listdir=lambda p: ["totes-nas", "movies"], isdir=isdir_kaputt, orte_wurzeln=CONTAINER)
assert gefunden == [f"/app/temp/raw/{JOB}"]
def test_listdir_kaputt_faellt_auf_den_standard_zurueck():
def listdir_kaputt(p):
raise OSError("kein /app/media")
gefunden = rohdaten.suche(
JOB, "", listdir=listdir_kaputt, isdir=lambda p: True, orte_wurzeln=CONTAINER)
assert gefunden == [f"/app/temp/raw/{JOB}"]
# --- Größe ------------------------------------------------------------------
def test_groesse_zaehlt_nur_dateien():
dateien = {
f"/app/media/rippy/{JOB}/title_t00.mkv": 79604951639,
f"/app/media/rippy/{JOB}/title_t01.mkv": 1000,
}
bytes_gesamt, anzahl = rohdaten.groesse(
[f"/app/media/rippy/{JOB}"],
listdir=lambda p: ["title_t00.mkv", "title_t01.mkv", "unterordner"],
isfile=lambda p: p in dateien,
getsize=lambda p: dateien[p],
)
# Die echte Größe des Akira-Rohschnitts, plus eine zweite Datei
assert bytes_gesamt == 79604952639
assert anzahl == 2
assert round(bytes_gesamt / 1024**3, 1) == 74.1
def test_groesse_ohne_pfade_ist_null():
assert rohdaten.groesse([], lambda p: [], lambda p: True, lambda p: 1) == (0, 0)
assert rohdaten.groesse(None, lambda p: [], lambda p: True, lambda p: 1) == (0, 0)
def test_groesse_ueberspringt_unlesbares():
def getsize_kaputt(p):
raise OSError("weg")
bytes_gesamt, anzahl = rohdaten.groesse(
["/x"], lambda p: ["a.mkv"], lambda p: True, getsize_kaputt)
assert (bytes_gesamt, anzahl) == (0, 0)
# --- Die harte Zeitgrenze: ein haengender Mount darf nichts toeten -----------
#
# Am 26.07.2026 hing os.path.isdir an der CIFS-Freigabe im
# KERNEL (Prozess-Zustand D). asyncio.to_thread kam nie zurueck, die
# Hintergrund-Schleife erreichte ihr sleep nie und war dauerhaft tot.
class _Lauf:
"""Merkt sich das Kommando und liefert einen gesetzten Rueckgabewert."""
def __init__(self, rc=0, wirf=None):
self.rc, self.wirf, self.cmd = rc, wirf, None
def __call__(self, cmd, **kwargs):
self.cmd = cmd
if self.wirf:
raise self.wirf
class E:
returncode = self.rc
return E()
def test_verzeichnis_da_nutzt_timeout_und_ls():
"""Ein Kind-PROZESS laesst sich abbrechen, ein im Kernel haengender Thread
nicht. Deshalb genau dieses Kommando - wie mounts.ist_erreichbar."""
lauf = _Lauf(rc=0)
assert rohdaten.verzeichnis_da("/app/media/rippy/x", lauf) is True
assert lauf.cmd[0] == "timeout"
assert lauf.cmd[1] == str(rohdaten.PRUEF_TIMEOUT_SEKUNDEN)
assert lauf.cmd[2:] == ["ls", "-d", "/app/media/rippy/x"]
def test_verzeichnis_da_nicht_vorhanden():
assert rohdaten.verzeichnis_da("/gibt/es/nicht", _Lauf(rc=2)) is False
def test_verzeichnis_in_der_zeitgrenze_gilt_als_nicht_da():
"""`timeout` beendet ls mit 124. Ein Ort, den man nicht in Sekunden ansehen
kann, ist fuer einen Rip ohnehin unbrauchbar."""
assert rohdaten.verzeichnis_da("/app/media/totes-nas/x", _Lauf(rc=124)) is False
def test_verzeichnis_da_ueberlebt_kaputte_umgebung():
import subprocess as sp
assert rohdaten.verzeichnis_da("/x", _Lauf(wirf=OSError("kein timeout"))) is False
assert rohdaten.verzeichnis_da(
"/x", _Lauf(wirf=sp.TimeoutExpired("ls", 6))) is False
assert rohdaten.verzeichnis_da("", _Lauf(rc=0)) is False
def test_suche_mit_der_zeitgrenze_findet_den_echten_fall():
"""Zusammenspiel: os.listdir fuer /app/media (lokal, sicher),
verzeichnis_da fuer die Kandidaten (koennen im Netz liegen)."""
def laufen(cmd, **kwargs):
pfad = cmd[-1]
class E:
returncode = 0 if pfad == f"/app/media/rippy/{JOB}" else 1
return E()
gefunden = rohdaten.suche(
JOB, "",
listdir=lambda p: ["bluray", "movies", "rippy"],
isdir=lambda p: rohdaten.verzeichnis_da(p, laufen),
orte_wurzeln=CONTAINER)
assert gefunden == [f"/app/media/rippy/{JOB}"]
# --- Drei Antworten: "konnte nicht nachsehen" ist nicht "ist weg" ------------
def test_pruefen_unterscheidet_drei_faelle():
assert rohdaten.pruefen("/x", _Lauf(rc=0)) == "da"
assert rohdaten.pruefen("/x", _Lauf(rc=2)) == "weg"
# 124 = `timeout` hat das Kind abgeschossen (coreutils) -> nicht angesehen
assert rohdaten.pruefen("/x", _Lauf(rc=124)) == "unklar"
assert rohdaten.pruefen("/x", _Lauf(wirf=OSError("kein timeout"))) == "unklar"
assert rohdaten.pruefen("", _Lauf(rc=0)) == "weg"
def test_verzeichnis_da_bleibt_streng():
"""Wer Ja/Nein braucht, bekommt im Zweifel Nein."""
assert rohdaten.verzeichnis_da("/x", _Lauf(rc=0)) is True
assert rohdaten.verzeichnis_da("/x", _Lauf(rc=124)) is False
def test_suche_mit_status_meldet_ungepruefte_orte():
"""Der echte Fall: Die Freigabe antwortet nicht, der lokale Ort ist leer.
Ein leeres Ergebnis darf dann NICHT als "nichts da" gelten."""
def pruefe(pfad):
return "unklar" if pfad.startswith("/app/media/rippy/") else "weg"
e = rohdaten.suche_mit_status(
JOB, "", listdir=lambda p: ["rippy"], pruefer=pruefe, orte_wurzeln=CONTAINER)
assert e == {"pfade": [], "unklar": True}
def test_suche_mit_status_ohne_zweifel():
def pruefe(pfad):
return "da" if pfad == f"/app/media/rippy/{JOB}" else "weg"
e = rohdaten.suche_mit_status(
JOB, "", listdir=lambda p: ["movies", "rippy"], pruefer=pruefe, orte_wurzeln=CONTAINER)
assert e == {"pfade": [f"/app/media/rippy/{JOB}"], "unklar": False}
def test_suche_mit_status_findet_trotz_unklarem_anderen_ort():
"""Ein Treffer bleibt ein Treffer, auch wenn ein anderer Ort schweigt."""
def pruefe(pfad):
if pfad == f"/app/media/rippy/{JOB}":
return "da"
return "unklar" if "totes-nas" in pfad else "weg"
e = rohdaten.suche_mit_status(
JOB, "", listdir=lambda p: ["rippy", "totes-nas"], pruefer=pruefe, orte_wurzeln=CONTAINER)
assert e["pfade"] == [f"/app/media/rippy/{JOB}"]
assert e["unklar"] is True
def test_nativ_wird_kein_prozess_gestartet(monkeypatch, tmp_path):
"""Befund 30.08.2026, an der laufenden Instanz beobachtet:
14:54:40 timeout.exe timeout 4 ls -d C:...d7ee6c06-...
14:54:40 WindowsTerminal.exe
Der Commander sah Fenster aufblitzen. `timeout` und `ls` sind
Linux-Befehle; die Windows-eigene timeout.exe kennt weder `ls` noch
`-d`, braucht aber eine Konsole. Dreifach falsch: ein Fenster je
Durchlauf, ein Prozess fuer nichts, und Rueckgabewert ungleich 0 — also
die Antwort weg fuer JEDES Verzeichnis.
"""
gestartet = []
monkeypatch.setattr(rohdaten, "nativ_nachsehen", lambda: True)
monkeypatch.setattr(rohdaten.subprocess, "run",
lambda *a, **k: gestartet.append(a))
assert rohdaten.pruefen(str(tmp_path)) == "da"
assert rohdaten.pruefen(str(tmp_path / "gibt-es-nicht")) == "weg"
assert gestartet == [], "es darf kein Prozess gestartet werden"
def test_im_container_bleibt_der_kindprozess(monkeypatch):
"""Dort ist der Umweg richtig: os.path.isdir kann an einem toten
CIFS-Mount im Kernel haengen (Begruendung in verzeichnis_da)."""
monkeypatch.setattr(rohdaten, "nativ_nachsehen", lambda: False)
aufrufe = []
class Antwort:
returncode = 0
monkeypatch.setattr(rohdaten.subprocess, "run",
lambda *a, **k: aufrufe.append(a[0]) or Antwort())
assert rohdaten.pruefen("/app/media/x") == "da"
assert aufrufe[0][0] == "timeout"
assert aufrufe[0][-1] == "/app/media/x"