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:
@@ -0,0 +1,124 @@
|
||||
"""Tests fuer _original_aufheben — der Pfad, der am 25.07.2026 die Platte fuellte.
|
||||
|
||||
Der Kern: Es wird NICHT vorhergesagt, ob umgehaengt werden kann, sondern
|
||||
os.rename versucht. Die alte Fassung verglich st_dev und lag damit falsch —
|
||||
auf der Rippy-VM sind st_dev von /app/temp und /app/media identisch (2050),
|
||||
os.rename scheitert zwischen ihnen trotzdem mit EXDEV, weil der Kernel den
|
||||
Mount vergleicht und nicht das Geraet. Die Platzpruefung wurde deshalb
|
||||
uebersprungen und shutil.move kopierte 75 GB bei 37 GB frei.
|
||||
"""
|
||||
|
||||
import errno
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
import tasks
|
||||
|
||||
|
||||
class FakeDb:
|
||||
"""Faengt nur die Log-Zeilen ab — mehr braucht _original_aufheben nicht."""
|
||||
|
||||
def __init__(self):
|
||||
self.logs = []
|
||||
|
||||
def add_log(self, level, source, message):
|
||||
self.logs.append((level, message))
|
||||
|
||||
def meldungen(self):
|
||||
return " ".join(m for _, m in self.logs)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_db(monkeypatch):
|
||||
ersatz = FakeDb()
|
||||
monkeypatch.setattr(tasks, "db", ersatz)
|
||||
return ersatz
|
||||
|
||||
|
||||
def _lege_rohdaten_an(tmp_path, groesse=2048):
|
||||
raw = tmp_path / "raw" / "job-1"
|
||||
raw.mkdir(parents=True)
|
||||
(raw / "title_t00.mkv").write_bytes(b"x" * groesse)
|
||||
final = tmp_path / "media" / "Film (2020)"
|
||||
final.mkdir(parents=True)
|
||||
return str(raw), str(final)
|
||||
|
||||
|
||||
def test_umhaengen_wenn_derselbe_mount(tmp_path, fake_db):
|
||||
"""Der gute Fall: rename klappt, nichts wird kopiert, kein Platz noetig."""
|
||||
raw, final = _lege_rohdaten_an(tmp_path)
|
||||
|
||||
tasks._original_aufheben("job-1", raw, final)
|
||||
|
||||
assert os.path.isdir(os.path.join(final, "original"))
|
||||
assert os.path.isfile(os.path.join(final, "original", "title_t00.mkv"))
|
||||
assert not os.path.exists(raw)
|
||||
assert "umgehängt" in fake_db.meldungen()
|
||||
|
||||
|
||||
def test_bei_exdev_und_zu_wenig_platz_wird_nur_gewarnt(tmp_path, fake_db, monkeypatch):
|
||||
"""Der Fall, der die Platte fuellte: rename geht nicht, Platz reicht nicht.
|
||||
|
||||
Vorher lief hier eine Vollkopie an, weil die st_dev-Pruefung „gleiches
|
||||
Dateisystem" meldete und die Platzpruefung deshalb ausblieb.
|
||||
"""
|
||||
raw, final = _lege_rohdaten_an(tmp_path)
|
||||
|
||||
def kein_rename(*_a, **_k):
|
||||
raise OSError(errno.EXDEV, "Invalid cross-device link")
|
||||
|
||||
monkeypatch.setattr(tasks.os, "rename", kein_rename)
|
||||
monkeypatch.setattr(tasks, "_frei_bytes", lambda _p: 1024) # weniger als die Rohdaten
|
||||
|
||||
tasks._original_aufheben("job-1", raw, final)
|
||||
|
||||
# Rohdaten bleiben unangetastet liegen, es wurde NICHTS kopiert
|
||||
assert os.path.isfile(os.path.join(raw, "title_t00.mkv"))
|
||||
assert not os.path.exists(os.path.join(final, "original"))
|
||||
meldungen = fake_db.meldungen()
|
||||
assert "NICHT aufgehoben" in meldungen
|
||||
assert "anderen Mount" in meldungen
|
||||
assert "Arbeitsverzeichnis" in meldungen # nennt die Abhilfe
|
||||
|
||||
|
||||
def test_bei_exdev_und_genug_platz_wird_kopiert(tmp_path, fake_db, monkeypatch):
|
||||
raw, final = _lege_rohdaten_an(tmp_path)
|
||||
|
||||
echtes_rename = os.rename
|
||||
aufrufe = {"n": 0}
|
||||
|
||||
def rename_erst_exdev(*args, **kwargs):
|
||||
# Nur der Versuch von _original_aufheben scheitert; shutil.move darf
|
||||
# intern weiter umbenennen (es kopiert selbst und benennt Teile um).
|
||||
aufrufe["n"] += 1
|
||||
if aufrufe["n"] == 1:
|
||||
raise OSError(errno.EXDEV, "Invalid cross-device link")
|
||||
return echtes_rename(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(tasks.os, "rename", rename_erst_exdev)
|
||||
monkeypatch.setattr(tasks, "_frei_bytes", lambda _p: 10 * 1024**3)
|
||||
|
||||
tasks._original_aufheben("job-1", raw, final)
|
||||
|
||||
assert os.path.isfile(os.path.join(final, "original", "title_t00.mkv"))
|
||||
assert "Original behalten" in fake_db.meldungen()
|
||||
|
||||
|
||||
def test_anderer_fehler_wird_ehrlich_gemeldet_und_reisst_job_nicht_mit(
|
||||
tmp_path, fake_db, monkeypatch
|
||||
):
|
||||
"""Ein Fehler beim Aufheben darf den Job NIE scheitern lassen — die
|
||||
komprimierte Datei ist zu diesem Zeitpunkt fertig und in Ordnung."""
|
||||
raw, final = _lege_rohdaten_an(tmp_path)
|
||||
|
||||
def zugriff_verweigert(*_a, **_k):
|
||||
raise OSError(errno.EACCES, "Permission denied")
|
||||
|
||||
monkeypatch.setattr(tasks.os, "rename", zugriff_verweigert)
|
||||
|
||||
tasks._original_aufheben("job-1", raw, final) # darf nicht werfen
|
||||
|
||||
meldungen = fake_db.meldungen()
|
||||
assert "konnte nicht aufgehoben werden" in meldungen
|
||||
assert raw in meldungen # sagt, WO die Rohdatei liegt
|
||||
Reference in New Issue
Block a user