fix(ui,transcode): Dialog-Falle, Arbeitsverzeichnis waehlbar, Original-Aufheben entschaerft
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:
Hitonabi
2026-07-25 19:48:10 +02:00
parent e84afc1718
commit c065967f4d
4 changed files with 168 additions and 10 deletions
+12 -2
View File
@@ -1,4 +1,5 @@
import { ReactNode, useEffect } from 'react' import { ReactNode, useEffect } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react' import { X } from 'lucide-react'
interface ModalProps { interface ModalProps {
@@ -35,7 +36,15 @@ export function Modal({ isOpen, onClose, title, children, maxWidth = 'lg' }: Mod
'4xl': 'max-w-4xl', '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(
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 overflow-y-auto"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4 overflow-y-auto">
{/* Glass Backdrop */} {/* Glass Backdrop */}
<div <div
@@ -61,6 +70,7 @@ export function Modal({ isOpen, onClose, title, children, maxWidth = 'lg' }: Mod
{children} {children}
</div> </div>
</div> </div>
</div> </div>,
document.body,
) )
} }
+38 -5
View File
@@ -86,6 +86,14 @@ interface KeydbStatus {
geaendert: string 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. // Antwort von GET/POST /system/keystore — MakeMKVs eigener Schluesselvorrat.
interface KeystoreStatus { interface KeystoreStatus {
vorhanden: boolean vorhanden: boolean
@@ -148,6 +156,9 @@ export default function SettingsPage() {
const [keydbBusy, setKeydbBusy] = useState(false) const [keydbBusy, setKeydbBusy] = useState(false)
const [dumps, setDumps] = useState<AacsDump[]>([]) const [dumps, setDumps] = useState<AacsDump[]>([])
const keydbInput = useRef<HTMLInputElement>(null) const keydbInput = useRef<HTMLInputElement>(null)
// Ablageziele unter /app/media inkl. eingehängter Freigaben — speist die
// Auswahl des Arbeitsverzeichnisses (vorher musste man den Pfad tippen).
const [ziele, setZiele] = useState<StorageZiel[]>([])
const [keystore, setKeystore] = useState<KeystoreStatus | null>(null) const [keystore, setKeystore] = useState<KeystoreStatus | null>(null)
const [keystoreBusy, setKeystoreBusy] = useState(false) const [keystoreBusy, setKeystoreBusy] = useState(false)
const keystoreInput = useRef<HTMLInputElement>(null) const keystoreInput = useRef<HTMLInputElement>(null)
@@ -265,6 +276,9 @@ export default function SettingsPage() {
api.get('/system/keystore') api.get('/system/keystore')
.then(r => setKeystore(r.data)) .then(r => setKeystore(r.data))
.catch(() => setKeystore(null)) .catch(() => setKeystore(null))
api.get('/storage-targets')
.then(r => setZiele(Array.isArray(r.data) ? r.data : []))
.catch(() => setZiele([]))
api.get('/system/aacs-dumps') api.get('/system/aacs-dumps')
.then(r => setDumps(r.data?.dumps || [])) .then(r => setDumps(r.data?.dumps || []))
.catch(() => setDumps([])) .catch(() => setDumps([]))
@@ -613,14 +627,33 @@ export default function SettingsPage() {
/> />
</div> </div>
<Input {/*
label="Arbeitsverzeichnis für Roh-Rips (optional)" Auswahl statt Eintippen (Befund 25.07.2026): Das Feld war ein
freies Textfeld — man musste den Container-Pfad KENNEN
(/app/media/…), um eine Netzwerk-Freigabe zu treffen. Genau
daran ist es gescheitert, und der 74-GB-Rohschnitt landete auf
der VM-Platte, bis sie voll war. Die Ziele kommen aus
/storage-targets — dieselbe Liste wie bei den Speicherzielen,
inklusive freiem Platz.
*/}
<Select
label="Arbeitsverzeichnis für Roh-Rips"
value={settings.workDir} value={settings.workDir}
onChange={(e) => handleChange('workDir', e.target.value)} onChange={(e) => handleChange('workDir', e.target.value)}
placeholder="leer = /app/temp (Container-Platte)" >
/> <option value="">Container-Platte (Standard) klein, nur für DVD/Blu-ray</option>
{ziele.map(z => (
<option key={z.path} value={z.path}>
{z.name}{z.is_mount ? ' (Netzwerk-Freigabe)' : ''}
{z.free_gb != null ? `${z.free_gb} GB frei` : ''}
</option>
))}
</Select>
<p className="text-xs text-slate-500 dark:text-slate-400"> <p className="text-xs text-slate-500 dark:text-slate-400">
Wichtig für 4K-UHD: Rohdaten sind bis 100 GB groß. <strong className="text-slate-700 dark:text-slate-300">Wichtig für 4K-UHD:</strong> 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 ? ( {systemInfo?.plaetze?.length ? (
<> Aktuell frei: {systemInfo.plaetze.map(p => `${p.name}: ${p.frei_gb} GB`).join(' · ')}</> <> Aktuell frei: {systemInfo.plaetze.map(p => `${p.name}: ${p.frei_gb} GB`).join(' · ')}</>
) : null} ) : null}
+59 -3
View File
@@ -172,6 +172,64 @@ def _makemkv_key_anwenden(einstellungen: dict) -> None:
db.add_log("warning", "worker", f"MakeMKV-Key konnte nicht gesetzt werden: {e}") 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: def _abbruch_angefordert(job_id: str) -> bool:
"""Kooperativer Abbruch: hat der Nutzer über die API abgebrochen?""" """Kooperativer Abbruch: hat der Nutzer über die API abgebrochen?"""
return db.get_job_status(job_id) == "canceling" 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 return ergebnis
if original_behalten: if original_behalten:
ziel_original = os.path.join(final_dir, "original") _original_aufheben(job_id, raw_dir, final_dir)
shutil.move(raw_dir, ziel_original)
db.add_log("info", "worker", f"Job {job_id}: Original behalten unter {ziel_original}")
else: else:
shutil.rmtree(raw_dir, ignore_errors=True) shutil.rmtree(raw_dir, ignore_errors=True)
+59
View File
@@ -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("/app/media/movies/Film (2020)", mapping) == "Z:\\media\\movies\\Film (2020)"
assert pfad_lokal("/woanders/x", mapping) == "/woanders/x" assert pfad_lokal("/woanders/x", mapping) == "/woanders/x"
assert pfad_lokal("/app/temp/raw/abc", "") == "/app/temp/raw/abc" 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]