Ampel / ampel (push) Successful in 1m20s
Commander: „Bro, du musst alles was rippy jetzt im code hat für Windows
Bauen! Jeden pfad, alles wo die tools drauf zugreifen. Diese Rippy version
MUSS 100% Windows Kompatibel sein. Prüfe bitte den kompletten Quellcode nach
Docker Resten."
Systematisch gesucht statt Fundstelle fuer Fundstelle: feste POSIX-Pfade,
Linux-Programme, POSIX-eigene Aufrufe, `shutil.which`, `posixpath` auf echten
Pfaden, Container-Texte. Sechs echte Fehler dabei.
## 1. `/dev/{name}` in drei Endpunkten — der schwerste
Das UI ruft `/devices/{id}/eject`, `/scan-tracks` und `/tracks` mit der
Kennung aus der Geraeteliste auf, unter Windows also `G`. Gebaut wurde daraus
`/dev/G` — steht in keiner Laufwerksliste. **Auswerfen und „Disc scannen"
antworteten unter Windows IMMER mit 404**, ohne dass irgendwo stand, warum.
Hin- und Rueckweg gehoeren zusammen: Beide Treiber haben jetzt `kennung()`
und `pfad_zu_kennung()`. Wer die Kennung vergibt, loest sie auch auf.
## 2. `os.path.isdir("/app")` — zum zweiten Mal
Nach `caps.py` (heute frueh) auch in `ablauf.py`: Der eigenstaendige
Windows-Rippy hielt sich fuer einen FREMDEN Worker und haette sich selbst
vorgeworfen, Container-Pfade nicht zu erreichen — auf einer Maschine ohne
Container. Die Entscheidung ist jetzt einspritzbar; vorher hing der Test
daran, ob es einen Ordner `/app` gibt.
## 3. `shutil.which` in `schluessel.py`
Ausgerechnet im Modul, das es NUR unter Windows gibt: Es suchte makemkvcon im
PATH, wo unter Windows nie ein Programm aus „Programme" steht. Die
Schluessel-Automatik fuer 4K-UHD lief damit nie an.
## 4. `posixpath.join` auf echten Pfaden
`rohdaten.py` baute `C:\Roh/datei.mkv` — gemischte Trenner, die im UI falsch
aussehen und jeden Vergleich brechen.
## 5. Container-Pfad in einer Nutzermeldung
„Roh-Datei bleibt in /app/temp erhalten" nennt jetzt den echten Ordner. Wer
die Datei retten will, sucht sonst am falschen Ort.
## 6. Container-Pfade als UI-Vorbelegung
Rip-Dialog und `useBetrieb` starteten mit `/app/media`, bis die Antwort da
war. Leer ist ehrlicher: Es behauptet nichts.
## Und HandBrakes „Code 0"
Code 0 heisst ERFOLG. Rippy meldete trotzdem „fehlgeschlagen", weil die Datei
nicht am erwarteten Ort lag: **HandBrake bestimmt den Container aus dem
PRESET, nicht aus der Endung** — ein MP4-Preset schreibt `.mp4` neben das
verlangte `.mkv`. Jetzt erzwingt `--format` den Container passend zur Endung
(an HandBrake 1.11.2 gegengeprueft), und falls doch etwas daneben liegt, wird
es gefunden statt weggeworfen.
## Der Waechter
`test_keine_container_reste.py` prueft mechanisch, dass im Windows-Weg kein
Container-Pfad ohne Begruendung steht. Die Ausnahmen stehen namentlich mit
Grund da (Linux-Zweige, benannte Rueckfaelle) — und ein zweiter Test wirft
jede Ausnahme raus, die niemand mehr braucht.
Ueber den Tokenizer, nicht ueber „faengt mit Anfuehrungszeichen an": Der
erste Anlauf blieb prompt an seinem eigenen `r\"\"\"`-Docstring haengen.
887 Tests gruen, ruff sauber.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
262 lines
11 KiB
Python
262 lines
11 KiB
Python
"""Wo liegen die Roh-MKVs eines Jobs? — Suchen statt annehmen.
|
||
|
||
## Der Fund, der dieses Modul nötig gemacht hat (26.07.2026, live gemessen)
|
||
|
||
Job `95afdc89` stand auf `failed`, und im Ablageziel lagen **79,6 GB** intakter
|
||
Rohschnitt (`/app/media/rippy/95afdc89-…/title_t00.mkv`). Der SAVEPOINT v3.16
|
||
schrieb dazu: „→ ‚Neu komprimieren' genügt, kein Neu-Rip". Die Gegenprobe an der
|
||
laufenden Instanz sagt: **`can_retry` war `false`** — den Knopf gab es gar nicht.
|
||
|
||
Ursache: `_kann_neu_komprimieren` suchte an genau zwei Orten — im
|
||
Container-Standard `/app/temp/raw/<id>` und unter dem AKTUELLEN Wert der
|
||
Einstellung `workDir`. Der Rip war aber mit einer Wahl *für diesen einen Rip*
|
||
auf die NAS gelegt worden (das gibt es seit v3.15 im Rip-Dialog), und die
|
||
Einstellung selbst stand auf leer. Damit zeigte nichts mehr auf die Datei:
|
||
|
||
workDir (Einstellung) = "" → geprüft wurde nur /app/temp/raw
|
||
tatsächlicher Ort = /app/media/rippy/<id>
|
||
Ergebnis → 75 GB unsichtbar, Neu-Rip scheinbar unvermeidlich
|
||
|
||
Das ist derselbe Fehler, der dieses Projekt schon mehrfach gekostet hat: aus
|
||
einem Zustandswert (der heutigen Einstellung) auf einen Mechanismus (wohin
|
||
damals gerippt wurde) geschlossen, statt nachzusehen.
|
||
|
||
## Warum gesucht und nicht gespeichert wird
|
||
|
||
Den Ort in die Job-Zeile zu schreiben wäre sauberer — aber die jobs-Tabelle
|
||
bräuchte eine neue Spalte, und `create_all` legt nur fehlende TABELLEN an, keine
|
||
fehlenden Spalten. Eine Migration für einen Suchraum von einer Handvoll
|
||
Verzeichnissen ist das falsche Werkzeug, und Bestandsjobs (genau der Fall hier)
|
||
hätten den Wert ohnehin nicht.
|
||
|
||
Der Suchraum ist nämlich klein und geschlossen: `_arbeitsverzeichnis()` im Worker
|
||
lässt ausschließlich den Container-Standard oder einen Pfad UNTER `/app/media`
|
||
zu. Es genügt also, `/app/temp/raw/<id>` und `<jedes Speicherziel>/<id>`
|
||
anzusehen — die oberste Ebene von `/app/media`, ohne Rekursion.
|
||
|
||
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 subprocess
|
||
|
||
from rippy import pfade as _pfade
|
||
|
||
# Container-Standard für Roh-Rips (RAW_DIR im Worker). Bleibt als Rueckfall
|
||
# stehen — die WURZELN dieses Betriebs liefert `wurzeln()`.
|
||
RAW_STANDARD = "/app/temp/raw"
|
||
MEDIA_ROOT = "/app/media"
|
||
|
||
|
||
def wurzeln(werte=None) -> tuple:
|
||
"""`(roh_standard, medien_wurzel, frei)` fuer DIESEN Betrieb.
|
||
|
||
## Warum das nicht fest sein darf (Befund 29.08.2026)
|
||
|
||
Dieses Modul findet die Rohdaten eines Jobs wieder — fuer den
|
||
Wiederholen-Dialog („auf der Platte liegen X GB Rohdaten") und fuer
|
||
„Rohdaten mitloeschen". Es suchte fest unter `/app/temp/raw` und
|
||
`/app/media`.
|
||
|
||
Auf einem Windows-PC gibt es beides nicht. Also fand es NIE etwas: Der
|
||
Dialog meldete „keine Rohdaten", das Aufraeumen loeschte nichts, und die
|
||
Bruchstuecke eines abgebrochenen Rips blieben unbemerkt liegen — bei einer
|
||
4K-UHD bis zu 100 GB.
|
||
"""
|
||
from rippy import betrieb, config
|
||
|
||
if werte is None:
|
||
try:
|
||
werte = config.laden()
|
||
except Exception: # noqa: BLE001
|
||
werte = {}
|
||
return (betrieb.arbeits_vorgabe(werte) or RAW_STANDARD,
|
||
betrieb.medien_wurzel(werte) or MEDIA_ROOT,
|
||
betrieb.frei_blaettern(werte))
|
||
|
||
# Harte Obergrenze für EINE Verzeichnis-Prüfung. Siehe verzeichnis_da().
|
||
PRUEF_TIMEOUT_SEKUNDEN = 4
|
||
|
||
|
||
def kandidaten(job_id: str, work_dir: str, media_unterordner,
|
||
orte_wurzeln=None) -> list:
|
||
"""Alle Orte, an denen die Roh-MKVs dieses Jobs liegen KÖNNTEN (pure).
|
||
|
||
`media_unterordner` sind die Namen der obersten Ebene unter /app/media
|
||
(Ablageziele inkl. eingehängter Freigaben) — die Liste kommt vom Aufrufer,
|
||
damit diese Funktion ohne Dateisystem testbar bleibt.
|
||
|
||
Reihenfolge: Container-Standard, dann die eingestellte Wahl, dann alle
|
||
Ablageziele. Doppelte fliegen raus, die Reihenfolge bleibt stabil.
|
||
|
||
posixpath, nicht os.path: Das sind Container-Pfade. os.path.join baut unter
|
||
Windows Backslashes daraus, und dann greift keine Prüfung mehr — dieselbe
|
||
Falle wie bei `_zielbasis()` (v3.14) und `_mountpoint()` (26.07.2026).
|
||
"""
|
||
if not job_id:
|
||
return []
|
||
roh, medien, frei = orte_wurzeln or (RAW_STANDARD, MEDIA_ROOT, False)
|
||
from rippy import pfade
|
||
|
||
orte = [pfade.verbinden(roh, job_id)]
|
||
wahl = (work_dir or "").strip().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 + "/")):
|
||
orte.append(pfade.verbinden(wahl, job_id))
|
||
for name in media_unterordner or []:
|
||
if name:
|
||
orte.append(pfade.verbinden(pfade.verbinden(medien, name), job_id))
|
||
gesehen, eindeutig = set(), []
|
||
for ort in orte:
|
||
if ort not in gesehen:
|
||
gesehen.add(ort)
|
||
eindeutig.append(ort)
|
||
return eindeutig
|
||
|
||
|
||
def pruefen(pfad: str, laufen=None) -> str:
|
||
"""Gibt es dieses Verzeichnis? „da" | „weg" | „unklar" — mit HARTER Zeitgrenze.
|
||
|
||
Drei Antworten statt zwei, weil „ich konnte nicht nachsehen" etwas anderes
|
||
ist als „es ist nicht da". Gemessen am 26.07.2026: Nach einem
|
||
Container-Neustart stallt der ERSTE Zugriff auf die CIFS-Freigabe mehrere
|
||
Sekunden (die SMB-Sitzung wird neu aufgebaut), danach antwortet sie in
|
||
0,01 s — zehn von zehn Versuchen. Ohne die Unterscheidung verschwindet in
|
||
diesem Fenster der Knopf „Neu komprimieren", und der Nutzer schließt daraus,
|
||
seine 74 GB seien weg. Genau diese Sorte Fehlschluss hat das Projekt schon
|
||
zweimal bezahlt.
|
||
|
||
Begründung der Technik siehe verzeichnis_da.
|
||
"""
|
||
if not pfad:
|
||
return "weg"
|
||
starten = laufen or subprocess.run
|
||
try:
|
||
ergebnis = starten(
|
||
["timeout", str(PRUEF_TIMEOUT_SEKUNDEN), "ls", "-d", pfad],
|
||
capture_output=True,
|
||
timeout=PRUEF_TIMEOUT_SEKUNDEN + 2,
|
||
)
|
||
except (OSError, subprocess.TimeoutExpired):
|
||
return "unklar"
|
||
if ergebnis.returncode == 0:
|
||
return "da"
|
||
# 124 ist der Rückgabewert von `timeout`, wenn es das Kind abgeschossen hat
|
||
# (dokumentiert in coreutils). Das heißt: nicht angesehen, nicht „weg".
|
||
return "unklar" if ergebnis.returncode == 124 else "weg"
|
||
|
||
|
||
def verzeichnis_da(pfad: str, laufen=None) -> bool:
|
||
"""Gibt es dieses Verzeichnis? — mit HARTER Zeitgrenze.
|
||
|
||
## Warum nicht os.path.isdir
|
||
|
||
Weil es an einem Netz-Mount unbegrenzt hängen kann, und zwar im Kernel
|
||
(Prozess-Zustand D, „uninterruptible sleep"). Genau das ist am 26.07.2026
|
||
passiert: Die Hintergrund-Schleife startete ihren ersten Durchlauf, während
|
||
Rippy die CIFS-Freigabe nach einem Container-Neustart neu einhängte. Ihr
|
||
`os.path.isdir` blieb stecken, `asyncio.to_thread` kam nie zurück, die
|
||
Schleife erreichte ihr `sleep` nie — und war damit für immer tot. Sichtbar
|
||
war nur, dass `can_retry` dauerhaft `false` blieb; zwei Threads standen im
|
||
Zustand D.
|
||
|
||
Ein Timeout um den Aufruf hätte nichts geholfen: Ein im Kernel hängender
|
||
Thread lässt sich aus Python nicht abbrechen, jeder Versuch hätte einen
|
||
weiteren Thread verbrannt, bis der Pool leer ist.
|
||
|
||
Ein Kind-PROZESS lässt sich abbrechen. Deshalb `timeout N ls -d <pfad>` —
|
||
dasselbe Werkzeug, das `mounts.ist_erreichbar` seit dem 24.07.2026 für
|
||
genau dieses Problem benutzt (dort für den toten NAS-Mount). Läuft es in
|
||
die Zeitgrenze, gilt das Verzeichnis als „nicht da": Ein Ort, den man nicht
|
||
innerhalb von Sekunden ansehen kann, ist für einen Rip ohnehin unbrauchbar.
|
||
|
||
`laufen` ist einspritzbar, damit das ohne echte Prozesse testbar bleibt.
|
||
|
||
Für den Fall „konnte nicht nachsehen" gibt es `pruefen()` mit drei
|
||
Antworten. Hier gilt nur „da" als ja — wer eine Ja/Nein-Antwort braucht,
|
||
soll im Zweifel Nein bekommen.
|
||
"""
|
||
return pruefen(pfad, laufen) == "da"
|
||
|
||
|
||
def suche(job_id: str, work_dir: str, listdir, isdir,
|
||
orte_wurzeln=None) -> list:
|
||
"""Die Orte, an denen wirklich etwas liegt.
|
||
|
||
`listdir` und `isdir` werden übergeben statt importiert — so ist die Suche
|
||
ohne Dateisystem prüfbar. Für `isdir` gehört `verzeichnis_da` eingesetzt und
|
||
NICHT os.path.isdir: Die Kandidaten liegen unter /app/media, und dort kann
|
||
ein Netz-Mount unbegrenzt hängen (Begründung bei verzeichnis_da).
|
||
|
||
`listdir` darf os.listdir bleiben: Gelistet wird nur /app/media selbst, und
|
||
das ist ein lokales Verzeichnis — die Freigaben sind Unterordner davon.
|
||
"""
|
||
orte_wurzeln = orte_wurzeln or wurzeln()
|
||
try:
|
||
unterordner = sorted(listdir(orte_wurzeln[1]))
|
||
except OSError:
|
||
unterordner = []
|
||
gefunden = []
|
||
for ort in kandidaten(job_id, work_dir, unterordner, orte_wurzeln):
|
||
try:
|
||
if isdir(ort):
|
||
gefunden.append(ort)
|
||
except OSError:
|
||
# Toter Mount → als „nicht da" werten. Ein Fehlschlag hier darf die
|
||
# Job-Liste nicht mitnehmen (Befund 24.07. bei /storage-targets).
|
||
continue
|
||
return gefunden
|
||
|
||
|
||
def suche_mit_status(job_id: str, work_dir: str, listdir, pruefer=None,
|
||
orte_wurzeln=None) -> dict:
|
||
"""Wie suche(), aber sagt auch, ob etwas UNGEPRÜFT geblieben ist.
|
||
|
||
Rückgabe: {"pfade": [...], "unklar": bool}. `unklar` heißt: Mindestens ein
|
||
Ort hat nicht geantwortet — ein leeres `pfade` ist dann kein Beweis für
|
||
„nichts da". Der Aufrufer soll in diesem Fall seine letzte bekannte Antwort
|
||
behalten, statt Abwesenheit zu behaupten (siehe pruefen()).
|
||
"""
|
||
pruefe = pruefer or pruefen
|
||
orte_wurzeln = orte_wurzeln or wurzeln()
|
||
try:
|
||
unterordner = sorted(listdir(orte_wurzeln[1]))
|
||
except OSError:
|
||
unterordner = []
|
||
gefunden, unklar = [], False
|
||
for ort in kandidaten(job_id, work_dir, unterordner, orte_wurzeln):
|
||
antwort = pruefe(ort)
|
||
if antwort == "da":
|
||
gefunden.append(ort)
|
||
elif antwort == "unklar":
|
||
unklar = True
|
||
return {"pfade": gefunden, "unklar": unklar}
|
||
|
||
|
||
def groesse(pfade: list, listdir, isfile, getsize) -> tuple:
|
||
"""(Bytes, Dateizahl) der Roh-Dateien — flach, nicht rekursiv.
|
||
|
||
Flach genügt: MakeMKV legt die Titel als `title_tNN.mkv` direkt in das
|
||
Job-Verzeichnis, Unterordner entstehen dort nicht.
|
||
"""
|
||
bytes_gesamt, dateien = 0, 0
|
||
for pfad in pfade or []:
|
||
try:
|
||
namen = listdir(pfad)
|
||
except OSError:
|
||
continue
|
||
for name in namen:
|
||
# `pfade.verbinden` statt posixpath: Auf Windows ist `pfad`
|
||
# ein echter Windows-Pfad, und `posixpath.join` baute daraus
|
||
# `C:\Roh/datei.mkv` — gemischte Trenner, die im UI falsch
|
||
# aussehen und jeden Vergleich brechen (Befund 29.08.2026).
|
||
voll = _pfade.verbinden(pfad, name)
|
||
try:
|
||
if isfile(voll):
|
||
bytes_gesamt += getsize(voll)
|
||
dateien += 1
|
||
except OSError:
|
||
continue
|
||
return bytes_gesamt, dateien
|