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:
Hitonabi
2026-07-25 21:05:11 +02:00
parent 883c1c290b
commit ef0a574a70
7 changed files with 455 additions and 116 deletions
+108 -32
View File
@@ -12,6 +12,7 @@ komprimiert danach auf Arbeitsgröße. Die Rohdatei liegt nur temporär in
/app/temp und wird nach Erfolg gelöscht (Setting keepOriginal behält sie).
"""
import errno
import glob
import json
import os
@@ -44,6 +45,7 @@ from ripping import (
rip_cd,
rip_video,
run_handbrake,
wirf_disc_aus,
)
API_URL = os.getenv("API_URL", "http://api:8000")
@@ -91,13 +93,38 @@ RAW_DIR = os.getenv("RAW_DIR", "/app/temp/raw")
MEDIA_ROOT = "/app/media"
def unter_wurzel(pfad: str, wurzel: str) -> bool:
"""Liegt `pfad` wirklich unterhalb von `wurzel` (oder IST es die Wurzel)?
Pure Funktion, testbar. Ein nacktes `startswith()` genügt nicht:
„/app/media-boese/x" beginnt mit „/app/media", liegt aber ausserhalb
(Befund 25.07.2026 bei der Durchsicht). Immer „/" als Trenner — das sind
Container-Pfade, auch wenn ein nativer Windows-Worker das Modul lädt.
Gleichlautend in docker/api/main.py; es gibt kein geteiltes Paket zwischen
den Containern.
"""
if not pfad or not wurzel:
return False
sauber = wurzel.rstrip("/") or "/"
return pfad == sauber or pfad.startswith(sauber + "/")
def _zielbasis(target_dir, disc_type: str) -> str:
"""Ablagebasis: vom Nutzer gewähltes Ziel (validiert) oder Standard."""
"""Ablagebasis: vom Nutzer gewähltes Ziel (validiert) oder Standard.
posixpath statt os.path — aus demselben Grund wie in _arbeitsverzeichnis:
das sind IMMER Container-Pfade. os.path.normpath macht unter Windows
Backslashes daraus, und dann greift die MEDIA_ROOT-Prüfung nicht mehr, das
gewählte Ziel fiele still auf den Standard zurück. Aufgefallen 25.07.2026,
als der Test dafür erstmals unter Windows lief. Live war es nie: aufgerufen
wird nur aus rip_disc, und das ist auf Windows-Workern verriegelt.
"""
if target_dir:
normalisiert = os.path.normpath(target_dir)
if normalisiert.startswith(MEDIA_ROOT):
normalisiert = posixpath.normpath(target_dir)
if unter_wurzel(normalisiert, MEDIA_ROOT):
return normalisiert
return os.path.join(RIP_OUTPUT_DIR, disc_type)
return posixpath.join(RIP_OUTPUT_DIR, disc_type)
def _arbeitsverzeichnis(einstellungen: dict, job_wahl: str = "") -> str:
@@ -121,7 +148,7 @@ def _arbeitsverzeichnis(einstellungen: dict, job_wahl: str = "") -> str:
wert = (kandidat or "").strip()
if wert:
normalisiert = posixpath.normpath(wert)
if normalisiert.startswith(MEDIA_ROOT):
if unter_wurzel(normalisiert, MEDIA_ROOT):
return normalisiert
return RAW_DIR
@@ -199,36 +226,65 @@ 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".
shutil.move(). Zwischen Arbeitsverzeichnis (/app/temp) und Ziel
(/app/media) scheitert os.rename mit EXDEV, shutil.move fällt auf Kopieren
zurück — eine 74-GB-Vollkopie auf dieselbe Platte, bis sie mit ENOSPC voll
war. Ergebnis: Platte 100 % voll, Worker-Container startete nicht mehr, und
der Job galt als FEHLGESCHLAGEN, obwohl die komprimierte Datei längst
fertig 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.
## Warum hier NICHT vorhergesagt, sondern versucht wird
Die erste Fassung dieses Schutzes verglich `os.stat(...).st_dev` und
schloss aus gleichen Werten auf „reines Umhängen, kein Platz nötig". Am
25.07.2026 im Worker-Container nachgemessen — beides zugleich wahr:
st_dev /app/temp = 2050
st_dev /app/media = 2050 → also identisch
os.rename(...) → EXDEV, „Invalid cross-device link"
Der Kernel vergleicht bei rename() den **Mount**, nicht das Gerät. /app/temp
(Docker-Volume) und /app/media (Bind-Mount) sind zwei Mounts DERSELBEN
ext4-Partition. Die st_dev-Prüfung war deshalb wirkungslos: sie sah
„gleiches Dateisystem", übersprang die Platzprüfung, und shutil.move kopierte
doch. Der Schutz hätte genau den Schaden zugelassen, gegen den er gebaut war.
Also: erst rename VERSUCHEN. Klappt es, ist es umgehängt und fertig.
Kommt EXDEV, steht fest, dass kopiert werden müsste — und erst dann wird
der Platz geprüft. Das ist keine Vermutung mehr, sondern die Antwort des
Kernels.
"""
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
# Der billige Weg zuerst — und er ist gleichzeitig der einzige
# verlässliche Test, ob überhaupt umgehängt werden kann.
try:
os.rename(raw_dir, ziel_original)
db.add_log(
"info", "worker",
f"Job {job_id}: Original behalten unter {ziel_original} (umgehängt, "
"kein zusätzlicher Platz nötig)",
)
return
except OSError as e:
if e.errno != errno.EXDEV:
raise # etwas anderes ist schiefgelaufen → unten ehrlich melden
# Ab hier ist eine echte Kopie unvermeidlich. Jetzt lohnt die Platzfrage.
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 — Ziel liegt auf einem "
f"anderen Mount, es müsste kopiert werden. 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:
@@ -500,7 +556,10 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
return ergebnis
if disc_type == "cd":
ergebnis = rip_cd(device_path, job_id, progress_cb=fortschritt, output_dir=final_dir)
ergebnis = rip_cd(
device_path, job_id, progress_cb=fortschritt, output_dir=final_dir,
auswerfen=bool(einstellungen.get("autoEject", True)),
)
elif transcode_an:
# Roh-Rip ins Arbeitsverzeichnis (wird nach erfolgreicher Kompression gelöscht)
ergebnis = rip_video(
@@ -562,6 +621,23 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
"Normale BD/DVD gehen weiterhin."
)
# Automatischer Auswurf. Die Disc ist nach dem Rip nicht mehr nötig — die
# Kompression arbeitet auf der Datei, nicht am Laufwerk.
#
# Befund 25.07.2026: Die Einstellung („Disc nach erfolgreichem Ripping
# automatisch auswerfen", Standard ein) wurde von NIEMANDEM gelesen. Bei
# DVD/Blu-ray warf Rippy deshalb nie aus, bei Audio-CD dagegen immer, weil
# abcde `-x` fest verdrahtet bekam. Jetzt entscheidet die Einstellung beides.
if ergebnis.get("status") == "success" and ist_video and einstellungen.get("autoEject", True):
if wirf_disc_aus(device_path):
db.add_log("info", "worker", f"Job {job_id}: Disc ausgeworfen")
else:
db.add_log(
"warning", "worker",
f"Job {job_id}: Disc konnte nicht ausgeworfen werden ({device_path}) — "
"der Rip ist davon unberührt.",
)
if ergebnis.get("status") == "success" and transcode_an:
# Kompression als eigener Task — an den im Rip-Dialog GEWÄHLTEN Worker
# (worker_direct), sonst an die geteilte transcode-Queue (irgendein