fix(jobs): Rohdaten werden gesucht statt geraten - "Neu komprimieren" ging nicht
Ampel / ampel (push) Successful in 28s

Bei der Live-Gegenprobe des neuen /rohdaten-Endpunkts aufgefallen, und der Fund
ist groesser als der Endpunkt: Job 95afdc89 hatte `can_retry = false`, obwohl
79,6 GB intakter Rohschnitt unter /app/media/rippy/<id> lagen. Der Knopf
"Neu komprimieren" existierte gar nicht.

Der SAVEPOINT v3.16 schrieb dazu: "Rohschnitt 79,6 GB intakt -> 'Neu
komprimieren' genuegt, kein Neu-Rip." Das war falsch, und zwar doppelt:

  _kann_neu_komprimieren  suchte in /app/temp/raw/<id> und unter dem AKTUELLEN
                          workDir -> Knopf erschien nicht
  retry_transcode         berechnete raw_dir aus demselben aktuellen workDir
                          -> haette am falschen Ort gesucht

Ursache in beiden Faellen: Der Rip war mit einer Wahl NUR FUER DIESEN RIP auf
die NAS gelegt worden (gibt es seit v3.15), die Einstellung selbst stand auf
leer. Damit zeigte nichts mehr auf die Datei. Wieder derselbe Fehler, den dieses
Projekt schon mehrfach bezahlt hat: aus einem Zustandswert (der heutigen
Einstellung) auf einen Mechanismus (wohin damals gerippt wurde) geschlossen,
statt nachzusehen.

Neues Modul rohdaten.py sucht jetzt an allen Orten, die ueberhaupt in Frage
kommen: Container-Standard, eingestelltes Arbeitsverzeichnis und jedes
Ablageziel unter /app/media. Der Suchraum ist geschlossen, weil
_arbeitsverzeichnis() im Worker nur diese zulaesst; verwechseln kann man nichts,
weil Roh-Verzeichnisse exakt wie die Job-ID heissen (vollstaendige UUID) und
fertige Ablagen "Titel (Jahr) [kurz-id]".

Nicht in die Job-Zeile geschrieben, obwohl das sauberer waere: create_all legt
nur fehlende TABELLEN an, keine Spalten - und Bestandsjobs (genau dieser Fall)
haetten den Wert ohnehin nicht.

11 Tests, darunter der echte Fall, ein toter CIFS-Mount und der Klassiker
"/app/media-boese".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-26 13:09:44 +02:00
parent 843c54cd1a
commit 108368d583
3 changed files with 274 additions and 32 deletions
+122
View File
@@ -0,0 +1,122 @@
"""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
# Container-Standard für Roh-Rips (RAW_DIR im Worker).
RAW_STANDARD = "/app/temp/raw"
MEDIA_ROOT = "/app/media"
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 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, und ein toter CIFS-Mount kann hier keinen
Import-Zyklus verursachen.
"""
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 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