diff --git a/docker/api/main.py b/docker/api/main.py index 08ec943..7265625 100644 --- a/docker/api/main.py +++ b/docker/api/main.py @@ -381,13 +381,25 @@ async def _rohdaten_schleife(): def _rohdaten_vorrat_auffrischen() -> None: - """Für jeden fehlgeschlagenen Job nachsehen, wo seine Rohdaten liegen.""" + """Für jeden fehlgeschlagenen Job nachsehen, wo seine Rohdaten liegen. + + „Konnte nicht nachsehen" behält die letzte bekannte Antwort: Nach einem + Container-Neustart stallt der erste Zugriff auf die CIFS-Freigabe mehrere + Sekunden. Ohne diese Regel verschwände in dem Fenster der Knopf + „Neu komprimieren", und der Nutzer schlösse daraus, seine 74 GB seien weg. + """ work_dir = os.path.normpath((db.get_settings().get("workDir") or "").strip() or "/") + alt = _ROHDATEN["treffer"] treffer = {} for zeile in db.list_jobs(): if zeile.get("status") != "failed": continue - treffer[zeile["id"]] = _rohdaten_suchen(zeile["id"], work_dir) + job_id = zeile["id"] + ergebnis = rohdaten.suche_mit_status(job_id, work_dir, os.listdir) + if not ergebnis["pfade"] and ergebnis["unklar"] and alt.get(job_id): + treffer[job_id] = alt[job_id] # letzte bekannte Antwort halten + else: + treffer[job_id] = ergebnis["pfade"] _ROHDATEN["treffer"] = treffer _ROHDATEN["stand"] = time.monotonic() diff --git a/docker/api/rohdaten.py b/docker/api/rohdaten.py index 1db87f1..c4fcef1 100644 --- a/docker/api/rohdaten.py +++ b/docker/api/rohdaten.py @@ -80,6 +80,38 @@ def kandidaten(job_id: str, work_dir: str, media_unterordner) -> list: 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. @@ -105,19 +137,12 @@ def verzeichnis_da(pfad: str, laufen=None) -> bool: 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. """ - if not pfad: - return False - 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 False - return ergebnis.returncode == 0 + return pruefen(pfad, laufen) == "da" def suche(job_id: str, work_dir: str, listdir, isdir) -> list: @@ -147,6 +172,29 @@ def suche(job_id: str, work_dir: str, listdir, isdir) -> list: 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. diff --git a/docker/api/test_rohdaten.py b/docker/api/test_rohdaten.py index 47245fe..f7428d4 100644 --- a/docker/api/test_rohdaten.py +++ b/docker/api/test_rohdaten.py @@ -179,3 +179,54 @@ def test_suche_mit_der_zeitgrenze_findet_den_echten_fall(): isdir=lambda p: rohdaten.verzeichnis_da(p, laufen), ) assert gefunden == [f"/app/media/rippy/{JOB}"] + + +# --- Drei Antworten: "konnte nicht nachsehen" ist nicht "ist weg" ------------ + + +def test_pruefen_unterscheidet_drei_faelle(): + assert rohdaten.pruefen("/x", _Lauf(rc=0)) == "da" + assert rohdaten.pruefen("/x", _Lauf(rc=2)) == "weg" + # 124 = `timeout` hat das Kind abgeschossen (coreutils) -> nicht angesehen + assert rohdaten.pruefen("/x", _Lauf(rc=124)) == "unklar" + assert rohdaten.pruefen("/x", _Lauf(wirf=OSError("kein timeout"))) == "unklar" + assert rohdaten.pruefen("", _Lauf(rc=0)) == "weg" + + +def test_verzeichnis_da_bleibt_streng(): + """Wer Ja/Nein braucht, bekommt im Zweifel Nein.""" + assert rohdaten.verzeichnis_da("/x", _Lauf(rc=0)) is True + assert rohdaten.verzeichnis_da("/x", _Lauf(rc=124)) is False + + +def test_suche_mit_status_meldet_ungepruefte_orte(): + """Der echte Fall: Die Freigabe antwortet nicht, der lokale Ort ist leer. + Ein leeres Ergebnis darf dann NICHT als "nichts da" gelten.""" + def pruefe(pfad): + return "unklar" if pfad.startswith("/app/media/rippy/") else "weg" + + e = rohdaten.suche_mit_status( + JOB, "", listdir=lambda p: ["rippy"], pruefer=pruefe) + assert e == {"pfade": [], "unklar": True} + + +def test_suche_mit_status_ohne_zweifel(): + def pruefe(pfad): + return "da" if pfad == f"/app/media/rippy/{JOB}" else "weg" + + e = rohdaten.suche_mit_status( + JOB, "", listdir=lambda p: ["movies", "rippy"], pruefer=pruefe) + assert e == {"pfade": [f"/app/media/rippy/{JOB}"], "unklar": False} + + +def test_suche_mit_status_findet_trotz_unklarem_anderen_ort(): + """Ein Treffer bleibt ein Treffer, auch wenn ein anderer Ort schweigt.""" + def pruefe(pfad): + if pfad == f"/app/media/rippy/{JOB}": + return "da" + return "unklar" if "totes-nas" in pfad else "weg" + + e = rohdaten.suche_mit_status( + JOB, "", listdir=lambda p: ["rippy", "totes-nas"], pruefer=pruefe) + assert e["pfade"] == [f"/app/media/rippy/{JOB}"] + assert e["unklar"] is True