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) -----------