fix(windows): Der Schalter, den es nicht gibt, und vierzehn weitere Funde
Commander: „Kompression fehlgeschlagen bei Spartacus … _t01.mkv: HandBrake
endete mit Code 0" — 16,5 GB fertiger Rohschnitt, und die Kompression war in
derselben Sekunde vorbei, in der sie begann.
Nachgestellt mit genau der Befehlszeile, die Rippy baute:
unknown option (--audio-codec)
HandBrake has exited. $? = 0
Den Schalter `--audio-codec` gibt es bei HandBrakeCLI nicht; er heisst
`-E` / `--aencoder`. Ein unbekannter Schalter ist fuer HandBrake kein Fehler,
der Rueckgabewert ist 0. Der Test dazu forderte den falschen Namen sogar ein.
Daraus wurde ein Rundgang durch den Windows-Pfad. Alles unten ist gemessen,
nichts vermutet (Regel D).
## Die Kompression
1. `--aencoder` statt `--audio-codec`. Am mitgelieferten HandBrakeCLI 1.11.2
gemessen, mit einem 5-Sekunden-Encode auf der echten Roh-Datei bestaetigt.
2. HandBrakes letzte Zeilen werden aufgehoben (12 gepuffert, 4 in der
Meldung) und `unknown option (...)` wird als eigener Fall erkannt, VOR
allen anderen. Vorher wurde jede Zeile weggeworfen, die kein Fortschritt
war — bei Rueckgabewert 0 blieb damit keine Auskunft uebrig. Die geratene
Zeile „Meist ist der Zielordner nicht beschreibbar" ist raus; sie war
falsch und hat die Suche in die falsche Richtung geschickt.
3. Ein `ue`-Umlaut im Pfad toetete die Kompression. HandBrake schreibt zwei
Kodierungen in denselben Strom (derselbe Pfad einmal UTF-8, einmal CP850).
In CP850 ist das Byte 0x81, und das ist in cp1252 — was `text=True` auf
deutschem Windows waehlt — undefiniert:
UnicodeDecodeError: charmap codec can't decode byte 0x81
Neu: `rip/handbrake_aufruf.py` mit `HB_LESEN`, benutzt von ripping.py und
caps.py. Bewusst nicht binaer wie bei makemkvcon: HandBrake trennt
Fortschrittszeilen mit CR, im Binaermodus waere der Balken weg.
## Die Rohdaten
4. `rohdaten.kandidaten` machte aus dem Arbeitsordner `F:\` ein `F:` und
verband damit weiter. Das ist unter Windows der aktuelle Ordner auf
Laufwerk F, nicht dessen Wurzel — 16,5 GB waren unsichtbar, und der
Wiederholen-Dialog bot nur „Neu rippen" an. Die Falle steht woertlich im
Kopf von `pfade.verbinden`.
5. Gesucht wurde unter der heutigen Einstellung statt unter der Wahl DIESES
Rips (`meta["work_dir"]`). Genau dafuer wurde rohdaten.py am 26.07.
gebaut; repariert wurde damals die Kandidatenliste, nicht der Aufrufer.
Neu: `_arbeitsverzeichnis_des_jobs`, benutzt an vier Stellen.
6. Zwei Speicher fuer dieselben Ordner: Die Oberflaeche schreibt
`outputDir`/`workDir` in die Datenbank, `betrieb` liest `storage.*` aus
der Konfigurationsdatei, und die schreibt niemand. Gemessen: eingestellt
`E:\Rippy`, angezeigt `C:\Users\...\Videos\Rippy`. Neu:
`betrieb.mit_einstellungen`.
## Das Laufwerk
7. `device_info` fing den OSError ab und lieferte „unknown" ohne den Grund.
Nach einem Rip mit Lesefehlern beantwortete das Laufwerk keine
Medien-Abfragen mehr (Win32-Fehler 1), die Geraete-Auskunft aber schon —
im UI stand eine volle Laufwerkskarte, kein Rip startbar, und im
Protokoll das laengst veraltete „Disc erkannt". Neu: `ZUGRIFFS_GRUENDE`,
ein Feld `grund` im Laufwerks-Eintrag und eine Protokollzeile je Wechsel.
Eine fehlgeschlagene Disc-Erkennung wird ebenfalls protokolliert.
8. Der Linux-Treiber nannte ein unzugaengliches Laufwerk „empty", waehrend
Windows richtig „unknown" sagt. Angeglichen, samt Feld-Paritaet.
9. `CreateFileW`, `DeviceIoControl` und `CloseHandle` hatten weder `restype`
noch `argtypes` — 32-Bit-`c_int` fuer einen 64-Bit-HANDLE, in beide
Richtungen. Mit `restype` aendert sich der Fehlerwert von -1 auf
0xFFFFFFFFFFFFFFFF; die Pruefung deckt jetzt beides ab. Am echten
Laufwerk gegengeprueft, Fehlerpfad eingeschlossen.
10. Der Vor-Scan lief bei JEDER eingelegten Disc ein `makemkvcon info` mit
120 s Zeitgrenze — 20 bis 120 Sekunden „Disc wird gelesen". Frueher war
das schnell, weil der Zweig unter Windows nie lief (`shutil.which`,
repariert am 28.08.). Das Ergebnis landete allein in `toc["tracks"]`,
das niemand liest: Der Rip-Dialog holt seine Liste ueber
`/devices/{id}/scan-tracks`, wenn sie gebraucht wird. Entfernt.
## Notbremsen
11. `_frei_bytes` suchte den naechsten vorhandenen Ordner selbst.
`os.path.dirname("Q:\\")` gibt sich selbst zurueck — ein
Arbeitsverzeichnis auf einer abgezogenen Platte haette den Job vor dem
Rip stumm haengen lassen. Benutzt jetzt
`pfade.naechster_vorhandener`, das den Abbruch seit V2-1 hat.
12. `naechster_vorhandener` haelt Laufwerks- und UNC-Wurzeln jetzt absolut.
13. `aufraeum_skript` baut sein `rmdir /s /q` aus `InstallLocation` in der
Registry. Waere das eine Laufwerks-Wurzel, loeschte die Deinstallation
das Laufwerk. Nicht beobachtet, aber nicht wiedergutzumachen — der
Loeschbefehl bleibt in dem Fall weg.
## Lesefehler
MakeMKV sicherte 1 von 2 Titeln, endete mit 0, und Rippy schrieb „Rip
fertig". Jetzt gibt es eine Warnung, auch wenn der Rip als Erfolg endet, und
die MSG-Nummer steht im Protokoll: MakeMKVs Texte sind uebersetzt, die
Nummern nicht.
## Aus der Gegenprobe am laufenden Rippy
Die erste Fassung dieses Standes war installiert, als der Commander meldete:
„nun oeffnen sich diverse fenster im hintergrund, gehen ganz kurz auf und dann
wieder zu. Das laufwerk hoert auch einfach auf zu lesen." Beides Altlasten,
die erst durch die neue Protokollzeile sichtbar wurden.
14. Prozesserzeugung mitgeschnitten:
14:54:40 timeout.exe timeout 4 ls -d C:\Users\...\d7ee6c06-...
14:54:40 WindowsTerminal.exe
`rohdaten.pruefen` fragt mit `timeout N ls -d`, ob es ein Verzeichnis
gibt. Unter Linux ist das richtig (os.path.isdir kann an einem toten
CIFS-Mount im Kernel haengen, ein Kindprozess laesst sich abbrechen).
Unter Windows ist es dreifach falsch: timeout.exe gibt es dort, kennt
aber weder `ls` noch `-d`; sie braucht eine Konsole, und die reisst
Windows auf; und ihr Rueckgabewert ist nie 0, die Antwort lautete also
„weg" fuer JEDES Verzeichnis. Rohdaten waren unter Windows
grundsaetzlich unsichtbar. Neu: `nativ_nachsehen()`. Die zwei
gleichartigen Aufrufe in mounts.py bekommen dieselbe Absicherung.
15. Der Waechter fragte das Laufwerk alle drei Sekunden ab — auch mitten im
Rip, also drei CreateFileW plus IOCTLs auf ein Geraet, das makemkvcon
gerade liest:
12:49:52 bluray-Rip gestartet
12:50:09 [watcher] Laufwerk G: beantwortet keine Medien-Abfragen
12:50:12 MSG 2003 SCSI-Fehler ILLEGAL REQUEST:INVALID FIELD IN CDB
12:50:12 makemkvcon endete mit Code 11
`_auto_prescan` haelt sich seit dem 29.08.2026 an die Regel „waehrend
eines Rips wird nicht gescannt"; die Laufwerksabfrage tat es nicht.
Jetzt gilt in der Zeit der letzte bekannte Stand.
## Zwei Tests, die gelogen haben
* `assert "--audio-codec" in cmd` schrieb den Fehler fest.
* `lambda: {}` als Doppelgaenger fuer `get_settings(key, bei_fehler_leer)`
brach, sobald ein Aufrufer einen Parameter benutzte — und zeigte dann auf
den Code statt auf sich selbst.
977 Tests gruen, ruff sauber.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
b6a93aa726
commit
d3e86d2641
+139
-7
@@ -233,6 +233,19 @@ async def _auto_prescan(pfad: str):
|
||||
except Exception as e:
|
||||
DISC_CACHE.pop(pfad, None)
|
||||
print(f"Auto-Pre-Scan {pfad}: {e}")
|
||||
# ... und ins PROTOKOLL, nicht nur auf die Konsole
|
||||
# (Befund 30.08.2026). Der Commander: „im log steht zwar
|
||||
# erkannt, aber ein start des rips ist nicht moeglich."
|
||||
# Genau so war es: Die letzte Zeile war das erfolgreiche
|
||||
# „Disc erkannt" von vorhin, die fehlgeschlagenen Versuche
|
||||
# danach standen nur auf einer Konsole, die niemand sieht.
|
||||
# Ein Protokoll, in dem nur die Erfolge stehen, luegt.
|
||||
try:
|
||||
db.add_log("warning", "watcher",
|
||||
"Disc-Erkennung auf %s fehlgeschlagen: %s"
|
||||
% (pfad, e))
|
||||
except Exception: # noqa: BLE001
|
||||
pass # Protokollieren darf die Wache nie anhalten
|
||||
|
||||
|
||||
async def _auto_rip_wenn_aktiviert(pfad: str):
|
||||
@@ -468,6 +481,9 @@ class Device(BaseModel):
|
||||
status: str
|
||||
model: Optional[str] = None
|
||||
serial: Optional[str] = None
|
||||
# Warum steht bei `type`/`status` „unknown"? Leer, solange alles geht.
|
||||
# Der Linux-Treiber setzt das Feld nicht — dann bleibt es leer.
|
||||
grund: str = ""
|
||||
disc: Optional[Dict] = None # Auto-Pre-Scan-Ergebnis (Titel/Jahr/Poster)
|
||||
# Läuft die Erkennung gerade noch? (Commander 29.08.2026: „das die disc
|
||||
# erkennung noch läuft muss sichtbar sein")
|
||||
@@ -789,6 +805,39 @@ def _rohdaten_suchen(job_id: str, work_dir: str) -> list:
|
||||
return rohdaten.suche(job_id, work_dir, os.listdir, rohdaten.verzeichnis_da)
|
||||
|
||||
|
||||
def _arbeitsverzeichnis_des_jobs(job, einstellungen=None) -> str:
|
||||
"""Wohin ging der Roh-Rip DIESES Jobs? Seine Wahl schlägt die Einstellung.
|
||||
|
||||
## Warum die Einstellung allein nicht reicht (Befund 30.08.2026)
|
||||
|
||||
Der Rip-Dialog lässt für JEDEN Rip einzeln wählen, wohin die Rohdaten
|
||||
gehen (seit v3.15). Die Wahl landet in den Job-Metadaten als
|
||||
`work_dir` — geschrieben in `start_rip`, gelesen im Worker von
|
||||
`_arbeitsverzeichnis()`. Gesucht wurde danach aber immer unter dem
|
||||
HEUTIGEN Wert der Einstellung `workDir`.
|
||||
|
||||
Genau daran scheiterte am 26.07.2026 schon einmal ein Job mit 79,6 GB
|
||||
Rohschnitt — der Fall steht im Kopf von `rohdaten.py`. Repariert wurde
|
||||
damals die Kandidatenliste, nicht der Aufrufer: Er reichte weiterhin
|
||||
die Einstellung hinein. Am 30.08.2026 stand deshalb erneut „Auf der
|
||||
Platte liegt zu diesem Job nichts (mehr)" — vor 16,5 GB, die dalagen.
|
||||
|
||||
Das ist das Muster aus AGENTS: aus einem Zustandswert (der heutigen
|
||||
Einstellung) auf einen Vorgang geschlossen (wohin DAMALS gerippt
|
||||
wurde), statt nachzusehen. Der Job weiß es selbst.
|
||||
"""
|
||||
holen = getattr(job, "get", None)
|
||||
eigen = ""
|
||||
if holen:
|
||||
eigen = (phasen.meta_von(holen("meta")).get("work_dir") or "").strip()
|
||||
if not eigen:
|
||||
werte = einstellungen if einstellungen is not None else db.get_settings()
|
||||
eigen = ((werte or {}).get("workDir") or "").strip()
|
||||
# normpath("") wäre "." — der aktuelle Ordner, und der ist hier nie
|
||||
# gemeint. "/" fällt in `kandidaten()` sauber durch.
|
||||
return os.path.normpath(eigen or "/")
|
||||
|
||||
|
||||
# Vorrat für die Job-Liste. Dasselbe Muster wie beim Celery-Ping in
|
||||
# /capabilities (v3.15): Der Endpunkt wird alle 4 Sekunden vom Dashboard
|
||||
# abgefragt und darf NIE am Dateisystem hängen. Ein schlafendes NAS hätte das
|
||||
@@ -915,13 +964,15 @@ def _rohdaten_vorrat_auffrischen() -> None:
|
||||
Sekunden. Ohne diese Regel verschwände in dem Fenster der Knopf
|
||||
„Neu komprimieren", und der Nutzer schlösse daraus, seine 74 GB seien weg.
|
||||
"""
|
||||
work_dir = os.path.normpath((db.get_settings().get("workDir") or "").strip() or "/")
|
||||
einstellungen = db.get_settings()
|
||||
alt = _ROHDATEN["treffer"]
|
||||
treffer = {}
|
||||
for zeile in db.list_jobs():
|
||||
if zeile.get("status") != "failed":
|
||||
continue
|
||||
job_id = zeile["id"]
|
||||
# Je Job SEINE Wahl — nicht die heutige Einstellung.
|
||||
work_dir = _arbeitsverzeichnis_des_jobs(zeile, einstellungen)
|
||||
ergebnis = rohdaten.suche_mit_status(job_id, work_dir, os.listdir)
|
||||
if not ergebnis["pfade"] and ergebnis["unklar"] and alt.get(job_id):
|
||||
treffer[job_id] = alt[job_id] # letzte bekannte Antwort halten
|
||||
@@ -1050,8 +1101,7 @@ async def job_rohdaten(job_id: str):
|
||||
raise HTTPException(status_code=404, detail="Job nicht gefunden")
|
||||
|
||||
def sammle():
|
||||
work_dir = os.path.normpath((db.get_settings().get("workDir") or "").strip() or "/")
|
||||
pfade = _rohdaten_suchen(job_id, work_dir)
|
||||
pfade = _rohdaten_suchen(job_id, _arbeitsverzeichnis_des_jobs(job))
|
||||
bytes_gesamt, dateien = _rohdaten_groesse(pfade)
|
||||
return {
|
||||
"pfade": pfade,
|
||||
@@ -1080,8 +1130,7 @@ async def delete_job(job_id: str, rohdaten: bool = False):
|
||||
geloescht_gb = 0.0
|
||||
if rohdaten:
|
||||
def raeume():
|
||||
work_dir = os.path.normpath((db.get_settings().get("workDir") or "").strip() or "/")
|
||||
pfade = _rohdaten_suchen(job_id, work_dir)
|
||||
pfade = _rohdaten_suchen(job_id, _arbeitsverzeichnis_des_jobs(job))
|
||||
bytes_gesamt, _ = _rohdaten_groesse(pfade)
|
||||
for pfad in pfade:
|
||||
shutil.rmtree(pfad, ignore_errors=True)
|
||||
@@ -1564,7 +1613,7 @@ async def retry_transcode(job_id: str):
|
||||
raise HTTPException(status_code=409, detail="Job rippt noch")
|
||||
|
||||
einstellungen = await asyncio.to_thread(db.get_settings)
|
||||
work_dir = os.path.normpath((einstellungen.get("workDir") or "").strip() or "/")
|
||||
work_dir = _arbeitsverzeichnis_des_jobs(job, einstellungen)
|
||||
gefunden = await asyncio.to_thread(_rohdaten_suchen, job_id, work_dir)
|
||||
if not gefunden:
|
||||
raise HTTPException(
|
||||
@@ -2528,6 +2577,13 @@ async def system_info():
|
||||
werte = rippy_config.laden()
|
||||
except Exception: # noqa: BLE001
|
||||
werte = {}
|
||||
# Die Oberflaeche schreibt nach `outputDir`/`workDir` in die
|
||||
# DATENBANK, `betrieb` liest `storage.*` aus der DATEI. Ohne
|
||||
# diese Bruecke zeigte die Uebersicht immer die Vorgabe, egal
|
||||
# was eingestellt war (Befund 30.08.2026, siehe
|
||||
# `betrieb.mit_einstellungen`).
|
||||
werte = betriebs_auskunft.mit_einstellungen(
|
||||
werte, db.get_settings(bei_fehler_leer=True))
|
||||
for ort in betriebs_auskunft.platz_orte(werte):
|
||||
# Frisch installiert gibt es den Ablage-Ordner noch nicht. Dann
|
||||
# das naechste vorhandene Elternverzeichnis messen: Der Nutzer
|
||||
@@ -2911,6 +2967,75 @@ async def get_devices():
|
||||
await asyncio.to_thread(laufwerke_mit_disc)]
|
||||
|
||||
|
||||
#: Zuletzt gemeldeter Grund je Laufwerk. Ohne dieses Gedaechtnis stuende die
|
||||
#: Zeile alle drei Sekunden im Protokoll und verdraengte alles andere.
|
||||
_LETZTER_GRUND: Dict[str, str] = {}
|
||||
|
||||
|
||||
def _grund_melden(pfad: str, grund: str) -> None:
|
||||
"""Warum ein Laufwerk „unknown" meldet — einmal ins Protokoll, beim
|
||||
Wechsel.
|
||||
|
||||
## Der Befund des Commanders (30.08.2026)
|
||||
|
||||
> „jetzt erkennt rippy die disk garnicht mehr (im log steht zwar
|
||||
> erkannt, aber ein start des rips ist nicht moeglich)"
|
||||
|
||||
Sein Laufwerk beantwortete nach einem Rip mit Lesefehlern keine
|
||||
Medien-Abfragen mehr (Win32-Fehler 1), die Geraete-Auskunft aber schon.
|
||||
Im UI stand deshalb eine vollstaendige Laufwerkskarte mit Modell und
|
||||
Seriennummer — nur „unknown" bei Typ und Status, und kein Rip startbar.
|
||||
Die letzte Protokollzeile war das laengst veraltete „Disc erkannt".
|
||||
|
||||
Der Treiber kennt den Grund (siehe `drives.windows.ZUGRIFFS_GRUENDE`).
|
||||
Hier wird er gesagt — samt Abhilfe, denn die Zeile soll nicht nur
|
||||
beschreiben, sondern weiterhelfen.
|
||||
"""
|
||||
if _LETZTER_GRUND.get(pfad, "") == grund:
|
||||
return
|
||||
vorher = _LETZTER_GRUND.get(pfad, "")
|
||||
_LETZTER_GRUND[pfad] = grund
|
||||
try:
|
||||
if grund:
|
||||
db.add_log("warning", "watcher", "Laufwerk %s: %s" % (pfad, grund))
|
||||
elif vorher:
|
||||
db.add_log("info", "watcher",
|
||||
"Laufwerk %s antwortet wieder." % pfad)
|
||||
except Exception: # noqa: BLE001
|
||||
pass # Protokollieren darf die Laufwerksliste nie aufhalten
|
||||
|
||||
|
||||
def _job_haelt_das_laufwerk(pfad: str) -> bool:
|
||||
"""Laeuft auf diesem Laufwerk gerade ein Rip?
|
||||
|
||||
## Warum das Laufwerk dann in Ruhe bleiben muss (Befund 30.08.2026)
|
||||
|
||||
Der Waechter fragt alle drei Sekunden `device_info` ab — das sind drei
|
||||
`CreateFileW` plus IOCTLs auf ein Geraet, das waehrenddessen makemkvcon
|
||||
gehoert. Am Protokoll des Commanders abgelesen:
|
||||
|
||||
12:49:52 bluray-Rip gestartet
|
||||
12:50:09 [watcher] Laufwerk G: beantwortet keine Medien-Abfragen
|
||||
12:50:12 MSG 2003 SCSI-Fehler ILLEGAL REQUEST:INVALID FIELD IN CDB
|
||||
12:50:12 MSG 5010 Das Oeffnen der Disk schlug fehl
|
||||
12:50:12 makemkvcon endete mit Code 11
|
||||
|
||||
Sein Befund dazu: „Das laufwerk hoert auch einfach auf zu lesen."
|
||||
|
||||
`_auto_prescan` haelt sich seit dem 29.08.2026 an genau diese Regel
|
||||
(„Es gibt keinen Grund, waehrend eines Rips zu scannen") — die
|
||||
Laufwerksabfrage tat es nicht. Sie hat dieselbe Begruendung: Wir wissen
|
||||
bereits, was drinliegt, der Job laeuft ja darauf.
|
||||
|
||||
Faellt die Auskunft aus, gilt der letzte bekannte Stand weiter. Das ist
|
||||
keine Notluege: Waehrend eines Rips aendert sich am Laufwerk nichts.
|
||||
"""
|
||||
try:
|
||||
return bool(db.has_active_job(pfad))
|
||||
except Exception: # noqa: BLE001
|
||||
return False # im Zweifel nachsehen, wie bisher
|
||||
|
||||
|
||||
def laufwerke_mit_disc() -> list:
|
||||
"""Laufwerke SAMT erkannter Disc — der eine Weg für beide Abnehmer.
|
||||
|
||||
@@ -2932,8 +3057,15 @@ def laufwerke_mit_disc() -> list:
|
||||
nur diese hier.
|
||||
"""
|
||||
geraete = []
|
||||
letzte = {g.get("path"): g for g in LETZTE_LAUFWERKE}
|
||||
for pfad in device_discovery.list_optical_devices():
|
||||
info = device_discovery.device_info(pfad)
|
||||
if _job_haelt_das_laufwerk(pfad) and pfad in letzte:
|
||||
# Nicht anfassen — der letzte bekannte Stand gilt weiter.
|
||||
info = {k: v for k, v in letzte[pfad].items()
|
||||
if k not in ("disc", "disc_wird_erkannt")}
|
||||
else:
|
||||
info = device_discovery.device_info(pfad)
|
||||
_grund_melden(pfad, info.get("grund") or "")
|
||||
disc = DISC_CACHE.get(pfad)
|
||||
if disc and disc.get("_laeuft"):
|
||||
# NICHT als Disc ausgeben — es gibt noch keinen Titel. Aber
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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.
|
||||
#
|
||||
|
||||
@@ -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
@@ -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 "")
|
||||
|
||||
@@ -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) -----------
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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):
|
||||
|
||||
@@ -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"}
|
||||
@@ -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
|
||||
@@ -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"] == {}
|
||||
|
||||
@@ -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) == ""
|
||||
|
||||
@@ -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,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:
|
||||
|
||||
Reference in New Issue
Block a user