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:
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user