diff --git a/docker/ui/src/components/ui/Modal.tsx b/docker/ui/src/components/ui/Modal.tsx index 0abe2e8..fc5cada 100644 --- a/docker/ui/src/components/ui/Modal.tsx +++ b/docker/ui/src/components/ui/Modal.tsx @@ -1,4 +1,5 @@ import { ReactNode, useEffect } from 'react' +import { createPortal } from 'react-dom' import { X } from 'lucide-react' interface ModalProps { @@ -35,7 +36,15 @@ export function Modal({ isOpen, onClose, title, children, maxWidth = 'lg' }: Mod '4xl': 'max-w-4xl', } - return ( + // WICHTIG (Befund 25.07.2026): Der Dialog wird per Portal direkt an + // document.body gehängt statt dort zu bleiben, wo er im Baum steht. + // Grund: `.glass-panel` in index.css setzt `backdrop-filter: blur(16px)`, + // und ein Element mit backdrop-filter wird zum Bezugsrahmen für + // `position: fixed` seiner Nachfahren. Ein Modal INNERHALB einer Card war + // damit nicht mehr am Fenster ausgerichtet, sondern an der Card — es klebte + // im Panel und wurde am Rand abgeschnitten (gemeldet für „Rippen starten" + // im Laufwerke-Tab). Das Portal löst das für ALLE Dialoge auf einmal. + return createPortal(
{/* Glass Backdrop */}
-
+ , + document.body, ) } diff --git a/docker/ui/src/pages/Settings.tsx b/docker/ui/src/pages/Settings.tsx index 3374fed..2976450 100644 --- a/docker/ui/src/pages/Settings.tsx +++ b/docker/ui/src/pages/Settings.tsx @@ -86,6 +86,14 @@ interface KeydbStatus { geaendert: string } +// Ein Ablageziel aus GET /storage-targets (Feldnamen exakt wie die API liefert). +interface StorageZiel { + name: string + path: string + is_mount: boolean + free_gb: number | null +} + // Antwort von GET/POST /system/keystore — MakeMKVs eigener Schluesselvorrat. interface KeystoreStatus { vorhanden: boolean @@ -148,6 +156,9 @@ export default function SettingsPage() { const [keydbBusy, setKeydbBusy] = useState(false) const [dumps, setDumps] = useState([]) const keydbInput = useRef(null) + // Ablageziele unter /app/media inkl. eingehängter Freigaben — speist die + // Auswahl des Arbeitsverzeichnisses (vorher musste man den Pfad tippen). + const [ziele, setZiele] = useState([]) const [keystore, setKeystore] = useState(null) const [keystoreBusy, setKeystoreBusy] = useState(false) const keystoreInput = useRef(null) @@ -265,6 +276,9 @@ export default function SettingsPage() { api.get('/system/keystore') .then(r => setKeystore(r.data)) .catch(() => setKeystore(null)) + api.get('/storage-targets') + .then(r => setZiele(Array.isArray(r.data) ? r.data : [])) + .catch(() => setZiele([])) api.get('/system/aacs-dumps') .then(r => setDumps(r.data?.dumps || [])) .catch(() => setDumps([])) @@ -613,14 +627,33 @@ export default function SettingsPage() { /> - handleChange('workDir', e.target.value)} - placeholder="leer = /app/temp (Container-Platte)" - /> + > + + {ziele.map(z => ( + + ))} +

- Wichtig für 4K-UHD: Rohdaten sind bis 100 GB groß. + Wichtig für 4K-UHD: Rohdaten + sind bis 100 GB groß und passen selten auf die Container-Platte. Lege das Arbeitsverzeichnis + auf eine Freigabe mit genug Platz — am besten auf dieselbe, auf der auch die fertigen Dateien + landen. Dann muss Rippy die Rohdatei am Ende nur umhängen statt sie zu kopieren. {systemInfo?.plaetze?.length ? ( <> Aktuell frei: {systemInfo.plaetze.map(p => `${p.name}: ${p.frei_gb} GB`).join(' · ')} ) : null} diff --git a/docker/worker/tasks.py b/docker/worker/tasks.py index fb78230..7d0fcaf 100644 --- a/docker/worker/tasks.py +++ b/docker/worker/tasks.py @@ -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) diff --git a/docker/worker/test_medien.py b/docker/worker/test_medien.py index 062b8c1..538f01a 100644 --- a/docker/worker/test_medien.py +++ b/docker/worker/test_medien.py @@ -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]