diff --git a/docker/api/main.py b/docker/api/main.py index 7edbcd6..f9a9e80 100644 --- a/docker/api/main.py +++ b/docker/api/main.py @@ -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/ - (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'}" diff --git a/docker/api/rohdaten.py b/docker/api/rohdaten.py new file mode 100644 index 0000000..7a6c854 --- /dev/null +++ b/docker/api/rohdaten.py @@ -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/` 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/ + 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/` und `/` +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 diff --git a/docker/api/test_rohdaten.py b/docker/api/test_rohdaten.py new file mode 100644 index 0000000..d8442f5 --- /dev/null +++ b/docker/api/test_rohdaten.py @@ -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/, 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)