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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
b6a93aa726
commit
d3e86d2641
+139
-7
@@ -233,6 +233,19 @@ async def _auto_prescan(pfad: str):
|
||||
except Exception as e:
|
||||
DISC_CACHE.pop(pfad, None)
|
||||
print(f"Auto-Pre-Scan {pfad}: {e}")
|
||||
# ... und ins PROTOKOLL, nicht nur auf die Konsole
|
||||
# (Befund 30.08.2026). Der Commander: „im log steht zwar
|
||||
# erkannt, aber ein start des rips ist nicht moeglich."
|
||||
# Genau so war es: Die letzte Zeile war das erfolgreiche
|
||||
# „Disc erkannt" von vorhin, die fehlgeschlagenen Versuche
|
||||
# danach standen nur auf einer Konsole, die niemand sieht.
|
||||
# Ein Protokoll, in dem nur die Erfolge stehen, luegt.
|
||||
try:
|
||||
db.add_log("warning", "watcher",
|
||||
"Disc-Erkennung auf %s fehlgeschlagen: %s"
|
||||
% (pfad, e))
|
||||
except Exception: # noqa: BLE001
|
||||
pass # Protokollieren darf die Wache nie anhalten
|
||||
|
||||
|
||||
async def _auto_rip_wenn_aktiviert(pfad: str):
|
||||
@@ -468,6 +481,9 @@ class Device(BaseModel):
|
||||
status: str
|
||||
model: Optional[str] = None
|
||||
serial: Optional[str] = None
|
||||
# Warum steht bei `type`/`status` „unknown"? Leer, solange alles geht.
|
||||
# Der Linux-Treiber setzt das Feld nicht — dann bleibt es leer.
|
||||
grund: str = ""
|
||||
disc: Optional[Dict] = None # Auto-Pre-Scan-Ergebnis (Titel/Jahr/Poster)
|
||||
# Läuft die Erkennung gerade noch? (Commander 29.08.2026: „das die disc
|
||||
# erkennung noch läuft muss sichtbar sein")
|
||||
@@ -789,6 +805,39 @@ def _rohdaten_suchen(job_id: str, work_dir: str) -> list:
|
||||
return rohdaten.suche(job_id, work_dir, os.listdir, rohdaten.verzeichnis_da)
|
||||
|
||||
|
||||
def _arbeitsverzeichnis_des_jobs(job, einstellungen=None) -> str:
|
||||
"""Wohin ging der Roh-Rip DIESES Jobs? Seine Wahl schlägt die Einstellung.
|
||||
|
||||
## Warum die Einstellung allein nicht reicht (Befund 30.08.2026)
|
||||
|
||||
Der Rip-Dialog lässt für JEDEN Rip einzeln wählen, wohin die Rohdaten
|
||||
gehen (seit v3.15). Die Wahl landet in den Job-Metadaten als
|
||||
`work_dir` — geschrieben in `start_rip`, gelesen im Worker von
|
||||
`_arbeitsverzeichnis()`. Gesucht wurde danach aber immer unter dem
|
||||
HEUTIGEN Wert der Einstellung `workDir`.
|
||||
|
||||
Genau daran scheiterte am 26.07.2026 schon einmal ein Job mit 79,6 GB
|
||||
Rohschnitt — der Fall steht im Kopf von `rohdaten.py`. Repariert wurde
|
||||
damals die Kandidatenliste, nicht der Aufrufer: Er reichte weiterhin
|
||||
die Einstellung hinein. Am 30.08.2026 stand deshalb erneut „Auf der
|
||||
Platte liegt zu diesem Job nichts (mehr)" — vor 16,5 GB, die dalagen.
|
||||
|
||||
Das ist das Muster aus AGENTS: aus einem Zustandswert (der heutigen
|
||||
Einstellung) auf einen Vorgang geschlossen (wohin DAMALS gerippt
|
||||
wurde), statt nachzusehen. Der Job weiß es selbst.
|
||||
"""
|
||||
holen = getattr(job, "get", None)
|
||||
eigen = ""
|
||||
if holen:
|
||||
eigen = (phasen.meta_von(holen("meta")).get("work_dir") or "").strip()
|
||||
if not eigen:
|
||||
werte = einstellungen if einstellungen is not None else db.get_settings()
|
||||
eigen = ((werte or {}).get("workDir") or "").strip()
|
||||
# normpath("") wäre "." — der aktuelle Ordner, und der ist hier nie
|
||||
# gemeint. "/" fällt in `kandidaten()` sauber durch.
|
||||
return os.path.normpath(eigen or "/")
|
||||
|
||||
|
||||
# Vorrat für die Job-Liste. Dasselbe Muster wie beim Celery-Ping in
|
||||
# /capabilities (v3.15): Der Endpunkt wird alle 4 Sekunden vom Dashboard
|
||||
# abgefragt und darf NIE am Dateisystem hängen. Ein schlafendes NAS hätte das
|
||||
@@ -915,13 +964,15 @@ def _rohdaten_vorrat_auffrischen() -> None:
|
||||
Sekunden. Ohne diese Regel verschwände in dem Fenster der Knopf
|
||||
„Neu komprimieren", und der Nutzer schlösse daraus, seine 74 GB seien weg.
|
||||
"""
|
||||
work_dir = os.path.normpath((db.get_settings().get("workDir") or "").strip() or "/")
|
||||
einstellungen = db.get_settings()
|
||||
alt = _ROHDATEN["treffer"]
|
||||
treffer = {}
|
||||
for zeile in db.list_jobs():
|
||||
if zeile.get("status") != "failed":
|
||||
continue
|
||||
job_id = zeile["id"]
|
||||
# Je Job SEINE Wahl — nicht die heutige Einstellung.
|
||||
work_dir = _arbeitsverzeichnis_des_jobs(zeile, einstellungen)
|
||||
ergebnis = rohdaten.suche_mit_status(job_id, work_dir, os.listdir)
|
||||
if not ergebnis["pfade"] and ergebnis["unklar"] and alt.get(job_id):
|
||||
treffer[job_id] = alt[job_id] # letzte bekannte Antwort halten
|
||||
@@ -1050,8 +1101,7 @@ async def job_rohdaten(job_id: str):
|
||||
raise HTTPException(status_code=404, detail="Job nicht gefunden")
|
||||
|
||||
def sammle():
|
||||
work_dir = os.path.normpath((db.get_settings().get("workDir") or "").strip() or "/")
|
||||
pfade = _rohdaten_suchen(job_id, work_dir)
|
||||
pfade = _rohdaten_suchen(job_id, _arbeitsverzeichnis_des_jobs(job))
|
||||
bytes_gesamt, dateien = _rohdaten_groesse(pfade)
|
||||
return {
|
||||
"pfade": pfade,
|
||||
@@ -1080,8 +1130,7 @@ async def delete_job(job_id: str, rohdaten: bool = False):
|
||||
geloescht_gb = 0.0
|
||||
if rohdaten:
|
||||
def raeume():
|
||||
work_dir = os.path.normpath((db.get_settings().get("workDir") or "").strip() or "/")
|
||||
pfade = _rohdaten_suchen(job_id, work_dir)
|
||||
pfade = _rohdaten_suchen(job_id, _arbeitsverzeichnis_des_jobs(job))
|
||||
bytes_gesamt, _ = _rohdaten_groesse(pfade)
|
||||
for pfad in pfade:
|
||||
shutil.rmtree(pfad, ignore_errors=True)
|
||||
@@ -1564,7 +1613,7 @@ async def retry_transcode(job_id: str):
|
||||
raise HTTPException(status_code=409, detail="Job rippt noch")
|
||||
|
||||
einstellungen = await asyncio.to_thread(db.get_settings)
|
||||
work_dir = os.path.normpath((einstellungen.get("workDir") or "").strip() or "/")
|
||||
work_dir = _arbeitsverzeichnis_des_jobs(job, einstellungen)
|
||||
gefunden = await asyncio.to_thread(_rohdaten_suchen, job_id, work_dir)
|
||||
if not gefunden:
|
||||
raise HTTPException(
|
||||
@@ -2528,6 +2577,13 @@ async def system_info():
|
||||
werte = rippy_config.laden()
|
||||
except Exception: # noqa: BLE001
|
||||
werte = {}
|
||||
# Die Oberflaeche schreibt nach `outputDir`/`workDir` in die
|
||||
# DATENBANK, `betrieb` liest `storage.*` aus der DATEI. Ohne
|
||||
# diese Bruecke zeigte die Uebersicht immer die Vorgabe, egal
|
||||
# was eingestellt war (Befund 30.08.2026, siehe
|
||||
# `betrieb.mit_einstellungen`).
|
||||
werte = betriebs_auskunft.mit_einstellungen(
|
||||
werte, db.get_settings(bei_fehler_leer=True))
|
||||
for ort in betriebs_auskunft.platz_orte(werte):
|
||||
# Frisch installiert gibt es den Ablage-Ordner noch nicht. Dann
|
||||
# das naechste vorhandene Elternverzeichnis messen: Der Nutzer
|
||||
@@ -2911,6 +2967,75 @@ async def get_devices():
|
||||
await asyncio.to_thread(laufwerke_mit_disc)]
|
||||
|
||||
|
||||
#: Zuletzt gemeldeter Grund je Laufwerk. Ohne dieses Gedaechtnis stuende die
|
||||
#: Zeile alle drei Sekunden im Protokoll und verdraengte alles andere.
|
||||
_LETZTER_GRUND: Dict[str, str] = {}
|
||||
|
||||
|
||||
def _grund_melden(pfad: str, grund: str) -> None:
|
||||
"""Warum ein Laufwerk „unknown" meldet — einmal ins Protokoll, beim
|
||||
Wechsel.
|
||||
|
||||
## Der Befund des Commanders (30.08.2026)
|
||||
|
||||
> „jetzt erkennt rippy die disk garnicht mehr (im log steht zwar
|
||||
> erkannt, aber ein start des rips ist nicht moeglich)"
|
||||
|
||||
Sein Laufwerk beantwortete nach einem Rip mit Lesefehlern keine
|
||||
Medien-Abfragen mehr (Win32-Fehler 1), die Geraete-Auskunft aber schon.
|
||||
Im UI stand deshalb eine vollstaendige Laufwerkskarte mit Modell und
|
||||
Seriennummer — nur „unknown" bei Typ und Status, und kein Rip startbar.
|
||||
Die letzte Protokollzeile war das laengst veraltete „Disc erkannt".
|
||||
|
||||
Der Treiber kennt den Grund (siehe `drives.windows.ZUGRIFFS_GRUENDE`).
|
||||
Hier wird er gesagt — samt Abhilfe, denn die Zeile soll nicht nur
|
||||
beschreiben, sondern weiterhelfen.
|
||||
"""
|
||||
if _LETZTER_GRUND.get(pfad, "") == grund:
|
||||
return
|
||||
vorher = _LETZTER_GRUND.get(pfad, "")
|
||||
_LETZTER_GRUND[pfad] = grund
|
||||
try:
|
||||
if grund:
|
||||
db.add_log("warning", "watcher", "Laufwerk %s: %s" % (pfad, grund))
|
||||
elif vorher:
|
||||
db.add_log("info", "watcher",
|
||||
"Laufwerk %s antwortet wieder." % pfad)
|
||||
except Exception: # noqa: BLE001
|
||||
pass # Protokollieren darf die Laufwerksliste nie aufhalten
|
||||
|
||||
|
||||
def _job_haelt_das_laufwerk(pfad: str) -> bool:
|
||||
"""Laeuft auf diesem Laufwerk gerade ein Rip?
|
||||
|
||||
## Warum das Laufwerk dann in Ruhe bleiben muss (Befund 30.08.2026)
|
||||
|
||||
Der Waechter fragt alle drei Sekunden `device_info` ab — das sind drei
|
||||
`CreateFileW` plus IOCTLs auf ein Geraet, das waehrenddessen makemkvcon
|
||||
gehoert. Am Protokoll des Commanders abgelesen:
|
||||
|
||||
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 MSG 5010 Das Oeffnen der Disk schlug fehl
|
||||
12:50:12 makemkvcon endete mit Code 11
|
||||
|
||||
Sein Befund dazu: „Das laufwerk hoert auch einfach auf zu lesen."
|
||||
|
||||
`_auto_prescan` haelt sich seit dem 29.08.2026 an genau diese Regel
|
||||
(„Es gibt keinen Grund, waehrend eines Rips zu scannen") — die
|
||||
Laufwerksabfrage tat es nicht. Sie hat dieselbe Begruendung: Wir wissen
|
||||
bereits, was drinliegt, der Job laeuft ja darauf.
|
||||
|
||||
Faellt die Auskunft aus, gilt der letzte bekannte Stand weiter. Das ist
|
||||
keine Notluege: Waehrend eines Rips aendert sich am Laufwerk nichts.
|
||||
"""
|
||||
try:
|
||||
return bool(db.has_active_job(pfad))
|
||||
except Exception: # noqa: BLE001
|
||||
return False # im Zweifel nachsehen, wie bisher
|
||||
|
||||
|
||||
def laufwerke_mit_disc() -> list:
|
||||
"""Laufwerke SAMT erkannter Disc — der eine Weg für beide Abnehmer.
|
||||
|
||||
@@ -2932,8 +3057,15 @@ def laufwerke_mit_disc() -> list:
|
||||
nur diese hier.
|
||||
"""
|
||||
geraete = []
|
||||
letzte = {g.get("path"): g for g in LETZTE_LAUFWERKE}
|
||||
for pfad in device_discovery.list_optical_devices():
|
||||
info = device_discovery.device_info(pfad)
|
||||
if _job_haelt_das_laufwerk(pfad) and pfad in letzte:
|
||||
# Nicht anfassen — der letzte bekannte Stand gilt weiter.
|
||||
info = {k: v for k, v in letzte[pfad].items()
|
||||
if k not in ("disc", "disc_wird_erkannt")}
|
||||
else:
|
||||
info = device_discovery.device_info(pfad)
|
||||
_grund_melden(pfad, info.get("grund") or "")
|
||||
disc = DISC_CACHE.get(pfad)
|
||||
if disc and disc.get("_laeuft"):
|
||||
# NICHT als Disc ausgeben — es gibt noch keinen Titel. Aber
|
||||
|
||||
Reference in New Issue
Block a user