fix(jobs): Rohdaten werden gesucht statt geraten - "Neu komprimieren" ging nicht
Ampel / ampel (push) Successful in 28s
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:
+41
-32
@@ -18,6 +18,7 @@ import makemkv_key
|
||||
import mounts as mount_verwaltung
|
||||
import notify
|
||||
import presets as preset_auswahl
|
||||
import rohdaten
|
||||
from celery_client import celery_client, start_rip
|
||||
from detection import CDS_DISC_OK, CDS_NO_DISC, CDS_TRAY_OPEN, drive_status
|
||||
|
||||
@@ -308,14 +309,26 @@ async def root():
|
||||
}
|
||||
|
||||
|
||||
def _rohdaten_suchen(job_id: str, work_dir: str) -> list:
|
||||
"""Wo liegen die Roh-MKVs dieses Jobs? (Details in rohdaten.py)"""
|
||||
return rohdaten.suche(job_id, work_dir, os.listdir, os.path.isdir)
|
||||
|
||||
|
||||
def _kann_neu_komprimieren(job: dict, work_dir: str) -> bool:
|
||||
"""Nur wenn Rohdaten wirklich noch daliegen — der „Neu komprimieren"-Knopf
|
||||
an einem Job, der nie gerippt hat, war Unsinn (Befund 24.07.)."""
|
||||
an einem Job, der nie gerippt hat, war Unsinn (Befund 24.07.).
|
||||
|
||||
⚠️ Reparatur 26.07.2026, an der laufenden Instanz gemessen: Gesucht wurde
|
||||
nur im Container-Standard und unter dem AKTUELLEN `workDir`. Der Rip von Job
|
||||
95afdc89 lag aber auf der NAS, weil beim Start eine Wahl NUR FÜR DIESEN RIP
|
||||
getroffen worden war (gibt es seit v3.15) — und die Einstellung selbst stand
|
||||
auf leer. Ergebnis: `can_retry` war `false`, obwohl 79,6 GB intakt dalagen.
|
||||
Der SAVEPOINT v3.16 behauptete „‚Neu komprimieren' genügt" — den Knopf gab
|
||||
es nicht. Jetzt wird an allen möglichen Orten nachgesehen.
|
||||
"""
|
||||
if job.get("status") != "failed":
|
||||
return False
|
||||
if os.path.isdir(os.path.join("/app/temp/raw", job["id"])):
|
||||
return True
|
||||
return unter_wurzel(work_dir, MEDIA_ROOT) and os.path.isdir(os.path.join(work_dir, job["id"]))
|
||||
return bool(_rohdaten_suchen(job["id"], work_dir))
|
||||
|
||||
|
||||
@app.get("/jobs", response_model=List[Job])
|
||||
@@ -347,27 +360,9 @@ async def get_jobs():
|
||||
return await asyncio.to_thread(sammle)
|
||||
|
||||
|
||||
def _rohdaten_verzeichnisse(job_id: str, work_dir: str) -> list:
|
||||
"""Wo könnten Roh-MKVs dieses Jobs liegen? (beide möglichen Orte)"""
|
||||
kandidaten = [os.path.join("/app/temp/raw", job_id)]
|
||||
if unter_wurzel(work_dir, MEDIA_ROOT):
|
||||
kandidaten.append(os.path.join(work_dir, job_id))
|
||||
return [p for p in kandidaten if os.path.isdir(p)]
|
||||
|
||||
|
||||
def _rohdaten_groesse(pfade: list) -> tuple:
|
||||
"""(Bytes, Dateizahl) der Roh-MKVs — nur die Dateien, nicht rekursiv tief."""
|
||||
bytes_gesamt, dateien = 0, 0
|
||||
for pfad in pfade:
|
||||
try:
|
||||
for name in os.listdir(pfad):
|
||||
voll = os.path.join(pfad, name)
|
||||
if os.path.isfile(voll):
|
||||
bytes_gesamt += os.path.getsize(voll)
|
||||
dateien += 1
|
||||
except OSError:
|
||||
continue
|
||||
return bytes_gesamt, dateien
|
||||
"""(Bytes, Dateizahl) der Roh-MKVs (Details in rohdaten.py)."""
|
||||
return rohdaten.groesse(pfade, os.listdir, os.path.isfile, os.path.getsize)
|
||||
|
||||
|
||||
@app.get("/jobs/{job_id}/rohdaten")
|
||||
@@ -389,7 +384,7 @@ async def job_rohdaten(job_id: str):
|
||||
|
||||
def sammle():
|
||||
work_dir = os.path.normpath((db.get_settings().get("workDir") or "").strip() or "/")
|
||||
pfade = _rohdaten_verzeichnisse(job_id, work_dir)
|
||||
pfade = _rohdaten_suchen(job_id, work_dir)
|
||||
bytes_gesamt, dateien = _rohdaten_groesse(pfade)
|
||||
return {
|
||||
"pfade": pfade,
|
||||
@@ -419,7 +414,7 @@ async def delete_job(job_id: str, rohdaten: bool = False):
|
||||
if rohdaten:
|
||||
def raeume():
|
||||
work_dir = os.path.normpath((db.get_settings().get("workDir") or "").strip() or "/")
|
||||
pfade = _rohdaten_verzeichnisse(job_id, work_dir)
|
||||
pfade = _rohdaten_suchen(job_id, work_dir)
|
||||
bytes_gesamt, _ = _rohdaten_groesse(pfade)
|
||||
for pfad in pfade:
|
||||
shutil.rmtree(pfad, ignore_errors=True)
|
||||
@@ -730,8 +725,15 @@ async def scan_tracks_ergebnis(name: str):
|
||||
async def retry_transcode(job_id: str):
|
||||
"""Stößt die Kompression eines Jobs neu an — OHNE die Disc neu zu rippen.
|
||||
|
||||
Voraussetzung: die Rohdateien liegen noch in /app/temp/raw/<job_id>
|
||||
(bei Kompressions-Fehlschlägen bleiben sie dort absichtlich erhalten).
|
||||
Voraussetzung: die Rohdateien liegen noch irgendwo (bei
|
||||
Kompressions-Fehlschlägen bleiben sie absichtlich erhalten).
|
||||
|
||||
⚠️ Reparatur 26.07.2026: Das Roh-Verzeichnis wurde hier aus dem AKTUELLEN
|
||||
Wert von `workDir` errechnet. Wer beim Rip-Start eine andere Ablage gewählt
|
||||
hatte (gibt es seit v3.15), bekam damit einen Pfad, an dem nichts liegt —
|
||||
und der Worker brach mit „Verzeichnis erreichbar, enthält aber keine
|
||||
MKV-Datei" ab. Beim Job 95afdc89 lagen 79,6 GB auf der NAS, gesucht wurde in
|
||||
/app/temp/raw. Jetzt wird nachgesehen statt gerechnet (rohdaten.py).
|
||||
"""
|
||||
job = await asyncio.to_thread(db.get_job, job_id)
|
||||
if not job:
|
||||
@@ -739,12 +741,19 @@ async def retry_transcode(job_id: str):
|
||||
if job["status"] in ("running", "pending"):
|
||||
raise HTTPException(status_code=409, detail="Job rippt noch")
|
||||
|
||||
# Roh-Verzeichnis: respektiert das konfigurierbare Arbeitsverzeichnis
|
||||
# (Einstellungen → Verarbeitung), sonst Container-Default /app/temp/raw.
|
||||
einstellungen = await asyncio.to_thread(db.get_settings)
|
||||
work_dir = os.path.normpath((einstellungen.get("workDir") or "").strip() or "/")
|
||||
raw_basis = work_dir if unter_wurzel(work_dir, MEDIA_ROOT) else "/app/temp/raw"
|
||||
raw_dir = f"{raw_basis}/{job_id}"
|
||||
gefunden = await asyncio.to_thread(_rohdaten_suchen, job_id, work_dir)
|
||||
if not gefunden:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
"Keine Rohdaten zu diesem Job gefunden — weder unter "
|
||||
"/app/temp/raw noch in einem der Ablageziele. Ohne sie muss die "
|
||||
"Disc neu gerippt werden. (Gelöscht? Freigabe nicht eingehängt?)"
|
||||
),
|
||||
)
|
||||
raw_dir = gefunden[0]
|
||||
# Zielordner: der Worker schreibt das geplante Ziel beim Rip-Start nach
|
||||
# output_path (sprechender Name statt UUID) — alter Fallback bleibt.
|
||||
basis = job.get("target_dir") or f"{MEDIA_ROOT}/{job.get('disc_type') or 'bluray'}"
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Tests der Rohdaten-Suche — mit dem echten Fall, der sie nötig gemacht hat.
|
||||
|
||||
Job `95afdc89` (26.07.2026, an der laufenden Instanz gemessen): 79,6 GB
|
||||
Rohschnitt unter /app/media/rippy/<id>, Einstellung `workDir` leer, `can_retry`
|
||||
= false. Der Knopf „Neu komprimieren" fehlte, obwohl die Datei intakt war.
|
||||
"""
|
||||
|
||||
import rohdaten
|
||||
|
||||
JOB = "95afdc89-2426-4d44-829e-ad6ce1411905"
|
||||
|
||||
|
||||
def test_der_echte_fall_wird_gefunden():
|
||||
"""workDir ist LEER (so stand es live) und der Rohschnitt liegt trotzdem auf
|
||||
der NAS — weil beim Start eine Wahl nur für diesen Rip getroffen wurde."""
|
||||
orte = rohdaten.kandidaten(JOB, "", ["movies", "music", "rippy", "series"])
|
||||
assert f"/app/media/rippy/{JOB}" in orte
|
||||
# Der Container-Standard bleibt der erste Kandidat (schnellster Treffer)
|
||||
assert orte[0] == f"/app/temp/raw/{JOB}"
|
||||
|
||||
|
||||
def test_suche_liefert_nur_was_existiert():
|
||||
vorhanden = {f"/app/media/rippy/{JOB}"}
|
||||
gefunden = rohdaten.suche(
|
||||
JOB, "",
|
||||
listdir=lambda p: ["movies", "rippy"],
|
||||
isdir=lambda p: p in vorhanden,
|
||||
)
|
||||
assert gefunden == [f"/app/media/rippy/{JOB}"]
|
||||
|
||||
|
||||
def test_eingestelltes_arbeitsverzeichnis_kommt_vor_den_zielen():
|
||||
orte = rohdaten.kandidaten(JOB, "/app/media/rippy", ["movies", "rippy"])
|
||||
assert orte[1] == f"/app/media/rippy/{JOB}"
|
||||
# ... und taucht nicht doppelt auf, obwohl „rippy" auch Ablageziel ist
|
||||
assert orte.count(f"/app/media/rippy/{JOB}") == 1
|
||||
|
||||
|
||||
def test_arbeitsverzeichnis_ausserhalb_media_wird_ignoriert():
|
||||
"""Nur /app/media ist erlaubt (so entscheidet _arbeitsverzeichnis im
|
||||
Worker) — ein Pfad daneben darf hier nicht durchrutschen."""
|
||||
orte = rohdaten.kandidaten(JOB, "/etc", [])
|
||||
assert orte == [f"/app/temp/raw/{JOB}"]
|
||||
# Der Klassiker: ein Pfad, der nur mit dem Präfix ANFÄNGT
|
||||
orte = rohdaten.kandidaten(JOB, "/app/media-boese", [])
|
||||
assert orte == [f"/app/temp/raw/{JOB}"]
|
||||
|
||||
|
||||
def test_media_root_selbst_ist_erlaubt():
|
||||
orte = rohdaten.kandidaten(JOB, "/app/media", [])
|
||||
assert f"/app/media/{JOB}" in orte
|
||||
|
||||
|
||||
def test_ohne_job_id_nichts():
|
||||
assert rohdaten.kandidaten("", "/app/media", ["x"]) == []
|
||||
|
||||
|
||||
def test_kaputter_mount_reisst_die_suche_nicht_mit():
|
||||
"""Ein toter CIFS-Mount lässt isdir mit OSError fliegen. Das darf die
|
||||
Job-Liste nicht mitnehmen (Befund 24.07. bei /storage-targets)."""
|
||||
def isdir_kaputt(p):
|
||||
if "totes-nas" in p:
|
||||
raise OSError("Stale file handle")
|
||||
return p == f"/app/temp/raw/{JOB}"
|
||||
|
||||
gefunden = rohdaten.suche(
|
||||
JOB, "", listdir=lambda p: ["totes-nas", "movies"], isdir=isdir_kaputt)
|
||||
assert gefunden == [f"/app/temp/raw/{JOB}"]
|
||||
|
||||
|
||||
def test_listdir_kaputt_faellt_auf_den_standard_zurueck():
|
||||
def listdir_kaputt(p):
|
||||
raise OSError("kein /app/media")
|
||||
|
||||
gefunden = rohdaten.suche(
|
||||
JOB, "", listdir=listdir_kaputt, isdir=lambda p: True)
|
||||
assert gefunden == [f"/app/temp/raw/{JOB}"]
|
||||
|
||||
|
||||
# --- Größe ------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_groesse_zaehlt_nur_dateien():
|
||||
dateien = {
|
||||
f"/app/media/rippy/{JOB}/title_t00.mkv": 79604951639,
|
||||
f"/app/media/rippy/{JOB}/title_t01.mkv": 1000,
|
||||
}
|
||||
bytes_gesamt, anzahl = rohdaten.groesse(
|
||||
[f"/app/media/rippy/{JOB}"],
|
||||
listdir=lambda p: ["title_t00.mkv", "title_t01.mkv", "unterordner"],
|
||||
isfile=lambda p: p in dateien,
|
||||
getsize=lambda p: dateien[p],
|
||||
)
|
||||
# Die echte Größe des Akira-Rohschnitts, plus eine zweite Datei
|
||||
assert bytes_gesamt == 79604952639
|
||||
assert anzahl == 2
|
||||
assert round(bytes_gesamt / 1024**3, 1) == 74.1
|
||||
|
||||
|
||||
def test_groesse_ohne_pfade_ist_null():
|
||||
assert rohdaten.groesse([], lambda p: [], lambda p: True, lambda p: 1) == (0, 0)
|
||||
assert rohdaten.groesse(None, lambda p: [], lambda p: True, lambda p: 1) == (0, 0)
|
||||
|
||||
|
||||
def test_groesse_ueberspringt_unlesbares():
|
||||
def getsize_kaputt(p):
|
||||
raise OSError("weg")
|
||||
|
||||
bytes_gesamt, anzahl = rohdaten.groesse(
|
||||
["/x"], lambda p: ["a.mkv"], lambda p: True, getsize_kaputt)
|
||||
assert (bytes_gesamt, anzahl) == (0, 0)
|
||||
Reference in New Issue
Block a user