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"
+49 -6
View File
@@ -256,12 +256,33 @@ def _arbeitsverzeichnis(einstellungen: dict, job_wahl: str = "",
def _frei_bytes(pfad: str) -> int:
"""Freier Platz am Pfad (nächster existierender Elternordner zählt)."""
kandidat = pfad
while kandidat and not os.path.exists(kandidat):
kandidat = os.path.dirname(kandidat)
"""Freier Platz am Pfad (nächster existierender Elternordner zählt).
## Warum die Suche nach oben NICHT hier steht (Befund 30.08.2026)
An dieser Stelle stand sie als eigene Schleife:
while kandidat and not os.path.exists(kandidat):
kandidat = os.path.dirname(kandidat)
Unter Windows gibt `os.path.dirname("Q:\\")` **sich selbst** zurueck an
einem freien Laufwerksbuchstaben nachgemessen. Zeigt das
Arbeitsverzeichnis oder ein Ablageziel auf ein Laufwerk, das gerade
nicht da ist (abgezogene USB-Platte, getrennte Netzlaufwerks-
Zuordnung), dreht diese Schleife **fuer immer**. Und zwar in
`_platz_pruefen`, also VOR dem Rip: Der Job bliebe ohne eine einzige
Meldung stehen, und im Protokoll stuende nichts, womit man das
aufklären koennte.
`pfade.naechster_vorhandener` macht dasselbe seit V2-1 richtig mit
Abbruch bei `eltern == pfad` und einer `gesehen`-Menge gegen Zyklen.
Es war die ganze Zeit da. Dieselbe Entscheidung an zwei Orten, einer
davon veraltet: genau das Muster aus dem Kopf von `makemkv_aufruf.py`.
"""
from rippy import pfade
try:
return shutil.disk_usage(kandidat or "/").free
return shutil.disk_usage(pfade.naechster_vorhandener(pfad) or "/").free
except OSError:
return -1
@@ -612,7 +633,14 @@ def rippen(device_path: str, job_id: str, target_dir: str = None,
if code == 1003 or len(gesehen) >= MAX_MELDUNGEN or text in gesehen:
return
gesehen.add(text)
db.add_log("info", "makemkv", f"Job {job_id}: {text[:300]}")
# Die MSG-NUMMER gehoert dazu (Befund 30.08.2026). MakeMKVs Texte
# sind uebersetzt, die Nummern nicht — sie sind die einzige
# verlaessliche Kennung (so arbeitet KRITISCHE_CODES). Ohne sie
# war an einem Lesefehler-Protokoll nicht abzulesen, WELCHE
# Meldung MakeMKV geschickt hatte; die Erkennung musste sich
# ersatzweise an einer URL im Text festhalten.
db.add_log("info", "makemkv",
f"Job {job_id}: MSG {code}{text[:300]}")
einstellungen = db.get_settings(bei_fehler_leer=True)
ist_video = disc_type in ("dvd", "bluray", "uhd")
@@ -740,6 +768,21 @@ def rippen(device_path: str, job_id: str, target_dir: str = None,
"Normale BD/DVD gehen weiterhin."
)
# Lesefehler laut sagen — auch (und gerade) wenn der Rip als Erfolg
# endet. Befund 30.08.2026: MakeMKV sicherte 1 von 2 Titeln, meldete
# sich mit Code 0, und Rippy schrieb „Rip fertig". Dass ein Titel
# fehlt, stand nur in den MakeMKV-Zeilen. Wer die nicht liest, haelt
# eine halbe Disc für eine ganze.
if ergebnis.get("lesefehler"):
db.add_log(
"warning", "worker",
f"Job {job_id}: Die Disc hat Lesefehler — MakeMKV kam an "
"mehreren Stellen nicht durch. Es kann sein, dass ein Titel "
"fehlt oder unvollständig ist. Abhilfe: Disc reinigen (radial "
"von innen nach außen, nicht kreisend) oder ein anderes "
"Laufwerk probieren — Laufwerke unterscheiden sich hier stark.",
)
# Automatischer Auswurf. Die Disc ist nach dem Rip nicht mehr nötig — die
# Kompression arbeitet auf der Datei, nicht am Laufwerk.
#
+5 -4
View File
@@ -11,6 +11,7 @@ import re
import subprocess
from rippy.platform.winlauf import OHNE_FENSTER
from rippy.rip.handbrake_aufruf import HB_LESEN
from rippy.tools import katalog as werkzeuge
@@ -191,7 +192,7 @@ def hole_handbrake_presets() -> list:
try:
aus = subprocess.run(
[_hb(), "--preset-list"],
capture_output=True, text=True, timeout=30,
capture_output=True, timeout=30, **HB_LESEN,
creationflags=OHNE_FENSTER,
)
return parse_preset_liste((aus.stdout or "") + (aus.stderr or ""))
@@ -304,8 +305,8 @@ def hole_handbrake_hilfe() -> str:
return ""
try:
aus = subprocess.run(
[_hb(), "--help"], capture_output=True, text=True, timeout=30,
creationflags=OHNE_FENSTER
[_hb(), "--help"], capture_output=True, timeout=30,
creationflags=OHNE_FENSTER, **HB_LESEN
)
return (aus.stdout or "") + (aus.stderr or "")
except (OSError, subprocess.TimeoutExpired):
@@ -403,7 +404,7 @@ def werkzeug_versionen() -> dict:
try:
aus = subprocess.run(
[_hb(), "--version"],
capture_output=True, text=True, timeout=15,
capture_output=True, timeout=15, **HB_LESEN,
creationflags=OHNE_FENSTER,
)
treffer = re.search(r"HandBrake\s+([\w.]+)", (aus.stdout or "") + (aus.stderr or ""))
+148 -8
View File
@@ -37,6 +37,7 @@ from rippy.drives.linux import ( # noqa: F401
)
from rippy.drives.linux import auswerfen_versuchen as wirf_disc_aus # noqa: F401
from rippy.platform.winlauf import OHNE_FENSTER
from rippy.rip.handbrake_aufruf import HB_LESEN
from rippy.rip.makemkv_aufruf import KRITISCHE_CODES, text_von
from rippy.rip.makemkv_aufruf import quelle as makemkv_quelle
from rippy.tools import katalog as werkzeuge
@@ -88,6 +89,24 @@ def check_cdparanoia_installed() -> bool:
return shutil.which("cdparanoia") is not None
#: Sprachunabhaengige Marke fuer „die Disc liess sich stellenweise nicht lesen".
#:
#: Am 30.08.2026 im Protokoll des Commanders abgelesen (Spartacus Disc 2,
#: deutschsprachiges MakeMKV):
#:
#: Encountered 29 errors of type 'Read Error' - see
#: http://www.makemkv.com/errors/read/
#: Das Kopieren wurde abgeschlossen. 1 Titel wurden gesichert, 1 schlugen fehl.
#:
#: Die URL steht auch in der deutschen Fassung englisch da — sie ist damit die
#: einzige Stelle dieser Meldung, auf die Verlass ist. Die MSG-NUMMER waere
#: besser (so macht es KRITISCHE_CODES), aber sie war im Protokoll nicht zu
#: sehen: `melde_makemkv` schrieb sie bis heute nicht mit. Das ist behoben —
#: beim naechsten Lesefehler steht die Nummer im Log, und dann gehoert sie
#: hierher statt dieser Textsuche.
LESEFEHLER_MARKE = "makemkv.com/errors/read"
def build_makemkv_cmd(device_path: str, output_dir: str, titel: str = "all") -> list:
"""Baut das MakeMKV-Kommando (pure Funktion, testbar).
@@ -273,6 +292,9 @@ def rip_titel_auswahl(device_path: str, output_dir: str, titel_liste: list,
Titel oder 'all' also ein Aufruf je Titel, Fortschritt anteilig)."""
gesamt = len(titel_liste)
alle_dateien = []
# Ein Lesefehler in Titel 1 darf nicht verschwinden, nur weil Titel 2
# sauber durchlief — je Titel laeuft ein eigener makemkvcon.
lesefehler = False
for index, nr in enumerate(titel_liste):
def anteilig(p, _i=index):
if progress_cb:
@@ -280,13 +302,16 @@ def rip_titel_auswahl(device_path: str, output_dir: str, titel_liste: list,
ergebnis = run_makemkv(device_path, output_dir, progress_cb=anteilig, titel=str(nr),
log_cb=log_cb)
lesefehler = lesefehler or bool(ergebnis.get("lesefehler"))
if ergebnis.get("status") == "cancelled":
return ergebnis
if ergebnis.get("status") != "success":
ergebnis["error"] = f"Titel {nr}: {ergebnis.get('error')}"
ergebnis["lesefehler"] = lesefehler
return ergebnis
alle_dateien = ergebnis.get("files", []) # kumulativ: run_makemkv listet den Ordner
return {"status": "success", "output_dir": output_dir, "files": alle_dateien}
return {"status": "success", "output_dir": output_dir, "files": alle_dateien,
"lesefehler": lesefehler}
def laengster_titel(dauern: dict, meta: dict = None):
@@ -362,7 +387,7 @@ def lies_datei_dauer(pfad: str, timeout: int = 120) -> int:
try:
ergebnis = subprocess.run(
[werkzeug("handbrake"), "--scan", "-i", pfad],
capture_output=True, text=True, timeout=timeout,
capture_output=True, timeout=timeout, **HB_LESEN,
creationflags=OHNE_FENSTER,
)
except (OSError, subprocess.TimeoutExpired):
@@ -507,7 +532,7 @@ def build_handbrake_cmd(input_path: str, output_path: str,
Sprachen drin und das ist bei einer verlustfreien Ablage richtig.
`--audio-lang-list` zusammen mit `--first-audio` heißt: HandBrake pickt pro
Sprache genau die erste (beste) Tonspur heraus. `--audio-codec copy` reicht
Sprache genau die erste (beste) Tonspur heraus. `--aencoder copy` reicht
diese dann verlustfrei durch, statt sie auf Stereo herunterzurechnen.
"""
befehl = [
@@ -539,7 +564,23 @@ def build_handbrake_cmd(input_path: str, output_path: str,
if audio:
befehl += ["--audio-lang-list", ",".join(audio)]
befehl.append("--first-audio")
befehl += ["--audio-codec", "copy", "--audio-fallback", "av_aac"]
# `--aencoder`, NICHT `--audio-codec` (Befund 30.08.2026).
#
# Den Schalter `--audio-codec` gibt es bei HandBrakeCLI nicht und hat es
# nie gegeben — er heisst `-E` / `--aencoder`. Am mitgelieferten
# HandBrakeCLI 1.11.2 nachgestellt, mit Rippys eigener Befehlszeile:
#
# unknown option (--audio-codec)
# HandBrake has exited. $? = 0
#
# Ein ganzer Blu-ray-Rip (16,5 GB) lief damit ins Leere: HandBrake war
# in derselben Sekunde wieder weg, in der es startete, und meldete das
# als ERFOLG. Der Test darunter forderte den falschen Namen sogar ein
# (`assert "--audio-codec" in cmd`) — ein Test, der einen Fehler
# festschreibt, statt ihn zu finden.
#
# `copy` ist als Wert gueltig (in `--help` gelistet, ebenda geprueft).
befehl += ["--aencoder", "copy", "--audio-fallback", "av_aac"]
untertitel = [s for s in (untertitel_sprachen or []) if s]
if untertitel:
befehl += ["--subtitle-lang-list", ",".join(untertitel)]
@@ -644,9 +685,9 @@ def run_handbrake(input_path: str, output_path: str, preset: str = DEFAULT_HB_PR
audio_sprachen, untertitel_sprachen),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
creationflags=OHNE_FENSTER,
**HB_LESEN,
)
return _handbrake_schleife(process, output_path, abbruch_cb, progress_cb)
except Exception as e:
@@ -673,10 +714,67 @@ def unbekanntes_preset(zeile: str) -> str:
return text[len(kopf):].strip() if text.startswith(kopf) else ""
#: Wie viele Ausgabezeilen von HandBrake fuer den Fehlerfall aufgehoben werden.
HB_ZEILEN_PUFFER = 12
#: ... und wie viele davon in die Fehlermeldung wandern. Sie steht in der
#: Jobzeile und im UI; zwoelf Zeilen Muxer-Statistik waeren dort unlesbar.
HB_ZEILEN_MELDUNG = 4
_UNBEKANNTER_SCHALTER = re.compile(r"unknown option \(([^)]*)\)")
def unbekannter_schalter(zeile: str) -> str:
"""Meldet diese Zeile einen Schalter, den DIESES HandBrake nicht kennt?
## Der Befund des Commanders (30.08.2026)
> Kompression fehlgeschlagen bei Spartacus _t01.mkv: HandBrake endete
> mit Code 0 Roh-Datei bleibt erhalten"
16,5 GB Rohschnitt, und die Kompression war in derselben Sekunde vorbei,
in der sie begann fuer einen Scan-Durchlauf haette das nicht gereicht.
Am mitgelieferten HandBrakeCLI 1.11.2 nachgestellt, mit genau der
Befehlszeile, die Rippy baute:
unknown option (--audio-codec)
HandBrake has exited.
$? = 0
**Der Rueckgabewert ist 0.** HandBrake meldet einen Tippfehler in seiner
eigenen Befehlszeile als ERFOLG. Rippy sah nur Code 0" und keine Datei —
und riet daraufhin auf Zielordner nicht beschreibbar". Das war falsch,
und es schickte die Suche in die vollkommen falsche Richtung.
Der Schalter ist repariert (siehe `build_handbrake_cmd`). Diese Pruefung
bleibt trotzdem: Der naechste falsche Schalter soll sich SELBST melden,
statt wieder einen ganzen Rip zu kosten.
"""
treffer = _UNBEKANNTER_SCHALTER.search(zeile or "")
return treffer.group(1).strip() if treffer else ""
def hb_schluss(zeilen) -> str:
"""HandBrakes letzte Worte als Anhang fuer eine Fehlermeldung (pure).
Bis zum 30.08.2026 warf `_handbrake_schleife` jede Zeile weg, die kein
Fortschritt war. Im Fehlerfall blieb damit nur der Rueckgabewert uebrig
und wenn der 0 ist, sagt er nichts. Der Grund stand die ganze Zeit in der
Ausgabe, nur hoerte niemand zu.
"""
sauber = [z.strip() for z in (zeilen or []) if z and z.strip()]
if not sauber:
return ""
return " — HandBrake sagte zuletzt: " + " | ".join(sauber[-HB_ZEILEN_MELDUNG:])
def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb=None) -> dict:
"""Liest HandBrakes Ausgabe und wertet sie aus. Eigene Funktion, damit die
Reihenfolge (Abbruch VOR Fortschritt) ohne echtes HandBrake testbar ist."""
falsches_preset = ""
falscher_schalter = ""
# Die letzten Zeilen aufheben — im Fehlerfall sind sie die einzige
# Auskunft, die es ueberhaupt gibt (siehe `hb_schluss`).
letzte_zeilen = []
try:
for line in process.stdout:
# Zuerst der Abbruch — unabhängig davon, ob die Zeile überhaupt
@@ -684,8 +782,14 @@ def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb=
# sich die Prozentzahl bewegt (Befund 25.07.2026).
if abbruch_cb:
abbruch_cb()
if line.strip():
letzte_zeilen.append(line)
if len(letzte_zeilen) > HB_ZEILEN_PUFFER:
del letzte_zeilen[0]
if not falsches_preset:
falsches_preset = unbekanntes_preset(line)
if not falscher_schalter:
falscher_schalter = unbekannter_schalter(line)
progress = get_progress_from_line(line)
# >= 0: ein echtes 0 % ist eine Angabe und muss durch. Der alte
# Filter `> 0` verwarf den gesamten ersten Prozentpunkt — bei
@@ -702,6 +806,24 @@ def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb=
if process.returncode == 0 and os.path.exists(output_path):
return {"status": "success", "output_path": output_path}
# Ein Schalter, den DIESES HandBrake nicht kennt, ist ein Fehler in
# RIPPY — und er kommt mit Rueckgabewert 0 daher (siehe
# `unbekannter_schalter`). Deshalb steht die Pruefung VOR allen
# anderen: sonst landet der Fall unten bei „HandBrake endete mit Code
# 0", und dort ist er nicht zu erraten. Genau das kostete am
# 30.08.2026 einen fertigen 16,5-GB-Rip.
if falscher_schalter:
return {
"status": "error",
"error": (
'Rippy hat HandBrake den Schalter „%s" übergeben, den '
'diese HandBrake-Fassung nicht kennt. Das ist ein Fehler '
'in Rippy, keine Einstellung — bitte melden.' % falscher_schalter
+ hb_schluss(letzte_zeilen)
),
"return_code": process.returncode,
}
# Code 0, aber die Datei fehlt: HandBrake hat sie woanders hingeschrieben.
#
# Commander 29.08.2026: „Kompression fehlgeschlagen bei title_t00.mkv:
@@ -721,11 +843,17 @@ def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb=
"(der Container kommt aus dem Preset)."
% (os.path.basename(daneben),
os.path.basename(output_path))}
# Frueher stand hier geraten „Meist ist der Zielordner nicht
# beschreibbar". Am 30.08.2026 war das falsch (der Ordner war da
# und leer, der Grund ein falscher Schalter) — und die Vermutung
# schickte die Suche in die falsche Richtung. Jetzt wird der
# Ordner GENANNT und HandBrake selbst zitiert.
return {
"status": "error",
"error": ("HandBrake meldet Erfolg, aber es ist keine Datei "
"entstanden. Meist ist der Zielordner nicht "
"beschreibbar: %s" % os.path.dirname(output_path)),
"entstanden (Zielordner: %s)."
% os.path.dirname(output_path)
+ hb_schluss(letzte_zeilen)),
"return_code": 0,
}
if falsches_preset:
@@ -742,7 +870,8 @@ def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb=
}
return {
"status": "error",
"error": f"HandBrake endete mit Code {process.returncode}",
"error": (f"HandBrake endete mit Code {process.returncode}"
+ hb_schluss(letzte_zeilen)),
"return_code": process.returncode,
}
@@ -862,6 +991,13 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
)
letzte_meldung = ""
# Lesefehler sind KEIN Abbruchgrund — MakeMKV ueberspringt den
# kaputten Titel und macht mit dem naechsten weiter. Genau deshalb
# muessen sie gesagt werden: Am 30.08.2026 endete ein Rip als
# „erfolgreich", obwohl von zwei Titeln nur einer ankam. Im
# Jobprotokoll stand „Rip fertig" — dass ein Titel fehlt, war nur
# den MakeMKV-Zeilen zu entnehmen, die niemand liest.
lesefehler = False
# Kritische Meldungen einsammeln: die LETZTE Zeile ist fast immer nur
# "Failed to open disc" — die URSACHE ("volume key is unknown", Key
# abgelaufen) steht Zeilen davor und ging im Fehlertext verloren
@@ -891,6 +1027,8 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
if meldung is None:
continue
code, letzte_meldung = meldung
if LESEFEHLER_MARKE in letzte_meldung:
lesefehler = True
if code in KRITISCHE_CODES:
kritische_meldungen.append(
"%s (%s)" % (KRITISCHE_CODES[code], letzte_meldung.strip()))
@@ -922,9 +1060,11 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
"output_dir": output_dir,
"files": mkv_dateien,
"return_code": process.returncode,
"lesefehler": lesefehler,
}
return {
"status": "error",
"lesefehler": lesefehler,
"error": (
f"makemkvcon endete mit Code {process.returncode}"
+ (f" — Ursache: {'; '.join(kritische_meldungen)}" if kritische_meldungen else "")
+93 -3
View File
@@ -13,7 +13,9 @@ from ripping import (
build_makemkv_cmd,
get_progress_from_line,
get_progress_from_prgv,
hb_schluss,
parse_msg,
unbekannter_schalter,
write_abcde_config,
)
@@ -49,10 +51,56 @@ def test_handbrake_cmd_arbeitet_auf_datei_nicht_geraet():
assert cmd[cmd.index("--output") + 1] == "/app/media/bluray/x/t00.mkv"
assert "--preset" in cmd
assert "--first-audio" in cmd # beste Spur pro Sprache behalten
assert "--audio-codec" in cmd
assert "--aencoder" in cmd
assert "--all-subtitles" in cmd
def test_handbrake_kennt_keinen_schalter_audio_codec():
"""Der Schalter heißt `--aencoder`. `--audio-codec` gibt es nicht.
Befund 30.08.2026, am mitgelieferten HandBrakeCLI 1.11.2 gemessen:
unknown option (--audio-codec)
HandBrake has exited. $? = 0
Rippy baute genau diesen Befehl. HandBrake stieg sofort aus und meldete
das mit Rückgabewert 0 als ERFOLG ein fertiger 16,5-GB-Rip lief damit
ins Leere, ohne dass irgendwo ein Grund stand.
Bis dahin stand hier `assert "--audio-codec" in cmd`: ein Test, der
den Fehler festschrieb, statt ihn zu finden. Ein Kommandozeilen-Schalter
ist eine externe Schnittstelle (AGENTS Regel D) er gehört am echten
Programm gemessen, nicht aus dem Gedächtnis behauptet.
"""
cmd = build_handbrake_cmd("/tmp/a.mkv", "/tmp/b.mkv")
assert "--audio-codec" not in cmd
assert cmd[cmd.index("--aencoder") + 1] == "copy"
def test_unbekannter_schalter_wird_erkannt():
"""Wortlaut aus dem echten Lauf (30.08.2026, HandBrakeCLI 1.11.2)."""
assert unbekannter_schalter(
"unknown option (--audio-codec)") == "--audio-codec"
# Mit Zeitstempel davor — HandBrake stellt vielen Zeilen einen voran.
assert unbekannter_schalter(
"[13:30:54] unknown option (--gibt-es-nicht)") == "--gibt-es-nicht"
assert unbekannter_schalter("Encoding: task 1 of 1, 5.00 %") == ""
assert unbekannter_schalter("") == ""
assert unbekannter_schalter(None) == ""
def test_hb_schluss_haengt_handbrakes_letzte_worte_an():
"""Ohne sie stand im Fehlerfall nur der Rückgabewert da — und wenn der
0 ist, sagt er nichts (Befund 30.08.2026)."""
assert hb_schluss([]) == ""
assert hb_schluss(None) == ""
assert hb_schluss([" ", " "]) == ""
text = hb_schluss(["eins", "zwei", "drei", "vier", "fünf"])
assert "fünf" in text and "vier" in text
# Nur die letzten HB_ZEILEN_MELDUNG — sonst steht Muxer-Statistik im UI.
assert "eins" not in text
def test_handbrake_progress_parsing():
# Testfund 22.07.: echtes HandBrake schreibt 45.50 % MIT Leerzeichen
assert get_progress_from_line("Encoding: task 1 of 1, 45.50 %") == 45
@@ -375,7 +423,13 @@ def test_falsches_preset_erklaert_den_fehlschlag_statt_nur_den_code():
assert ergebnis["return_code"] == 3
def test_fehler_ohne_preset_problem_bleibt_der_alte():
def test_fehler_ohne_preset_problem_nennt_handbrakes_letzte_worte():
"""Der Code allein reicht nicht — HandBrake selbst muss zu Wort kommen.
Bis zum 30.08.2026 stand hier nur HandBrake endete mit Code N", und
jede Ausgabezeile wurde weggeworfen. Bei Code 0 (den HandBrake auch
für Fehler vergibt) blieb damit gar keine Auskunft übrig.
"""
import ripping
class FakeProcess:
@@ -390,7 +444,43 @@ def test_fehler_ohne_preset_problem_bleibt_der_alte():
return 1
ergebnis = ripping._handbrake_schleife(FakeProcess(), "/gibt-es-nicht.mkv")
assert ergebnis["error"] == "HandBrake endete mit Code 1"
assert ergebnis["error"].startswith("HandBrake endete mit Code 1")
assert "irgendwas ganz anderes" in ergebnis["error"]
assert ergebnis["return_code"] == 1
def test_unbekannter_schalter_schlaegt_den_nichtssagenden_code_null():
"""Der Fall vom 30.08.2026, nachgestellt: HandBrake steigt an einem
Schalter aus, den es nicht kennt, und meldet das mit 0 als Erfolg.
Ohne diese Erkennung landete er unten bei HandBrake meldet Erfolg,
aber es ist keine Datei entstanden" samt der falschen Vermutung
Zielordner nicht beschreibbar" — und die schickte die Suche in die
vollkommen falsche Richtung.
"""
import ripping
class FakeProcess:
def __init__(self):
self.stdout = iter([
"[13:30:54] hb_init: starting libhb thread\n",
"unknown option (--audio-codec)\n",
"HandBrake has exited.\n",
])
self.returncode = 0
def kill(self):
pass
def wait(self):
return 0
ergebnis = ripping._handbrake_schleife(FakeProcess(), "/gibt-es-nicht.mkv")
assert ergebnis["status"] == "error"
assert "--audio-codec" in ergebnis["error"]
assert "Fehler in Rippy" in ergebnis["error"]
# ... und NICHT die alte Vermutung über den Zielordner
assert "beschreibbar" not in ergebnis["error"]
# --- Sprachen der Disc: gemessen an der Akira-Blu-ray (26.07.2026) -----------
+38
View File
@@ -292,6 +292,44 @@ def windows_laufwerke(art=None, buchstaben=None) -> list:
return gefunden
def mit_einstellungen(werte: dict, einstellungen: dict) -> dict:
r"""Die in der Oberflaeche gesetzten Orte in die Betriebs-Werte legen.
## Warum es diese Bruecke braucht (Befund 30.08.2026)
Der Commander: die verzeichnise sind andere als dort steht."
Rippy hat ZWEI Speicher fuer dieselbe Frage:
Oberflaeche -> Datenbank, Schluessel `outputDir` und `workDir`
Betrieb -> Konfigurationsdatei, `storage.medien` / `storage.temp`
Geschrieben wird nur der erste die Einstellungsseite kennt die
Konfigurationsdatei gar nicht. `ablage_vorgabe` und `arbeits_vorgabe`
lesen aber den zweiten, und der ist leer. Sie fielen deshalb IMMER auf
die Vorgabe zurueck: In Einstellungen -> System" standen dauerhaft
`\Videos\Rippy` und `\Videos\Rippy\_arbeit`, egal was
eingestellt war waehrend der Rip in Wahrheit nach `F:\` lief.
Der Worker macht es richtig herum: `_arbeitsverzeichnis()` liest
`workDir` aus der Datenbank und faellt erst DANN auf die Vorgabe
zurueck. Diese Funktion stellt dieselbe Reihenfolge fuer alle her, die
ueber `betrieb` fragen statt sie ein drittes Mal nachzubauen.
Pure Funktion: Sie nimmt beide Woerterbuecher und gibt ein neues zurueck.
"""
lager = dict(((werte or {}).get("storage") or {}))
ablage = ((einstellungen or {}).get("outputDir") or "").strip()
arbeit = ((einstellungen or {}).get("workDir") or "").strip()
if ablage:
lager["medien"] = ablage
if arbeit:
lager["temp"] = arbeit
zusammen = dict(werte or {})
zusammen["storage"] = lager
return zusammen
def ablage_vorgabe(werte: dict, container: bool, system: str) -> str:
"""Wohin Rippy standardmäßig ablegt — je Betrieb ein anderer Ort."""
eigen = ((werte or {}).get("storage", {}) or {}).get("medien", "")
+14 -3
View File
@@ -269,19 +269,27 @@ def device_info(device_path: str) -> dict:
vendor = read_sys_attr(name, "vendor")
model = read_sys_attr(name, "model")
grund = ""
try:
status_code = drive_status(device_path)
except OSError:
except OSError as e:
status_code = -1
grund = "Das Laufwerk antwortet nicht (%s)." % (e.strerror or e)
disc_type = "unknown"
status = "empty"
# Ein Laufwerk, das sich nicht ansprechen laesst, ist nicht LEER —
# man weiss es nur nicht. Der Windows-Treiber haelt sich seit V2-1
# daran („DIE Regel des Ports", siehe dort); hier stand weiterhin
# „empty", und damit behauptete derselbe Ereignisstrom je nach
# Plattform etwas anderes ueber dieselbe Lage (Befund 30.08.2026).
status = "unknown" if status_code < 0 else "empty"
if status_code == CDS_DISC_OK:
status = "ready"
try:
disc_type = classify(disc_status(device_path), disc_size_bytes(device_path))
except OSError:
except OSError as e:
disc_type = "unknown"
grund = "Die Disc liess sich nicht einordnen (%s)." % (e.strerror or e)
return {
"id": name,
@@ -291,4 +299,7 @@ def device_info(device_path: str) -> dict:
"status": status,
"model": model,
"serial": read_sys_attr(name, "wwid"),
# Gleiche Felder wie im Windows-Treiber — das UI unterscheidet
# nicht nach Plattform (siehe test_windows: Feld-Parität).
"grund": grund,
}
+33 -1
View File
@@ -186,6 +186,37 @@ def test_unzugaengliches_laufwerk_ist_UNBEKANNT_und_nicht_leer():
assert windows.device_info(r"\\.\D:", api=api)["status"] == "unknown"
def test_unzugaengliches_laufwerk_sagt_auch_WARUM():
"""Befund 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 kam weiter
durch. Im UI stand eine vollstaendige Laufwerkskarte mit Modell und
Seriennummer und unknown" ohne ein Wort dazu. Rippy kannte den
Grund und behielt ihn fuer sich.
"""
api = FakeLaufwerk(oeffnen_fehler=windows.Win32Fehler(
"CreateFileW", w.ERROR_INVALID_FUNCTION))
info = windows.device_info(r"\\.\D:", api=api)
assert info["status"] == "unknown"
assert "auswerfen" in info["grund"]
def test_grund_bleibt_leer_solange_alles_geht():
"""Ein Feld, das immer gefuellt ist, sagt nichts mehr."""
api = FakeLaufwerk(medium=True, disk_flags=w.CDROM_DISK_DATA_TRACK,
groesse=25 * 1024**3)
assert windows.device_info(r"\\.\D:", api=api)["grund"] == ""
def test_unbekannte_fehlernummer_wird_GENANNT():
"""Eine Nummer, nach der man suchen kann, ist mehr als ein leerer Satz."""
text = windows.zugriffs_grund(windows.Win32Fehler("CreateFileW", 4711))
assert "4711" in text
assert windows.zugriffs_grund(OSError("ohne Nummer"))
@pytest.mark.parametrize("flags,groesse,erwartet", [
(w.CDROM_DISK_AUDIO_TRACK, 700 * 1024**2, "cd"),
(w.CDROM_DISK_DATA_TRACK, 8 * 1024**3, "dvd"),
@@ -215,7 +246,8 @@ def test_device_info_hat_dieselben_felder_wie_unter_linux():
Karte im Dashboard leer."""
api = FakeLaufwerk(medium=True, disk_flags=w.CDROM_DISK_DATA_TRACK, groesse=25 * 1024**3)
eintrag = windows.device_info(r"\\.\D:", api=api)
assert set(eintrag) == {"id", "name", "type", "path", "status", "model", "serial"}
assert set(eintrag) == {"id", "name", "type", "path", "status", "model",
"serial", "grund"}
assert eintrag["id"] == "D"
assert eintrag["status"] == "ready"
assert eintrag["type"] == "bluray"
+1
View File
@@ -117,6 +117,7 @@ ERROR_NOT_READY = 21 # kein Medium eingelegt
ERROR_ACCESS_DENIED = 5
ERROR_FILE_NOT_FOUND = 2
ERROR_INVALID_FUNCTION = 1 # Gerät kennt diesen Steuercode nicht
ERROR_NOT_SUPPORTED = 50 # Geraet lehnt den Steuercode gerade ab
ERROR_MEDIA_CHANGED = 1110
ERROR_NO_MEDIA_IN_DRIVE = 1112
+114 -3
View File
@@ -154,6 +154,7 @@ class Win32:
self._ctypes = ctypes
self._wintypes = wintypes
self._k32 = ctypes.WinDLL("kernel32", use_last_error=True)
_typen_erklaeren(ctypes, wintypes, self._k32)
# ── Laufwerke finden ────────────────────────────────────────────────
def laufwerksbuchstaben(self) -> list:
@@ -174,7 +175,12 @@ class Win32:
0,
None,
)
if handle == w.INVALID_HANDLE_VALUE or handle in (0, None):
# Mit erklaertem `restype` ist der Fehlerwert nicht mehr -1, sondern
# 0xFFFFFFFFFFFFFFFF — siehe `_typen_erklaeren`. Beides pruefen: die
# nackte -1 bliebe sonst als tote Bedingung stehen und taeuschte
# Absicherung vor.
ungueltig = self._ctypes.c_void_p(-1).value
if handle in (None, 0, ungueltig, w.INVALID_HANDLE_VALUE):
raise Win32Fehler("CreateFileW", self._ctypes.get_last_error())
return handle
@@ -200,6 +206,48 @@ class Win32:
return aus_puffer.raw[:zurueck.value] if aus_puffer else b""
def _typen_erklaeren(ctypes, wintypes, k) -> None:
r"""Den kernel32-Funktionen ihre echten Typen beibringen.
## Warum das keine Formsache ist (Befund 30.08.2026)
Ohne `restype` nimmt ctypes `c_int` an 32 Bit, mit Vorzeichen. Ein
Windows-HANDLE ist auf einem 64-Bit-System aber ein Zeiger, und dasselbe
gilt fuer den Weg HINEIN: Ein Handle, das ohne `argtypes` uebergeben wird,
geht als `c_int` durch. Solange Windows kleine Handle-Werte vergibt das
tut es meistens faellt nichts auf. Oberhalb von 2^31 wird still
abgeschnitten, und das folgende `DeviceIoControl` arbeitet auf einem
Handle, das es nie gab.
Dieselbe Lehre steht seit dem 29.08.2026 im Kopf von
`platform/winlauf.py` (dort die Job-Objekte). Hier war sie noch nicht
angekommen und das ist der Treiber, durch den JEDE Disc-Erkennung und
jeder Auswurf laeuft.
Mit `restype` aendert sich der FEHLERWERT von `CreateFileW`: Es gibt
`(HANDLE)-1` zurueck, und als Zeiger gelesen ist das
0xFFFFFFFFFFFFFFFF, nicht -1. Wer nur die Typen erklaert und die alte
Pruefung stehen laesst, legt damit die Fehlerbehandlung still ein
fehlgeschlagenes Oeffnen saehe aus wie ein Erfolg. Siehe `Win32.oeffnen`.
"""
k.CreateFileW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD,
ctypes.c_void_p, wintypes.DWORD, wintypes.DWORD,
wintypes.HANDLE]
k.CreateFileW.restype = wintypes.HANDLE
k.CloseHandle.argtypes = [wintypes.HANDLE]
k.CloseHandle.restype = wintypes.BOOL
k.DeviceIoControl.argtypes = [wintypes.HANDLE, wintypes.DWORD,
ctypes.c_void_p, wintypes.DWORD,
ctypes.c_void_p, wintypes.DWORD,
ctypes.POINTER(wintypes.DWORD),
ctypes.c_void_p]
k.DeviceIoControl.restype = wintypes.BOOL
k.GetLogicalDrives.argtypes = []
k.GetLogicalDrives.restype = wintypes.DWORD
k.GetDriveTypeW.argtypes = [wintypes.LPCWSTR]
k.GetDriveTypeW.restype = wintypes.UINT
def _api(api):
return api if api is not None else Win32()
@@ -464,6 +512,63 @@ def pfad_zu_kennung(name: str, geraete=None) -> str:
return ""
#: Klartext zu den Win32-Fehlern, an denen ein Laufwerks-Zugriff scheitert.
#:
#: ## Warum der Grund nicht im „unknown" verschwinden darf (30.08.2026)
#:
#: Nach einem Rip mit 29 Lesefehlern und einem gescheiterten Auswurf
#: antwortete das Laufwerk des Commanders auf nichts mehr, was mit dem
#: MEDIUM zu tun hat — an seinem Geraet gemessen:
#:
#: CreateFileW mit GENERIC_READ -> Win32-Fehler 1
#: IOCTL_STORAGE_CHECK_VERIFY2 -> Win32-Fehler 1
#: IOCTL_CDROM_DISK_TYPE -> Win32-Fehler 50
#: CreateFileW mit Zugriff 0 -> geht
#: IOCTL_STORAGE_QUERY_PROPERTY -> geht
#:
#: Die GERAETE-Auskunft kam also weiter durch: Modell und Seriennummer
#: standen im UI, waehrend Typ und Status auf „unknown" fielen und sich
#: kein Rip mehr starten liess. Im Protokoll stand als letztes die laengst
#: veraltete Zeile „Disc erkannt". Sein Befund dazu: „jetzt erkennt rippy
#: die disk garnicht mehr (im log steht zwar erkannt, aber ein start des
#: rips ist nicht moeglich)".
#:
#: Rippy KANNTE die Ursache und behielt sie fuer sich. Das ist dieselbe
#: Sorte Nichtauskunft wie „Platz fuer Rippy: unbekannt" (28.08.2026) —
#: sie sieht aus wie eine Auskunft.
ZUGRIFFS_GRUENDE = {
w.ERROR_INVALID_FUNCTION:
"Das Laufwerk beantwortet keine Medien-Abfragen mehr. Das passiert "
"nach abgebrochenen Lesevorgaengen. Abhilfe: Disc ueber die Taste am "
"Laufwerk auswerfen und neu einlegen; hilft das nicht, den Rechner "
"neu starten.",
w.ERROR_NOT_SUPPORTED:
"Das Laufwerk lehnt die Abfrage gerade ab. Abhilfe: Disc auswerfen "
"und neu einlegen.",
w.ERROR_ACCESS_DENIED:
"Ein anderes Programm haelt das Laufwerk fest.",
w.ERROR_FILE_NOT_FOUND:
"Dieses Laufwerk gibt es nicht mehr.",
w.ERROR_NOT_READY:
"Es liegt keine Disc im Laufwerk.",
}
def zugriffs_grund(fehler) -> str:
"""Klartext zu einem fehlgeschlagenen Laufwerks-Zugriff (pure Funktion).
Unbekannte Nummern werden GENANNT, nicht verschwiegen eine Nummer,
nach der man suchen kann, ist mehr als ein leerer Satz.
"""
code = getattr(fehler, "winerror", None) or getattr(fehler, "code", None)
if code in ZUGRIFFS_GRUENDE:
return ZUGRIFFS_GRUENDE[code]
if code:
return ("Das Laufwerk antwortet nicht (Win32-Fehler %s). Abhilfe: "
"Disc auswerfen und neu einlegen." % code)
return "Das Laufwerk antwortet nicht."
def device_info(geraet: str, api=None) -> dict:
"""Der Geräte-Eintrag fürs UI — gleiche Felder wie beim Linux-Treiber.
@@ -474,18 +579,21 @@ def device_info(geraet: str, api=None) -> dict:
api = _api(api)
status = "unknown"
disc_typ = "unknown"
grund = ""
try:
if drive_status(geraet, api) == CDS_DISC_OK:
status = "ready"
try:
disc_typ = classify(disc_status(geraet, api),
disc_size_bytes(geraet, api))
except OSError:
except OSError as e:
disc_typ = "unknown"
grund = zugriffs_grund(e)
else:
status = "empty"
except OSError:
except OSError as e:
status = "unknown"
grund = zugriffs_grund(e)
buchstabe = kennung(geraet)
angaben = _angaben_gemerkt(geraet, api)
@@ -499,6 +607,9 @@ def device_info(geraet: str, api=None) -> dict:
"status": status,
"model": modell,
"serial": angaben.get("seriennummer", ""),
# Leer, solange alles geht. Sonst steht hier, WARUM „unknown"
# dasteht — siehe ZUGRIFFS_GRUENDE.
"grund": grund,
}
+10 -1
View File
@@ -81,7 +81,16 @@ def naechster_vorhandener(pfad: str, existiert=None) -> str:
existiert = existiert or os.path.isdir
modul = _modul(pfad)
pfad = (pfad or "").rstrip("\\/")
pfad = (pfad or "").strip()
# Den Schluss-Trenner nur abstreifen, wenn danach mehr uebrig bleibt als
# der blosse Laufwerksname. Aus "F:\" wurde sonst "F:", und das ist
# unter Windows der AKTUELLE Ordner auf Laufwerk F, nicht dessen Wurzel
# (siehe `verbinden`). Dieselbe Falle kostete am 30.08.2026 in
# `rohdaten.kandidaten` 16,5 GB Sichtbarkeit. Fuer UNC gilt dasselbe:
# aus "\\server\freigabe\" darf kein Ort ohne Trenner werden.
gekuerzt = pfad.rstrip("\\/")
if gekuerzt and gekuerzt != modul.splitdrive(pfad)[0]:
pfad = gekuerzt
gesehen = set()
while pfad and pfad not in gesehen:
if existiert(pfad):
+44
View File
@@ -0,0 +1,44 @@
"""Wie HandBrakeCLI angesprochen und wie seine Ausgabe gelesen wird.
## Warum das im GEMEINSAMEN Paket liegt
Zwei Module rufen HandBrake auf: `docker/worker/ripping.py` (der Encode) und
`docker/worker/caps.py` (Preset-Liste, Hilfe, Version). `caps` wird auch vom
Standalone-Betrieb geladen und darf `ripping` nicht importieren, nur um an
eine Konstante zu kommen.
Dasselbe Muster wie bei `makemkv_aufruf.py`: Eine Entscheidung, zwei Orte
einer altert. Dort kostete es die Formatzeile, hier waere es die Kodierung.
"""
#: Wie HandBrakes Ausgabe gelesen wird — und warum mit errors="replace".
#:
#: ## Der Befund (30.08.2026, am mitgelieferten HandBrakeCLI 1.11.2 gemessen)
#:
#: HandBrake schreibt **zwei Kodierungen in denselben Strom**. Eine Datei
#: namens „Glück über München.mkv" gescannt, die Bytes des Pfades in
#: derselben Ausgabe:
#:
#: Zeile „Opening ..." C3 BC = UTF-8
#: Zeile „..., title 1 ..." 81 = CP850 (OEM)
#:
#: Es gibt hier also keine richtige Kodierung, nur eine, die nicht
#: abstuerzt. Und ohne diese Zeile stuerzte es ab: In CP850 ist ü das Byte
#: **0x81**, und 0x81 ist in cp1252 — der Gebietsschema-Kodierung eines
#: deutschen Windows, die text=True von sich aus waehlt — **undefiniert**:
#:
#: UnicodeDecodeError: 'charmap' codec can't decode byte 0x81
#: in position 785: character maps to <undefined>
#:
#: In `run_handbrake` faellt das mitten in der Leseschleife an, wird von
#: `except Exception` eingefangen und landet als Fehlertext im Job. **Jeder
#: Film, dessen Pfad ein ü enthaelt, liess sich damit nicht komprimieren**
#: — „Glück", „Tür", „München", „Über", „Grün". Dasselbe gilt fuer
#: ì, Å, É, Ø (0x8D, 0x8F, 0x90, 0x9D).
#:
#: ⚠️ NICHT `text_von()` wie bei makemkvcon: Das liest binaer, und im
#: Binaermodus gibt es keine Universal-Newlines. HandBrake trennt seine
#: Fortschrittszeilen aber mit CR (0x0D) — der Balken waere weg. Alles, was
#: Rippy aus der Ausgabe liest, ist ASCII (Prozente, „Invalid preset",
#: „unknown option"); ersetzt wird also nur, was ohnehin nur Anzeige ist.
HB_LESEN = {"text": True, "errors": "replace"}
+32
View File
@@ -0,0 +1,32 @@
"""Warum HandBrakes Ausgabe mit errors="replace" gelesen wird.
Der Test prueft die VORAUSSETZUNG, nicht die Zuweisung: dass HandBrakes
OEM-Bytes in cp1252 wirklich nicht dekodierbar sind. Gemessen am 30.08.2026
am mitgelieferten HandBrakeCLI 1.11.2 (AGENTS Regel D).
"""
from rippy.rip import handbrake_aufruf
def test_oem_umlaute_sprengen_cp1252():
"""u-Umlaut ist in CP850 das Byte 0x81 — und in cp1252 undefiniert."""
oem = "Glück über München".encode("cp850")
assert b"\x81" in oem
try:
oem.decode("cp1252")
raise AssertionError("cp1252 haette 0x81 ablehnen muessen")
except UnicodeDecodeError:
pass
def test_mit_der_eingestellten_fehlerbehandlung_bricht_nichts_mehr():
oem = "Glück über München".encode("cp850")
text = oem.decode("cp1252", handbrake_aufruf.HB_LESEN["errors"])
assert text.startswith("Gl") # ASCII bleibt heil
assert "ber" in text and "nchen" in text
def test_universal_newlines_bleiben_an():
"""HandBrake trennt Fortschrittszeilen mit CR — ohne text=True waere der
Fortschrittsbalken weg (deshalb NICHT binaer wie bei makemkvcon)."""
assert handbrake_aufruf.HB_LESEN["text"] is True
+36
View File
@@ -20,6 +20,8 @@ nie auf.
from rippy import betrieb
B = chr(92) # Backslash, nie woertlich (siehe test_pfade)
DOCKER = {"profil": "api", "queue": {"treiber": "celery", "broker": "redis://x"}}
WINDOWS = {"profil": "standalone", "queue": {"treiber": "lokal"}}
@@ -217,3 +219,37 @@ def test_noch_nicht_angelegter_ordner_faellt_auf_das_laufwerk_zurueck():
def test_wenn_gar_nichts_existiert_wird_nichts_behauptet():
assert betrieb.naechster_vorhandener(r"Z:\gibt\es\nicht",
existiert=lambda p: False) == ""
def test_eingestellte_orte_schlagen_die_vorgabe():
"""Der Befund vom 30.08.2026: „die verzeichnise sind andere als dort
steht."
Die Oberflaeche schreibt `outputDir`/`workDir` in die Datenbank,
`betrieb` las `storage.*` aus der Konfigurationsdatei und die ist
leer. In Einstellungen -> System" stand deshalb dauerhaft die Vorgabe,
waehrend der Rip woanders hin lief.
"""
werte = betrieb.mit_einstellungen(
{"storage": {"medien": "", "temp": ""}},
{"outputDir": "F:" + B + "Filme", "workDir": "F:" + B})
assert werte["storage"]["medien"] == "F:" + B + "Filme"
assert werte["storage"]["temp"] == "F:" + B
assert betrieb.ablage_vorgabe(werte, False, "windows") == "F:" + B + "Filme"
assert betrieb.arbeits_vorgabe(werte, False, "windows") == "F:" + B
def test_ohne_einstellung_bleibt_die_vorgabe():
"""Leere Felder duerfen NICHTS ueberschreiben — sonst waere ein
ungesetztes Feld schlimmer als gar keine Bruecke."""
vorher = {"storage": {"medien": "/app/media", "temp": "/app/temp"}}
werte = betrieb.mit_einstellungen(vorher, {"outputDir": "", "workDir": " "})
assert werte["storage"]["medien"] == "/app/media"
assert werte["storage"]["temp"] == "/app/temp"
# ... und das Original bleibt unangetastet (pure Funktion)
assert vorher["storage"]["medien"] == "/app/media"
def test_bruecke_vertraegt_leere_eingaben():
assert betrieb.mit_einstellungen({}, {})["storage"] == {}
assert betrieb.mit_einstellungen(None, None)["storage"] == {}
+33
View File
@@ -52,3 +52,36 @@ def test_naechster_vorhandener_auf_posix():
def test_wenn_gar_nichts_existiert_wird_nichts_behauptet():
assert pfade.naechster_vorhandener("Z:" + B + "nix",
existiert=lambda p: False) == ""
def test_laufwerks_wurzel_bleibt_absolut():
"""Befund 30.08.2026: Der Schluss-Trenner wurde immer abgestreift.
Aus "F:\" wurde "F:" — unter Windows der AKTUELLE Ordner auf
Laufwerk F, nicht dessen Wurzel. Wer das Ergebnis weiterverbindet,
landet woanders; genau so verschwanden in `rohdaten.kandidaten` 16,5 GB.
"""
assert pfade.naechster_vorhandener(
"F:" + B, existiert=lambda p: True) == "F:" + B
# UNC-Wurzel genauso
unc = B + B + "server" + B + "freigabe" + B
assert pfade.naechster_vorhandener(unc, existiert=lambda p: True) == unc
# Und POSIX bleibt POSIX
assert pfade.naechster_vorhandener("/", existiert=lambda p: True) == "/"
def test_unterordner_verliert_seinen_schluss_trenner_weiterhin():
"""Das Abstreifen war ja richtig — nur nicht bis auf den Laufwerksnamen."""
da = "F:" + B + "Roh"
assert pfade.naechster_vorhandener(da + B, existiert=lambda p: p == da) == da
def test_fehlendes_laufwerk_terminiert():
"""Die Endlosschleife, an der `ablauf._frei_bytes` haengen blieb.
`ntpath.dirname("Q:\\")` gibt sich selbst zurueck (30.08.2026 an
einem freien Laufwerksbuchstaben gemessen). Ohne den Abbruch bei
`eltern == pfad` dreht die Suche fuer immer vor dem Rip, ohne Meldung.
"""
assert pfade.naechster_vorhandener("Q:" + B + "Rippy" + B + "_arbeit",
existiert=lambda p: False) == ""
+24
View File
@@ -67,6 +67,30 @@ def test_skript_raeumt_den_ordner_MIT_inhalt():
assert "rmdir /s /q" in s, "ohne /s /q bleibt jeder nicht leere Ordner stehen"
def test_laufwerks_wurzel_wird_nicht_geloescht():
"""Notbremse (30.08.2026): `ordner` stammt aus `InstallLocation` in der
Registry, gesetzt aus dem `--ziel` beim Installieren also aus fremdem
Text. Waere er ein Laufwerks-Stammverzeichnis, loeschte die
Deinstallation das Laufwerk. Der noetige rstrip macht den Fall erst
scharf: aus "F:\" wird "F:", und `rmdir /s /q "F:"` trifft, was
Windows gerade fuer den aktuellen Ordner auf F haelt.
"""
for wurzel in ("F:" + BACKSLASH, "F:", BACKSLASH + BACKSLASH + "srv" +
BACKSLASH + "freigabe" + BACKSLASH, "", "/"):
s = windows_app.aufraeum_skript("F:" + BACKSLASH + "Rippy.exe", wurzel)
assert "rmdir" not in s, "Wurzel %r haette geloescht werden koennen" % wurzel
assert "NICHT geloescht" in s
# Die EXE geht trotzdem weg, und das Skript raeumt sich selbst auf
assert "%~f0" in s
def test_echter_programmordner_wird_weiterhin_geloescht():
"""Die Bremse darf den Normalfall nicht treffen."""
s = windows_app.aufraeum_skript("C:" + BACKSLASH + "R" + BACKSLASH +
"Rippy.exe", "C:" + BACKSLASH + "R")
assert ('rmdir /s /q "C:' + BACKSLASH + 'R"') in s
# ── Installation (echt, in einem Testordner) ────────────────────────────
@nur_windows
def test_installation_legt_die_dateien_an_und_meldet_sich_bei_windows(tmp_path, monkeypatch):
+41 -7
View File
@@ -41,6 +41,7 @@ import threading
import time
import webbrowser
from rippy import pfade
from rippy.platform import verknuepfungen
from rippy.platform import win_registry as reg
@@ -501,19 +502,52 @@ for /l %%n in (1,1,15) do (
)
goto ende
:weg
rem /s /q, nicht nur rmdir: Ein blosses rmdir scheitert an JEDER
rem verbliebenen Datei. Am 28.08.2026 blieben 148 Dateien im
rem WebView2-Zwischenspeicher liegen, weil deren Prozesse Rippy ueberlebt
rem hatten -- der Ordner blieb dann samt Inhalt stehen.
rmdir /s /q "{ordner}" >nul 2>&1
{ordner_zeile}
:ende
del /q "%~f0" >nul 2>&1
"""
#: Der Loeschbefehl fuer den Programmordner — samt Begruendung, die im
#: erzeugten Skript stehen bleiben soll.
_ORDNER_LOESCHEN = """rem /s /q, nicht nur rmdir: Ein blosses rmdir scheitert an JEDER
rem verbliebenen Datei. Am 28.08.2026 blieben 148 Dateien im
rem WebView2-Zwischenspeicher liegen, weil deren Prozesse Rippy ueberlebt
rem hatten -- der Ordner blieb dann samt Inhalt stehen.
rmdir /s /q "%s" >nul 2>&1"""
def aufraeum_skript(exe: str, ordner: str) -> str:
"""Der Inhalt des Aufraeum-Skripts (reine Funktion, damit pruefbar)."""
return AUFRAEUM_SKRIPT.format(exe=exe, ordner=ordner.rstrip("\\/"))
r"""Der Inhalt des Aufraeum-Skripts (reine Funktion, damit pruefbar).
## Warum hier eine Notbremse sitzt (30.08.2026)
`ordner` kommt aus `InstallLocation` in der Registry also aus dem, was
beim Installieren als `--ziel` angegeben wurde. Das ist nicht Rippys
eigenes Wort, sondern fremder Text, und daraus entsteht hier ein
`rmdir /s /q`.
Waere er ein Laufwerks-Stammverzeichnis, loeschte die Deinstallation das
Laufwerk. Der noetige `rstrip` macht den Fall sogar erst scharf: Aus
"F:\" wird "F:", und `rmdir /s /q "F:"` trifft, was Windows gerade fuer
den aktuellen Ordner auf F haelt.
Beobachtet wurde das nicht aber die Notbremse kostet eine Zeile, und
der Fall waere nicht wiedergutzumachen. Bleibt der Ordner stehen, sieht
man das; verschwindet das Falsche, ist es weg.
Der `rstrip` selbst muss bleiben: `rmdir "F:\Rippy\"` scheitert, weil
cmd.exe den Backslash vor dem Anfuehrungszeichen als Maskierung liest.
"""
ziel = (ordner or "").rstrip(chr(92) + "/")
# `pfade.laufwerk_von` urteilt nach der FORM des Pfades, nicht nach dem
# laufenden Rechner — damit greift die Bremse auch in der Linux-Ampel.
if ziel and ziel != pfade.laufwerk_von(ziel):
zeile = _ORDNER_LOESCHEN % ziel
else:
zeile = ("rem Programmordner NICHT geloescht: kein gueltiger Unterordner (%s)"
% (ordner or "leer"))
return AUFRAEUM_SKRIPT.format(exe=exe, ordner_zeile=zeile)
def selbst_loeschen(exe: str, ordner: str) -> str: