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
+23 -9
View File
@@ -155,7 +155,7 @@ async def _auto_rip_wenn_aktiviert(pfad: str):
else: else:
unterordner = einstellungen.get("movieDir") or "movies" unterordner = einstellungen.get("movieDir") or "movies"
ziel = os.path.normpath(os.path.join(basis, unterordner)) ziel = os.path.normpath(os.path.join(basis, unterordner))
if not ziel.startswith(MEDIA_ROOT): if not unter_wurzel(ziel, MEDIA_ROOT):
ziel = None ziel = None
job_id = str(uuid.uuid4()) job_id = str(uuid.uuid4())
@@ -308,7 +308,7 @@ def _kann_neu_komprimieren(job: dict, work_dir: str) -> bool:
return False return False
if os.path.isdir(os.path.join("/app/temp/raw", job["id"])): if os.path.isdir(os.path.join("/app/temp/raw", job["id"])):
return True return True
return work_dir.startswith(MEDIA_ROOT) and os.path.isdir(os.path.join(work_dir, job["id"])) return unter_wurzel(work_dir, MEDIA_ROOT) and os.path.isdir(os.path.join(work_dir, job["id"]))
@app.get("/jobs", response_model=List[Job]) @app.get("/jobs", response_model=List[Job])
@@ -373,7 +373,7 @@ def _validiere_ziel(target_dir: Optional[str]) -> Optional[str]:
if not target_dir: if not target_dir:
return None return None
normalisiert = os.path.normpath(target_dir) normalisiert = os.path.normpath(target_dir)
if not normalisiert.startswith(MEDIA_ROOT): if not unter_wurzel(normalisiert, MEDIA_ROOT):
raise HTTPException( raise HTTPException(
status_code=422, status_code=422,
detail=f"Ziel muss unter {MEDIA_ROOT} liegen (Shares dort einhängen)", detail=f"Ziel muss unter {MEDIA_ROOT} liegen (Shares dort einhängen)",
@@ -462,10 +462,24 @@ def _sicherer_dateiname(name: str) -> bool:
return bool(name) and "/" not in name and "\\" not in name and not name.startswith(".") return bool(name) and "/" not in name and "\\" not in name and not name.startswith(".")
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 hier nicht:
„/app/media-boese/x" beginnt mit „/app/media", liegt aber ausserhalb
(Befund 25.07.2026 bei der Durchsicht). Deshalb Gleichheit ODER Wurzel
samt Trennzeichen. Erwartet werden normalisierte Container-Pfade mit „/".
"""
if not pfad or not wurzel:
return False
sauber = wurzel.rstrip("/") or "/"
return pfad == sauber or pfad.startswith(sauber + "/")
def _job_ausgabeordner(job: dict) -> str: def _job_ausgabeordner(job: dict) -> str:
"""Validierter Ausgabeordner eines Jobs — strikt unter /app/media.""" """Validierter Ausgabeordner eines Jobs — strikt unter /app/media."""
ausgabe = os.path.normpath(job.get("output_path") or "") ausgabe = os.path.normpath(job.get("output_path") or "")
if not ausgabe.startswith(MEDIA_ROOT): if not unter_wurzel(ausgabe, MEDIA_ROOT):
raise HTTPException(status_code=404, detail="Job hat keinen Ausgabeordner unter /app/media") raise HTTPException(status_code=404, detail="Job hat keinen Ausgabeordner unter /app/media")
return ausgabe return ausgabe
@@ -522,7 +536,7 @@ async def download_job_file(job_id: str, dateiname: str):
pfad = os.path.join(ausgabe, dateiname) pfad = os.path.join(ausgabe, dateiname)
def pruefe(): def pruefe():
return os.path.isfile(pfad) and os.path.realpath(pfad).startswith(MEDIA_ROOT) return os.path.isfile(pfad) and unter_wurzel(os.path.realpath(pfad), MEDIA_ROOT)
if not await asyncio.to_thread(pruefe): if not await asyncio.to_thread(pruefe):
raise HTTPException(status_code=404, detail="Datei nicht gefunden") raise HTTPException(status_code=404, detail="Datei nicht gefunden")
@@ -633,7 +647,7 @@ async def retry_transcode(job_id: str):
# (Einstellungen → Verarbeitung), sonst Container-Default /app/temp/raw. # (Einstellungen → Verarbeitung), sonst Container-Default /app/temp/raw.
einstellungen = await asyncio.to_thread(db.get_settings) einstellungen = await asyncio.to_thread(db.get_settings)
work_dir = os.path.normpath((einstellungen.get("workDir") or "").strip() or "/") work_dir = os.path.normpath((einstellungen.get("workDir") or "").strip() or "/")
raw_basis = work_dir if work_dir.startswith(MEDIA_ROOT) else "/app/temp/raw" raw_basis = work_dir if unter_wurzel(work_dir, MEDIA_ROOT) else "/app/temp/raw"
raw_dir = f"{raw_basis}/{job_id}" raw_dir = f"{raw_basis}/{job_id}"
# Zielordner: der Worker schreibt das geplante Ziel beim Rip-Start nach # Zielordner: der Worker schreibt das geplante Ziel beim Rip-Start nach
# output_path (sprechender Name statt UUID) — alter Fallback bleibt. # output_path (sprechender Name statt UUID) — alter Fallback bleibt.
@@ -988,7 +1002,7 @@ async def delete_storage_mount(name: str):
async def browse(path: str = MEDIA_ROOT): async def browse(path: str = MEDIA_ROOT):
"""Server-seitiger Ordner-Browser für die Ziel-Auswahl (nur unter /app/media).""" """Server-seitiger Ordner-Browser für die Ziel-Auswahl (nur unter /app/media)."""
normalisiert = os.path.normpath(path) normalisiert = os.path.normpath(path)
if not normalisiert.startswith(MEDIA_ROOT): if not unter_wurzel(normalisiert, MEDIA_ROOT):
raise HTTPException(status_code=422, detail=f"Nur Pfade unter {MEDIA_ROOT}") raise HTTPException(status_code=422, detail=f"Nur Pfade unter {MEDIA_ROOT}")
def liste(): def liste():
@@ -1029,7 +1043,7 @@ class MkdirRequest(BaseModel):
async def browse_mkdir(request: MkdirRequest): async def browse_mkdir(request: MkdirRequest):
"""Neuen Ordner unter /app/media anlegen (Speicherziele-Verwaltung).""" """Neuen Ordner unter /app/media anlegen (Speicherziele-Verwaltung)."""
basis = os.path.normpath(request.path) basis = os.path.normpath(request.path)
if not basis.startswith(MEDIA_ROOT): if not unter_wurzel(basis, MEDIA_ROOT):
raise HTTPException(status_code=422, detail=f"Nur Pfade unter {MEDIA_ROOT}") raise HTTPException(status_code=422, detail=f"Nur Pfade unter {MEDIA_ROOT}")
name = request.name.strip() name = request.name.strip()
if not name or "/" in name or "\\" in name or name.startswith("."): if not name or "/" in name or "\\" in name or name.startswith("."):
@@ -1388,7 +1402,7 @@ async def download_aacs_dump(dateiname: str):
pfad = os.path.join(MAKEMKV_DATA_ROOT, dateiname) pfad = os.path.join(MAKEMKV_DATA_ROOT, dateiname)
def pruefe(): def pruefe():
return os.path.isfile(pfad) and os.path.realpath(pfad).startswith(MAKEMKV_DATA_ROOT) return os.path.isfile(pfad) and unter_wurzel(os.path.realpath(pfad), MAKEMKV_DATA_ROOT)
if not await asyncio.to_thread(pruefe): if not await asyncio.to_thread(pruefe):
raise HTTPException( raise HTTPException(
+28
View File
@@ -66,3 +66,31 @@ def test_remount_blockiert_den_api_start_nicht():
"remount muss als Hintergrund-Task laufen (nicht blockierend)" "remount muss als Hintergrund-Task laufen (nicht blockierend)"
assert "await asyncio.to_thread(remount)" not in quelle, \ assert "await asyncio.to_thread(remount)" not in quelle, \
"remount darf nicht mehr direkt awaited werden (blockiert sonst den Start)" "remount darf nicht mehr direkt awaited werden (blockiert sonst den Start)"
def test_unter_wurzel_faellt_nicht_auf_praefix_namen_herein():
"""Befund 25.07.2026: In main.py prueften neun Stellen mit nacktem
startswith(MEDIA_ROOT) — darunter /browse und /browse/mkdir, wo der Pfad
vom Nutzer kommt. „/app/media-boese/x" beginnt mit „/app/media", liegt
aber ausserhalb. Zwilling von tasks.unter_wurzel im Worker."""
from main import unter_wurzel
assert unter_wurzel("/app/media", "/app/media") is True
assert unter_wurzel("/app/media/movies", "/app/media") is True
assert unter_wurzel("/app/media-boese/x", "/app/media") is False
assert unter_wurzel("/app/mediaX", "/app/media") is False
assert unter_wurzel("/etc/passwd", "/app/media") is False
assert unter_wurzel("", "/app/media") is False
assert unter_wurzel("/app/media", "") is False
assert unter_wurzel("/app/media/movies", "/app/media/") is True
def test_sicherer_dateiname_blockt_pfad_tricks():
from main import _sicherer_dateiname
assert _sicherer_dateiname("film.mkv") is True
assert _sicherer_dateiname("../../etc/passwd") is False
assert _sicherer_dateiname("a/b.mkv") is False
assert _sicherer_dateiname("a\b.mkv") is False
assert _sicherer_dateiname(".versteckt") is False
assert _sicherer_dateiname("") is False
+84 -16
View File
@@ -25,6 +25,36 @@ class RipAbbruch(Exception):
Nutzer den Job abgebrochen hat (Status 'canceling' in der DB).""" Nutzer den Job abgebrochen hat (Status 'canceling' in der DB)."""
# include/uapi/linux/cdrom.h — dasselbe ioctl wie in api/devices.py
CDROMEJECT = 0x5309
def wirf_disc_aus(device_path: str) -> bool:
"""Wirft die Disc aus; gibt zurück, ob es geklappt hat. Wirft NIE.
Befund 25.07.2026: Die Einstellung „Automatischer Auswurf" (Standard: ein,
Beschreibung „Disc nach erfolgreichem Ripping automatisch auswerfen") wurde
von niemandem gelesen — bei DVD/Blu-ray warf Rippy nie aus.
Bewusst hier und nicht in detection.py: das Modul ist ein byteweiser
Zwilling der API-Kopie. `fcntl` gibt es nur unter Linux — der native
Windows-Worker lädt ripping.py ebenfalls, rippt dort aber nie.
"""
try:
from fcntl import ioctl
except ImportError: # Windows
return False
try:
fd = os.open(device_path, os.O_RDONLY | os.O_NONBLOCK)
try:
ioctl(fd, CDROMEJECT, 0)
finally:
os.close(fd)
return True
except OSError:
return False
def check_makemkv_installed() -> bool: def check_makemkv_installed() -> bool:
"""Prüft, ob makemkvcon installiert ist.""" """Prüft, ob makemkvcon installiert ist."""
return shutil.which("makemkvcon") is not None return shutil.which("makemkvcon") is not None
@@ -271,16 +301,42 @@ def build_handbrake_cmd(input_path: str, output_path: str, preset: str = DEFAULT
] ]
def get_progress_from_line(line: str) -> int: HB_ENCODE_RE = re.compile(
"""Extrahiert Fortschritt in Prozent aus HandBrake-Ausgabe. r'^Encoding:\s*task\s+(\d+)\s+of\s+(\d+),\s*(\d+(?:\.\d+)?)\s*%'
)
Testfund 22.07.: echtes HandBrake schreibt „45.50 %" MIT Leerzeichen vor
dem Prozentzeichen — eine Regex ohne \\s* parst NIE echte Ausgabe. def get_progress_from_line(line: str) -> int:
"""Extrahiert den ENCODE-Fortschritt in Prozent aus HandBrake-Ausgabe.
Gibt **-1** zurück, wenn die Zeile kein Encode-Fortschritt ist — genau wie
get_progress_from_prgv. Sonst wäre ein echtes „0 %" nicht von „keine
Angabe" zu unterscheiden, und der Anfang des Encodes bliebe unsichtbar.
Formatstrings aus dem HandBrakeCLI-Binary im Worker-Image gelesen
(AGENTS Regel D — nicht aus dem Kopf):
Scanning title %d of %d, preview %d, %.2f %% → Scan, NICHT Encode
Encoding: task %d of %d, %.2f %% (%.2f fps, avg → Encode
Encoding: task %d of %d, Searching for start time, %.2f → Vorlauf, NICHT Encode
Befund 25.07.2026 (Akira-UHD, live gemessen): Die alte Regex nahm JEDE
Zahl vor einem Prozentzeichen — also auch den Scan-Durchlauf, der VOR dem
Encodieren einmal bis 100 % hochläuft. Die Anzeige stand deshalb auf 99 %,
während der eigentliche Encode bei 1 % war. Zusammen mit dem alten Filter
`progress > 0` im Aufrufer blieb sie dort stundenlang kleben, weil
int(0.87) == 0 verworfen wurde.
`task N of M` wird mitgerechnet: Presets mit zwei Durchläufen zählen die
Prozente je Durchlauf neu, sonst sprang die Anzeige mitten in der Datei
zurück auf 0.
""" """
match = re.search(r'(\d+\.\d+)\s*%', line) match = HB_ENCODE_RE.match(line.strip())
if match: if not match:
return int(float(match.group(1))) return -1
return 0 aktuell, gesamt, prozent = int(match.group(1)), int(match.group(2)), float(match.group(3))
if gesamt < 1 or aktuell < 1:
return -1
return int(((aktuell - 1) * 100 + prozent) / gesamt)
def run_handbrake(input_path: str, output_path: str, preset: str = DEFAULT_HB_PRESET, progress_cb=None) -> dict: def run_handbrake(input_path: str, output_path: str, preset: str = DEFAULT_HB_PRESET, progress_cb=None) -> dict:
@@ -300,7 +356,10 @@ def run_handbrake(input_path: str, output_path: str, preset: str = DEFAULT_HB_PR
try: try:
for line in process.stdout: for line in process.stdout:
progress = get_progress_from_line(line) progress = get_progress_from_line(line)
if progress > 0 and progress_cb: # >= 0: ein echtes 0 % ist eine Angabe und muss durch. Der alte
# Filter `> 0` verwarf den gesamten ersten Prozentpunkt — bei
# einem 4K-Encode sind das Stunden ohne sichtbare Bewegung.
if progress >= 0 and progress_cb:
progress_cb(progress) progress_cb(progress)
except RipAbbruch: except RipAbbruch:
process.kill() process.kill()
@@ -320,20 +379,27 @@ def run_handbrake(input_path: str, output_path: str, preset: str = DEFAULT_HB_PR
return {"status": "error", "error": str(e)} return {"status": "error", "error": str(e)}
def build_abcde_cmd(device_path: str, config_path: str) -> list: def build_abcde_cmd(device_path: str, config_path: str, auswerfen: bool = True) -> list:
"""Baut das abcde-Kommando (pure Funktion, testbar). """Baut das abcde-Kommando (pure Funktion, testbar).
-o = Ausgabeformat (flac), -N = nicht-interaktiv, -x = Eject am Ende, -o = Ausgabeformat (flac), -N = nicht-interaktiv, -x = Eject am Ende,
-c = Config-Datei (enthält OUTPUTDIR). NIE ein Verzeichnis an -o geben. -c = Config-Datei (enthält OUTPUTDIR). NIE ein Verzeichnis an -o geben.
Befund 25.07.2026: `-x` stand fest verdrahtet drin. Damit warf eine
Audio-CD IMMER aus, während eine DVD/Blu-ray NIE auswarf — und die
Einstellung „Automatischer Auswurf" regelte keines von beidem, weil sie
nirgends gelesen wurde. Jetzt entscheidet sie hier mit.
""" """
return [ cmd = [
"abcde", "abcde",
"-d", device_path, "-d", device_path,
"-o", "flac", "-o", "flac",
"-N", "-N",
"-x",
"-c", config_path
] ]
if auswerfen:
cmd.append("-x")
cmd += ["-c", config_path]
return cmd
def write_abcde_config(output_dir: str) -> str: def write_abcde_config(output_dir: str) -> str:
@@ -467,8 +533,10 @@ def rip_video(device_path: str, disc_id: str, disc_type: str = "dvd", progress_c
log_cb=log_cb) log_cb=log_cb)
def rip_cd(device_path: str, disc_id: str, progress_cb=None, output_dir: str = None) -> dict: def rip_cd(device_path: str, disc_id: str, progress_cb=None, output_dir: str = None,
"""Rippt eine CD mit abcde (FLAC).""" auswerfen: bool = True) -> dict:
"""Rippt eine CD mit abcde (FLAC). `auswerfen` = Einstellung „Automatischer
Auswurf" (abcde macht das selbst per -x)."""
if not check_abcde_installed(): if not check_abcde_installed():
return { return {
"status": "error", "status": "error",
@@ -492,7 +560,7 @@ def rip_cd(device_path: str, disc_id: str, progress_cb=None, output_dir: str = N
try: try:
process = subprocess.Popen( process = subprocess.Popen(
build_abcde_cmd(device_path, config_path), build_abcde_cmd(device_path, config_path, auswerfen=auswerfen),
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, stderr=subprocess.STDOUT,
text=True, text=True,
+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). /app/temp und wird nach Erfolg gelöscht (Setting keepOriginal behält sie).
""" """
import errno
import glob import glob
import json import json
import os import os
@@ -44,6 +45,7 @@ from ripping import (
rip_cd, rip_cd,
rip_video, rip_video,
run_handbrake, run_handbrake,
wirf_disc_aus,
) )
API_URL = os.getenv("API_URL", "http://api:8000") 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" 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: 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: if target_dir:
normalisiert = os.path.normpath(target_dir) normalisiert = posixpath.normpath(target_dir)
if normalisiert.startswith(MEDIA_ROOT): if unter_wurzel(normalisiert, MEDIA_ROOT):
return normalisiert 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: def _arbeitsverzeichnis(einstellungen: dict, job_wahl: str = "") -> str:
@@ -121,7 +148,7 @@ def _arbeitsverzeichnis(einstellungen: dict, job_wahl: str = "") -> str:
wert = (kandidat or "").strip() wert = (kandidat or "").strip()
if wert: if wert:
normalisiert = posixpath.normpath(wert) normalisiert = posixpath.normpath(wert)
if normalisiert.startswith(MEDIA_ROOT): if unter_wurzel(normalisiert, MEDIA_ROOT):
return normalisiert return normalisiert
return RAW_DIR 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. """Roh-Rip zusätzlich aufheben — darf den Job NIEMALS scheitern lassen.
Befund 25.07.2026 (Akira-UHD, echter Schaden): Hier stand ein nacktes Befund 25.07.2026 (Akira-UHD, echter Schaden): Hier stand ein nacktes
shutil.move(). Arbeitsverzeichnis (/app/temp, Docker-Volume) und Ziel shutil.move(). Zwischen Arbeitsverzeichnis (/app/temp) und Ziel
(/app/media, Bind-Mount) sind VERSCHIEDENE Dateisysteme — os.rename (/app/media) scheitert os.rename mit EXDEV, shutil.move fällt auf Kopieren
scheitert dort mit EXDEV, und shutil.move fällt auf Kopieren zurück. Damit zurück — eine 74-GB-Vollkopie auf dieselbe Platte, bis sie mit ENOSPC voll
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
war. Ergebnis: Platte 100 % voll, Worker-Container startete nicht mehr — der Job galt als FEHLGESCHLAGEN, obwohl die komprimierte Datei längst
und der Job wurde als FEHLGESCHLAGEN markiert, obwohl die komprimierte fertig war. Der Nutzer sah nur „nichts da".
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 ## Warum hier NICHT vorhergesagt, sondern versucht wird
Zweifel die Rohdatei einfach liegen lassen, wo sie ist.
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") ziel_original = os.path.join(final_dir, "original")
try: try:
# Liegt beides auf demselben Dateisystem, ist es ein reines Umhängen — # Der billige Weg zuerst — und er ist gleichzeitig der einzige
# sofort fertig, kein zusätzlicher Platz nötig. # verlässliche Test, ob überhaupt umgehängt werden kann.
gleiches_dateisystem = os.stat(raw_dir).st_dev == os.stat(final_dir).st_dev try:
if not gleiches_dateisystem: os.rename(raw_dir, ziel_original)
benoetigt = _ordner_groesse(raw_dir) db.add_log(
frei = _frei_bytes(final_dir) "info", "worker",
if frei < benoetigt * 1.05: f"Job {job_id}: Original behalten unter {ziel_original} (umgehängt, "
db.add_log( "kein zusätzlicher Platz nötig)",
"warning", "worker", )
f"Job {job_id}: Original NICHT aufgehoben — dafür wären " return
f"{benoetigt / 1024**3:.1f} GB nötig, frei sind nur " except OSError as e:
f"{frei / 1024**3:.1f} GB. Die Roh-Datei bleibt unter " if e.errno != errno.EXDEV:
f"{raw_dir} liegen. Abhilfe: Arbeitsverzeichnis " raise # etwas anderes ist schiefgelaufen → unten ehrlich melden
"(Einstellungen → Verarbeitung) auf dieselbe Freigabe legen "
"wie das Ziel — dann wird nur umgehängt statt kopiert." # Ab hier ist eine echte Kopie unvermeidlich. Jetzt lohnt die Platzfrage.
) benoetigt = _ordner_groesse(raw_dir)
return 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) shutil.move(raw_dir, ziel_original)
db.add_log("info", "worker", f"Job {job_id}: Original behalten unter {ziel_original}") db.add_log("info", "worker", f"Job {job_id}: Original behalten unter {ziel_original}")
except OSError as e: except OSError as e:
@@ -500,7 +556,10 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
return ergebnis return ergebnis
if disc_type == "cd": 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: elif transcode_an:
# Roh-Rip ins Arbeitsverzeichnis (wird nach erfolgreicher Kompression gelöscht) # Roh-Rip ins Arbeitsverzeichnis (wird nach erfolgreicher Kompression gelöscht)
ergebnis = rip_video( 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." "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: if ergebnis.get("status") == "success" and transcode_an:
# Kompression als eigener Task — an den im Rip-Dialog GEWÄHLTEN Worker # Kompression als eigener Task — an den im Rip-Dialog GEWÄHLTEN Worker
# (worker_direct), sonst an die geteilte transcode-Queue (irgendein # (worker_direct), sonst an die geteilte transcode-Queue (irgendein
+39 -57
View File
@@ -114,63 +114,11 @@ def test_pfad_lokal_uebersetzt_fuer_windows_worker():
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): # Die beiden frueheren Tests zu _original_aufheben sind nach
"""Der Schritt "Original behalten" darf einen fertigen Job NICHT killen. # test_original_aufheben.py gewandert und dort erweitert. Sie pruefen jetzt das
# richtige Kriterium: Vorher taeuschten sie per gefaelschtem os.stat
Schaden vom 25.07.2026 (Akira-UHD): shutil.move() ueber Dateisystemgrenzen # „verschiedene Dateisysteme" vor — genau die Annahme, an der der Schutz in der
kopierte 74 GB, lief in ENOSPC, und der Job galt als fehlgeschlagen — # Praxis scheiterte (st_dev war identisch, os.rename trotzdem EXDEV).
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]
def test_arbeitsverzeichnis_wahl_des_rips_schlaegt_die_einstellung(): def test_arbeitsverzeichnis_wahl_des_rips_schlaegt_die_einstellung():
@@ -191,3 +139,37 @@ def test_arbeitsverzeichnis_wahl_des_rips_schlaegt_die_einstellung():
assert tasks._arbeitsverzeichnis({}, "/app/media/../etc") == tasks.RAW_DIR assert tasks._arbeitsverzeichnis({}, "/app/media/../etc") == tasks.RAW_DIR
# Leere Wahl faellt sauber auf die Einstellung zurueck # Leere Wahl faellt sauber auf die Einstellung zurueck
assert tasks._arbeitsverzeichnis(einst, " ") == "/app/media/movies" assert tasks._arbeitsverzeichnis(einst, " ") == "/app/media/movies"
def test_unter_wurzel_faellt_nicht_auf_praefix_namen_herein():
"""Befund 25.07.2026: Elf Stellen prueften mit nacktem startswith().
„/app/media-boese/x" beginnt mit „/app/media", liegt aber ausserhalb."""
import tasks
assert tasks.unter_wurzel("/app/media", "/app/media") is True
assert tasks.unter_wurzel("/app/media/movies", "/app/media") is True
assert tasks.unter_wurzel("/app/media/movies/Film (2020)", "/app/media") is True
# Der Fall, den startswith() durchgelassen haette
assert tasks.unter_wurzel("/app/media-boese/x", "/app/media") is False
assert tasks.unter_wurzel("/app/mediaX", "/app/media") is False
assert tasks.unter_wurzel("/etc/passwd", "/app/media") is False
assert tasks.unter_wurzel("", "/app/media") is False
assert tasks.unter_wurzel("/app/media", "") is False
# Wurzel mit Schrägstrich am Ende darf nichts aendern
assert tasks.unter_wurzel("/app/media/movies", "/app/media/") is True
def test_zielbasis_lehnt_praefix_ausbruch_ab():
import tasks
assert tasks._zielbasis("/app/media/movies", "bluray") == "/app/media/movies"
# Ausbruch per Praefix-Namen faellt auf den Standard zurueck
assert tasks._zielbasis("/app/media-boese", "bluray") != "/app/media-boese"
assert tasks._zielbasis("/etc", "bluray") != "/etc"
def test_arbeitsverzeichnis_lehnt_praefix_ausbruch_ab():
import tasks
assert tasks._arbeitsverzeichnis({}, "/app/media-boese") == tasks.RAW_DIR
assert tasks._arbeitsverzeichnis({"workDir": "/app/mediaX"}) == tasks.RAW_DIR
+124
View File
@@ -0,0 +1,124 @@
"""Tests fuer _original_aufheben — der Pfad, der am 25.07.2026 die Platte fuellte.
Der Kern: Es wird NICHT vorhergesagt, ob umgehaengt werden kann, sondern
os.rename versucht. Die alte Fassung verglich st_dev und lag damit falsch —
auf der Rippy-VM sind st_dev von /app/temp und /app/media identisch (2050),
os.rename scheitert zwischen ihnen trotzdem mit EXDEV, weil der Kernel den
Mount vergleicht und nicht das Geraet. Die Platzpruefung wurde deshalb
uebersprungen und shutil.move kopierte 75 GB bei 37 GB frei.
"""
import errno
import os
import pytest
import tasks
class FakeDb:
"""Faengt nur die Log-Zeilen ab — mehr braucht _original_aufheben nicht."""
def __init__(self):
self.logs = []
def add_log(self, level, source, message):
self.logs.append((level, message))
def meldungen(self):
return " ".join(m for _, m in self.logs)
@pytest.fixture
def fake_db(monkeypatch):
ersatz = FakeDb()
monkeypatch.setattr(tasks, "db", ersatz)
return ersatz
def _lege_rohdaten_an(tmp_path, groesse=2048):
raw = tmp_path / "raw" / "job-1"
raw.mkdir(parents=True)
(raw / "title_t00.mkv").write_bytes(b"x" * groesse)
final = tmp_path / "media" / "Film (2020)"
final.mkdir(parents=True)
return str(raw), str(final)
def test_umhaengen_wenn_derselbe_mount(tmp_path, fake_db):
"""Der gute Fall: rename klappt, nichts wird kopiert, kein Platz noetig."""
raw, final = _lege_rohdaten_an(tmp_path)
tasks._original_aufheben("job-1", raw, final)
assert os.path.isdir(os.path.join(final, "original"))
assert os.path.isfile(os.path.join(final, "original", "title_t00.mkv"))
assert not os.path.exists(raw)
assert "umgehängt" in fake_db.meldungen()
def test_bei_exdev_und_zu_wenig_platz_wird_nur_gewarnt(tmp_path, fake_db, monkeypatch):
"""Der Fall, der die Platte fuellte: rename geht nicht, Platz reicht nicht.
Vorher lief hier eine Vollkopie an, weil die st_dev-Pruefung „gleiches
Dateisystem" meldete und die Platzpruefung deshalb ausblieb.
"""
raw, final = _lege_rohdaten_an(tmp_path)
def kein_rename(*_a, **_k):
raise OSError(errno.EXDEV, "Invalid cross-device link")
monkeypatch.setattr(tasks.os, "rename", kein_rename)
monkeypatch.setattr(tasks, "_frei_bytes", lambda _p: 1024) # weniger als die Rohdaten
tasks._original_aufheben("job-1", raw, final)
# Rohdaten bleiben unangetastet liegen, es wurde NICHTS kopiert
assert os.path.isfile(os.path.join(raw, "title_t00.mkv"))
assert not os.path.exists(os.path.join(final, "original"))
meldungen = fake_db.meldungen()
assert "NICHT aufgehoben" in meldungen
assert "anderen Mount" in meldungen
assert "Arbeitsverzeichnis" in meldungen # nennt die Abhilfe
def test_bei_exdev_und_genug_platz_wird_kopiert(tmp_path, fake_db, monkeypatch):
raw, final = _lege_rohdaten_an(tmp_path)
echtes_rename = os.rename
aufrufe = {"n": 0}
def rename_erst_exdev(*args, **kwargs):
# Nur der Versuch von _original_aufheben scheitert; shutil.move darf
# intern weiter umbenennen (es kopiert selbst und benennt Teile um).
aufrufe["n"] += 1
if aufrufe["n"] == 1:
raise OSError(errno.EXDEV, "Invalid cross-device link")
return echtes_rename(*args, **kwargs)
monkeypatch.setattr(tasks.os, "rename", rename_erst_exdev)
monkeypatch.setattr(tasks, "_frei_bytes", lambda _p: 10 * 1024**3)
tasks._original_aufheben("job-1", raw, final)
assert os.path.isfile(os.path.join(final, "original", "title_t00.mkv"))
assert "Original behalten" in fake_db.meldungen()
def test_anderer_fehler_wird_ehrlich_gemeldet_und_reisst_job_nicht_mit(
tmp_path, fake_db, monkeypatch
):
"""Ein Fehler beim Aufheben darf den Job NIE scheitern lassen — die
komprimierte Datei ist zu diesem Zeitpunkt fertig und in Ordnung."""
raw, final = _lege_rohdaten_an(tmp_path)
def zugriff_verweigert(*_a, **_k):
raise OSError(errno.EACCES, "Permission denied")
monkeypatch.setattr(tasks.os, "rename", zugriff_verweigert)
tasks._original_aufheben("job-1", raw, final) # darf nicht werfen
meldungen = fake_db.meldungen()
assert "konnte nicht aufgehoben werden" in meldungen
assert raw in meldungen # sagt, WO die Rohdatei liegt
+49 -2
View File
@@ -43,10 +43,43 @@ def test_handbrake_cmd_arbeitet_auf_datei_nicht_geraet():
def test_handbrake_progress_parsing(): def test_handbrake_progress_parsing():
# Testfund 22.07.: echtes HandBrake schreibt 45.50 %" MIT Leerzeichen # Testfund 22.07.: echtes HandBrake schreibt 45.50 % MIT Leerzeichen
assert get_progress_from_line("Encoding: task 1 of 1, 45.50 %") == 45 assert get_progress_from_line("Encoding: task 1 of 1, 45.50 %") == 45
assert get_progress_from_line("Encoding: task 1 of 1, 100.00 %") == 100 assert get_progress_from_line("Encoding: task 1 of 1, 100.00 %") == 100
assert get_progress_from_line("kein Fortschritt hier") == 0 # Echte Zeile mit fps-Anhang, wie sie im Binary steht
assert get_progress_from_line(
"Encoding: task 1 of 1, 12.34 % (5.67 fps, avg 4.32 fps, ETA 00h12m34s)"
) == 12
# Ein echtes 0 % ist eine ANGABE, keine Leermeldung
assert get_progress_from_line("Encoding: task 1 of 1, 0.00 %") == 0
def test_handbrake_progress_ignoriert_scan_durchlauf():
"""Befund 25.07.2026 (Akira-UHD, live gemessen): HandBrake läuft VOR dem
Encodieren einen Scan-Durchlauf, der ebenfalls Prozente ausgibt und dabei
bis 100 % steigt. Die alte Regex nahm jede Zahl vor einem Prozentzeichen
und meldete deshalb 99 %, während der Encode bei 1 % stand.
-1 heißt „keine Encode-Fortschrittszeile" — dasselbe Muster wie bei
get_progress_from_prgv. Fremde Zeilen dürfen NIE als 0 % gelten.
"""
assert get_progress_from_line("Scanning title 1 of 1, preview 3, 30.00 %") == -1
assert get_progress_from_line("Scanning title 1 of 1, preview 10, 100.00 %") == -1
# Vorlauf-Phase: Prozente beziehen sich auf die Suche, nicht auf den Encode
assert get_progress_from_line(
"Encoding: task 1 of 1, Searching for start time, 42.00 %"
) == -1
assert get_progress_from_line("kein Fortschritt hier") == -1
assert get_progress_from_line("Muxing: this may take awhile...") == -1
assert get_progress_from_line("") == -1
def test_handbrake_progress_rechnet_zwei_durchlaeufe_zusammen():
"""Presets mit zwei Durchläufen zählen die Prozente je Durchlauf neu.
Ohne Verrechnung sprang die Anzeige mitten in der Datei zurück auf 0."""
assert get_progress_from_line("Encoding: task 1 of 2, 50.00 %") == 25
assert get_progress_from_line("Encoding: task 2 of 2, 0.00 %") == 50
assert get_progress_from_line("Encoding: task 2 of 2, 100.00 %") == 100
def test_prgv_parsing(): def test_prgv_parsing():
@@ -129,6 +162,20 @@ def test_abcde_cmd_hat_genau_ein_ausgabeformat():
assert "-N" in cmd # nicht-interaktiv, sonst hängt der Worker assert "-N" in cmd # nicht-interaktiv, sonst hängt der Worker
def test_abcde_auswurf_folgt_der_einstellung():
"""Befund 25.07.2026: `-x` (Auswurf) stand fest verdrahtet drin. Eine
Audio-CD warf damit IMMER aus, eine DVD/Blu-ray NIE — und die Einstellung
„Automatischer Auswurf" regelte keines von beidem, weil sie nirgends
gelesen wurde."""
assert "-x" in build_abcde_cmd("/dev/sr0", "/tmp/c.conf", auswerfen=True)
assert "-x" not in build_abcde_cmd("/dev/sr0", "/tmp/c.conf", auswerfen=False)
# Standard bleibt „auswerfen" — so war das Verhalten bisher
assert "-x" in build_abcde_cmd("/dev/sr0", "/tmp/c.conf")
# Die Config darf durch das weggefallene -x nicht verrutschen
ohne = build_abcde_cmd("/dev/sr0", "/tmp/c.conf", auswerfen=False)
assert ohne[ohne.index("-c") + 1] == "/tmp/c.conf"
def test_abcde_config_enthaelt_zielverzeichnis(): def test_abcde_config_enthaelt_zielverzeichnis():
pfad = write_abcde_config("/app/media/cd/test123") pfad = write_abcde_config("/app/media/cd/test123")
try: try: