8ccca90e3c
Ampel / ampel (push) Successful in 28s
Gemessen nach dem letzten Deploy: Der ERSTE Zugriff auf die CIFS-Freigabe stallt
nach einem Container-Neustart mehrere Sekunden (die SMB-Sitzung wird neu
aufgebaut), danach antwortet sie in 0,01 s - zehn von zehn Versuchen, NAS per
Ping bei 0,47 ms. Die Freigabe ist also gesund, nur der erste Griff ist teuer.
In diesem Fenster lief die Pruefung in ihre Zeitgrenze, der Vorrat notierte
"keine Rohdaten", und der Knopf "Neu komprimieren" verschwand - obwohl 74 GB
dalagen. Der Nutzer haette daraus geschlossen, seine Daten seien weg. Genau
diese Sorte Fehlschluss ("aus einem Zustandswert auf einen Mechanismus") hat das
Projekt schon zweimal bezahlt.
Deshalb drei Antworten statt zwei: "da", "weg", "unklar". 124 ist der
Rueckgabewert von `timeout`, wenn es das Kind abgeschossen hat - das heisst
NICHT angesehen. Bleibt eine Pruefung unklar und der Vorrat hatte vorher einen
Treffer, wird die letzte bekannte Antwort gehalten statt Abwesenheit behauptet.
Wer eine Ja/Nein-Antwort braucht (verzeichnis_da), bekommt im Zweifel weiter
Nein - das ist die richtige Richtung fuer eine einzelne Abfrage.
Live davor geprueft: Die Schleife LEBT jetzt (alter_sekunden 30 -> 21 -> 12 -> 3
ueber 100 s) und heilt sich selbst - sobald die Freigabe antwortete, stand
mit_treffer=1 und can_retry=true.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
219 lines
9.2 KiB
Python
219 lines
9.2 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 posixpath
|
||
import subprocess
|
||
|
||
# Container-Standard für Roh-Rips (RAW_DIR im Worker).
|
||
RAW_STANDARD = "/app/temp/raw"
|
||
MEDIA_ROOT = "/app/media"
|
||
|
||
# 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) -> 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 []
|
||
orte = [posixpath.join(RAW_STANDARD, job_id)]
|
||
wahl = (work_dir or "").strip().rstrip("/")
|
||
if wahl and (wahl == MEDIA_ROOT or wahl.startswith(MEDIA_ROOT + "/")):
|
||
orte.append(posixpath.join(wahl, job_id))
|
||
for name in media_unterordner or []:
|
||
if name:
|
||
orte.append(posixpath.join(MEDIA_ROOT, 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) -> 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.
|
||
"""
|
||
try:
|
||
unterordner = sorted(listdir(MEDIA_ROOT))
|
||
except OSError:
|
||
unterordner = []
|
||
gefunden = []
|
||
for ort in kandidaten(job_id, work_dir, unterordner):
|
||
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) -> 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
|
||
try:
|
||
unterordner = sorted(listdir(MEDIA_ROOT))
|
||
except OSError:
|
||
unterordner = []
|
||
gefunden, unklar = [], False
|
||
for ort in kandidaten(job_id, work_dir, unterordner):
|
||
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:
|
||
voll = posixpath.join(pfad, name)
|
||
try:
|
||
if isfile(voll):
|
||
bytes_gesamt += getsize(voll)
|
||
dateien += 1
|
||
except OSError:
|
||
continue
|
||
return bytes_gesamt, dateien
|