fix(ui,transcode): Dialog-Falle, Arbeitsverzeichnis waehlbar, Original-Aufheben entschaerft
Ampel / ampel (push) Successful in 28s
Ampel / ampel (push) Successful in 28s
Drei Befunde aus dem ersten echten UHD-Durchlauf (Akira). Der Rip und die
Kompression liefen sauber durch - danach hat "Original behalten" die Platte
vollgeschrieben und den fertigen Job als fehlgeschlagen markiert.
1. ORIGINAL-AUFHEBEN DARF DEN JOB NICHT MEHR TOETEN (tasks.py)
Vorher stand dort ein nacktes shutil.move(raw_dir, ziel). Arbeits-
verzeichnis (/app/temp, Docker-Volume) und Ziel (/app/media, Bind-Mount)
sind VERSCHIEDENE Dateisysteme - os.rename scheitert mit EXDEV, shutil.move
faellt auf Kopieren zurueck. Ergebnis am 25.07.: 74-GB-Vollkopie auf
dieselbe Platte, Abbruch bei 41 GB mit ENOSPC, Platte 100 % voll, Worker-
Container startete nicht mehr ("failed to mount: no space left on device"),
und der Job galt als FEHLGESCHLAGEN - obwohl die komprimierte Datei
(4,8 GB) fertig und in Ordnung war. Der Nutzer sah nur eine leere Queue.
Jetzt: _original_aufheben() prueft erst, ob ueberhaupt kopiert werden muss
(gleiches Dateisystem -> reines Umhaengen), prueft sonst den freien Platz
VORHER, faengt jeden OSError ab, raeumt eine halbe Kopie weg und meldet das
als WARNUNG. Der Job bleibt erfolgreich, die Roh-Datei bleibt liegen.
Zwei Tests decken beide Wege ab.
2. ARBEITSVERZEICHNIS IST JETZT WAEHLBAR (Settings.tsx)
Es war ein freies Textfeld - man musste den Container-Pfad (/app/media/...)
KENNEN, um eine Netzwerk-Freigabe zu treffen. Genau daran ist es
gescheitert, weshalb der 74-GB-Rohschnitt ueberhaupt erst auf der VM-Platte
landete. Jetzt eine Auswahl aus /storage-targets (dieselbe Liste wie bei
den Speicherzielen), inklusive Kennzeichnung als Netzwerk-Freigabe und
freiem Platz je Ziel.
3. DIALOGE KLEBTEN IM PANEL (ui/Modal.tsx)
"Rippen starten" im Laufwerke-Tab oeffnete den Dialog INNERHALB des
Bereichs, teils abgeschnitten. Ursache: .glass-panel in index.css setzt
backdrop-filter: blur(16px), und ein Element mit backdrop-filter wird zum
Bezugsrahmen fuer position: fixed seiner Nachfahren - das Modal war damit
an der Card ausgerichtet statt am Fenster. Modal rendert jetzt per
createPortal an document.body. Behebt es fuer ALLE Dialoge auf einmal.
Aufgeraeumt: die abgebrochene 41-GB-Teilkopie unter
"/app/media/movies/Akira (1988)/original/" geloescht (nachweislich
unvollstaendig - 41 GB gegen 79,6 GB Quelle, Quelle intakt). Platte wieder
bei 78 %, Container laufen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+59
-3
@@ -172,6 +172,64 @@ def _makemkv_key_anwenden(einstellungen: dict) -> None:
|
||||
db.add_log("warning", "worker", f"MakeMKV-Key konnte nicht gesetzt werden: {e}")
|
||||
|
||||
|
||||
def _ordner_groesse(pfad: str) -> int:
|
||||
"""Belegter Platz eines Ordners in Bytes (0, wenn nicht lesbar)."""
|
||||
summe = 0
|
||||
for wurzel, _, dateien in os.walk(pfad):
|
||||
for name in dateien:
|
||||
try:
|
||||
summe += os.path.getsize(os.path.join(wurzel, name))
|
||||
except OSError:
|
||||
pass
|
||||
return summe
|
||||
|
||||
|
||||
def _original_aufheben(job_id: str, raw_dir: str, final_dir: str) -> None:
|
||||
"""Roh-Rip zusätzlich aufheben — darf den Job NIEMALS scheitern lassen.
|
||||
|
||||
Befund 25.07.2026 (Akira-UHD, echter Schaden): Hier stand ein nacktes
|
||||
shutil.move(). Arbeitsverzeichnis (/app/temp, Docker-Volume) und Ziel
|
||||
(/app/media, Bind-Mount) sind VERSCHIEDENE Dateisysteme — os.rename
|
||||
scheitert dort mit EXDEV, und shutil.move fällt auf Kopieren zurück. Damit
|
||||
lief eine 74-GB-Vollkopie auf dieselbe Platte an, bis sie mit ENOSPC voll
|
||||
war. Ergebnis: Platte 100 % voll, Worker-Container startete nicht mehr —
|
||||
und der Job wurde als FEHLGESCHLAGEN markiert, obwohl die komprimierte
|
||||
Datei längst fertig und in Ordnung war. Der Nutzer sah nur „nichts da".
|
||||
|
||||
Deshalb dreifach abgesichert: Platz vorher prüfen, Fehler abfangen, und im
|
||||
Zweifel die Rohdatei einfach liegen lassen, wo sie ist.
|
||||
"""
|
||||
ziel_original = os.path.join(final_dir, "original")
|
||||
try:
|
||||
# Liegt beides auf demselben Dateisystem, ist es ein reines Umhängen —
|
||||
# sofort fertig, kein zusätzlicher Platz nötig.
|
||||
gleiches_dateisystem = os.stat(raw_dir).st_dev == os.stat(final_dir).st_dev
|
||||
if not gleiches_dateisystem:
|
||||
benoetigt = _ordner_groesse(raw_dir)
|
||||
frei = _frei_bytes(final_dir)
|
||||
if frei < benoetigt * 1.05:
|
||||
db.add_log(
|
||||
"warning", "worker",
|
||||
f"Job {job_id}: Original NICHT aufgehoben — dafür wären "
|
||||
f"{benoetigt / 1024**3:.1f} GB nötig, frei sind nur "
|
||||
f"{frei / 1024**3:.1f} GB. Die Roh-Datei bleibt unter "
|
||||
f"{raw_dir} liegen. Abhilfe: Arbeitsverzeichnis "
|
||||
"(Einstellungen → Verarbeitung) auf dieselbe Freigabe legen "
|
||||
"wie das Ziel — dann wird nur umgehängt statt kopiert."
|
||||
)
|
||||
return
|
||||
shutil.move(raw_dir, ziel_original)
|
||||
db.add_log("info", "worker", f"Job {job_id}: Original behalten unter {ziel_original}")
|
||||
except OSError as e:
|
||||
# Halb geschriebene Kopie wegräumen, sonst belegt sie für immer Platz.
|
||||
shutil.rmtree(ziel_original, ignore_errors=True)
|
||||
db.add_log(
|
||||
"warning", "worker",
|
||||
f"Job {job_id}: Original konnte nicht aufgehoben werden ({e}). "
|
||||
f"Die komprimierte Datei ist fertig; die Roh-Datei bleibt unter {raw_dir}."
|
||||
)
|
||||
|
||||
|
||||
def _abbruch_angefordert(job_id: str) -> bool:
|
||||
"""Kooperativer Abbruch: hat der Nutzer über die API abgebrochen?"""
|
||||
return db.get_job_status(job_id) == "canceling"
|
||||
@@ -610,9 +668,7 @@ def transcode_files(self, job_id: str, raw_dir: str, final_dir: str):
|
||||
return ergebnis
|
||||
|
||||
if original_behalten:
|
||||
ziel_original = os.path.join(final_dir, "original")
|
||||
shutil.move(raw_dir, ziel_original)
|
||||
db.add_log("info", "worker", f"Job {job_id}: Original behalten unter {ziel_original}")
|
||||
_original_aufheben(job_id, raw_dir, final_dir)
|
||||
else:
|
||||
shutil.rmtree(raw_dir, ignore_errors=True)
|
||||
|
||||
|
||||
@@ -112,3 +112,62 @@ def test_pfad_lokal_uebersetzt_fuer_windows_worker():
|
||||
assert pfad_lokal("/app/media/movies/Film (2020)", mapping) == "Z:\\media\\movies\\Film (2020)"
|
||||
assert pfad_lokal("/woanders/x", mapping) == "/woanders/x"
|
||||
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]
|
||||
|
||||
Reference in New Issue
Block a user