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:
Hitonabi
2026-08-30 15:11:00 +02:00
co-authored by Claude Opus 5
parent b6a93aa726
commit d3e86d2641
23 changed files with 1098 additions and 84 deletions
+139 -7
View File
@@ -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
+6 -2
View File
@@ -12,6 +12,8 @@ sondern über eine temporäre credentials-Datei).
import os
import posixpath
from rippy.platform.winlauf import OHNE_FENSTER
import re
import subprocess
import tempfile
@@ -60,7 +62,8 @@ def ist_erreichbar(name: str) -> bool:
ziel = _mountpoint(name)
try:
ergebnis = subprocess.run(
["timeout", "3", "ls", ziel], capture_output=True, timeout=5
["timeout", "3", "ls", ziel], capture_output=True,
creationflags=OHNE_FENSTER, timeout=5
)
return ergebnis.returncode == 0
except (OSError, subprocess.TimeoutExpired):
@@ -89,7 +92,8 @@ def pfad_lage(ziel: str) -> str:
"""
try:
ergebnis = subprocess.run(
["timeout", "4", "ls", "-d", ziel], capture_output=True, timeout=6
["timeout", "4", "ls", "-d", ziel], capture_output=True,
creationflags=OHNE_FENSTER, timeout=6
)
except (OSError, subprocess.TimeoutExpired):
return "unklar"
+7 -3
View File
@@ -44,8 +44,12 @@ UNKLAR = "unklar"
NICHTS = ""
def _meta(meta_json) -> dict:
"""Metadaten lesen, ohne an kaputtem JSON zu scheitern."""
def meta_von(meta_json) -> dict:
"""Metadaten lesen, ohne an kaputtem JSON zu scheitern.
Oeffentlich, seit auch `main.py` sie braucht: Dort muss die Wahl des
Rip-Dialogs (`work_dir`) aus denselben Metadaten gelesen werden.
"""
if isinstance(meta_json, dict):
return meta_json
if not meta_json:
@@ -68,7 +72,7 @@ def retry_art(job) -> str:
"""
if (job.get("status") or "") != "failed":
return NICHTS
marke = _meta(job.get("meta")).get(RIP_FERTIG)
marke = meta_von(job.get("meta")).get(RIP_FERTIG)
if marke is True:
return NEU_KOMPRIMIEREN
if marke is False:
+30 -33
View File
@@ -11,9 +11,6 @@ from typing import Dict, List, Optional
from rippy import drives as _laufwerks_schicht
from rippy.platform.winlauf import OHNE_FENSTER
from rippy.rip.makemkv_aufruf import quelle as makemkv_quelle
from rippy.rip.makemkv_aufruf import text_von
from rippy.tools import katalog as werkzeug_katalog
from clients.tmdb import TMDBClient
from clients.jikan import JikanClient
from clients.musicbrainz import MusicBrainzClient
@@ -359,37 +356,37 @@ class PreScan:
if label:
toc["title"] = normalize_disc_label(label)
# Titel-Quelle 3: die Titelliste von makemkvcon (optional).
# Titel-Quelle 3 (makemkvcon) gibt es hier NICHT mehr.
#
# ⚠️ Über den Werkzeug-Katalog, NICHT über `shutil.which`
# (Befund 28.08.2026). `which` sucht nur im PATH — unter
# Windows liegen Programme in „Programme", nicht im PATH.
# Gemessen: `shutil.which("makemkvcon")` gibt dort auch bei
# installiertem MakeMKV None zurück, und dieser Zweig lief
# NIE. Genau derselbe Fehler, für den es `tools/katalog.py`
# gibt.
makemkv = werkzeug_katalog.finden("makemkv")
if makemkv:
result = subprocess.run(
[makemkv, "-r", "--noscan", "--minlength=300", "info", makemkv_quelle(device_path)],
capture_output=True,
timeout=120, # binaer lesen — Begruendung in text_von
# OHNE_FENSTER: Sonst blitzt bei JEDER Disc-Erkennung
# eine Konsole auf. Rippy laeuft als Fenster-Programm
# ohne eigene Konsole — Windows legt fuer ein
# Konsolenprogramm dann eine NEUE an, und die ist
# sichtbar (Commander 29.08.2026: „nun oeffnen sich
# immer irgendwelche fenster ganz kurz").
creationflags=OHNE_FENSTER,
)
for line in text_von(result.stdout).split('\n'):
if line.startswith('TINFO:'):
parts = line.split(',')
if len(parts) >= 5:
toc["tracks"].append({
"title": parts[4].strip().strip('"') if len(parts) > 4 else "Title",
"duration": int(parts[2]) if len(parts) > 2 else 0
})
# ## Warum sie weg ist (Befund 30.08.2026)
#
# Der Commander: „das erkennen der disk dauert sehr sehr
# lange. Das ging mal viel schneller."
#
# Hier stand ein `makemkvcon -r --noscan --minlength=300
# info` mit 120 s Zeitgrenze — bei jeder eingelegten Disc,
# vor jeder Anzeige. Auf einer Blu-ray dauert dieser Aufruf
# 20 bis 120 Sekunden; solange steht „Disc wird gelesen".
#
# Dass es frueher schnell war, hat einen unschoenen Grund:
# Der Zweig lief unter Windows NIE. Er suchte makemkvcon mit
# `shutil.which`, und das findet unter Windows nichts
# (Programme liegen nicht im PATH). `be3fac5` hat das am
# 28.08.2026 richtig repariert — und damit erst die Kosten
# sichtbar gemacht, die hier immer schon standen.
#
# Der Aufwand war umsonst: Das Ergebnis landete allein in
# `toc["tracks"]`, und **die liest niemand**. Der Rip-Dialog
# holt seine Titelliste ueber `POST /devices/{id}/scan-tracks`
# und `GET /devices/{id}/tracks` — also dann, wenn sie
# gebraucht wird, statt bei jedem Einlegen auf Verdacht.
# Der Weg fuer eine Disc, die man gar nicht rippen will,
# sind so zwei Minuten Warten fuer nichts.
#
# Der Titel kommt aus Quelle 1 und 2 darueber; die sind
# billig. Wer den Aufruf je wieder braucht, braucht dazu
# `makemkv_aufruf.quelle`, `makemkv_aufruf.text_von` und
# `tools.katalog` — die Importe sind mit ihm gegangen.
except Exception as e:
print(f"Pre-Scan TOC Error: {e}")
return toc
+81 -2
View File
@@ -38,6 +38,7 @@ Verwechslungsgefahr gibt es dabei nicht: Roh-Verzeichnisse heißen exakt wie die
Job-ID (vollständige UUID), fertige Ablagen heißen `Titel (Jahr) [kurz-id]`.
"""
import os
import subprocess
from rippy import pfade as _pfade
@@ -48,6 +49,23 @@ RAW_STANDARD = "/app/temp/raw"
MEDIA_ROOT = "/app/media"
def _ui_einstellungen() -> dict:
"""Was in der Oberflaeche eingestellt ist — leer, wenn die Datenbank
gerade nicht antwortet.
Bewusst gekapselt und abgesichert: `wurzeln()` wird auch aus der
Jobliste heraus aufgerufen, die alle vier Sekunden laeuft. Sie darf an
einer klemmenden Datenbank nicht scheitern — dann gilt eben die
Vorgabe, wie bisher.
"""
try:
from rippy import store as db
return db.get_settings(bei_fehler_leer=True) or {}
except Exception: # noqa: BLE001
return {}
def wurzeln(werte=None) -> tuple:
"""`(roh_standard, medien_wurzel, frei)` fuer DIESEN Betrieb.
@@ -70,6 +88,11 @@ def wurzeln(werte=None) -> tuple:
werte = config.laden()
except Exception: # noqa: BLE001
werte = {}
# Dieselbe Bruecke wie in /system/info: Die Oberflaeche legt
# ihre Orte in der Datenbank ab, nicht in der Datei. Ohne sie
# suchte die Rohdaten-Suche unter der Vorgabe statt unter dem,
# was eingestellt ist (Befund 30.08.2026).
werte = betrieb.mit_einstellungen(werte, _ui_einstellungen())
return (betrieb.arbeits_vorgabe(werte) or RAW_STANDARD,
betrieb.medien_wurzel(werte) or MEDIA_ROOT,
betrieb.frei_blaettern(werte))
@@ -99,10 +122,29 @@ def kandidaten(job_id: str, work_dir: str, media_unterordner,
from rippy import pfade
orte = [pfade.verbinden(roh, job_id)]
wahl = (work_dir or "").strip().rstrip("/\\")
# Zum VERGLEICHEN ohne Schluss-Trenner, zum VERBINDEN mit.
#
# ⚠️ Befund 30.08.2026, am Rechner des Commanders nachgerechnet:
# Hier stand `wahl = (work_dir or "").strip().rstrip("/\\")`, und mit
# dieser einen abgestreiften Zeichenkette wurde dann auch VERBUNDEN.
# Sein Arbeitsordner ist `F:\` — ein Laufwerks-Stammverzeichnis:
#
# "F:\\".rstrip("/\\") -> "F:"
# verbinden("F:", job_id) -> "F:1aa41fef-…" isdir: False
# verbinden("F:\\", job_id) -> "F:\1aa41fef-…" isdir: True
#
# `F:` ohne Trenner heisst unter Windows „der aktuelle Ordner auf
# Laufwerk F", nicht die Wurzel — die Falle steht woertlich im Kopf von
# `pfade.verbinden`, und diese Zeile ist hineingetreten. Folge: 16,5 GB
# Rohschnitt unsichtbar, und der Wiederholen-Dialog bot nur „Neu
# rippen" an — Stunden am beschaedigten Datentraeger fuer nichts.
wahl = (work_dir or "").strip()
vergleich = wahl.rstrip("/\\")
grenze = (medien or "").rstrip("/\\")
# Nativ zaehlt jede Wahl — dort liegt der Arbeitsordner oft auf einem
# ganz anderen Laufwerk und damit unter gar keiner Wurzel.
if wahl and (frei or wahl == medien or wahl.startswith(medien + "/")):
if vergleich and (frei or vergleich == grenze
or vergleich.startswith(grenze + "/")):
orte.append(pfade.verbinden(wahl, job_id))
for name in media_unterordner or []:
if name:
@@ -115,6 +157,16 @@ def kandidaten(job_id: str, work_dir: str, media_unterordner,
return eindeutig
def nativ_nachsehen() -> bool:
"""Darf direkt nachgesehen werden, statt einen Prozess dafuer zu starten?
Eigene Funktion, damit beide Zweige ueberall pruefbar sind — dieselbe
Regel wie bei `betrieb.im_container`. Die Begruendung steht in
`pruefen`.
"""
return os.name == "nt"
def pruefen(pfad: str, laufen=None) -> str:
"""Gibt es dieses Verzeichnis? „da" | „weg" | „unklar" — mit HARTER Zeitgrenze.
@@ -131,6 +183,33 @@ def pruefen(pfad: str, laufen=None) -> str:
"""
if not pfad:
return "weg"
if laufen is None and nativ_nachsehen():
# ## Warum Windows hier NICHT den Umweg ueber einen Prozess geht
#
# ⚠️ 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: „nun oeffnen sich diverse fenster im hintergrund,
# gehen ganz kurz auf und dann wieder zu."
#
# `timeout` und `ls` sind Linux-Befehle. Unter Windows GIBT es eine
# `timeout.exe` — sie wartet nur Sekunden ab und kennt weder `ls`
# noch `-d`. Sie braucht aber eine Konsole, und die reisst Windows
# dann auf. Dreifach falsch also: ein Fenster bei jedem Durchlauf,
# ein Prozess fuer nichts, und ein Rueckgabewert ungleich 0 — also
# die Antwort „weg" fuer JEDES Verzeichnis. Rohdaten waren damit
# unter Windows grundsaetzlich unsichtbar.
#
# Der Grund fuer den Umweg gilt hier nicht: Der Kernel-Hang im
# Zustand D (siehe `verzeichnis_da`) ist eine Linux-Eigenheit. Ein
# totes Netzlaufwerk laesst `os.path.isdir` unter Windows mit einem
# Fehler zurueckkommen, nicht unabbrechbar haengen.
try:
return "da" if os.path.isdir(pfad) else "weg"
except OSError:
return "unklar"
starten = laufen or subprocess.run
try:
ergebnis = starten(
+53 -1
View File
@@ -346,7 +346,15 @@ def test_snapshot_liefert_das_ganze_bild(monkeypatch):
monkeypatch.setattr(main.device_discovery, "list_optical_devices", lambda: [])
# Ohne DB liefe system_info() in eine Ausnahme, und die Ampel hat keine
# Datenbank (Lauf 170). Geprueft wird hier die FORM des Schnappschusses.
monkeypatch.setattr(main.db, "get_settings", lambda: {})
#
# `*a, **k`, nicht `lambda: {}` (Befund 30.08.2026): Die echte
# Funktion heisst `get_settings(key="ui", bei_fehler_leer=False)`.
# Der zu enge Doppelgaenger warf `TypeError`, sobald ein Aufrufer
# einen der Parameter benutzte — `system_info` fiel damit aus dem
# Schnappschuss, und dieser Test zeigte auf den Code statt auf sich
# selbst. Ein Doppelgaenger muss die Schnittstelle abbilden, die er
# ersetzt, nicht nur den einen Aufruf, den es gerade gibt.
monkeypatch.setattr(main.db, "get_settings", lambda *a, **k: {})
zustand = asyncio.run(main._snapshot())
@@ -880,3 +888,47 @@ def test_auswurf_bleibt_waehrend_der_kompression_erlaubt():
from rippy import store
assert "transcoding" not in inspect.getsource(store.has_active_job)
def test_laufwerk_bleibt_waehrend_eines_rips_unberuehrt(monkeypatch):
"""Befund 30.08.2026, aus dem Protokoll des Commanders:
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
Der Waechter fragt alle drei Sekunden ab — drei CreateFileW plus IOCTLs
auf ein Geraet, das makemkvcon gerade liest. Sein Befund: das Laufwerk
hoert einfach auf zu lesen. `_auto_prescan` haelt sich seit dem
29.08.2026 an dieselbe Regel; die Laufwerksabfrage tat es nicht.
"""
import main
gefragt = []
monkeypatch.setattr(main.device_discovery, "list_optical_devices",
lambda: ["/dev/sr0"])
monkeypatch.setattr(main.device_discovery, "device_info",
lambda p: gefragt.append(p) or dict({"id": "sr0", "name": "Laufwerk", "type": "bluray", "path": "/dev/sr0", "status": "ready", "model": "X", "serial": "Y", "grund": ""}))
monkeypatch.setattr(main, "LETZTE_LAUFWERKE", [dict({"id": "sr0", "name": "Laufwerk", "type": "bluray", "path": "/dev/sr0", "status": "ready", "model": "X", "serial": "Y", "grund": ""})])
monkeypatch.setattr(main.db, "has_active_job", lambda p: True)
stand = main.laufwerke_mit_disc()
assert gefragt == [], "waehrend eines Rips darf niemand das Laufwerk anfassen"
assert stand[0]["type"] == "bluray" # letzter bekannter Stand gilt
def test_ohne_rip_wird_das_laufwerk_normal_abgefragt(monkeypatch):
"""Die Ausnahme darf nur fuer den laufenden Rip gelten."""
import main
gefragt = []
monkeypatch.setattr(main.device_discovery, "list_optical_devices",
lambda: ["/dev/sr0"])
monkeypatch.setattr(main.device_discovery, "device_info",
lambda p: gefragt.append(p) or dict({"id": "sr0", "name": "Laufwerk", "type": "bluray", "path": "/dev/sr0", "status": "ready", "model": "X", "serial": "Y", "grund": ""}))
monkeypatch.setattr(main, "LETZTE_LAUFWERKE", [dict({"id": "sr0", "name": "Laufwerk", "type": "bluray", "path": "/dev/sr0", "status": "ready", "model": "X", "serial": "Y", "grund": ""})])
monkeypatch.setattr(main.db, "has_active_job", lambda p: False)
main.laufwerke_mit_disc()
assert gefragt == ["/dev/sr0"]
+67
View File
@@ -58,6 +58,36 @@ def test_media_root_selbst_ist_erlaubt():
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"]) == []
@@ -237,3 +267,40 @@ def test_suche_mit_status_findet_trotz_unklarem_anderen_ort():
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"