fix(worker,api): Platten-Schutz griff nicht, Fortschritt log, Auswurf tat nichts

Vier Funde aus der Durchsicht, alle auf der VM gemessen.

1. DER PLATTEN-SCHUTZ AUS c065967 WAR WIRKUNGSLOS

_original_aufheben() entschied per os.stat().st_dev, ob umgehaengt oder
kopiert werden muss. Im Worker-Container gemessen - beides gleichzeitig wahr:

    st_dev /app/temp  = 2050
    st_dev /app/media = 2050        → identisch
    os.rename(...)    → EXDEV, "Invalid cross-device link"

Der Kernel vergleicht bei rename() den MOUNT, nicht das Geraet. /app/temp
(Docker-Volume) und /app/media (Bind-Mount) sind zwei Mounts DERSELBEN
ext4-Partition. Die Pruefung sah "gleiches Dateisystem", uebersprang die
Platzpruefung, und shutil.move kopierte doch - 75 GB bei 37 GB frei. Der
Schutz haette genau den Schaden zugelassen, gegen den er gebaut wurde.

Jetzt wird os.rename VERSUCHT statt vorhergesagt: klappt es, ist es
umgehaengt und fertig; kommt EXDEV, steht die Kopie fest und ERST DANN wird
der Platz geprueft. Das ist keine Vermutung mehr, sondern die Antwort des
Kernels. Vier Tests in test_original_aufheben.py, darunter genau der Fall,
der die Platte fuellte. Die zwei alten Tests in test_medien.py sind dorthin
gewandert - sie taeuschten per gefaelschtem os.stat "verschiedene
Dateisysteme" vor, also genau die Annahme, an der der Schutz scheiterte.

2. DIE FORTSCHRITTSANZEIGE ZEIGTE DEN SCAN, NICHT DEN ENCODE

get_progress_from_line matchte jede Zahl vor einem Prozentzeichen. HandBrake
gibt Prozente aber in drei Phasen aus (Formatstrings aus dem Binary gelesen):

    Scanning title %d of %d, preview %d, %.2f %%          → laeuft VOR dem
                                                            Encode bis 100 %
    Encoding: task %d of %d, %.2f %%       (%.2f fps, avg  → der echte Wert
    Encoding: task %d of %d, Searching for start time, ... → Vorlauf

Dazu warf `if progress > 0` im Aufrufer jeden Wert unter 1,00 % weg. Live
beobachtet: Anzeige stand auf 99 %, der Encode bei 1,06 %; sie fiel erst auf
1, als der Encode die 1-%-Marke ueberschritt. Jetzt wird nur die
Encoding-Zeile gelesen, `task N of M` mitgerechnet (sonst springt die
Anzeige bei Zwei-Pass-Presets mitten in der Datei zurueck), und -1 heisst
"keine Angabe" - dasselbe Muster wie bei get_progress_from_prgv.

3. "AUTOMATISCHER AUSWURF" WURDE VON NIEMANDEM GELESEN

Die Einstellung (Standard: ein, "Disc nach erfolgreichem Ripping automatisch
auswerfen") kam in keiner Zeile Backend-Code vor. DVD/Blu-ray warfen deshalb
NIE aus, Audio-CDs IMMER, weil abcde `-x` fest verdrahtet bekam. Jetzt
entscheidet die Einstellung beides: wirf_disc_aus() per CDROMEJECT-ioctl
(fcntl-guarded, der native Windows-Worker laedt das Modul auch) und `-x` nur
noch, wenn gewuenscht.

4. PFAD-PRUEFUNG FIEL AUF PRAEFIX-NAMEN HEREIN

Elf Stellen prueften mit nacktem startswith(MEDIA_ROOT). "/app/media-boese/x"
beginnt mit "/app/media", liegt aber ausserhalb - betroffen waren auch
/browse und /browse/mkdir, wo der Pfad vom Nutzer kommt. Neuer
Zwillings-Helfer unter_wurzel() in api/main.py und worker/tasks.py, alle elf
Stellen umgestellt, Tests in beiden.

Nebenbefund: _zielbasis() benutzte os.path.normpath - unter Windows werden
daraus Backslashes, die MEDIA_ROOT-Pruefung greift nicht mehr, und das
gewaehlte Ziel faellt still auf den Standard zurueck. Genau die Falle, die
_arbeitsverzeichnis() drei Zeilen weiter dokumentiert und mit posixpath
vermeidet. Live war es nie (nur aus rip_disc, das auf Windows verriegelt
ist), jetzt konsistent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-25 21:05:11 +02:00
parent 883c1c290b
commit ef0a574a70
7 changed files with 455 additions and 116 deletions
+39 -57
View File
@@ -114,63 +114,11 @@ def test_pfad_lokal_uebersetzt_fuer_windows_worker():
assert pfad_lokal("/app/temp/raw/abc", "") == "/app/temp/raw/abc"
def test_original_aufheben_laesst_job_nie_scheitern(tmp_path, monkeypatch):
"""Der Schritt "Original behalten" darf einen fertigen Job NICHT killen.
Schaden vom 25.07.2026 (Akira-UHD): shutil.move() ueber Dateisystemgrenzen
kopierte 74 GB, lief in ENOSPC, und der Job galt als fehlgeschlagen —
obwohl die komprimierte Datei laengst fertig war. Der Nutzer sah nur eine
leere Queue.
"""
import tasks
meldungen = []
monkeypatch.setattr(tasks.db, "add_log", lambda *a: meldungen.append(a))
raw = tmp_path / "raw"
raw.mkdir()
(raw / "title_t00.mkv").write_bytes(b"x" * 1024)
final = tmp_path / "final"
final.mkdir()
def platzt(*_a, **_k):
raise OSError(28, "No space left on device")
monkeypatch.setattr(tasks.shutil, "move", platzt)
# Darf NICHT werfen
tasks._original_aufheben("job-1", str(raw), str(final))
assert meldungen and meldungen[-1][0] == "warning"
assert "Roh-Datei bleibt" in meldungen[-1][2]
def test_original_aufheben_warnt_statt_platte_vollzuschreiben(tmp_path, monkeypatch):
"""Zu wenig Platz auf einem ANDEREN Dateisystem -> gar nicht erst anfangen."""
import tasks
meldungen = []
monkeypatch.setattr(tasks.db, "add_log", lambda *a: meldungen.append(a))
bewegt = []
monkeypatch.setattr(tasks.shutil, "move", lambda *a: bewegt.append(a))
raw = tmp_path / "raw"
raw.mkdir()
(raw / "gross.mkv").write_bytes(b"x" * 4096)
final = tmp_path / "final"
final.mkdir()
# Verschiedene Dateisysteme vortaeuschen und den freien Platz kleinrechnen.
# _ordner_groesse wird mitgefaelscht, weil ein selbstgebautes os.stat sonst
# os.path.getsize mitreisst (das liest st_size an derselben Stelle).
class Stat:
def __init__(self, dev): self.st_dev = dev
monkeypatch.setattr(tasks.os, "stat", lambda p: Stat(1 if "raw" in str(p) else 2))
monkeypatch.setattr(tasks, "_ordner_groesse", lambda _p: 4096)
monkeypatch.setattr(tasks, "_frei_bytes", lambda _p: 100)
tasks._original_aufheben("job-2", str(raw), str(final))
assert bewegt == [], "es darf NICHT kopiert werden, wenn der Platz nicht reicht"
assert meldungen[-1][0] == "warning"
assert "NICHT aufgehoben" in meldungen[-1][2]
# Die beiden frueheren Tests zu _original_aufheben sind nach
# test_original_aufheben.py gewandert und dort erweitert. Sie pruefen jetzt das
# richtige Kriterium: Vorher taeuschten sie per gefaelschtem os.stat
# „verschiedene Dateisysteme" vor — genau die Annahme, an der der Schutz in der
# Praxis scheiterte (st_dev war identisch, os.rename trotzdem EXDEV).
def test_arbeitsverzeichnis_wahl_des_rips_schlaegt_die_einstellung():
@@ -191,3 +139,37 @@ def test_arbeitsverzeichnis_wahl_des_rips_schlaegt_die_einstellung():
assert tasks._arbeitsverzeichnis({}, "/app/media/../etc") == tasks.RAW_DIR
# Leere Wahl faellt sauber auf die Einstellung zurueck
assert tasks._arbeitsverzeichnis(einst, " ") == "/app/media/movies"
def test_unter_wurzel_faellt_nicht_auf_praefix_namen_herein():
"""Befund 25.07.2026: Elf Stellen prueften mit nacktem startswith().
„/app/media-boese/x" beginnt mit „/app/media", liegt aber ausserhalb."""
import tasks
assert tasks.unter_wurzel("/app/media", "/app/media") is True
assert tasks.unter_wurzel("/app/media/movies", "/app/media") is True
assert tasks.unter_wurzel("/app/media/movies/Film (2020)", "/app/media") is True
# Der Fall, den startswith() durchgelassen haette
assert tasks.unter_wurzel("/app/media-boese/x", "/app/media") is False
assert tasks.unter_wurzel("/app/mediaX", "/app/media") is False
assert tasks.unter_wurzel("/etc/passwd", "/app/media") is False
assert tasks.unter_wurzel("", "/app/media") is False
assert tasks.unter_wurzel("/app/media", "") is False
# Wurzel mit Schrägstrich am Ende darf nichts aendern
assert tasks.unter_wurzel("/app/media/movies", "/app/media/") is True
def test_zielbasis_lehnt_praefix_ausbruch_ab():
import tasks
assert tasks._zielbasis("/app/media/movies", "bluray") == "/app/media/movies"
# Ausbruch per Praefix-Namen faellt auf den Standard zurueck
assert tasks._zielbasis("/app/media-boese", "bluray") != "/app/media-boese"
assert tasks._zielbasis("/etc", "bluray") != "/etc"
def test_arbeitsverzeichnis_lehnt_praefix_ausbruch_ab():
import tasks
assert tasks._arbeitsverzeichnis({}, "/app/media-boese") == tasks.RAW_DIR
assert tasks._arbeitsverzeichnis({"workDir": "/app/mediaX"}) == tasks.RAW_DIR