Commander: „Kompression fehlgeschlagen bei Spartacus … _t01.mkv: HandBrake
endete mit Code 0" — 16,5 GB fertiger Rohschnitt, und die Kompression war in
derselben Sekunde vorbei, in der sie begann.
Nachgestellt mit genau der Befehlszeile, die Rippy baute:
unknown option (--audio-codec)
HandBrake has exited. $? = 0
Den Schalter `--audio-codec` gibt es bei HandBrakeCLI nicht; er heisst
`-E` / `--aencoder`. Ein unbekannter Schalter ist fuer HandBrake kein Fehler,
der Rueckgabewert ist 0. Der Test dazu forderte den falschen Namen sogar ein.
Daraus wurde ein Rundgang durch den Windows-Pfad. Alles unten ist gemessen,
nichts vermutet (Regel D).
## Die Kompression
1. `--aencoder` statt `--audio-codec`. Am mitgelieferten HandBrakeCLI 1.11.2
gemessen, mit einem 5-Sekunden-Encode auf der echten Roh-Datei bestaetigt.
2. HandBrakes letzte Zeilen werden aufgehoben (12 gepuffert, 4 in der
Meldung) und `unknown option (...)` wird als eigener Fall erkannt, VOR
allen anderen. Vorher wurde jede Zeile weggeworfen, die kein Fortschritt
war — bei Rueckgabewert 0 blieb damit keine Auskunft uebrig. Die geratene
Zeile „Meist ist der Zielordner nicht beschreibbar" ist raus; sie war
falsch und hat die Suche in die falsche Richtung geschickt.
3. Ein `ue`-Umlaut im Pfad toetete die Kompression. HandBrake schreibt zwei
Kodierungen in denselben Strom (derselbe Pfad einmal UTF-8, einmal CP850).
In CP850 ist das Byte 0x81, und das ist in cp1252 — was `text=True` auf
deutschem Windows waehlt — undefiniert:
UnicodeDecodeError: charmap codec can't decode byte 0x81
Neu: `rip/handbrake_aufruf.py` mit `HB_LESEN`, benutzt von ripping.py und
caps.py. Bewusst nicht binaer wie bei makemkvcon: HandBrake trennt
Fortschrittszeilen mit CR, im Binaermodus waere der Balken weg.
## Die Rohdaten
4. `rohdaten.kandidaten` machte aus dem Arbeitsordner `F:\` ein `F:` und
verband damit weiter. Das ist unter Windows der aktuelle Ordner auf
Laufwerk F, nicht dessen Wurzel — 16,5 GB waren unsichtbar, und der
Wiederholen-Dialog bot nur „Neu rippen" an. Die Falle steht woertlich im
Kopf von `pfade.verbinden`.
5. Gesucht wurde unter der heutigen Einstellung statt unter der Wahl DIESES
Rips (`meta["work_dir"]`). Genau dafuer wurde rohdaten.py am 26.07.
gebaut; repariert wurde damals die Kandidatenliste, nicht der Aufrufer.
Neu: `_arbeitsverzeichnis_des_jobs`, benutzt an vier Stellen.
6. Zwei Speicher fuer dieselben Ordner: Die Oberflaeche schreibt
`outputDir`/`workDir` in die Datenbank, `betrieb` liest `storage.*` aus
der Konfigurationsdatei, und die schreibt niemand. Gemessen: eingestellt
`E:\Rippy`, angezeigt `C:\Users\...\Videos\Rippy`. Neu:
`betrieb.mit_einstellungen`.
## Das Laufwerk
7. `device_info` fing den OSError ab und lieferte „unknown" ohne den Grund.
Nach einem Rip mit Lesefehlern beantwortete das Laufwerk keine
Medien-Abfragen mehr (Win32-Fehler 1), die Geraete-Auskunft aber schon —
im UI stand eine volle Laufwerkskarte, kein Rip startbar, und im
Protokoll das laengst veraltete „Disc erkannt". Neu: `ZUGRIFFS_GRUENDE`,
ein Feld `grund` im Laufwerks-Eintrag und eine Protokollzeile je Wechsel.
Eine fehlgeschlagene Disc-Erkennung wird ebenfalls protokolliert.
8. Der Linux-Treiber nannte ein unzugaengliches Laufwerk „empty", waehrend
Windows richtig „unknown" sagt. Angeglichen, samt Feld-Paritaet.
9. `CreateFileW`, `DeviceIoControl` und `CloseHandle` hatten weder `restype`
noch `argtypes` — 32-Bit-`c_int` fuer einen 64-Bit-HANDLE, in beide
Richtungen. Mit `restype` aendert sich der Fehlerwert von -1 auf
0xFFFFFFFFFFFFFFFF; die Pruefung deckt jetzt beides ab. Am echten
Laufwerk gegengeprueft, Fehlerpfad eingeschlossen.
10. Der Vor-Scan lief bei JEDER eingelegten Disc ein `makemkvcon info` mit
120 s Zeitgrenze — 20 bis 120 Sekunden „Disc wird gelesen". Frueher war
das schnell, weil der Zweig unter Windows nie lief (`shutil.which`,
repariert am 28.08.). Das Ergebnis landete allein in `toc["tracks"]`,
das niemand liest: Der Rip-Dialog holt seine Liste ueber
`/devices/{id}/scan-tracks`, wenn sie gebraucht wird. Entfernt.
## Notbremsen
11. `_frei_bytes` suchte den naechsten vorhandenen Ordner selbst.
`os.path.dirname("Q:\\")` gibt sich selbst zurueck — ein
Arbeitsverzeichnis auf einer abgezogenen Platte haette den Job vor dem
Rip stumm haengen lassen. Benutzt jetzt
`pfade.naechster_vorhandener`, das den Abbruch seit V2-1 hat.
12. `naechster_vorhandener` haelt Laufwerks- und UNC-Wurzeln jetzt absolut.
13. `aufraeum_skript` baut sein `rmdir /s /q` aus `InstallLocation` in der
Registry. Waere das eine Laufwerks-Wurzel, loeschte die Deinstallation
das Laufwerk. Nicht beobachtet, aber nicht wiedergutzumachen — der
Loeschbefehl bleibt in dem Fall weg.
## Lesefehler
MakeMKV sicherte 1 von 2 Titeln, endete mit 0, und Rippy schrieb „Rip
fertig". Jetzt gibt es eine Warnung, auch wenn der Rip als Erfolg endet, und
die MSG-Nummer steht im Protokoll: MakeMKVs Texte sind uebersetzt, die
Nummern nicht.
## Aus der Gegenprobe am laufenden Rippy
Die erste Fassung dieses Standes war installiert, als der Commander meldete:
„nun oeffnen sich diverse fenster im hintergrund, gehen ganz kurz auf und dann
wieder zu. Das laufwerk hoert auch einfach auf zu lesen." Beides Altlasten,
die erst durch die neue Protokollzeile sichtbar wurden.
14. Prozesserzeugung mitgeschnitten:
14:54:40 timeout.exe timeout 4 ls -d C:\Users\...\d7ee6c06-...
14:54:40 WindowsTerminal.exe
`rohdaten.pruefen` fragt mit `timeout N ls -d`, ob es ein Verzeichnis
gibt. Unter Linux ist das richtig (os.path.isdir kann an einem toten
CIFS-Mount im Kernel haengen, ein Kindprozess laesst sich abbrechen).
Unter Windows ist es dreifach falsch: timeout.exe gibt es dort, kennt
aber weder `ls` noch `-d`; sie braucht eine Konsole, und die reisst
Windows auf; und ihr Rueckgabewert ist nie 0, die Antwort lautete also
„weg" fuer JEDES Verzeichnis. Rohdaten waren unter Windows
grundsaetzlich unsichtbar. Neu: `nativ_nachsehen()`. Die zwei
gleichartigen Aufrufe in mounts.py bekommen dieselbe Absicherung.
15. Der Waechter fragte das Laufwerk alle drei Sekunden ab — auch mitten im
Rip, also drei CreateFileW plus IOCTLs auf ein Geraet, das makemkvcon
gerade liest:
12:49:52 bluray-Rip gestartet
12:50:09 [watcher] Laufwerk G: beantwortet keine Medien-Abfragen
12:50:12 MSG 2003 SCSI-Fehler ILLEGAL REQUEST:INVALID FIELD IN CDB
12:50:12 makemkvcon endete mit Code 11
`_auto_prescan` haelt sich seit dem 29.08.2026 an die Regel „waehrend
eines Rips wird nicht gescannt"; die Laufwerksabfrage tat es nicht.
Jetzt gilt in der Zeit der letzte bekannte Stand.
## Zwei Tests, die gelogen haben
* `assert "--audio-codec" in cmd` schrieb den Fehler fest.
* `lambda: {}` als Doppelgaenger fuer `get_settings(key, bei_fehler_leer)`
brach, sobald ein Aufrufer einen Parameter benutzte — und zeigte dann auf
den Code statt auf sich selbst.
977 Tests gruen, ruff sauber.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1145 lines
51 KiB
Python
1145 lines
51 KiB
Python
"""Der Ablauf eines Jobs — rippen, komprimieren, ablegen. OHNE Celery.
|
||
|
||
## Warum es diese Datei gibt (V2-4, 28.08.2026)
|
||
|
||
Bis hierher stand der ganze Ablauf in `tasks.py`, und die Datei importierte
|
||
auf Modulebene `celery_app`. Damit war der Ablauf an einen Broker gebunden —
|
||
und im nativen Windows-Betrieb, der bewusst KEINEN Broker hat, ueberhaupt
|
||
nicht ladbar. Rippy konnte dort die Oberflaeche zeigen und Laufwerke lesen,
|
||
aber nicht rippen. Genau das war die Luecke.
|
||
|
||
Dabei hing der Ablauf an GENAU DREI Stellen an Celery — in 234 Zeilen:
|
||
|
||
self.update_state(...) Zwischenstand melden
|
||
_transcode_queue(node) Ziel-Queue waehlen
|
||
transcode_files.apply_async(...) Kompression weiterreichen
|
||
|
||
Alle drei sind Fragen der ZUSTELLUNG, nicht des Ablaufs. Sie sind jetzt
|
||
Rueckrufe, die der Aufrufer stellt:
|
||
|
||
melde(zustand) wohin der Zwischenstand geht
|
||
weiterreichen(job, roh, ziel, knoten) wer komprimiert
|
||
|
||
`tasks.py` reicht die Celery-Fassung herein, der Standalone-Laeufer die
|
||
lokale. **Ohne `weiterreichen` komprimiert dieselbe Funktion gleich selbst** —
|
||
genau das, was ein Ein-Prozess-Rippy braucht.
|
||
|
||
## Was das NICHT ist
|
||
|
||
Kein Neuschreiben. Der Rumpf ist der aus `tasks.py`, Zeile fuer Zeile — samt
|
||
aller Befunde, die dort ueber Monate hineingewandert sind (Platzpruefung,
|
||
Zombie-Marken, Pfad-Uebersetzung fuer native Worker, Serien-Zuordnung). Was
|
||
sich geaendert hat, sind die drei Stellen oben.
|
||
|
||
Das ist `core/pipeline.py` aus KONZEPT-V2.md § 2.1, nur noch am alten Platz:
|
||
Der Umzug nach `rippy/` kommt, wenn die flachen Worker-Importe aufgeloest
|
||
sind (`import db`, `import medien`, `import ripping`).
|
||
"""
|
||
|
||
import errno
|
||
import glob
|
||
import json
|
||
import os
|
||
import posixpath
|
||
import shutil
|
||
import time
|
||
|
||
import requests
|
||
|
||
from rippy import store as db
|
||
from rippy.rip import makemkv_aufruf, makemkv_daten
|
||
import medien
|
||
from rippy.core import notify
|
||
|
||
# Der Laufwerks-Treiber DIESER Maschine — Linux (ioctl) oder Windows (Win32).
|
||
#
|
||
# ⚠️ Hier stand bis V2-4:
|
||
#
|
||
# try:
|
||
# from rippy.drives.detection import detect_disc_type, disc_size_bytes
|
||
# except ImportError: # Windows: kein fcntl
|
||
# detect_disc_type = None
|
||
#
|
||
# Das war fuer den NATIVEN WINDOWS-WORKER gedacht, der nur komprimiert. Es
|
||
# haette aber auch den eigenstaendigen Windows-Rippy getroffen: Der
|
||
# Linux-Treiber zieht ueber detection.py das Modul `fcntl` nach, das es unter
|
||
# Windows nicht gibt — also waere detect_disc_type dort IMMER None gewesen,
|
||
# und Rippen unten hart verriegelt. Rippy haette unter Windows alles anzeigen
|
||
# und nichts rippen koennen, ohne dass irgendwo ein Fehler stuende.
|
||
#
|
||
# Jetzt fragt der Ablauf den Port: Welcher Treiber es kann, entscheidet
|
||
# rippy/drives/__init__.py. `None` bleibt der ehrliche Wert fuer den Fall,
|
||
# dass diese Maschine wirklich keine Laufwerke lesen kann.
|
||
try:
|
||
from rippy import drives as _laufwerks_schicht
|
||
|
||
_treiber = _laufwerks_schicht.treiber()
|
||
detect_disc_type = _treiber.detect_disc_type
|
||
disc_size_bytes = _treiber.disc_size_bytes
|
||
except (ImportError, AttributeError):
|
||
detect_disc_type = None
|
||
disc_size_bytes = None
|
||
|
||
from ripping import (
|
||
RIP_OUTPUT_DIR,
|
||
RipAbbruch,
|
||
komprimieren_fuer,
|
||
lies_datei_dauer,
|
||
lies_titel_info,
|
||
preset_fuer,
|
||
rip_cd,
|
||
rip_video,
|
||
run_handbrake,
|
||
sprachen_zusammenfassen,
|
||
sprachliste,
|
||
wirf_disc_aus,
|
||
)
|
||
|
||
API_URL = os.getenv("API_URL", "http://api:8000")
|
||
|
||
# Phasen-Marke in den Job-Metadaten: war der RIP fertig, als es schiefging?
|
||
#
|
||
# Sobald `status = "failed"` in der Zeile steht, ist die Phase sonst
|
||
# unwiederbringlich fort — und genau die entscheidet, was danach hilft: Nach
|
||
# einem toten Transcode genügt „Neu komprimieren"; nach einem toten Rip liegt
|
||
# nur ein Bruchstück da (Vorfall 26.07.2026: 5,1 GB von rund 40 GB) und es muss
|
||
# neu gerippt werden. Gelesen wird die Marke in api/phasen.py — der Name steht
|
||
# in beiden Dateien und wird von test_phasen.py gegeneinander geprüft, weil es
|
||
# kein geteiltes Paket zwischen den Containern gibt.
|
||
RIP_FERTIG = "rip_fertig"
|
||
|
||
|
||
def pfad_lokal(pfad: str, mapping: str = None) -> str:
|
||
"""Übersetzt Rippy-Container-Pfade für native Worker (pure Funktion).
|
||
|
||
Der native Windows-Worker sieht /app/temp und /app/media nicht — er
|
||
mountet Rippys Freigabe als Netzlaufwerk und setzt RIPPY_PATH_MAP,
|
||
z. B. "/app/media=Z:\\media;/app/temp=Y:\\temp". Ohne Mapping (Docker-
|
||
Worker: identische Mounts) kommt der Pfad unverändert zurück.
|
||
"""
|
||
mapping = mapping if mapping is not None else os.getenv("RIPPY_PATH_MAP", "")
|
||
if not pfad or not mapping:
|
||
return pfad
|
||
for paar in mapping.split(";"):
|
||
if "=" not in paar:
|
||
continue
|
||
quelle, ziel = paar.split("=", 1)
|
||
if quelle and pfad.startswith(quelle):
|
||
rest = pfad[len(quelle):]
|
||
if "\\" in ziel:
|
||
rest = rest.replace("/", "\\")
|
||
return ziel + rest
|
||
return pfad
|
||
|
||
RAW_DIR = os.getenv("RAW_DIR", "/app/temp/raw")
|
||
MEDIA_ROOT = "/app/media"
|
||
|
||
# Wie oft während einer Kompression nachgesehen wird, ob der Nutzer abgebrochen
|
||
# hat. Eine DB-Abfrage alle paar Sekunden ist nichts gegen einen Encode, der
|
||
# Stunden läuft — und „Abbrechen" fühlt sich damit sofort an.
|
||
ABBRUCH_INTERVALL_SEKUNDEN = 5
|
||
|
||
|
||
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 außerhalb
|
||
(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, wurzeln=None) -> str:
|
||
"""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.
|
||
"""
|
||
wurzel, _vorgabe, frei = wurzeln or _betriebs_wurzeln()
|
||
if target_dir:
|
||
normalisiert = _normalisiert(target_dir)
|
||
if frei or unter_wurzel(normalisiert, wurzel):
|
||
return normalisiert
|
||
# Ohne Wahl: die Wurzel dieses Betriebs, nicht die des Containers. Sonst
|
||
# landete die fertige Datei unter Windows in `X:\app\media\bluray` —
|
||
# einem Ordner, den niemand gesucht hat (gemessen 29.08.2026).
|
||
from rippy import pfade
|
||
|
||
return pfade.verbinden(wurzel if frei else RIP_OUTPUT_DIR, disc_type)
|
||
|
||
|
||
def _betriebs_wurzeln() -> tuple:
|
||
"""`(medien_wurzel, arbeits_vorgabe, frei)` für DIESEN Betrieb.
|
||
|
||
## Warum das hier gebraucht wird (Befund 29.08.2026)
|
||
|
||
Beim Nachstellen des leeren Bildschirms lief ein echter Test-Rip durch —
|
||
und die Rohdaten landeten in **`F:\\app\\temp\\raw`**. Also in einem
|
||
Ordner namens `app` auf dem Laufwerk, von dem Rippy gerade lief.
|
||
|
||
`RAW_DIR` ist `/app/temp/raw` und `MEDIA_ROOT` ist `/app/media`; unter
|
||
Windows sind das keine Pfade, sondern Unfälle. Schlimmer noch: Die
|
||
Prüfung `unter_wurzel(wahl, MEDIA_ROOT)` verwarf **auch eine ausdrückliche
|
||
Wahl** — ein Arbeitsordner wie `D:\\Roh` liegt nicht unter `/app/media`,
|
||
also fiel er still auf den Container-Standard zurück.
|
||
|
||
Damit kam der Arbeitsordner, den der Commander am 28.08.2026 ausdrücklich
|
||
bestellt hat („kannst du noch einbauen das man den arbeitsordner … setzen
|
||
kann"), unter Windows nie an. Der Dialog zeigte ihn, das Setzen ging, und
|
||
der Worker ignorierte ihn — ohne ein Wort.
|
||
|
||
Im Container ändert sich nichts: Dort ist `/app/media` die Wurzel, und
|
||
`frei` ist falsch.
|
||
"""
|
||
from rippy import betrieb, config
|
||
|
||
try:
|
||
werte = config.laden()
|
||
except Exception: # noqa: BLE001
|
||
werte = {}
|
||
return (betrieb.medien_wurzel(werte) or MEDIA_ROOT,
|
||
betrieb.arbeits_vorgabe(werte) or RAW_DIR,
|
||
betrieb.frei_blaettern(werte))
|
||
|
||
|
||
def _normalisiert(wert: str) -> str:
|
||
"""Pfad säubern — nach seiner FORM, nicht nach dem laufenden Rechner.
|
||
|
||
`posixpath` für Container-Pfade (sonst macht Windows Backslashes daraus
|
||
und die Wurzelprüfung greift nicht mehr), `os.path` für echte
|
||
Windows-Pfade. Dieselbe Regel wie in `rippy.pfade`.
|
||
"""
|
||
from rippy import pfade
|
||
|
||
return os.path.normpath(wert) if pfade.ist_windows_pfad(wert) \
|
||
else posixpath.normpath(wert)
|
||
|
||
|
||
def _arbeitsverzeichnis(einstellungen: dict, job_wahl: str = "",
|
||
wurzeln=None) -> str:
|
||
"""Basis für Roh-Rips. Reihenfolge: Wahl DIESES Rips → UI-Setting
|
||
`workDir` → Vorgabe dieses Betriebs (Container: /app/temp/raw).
|
||
|
||
Hintergrund (Commander 24.07.): Die VM-Platte (150 GB) reicht für BD-50,
|
||
aber eine 4K-UHD (bis 100 GB roh + Kompression daneben) sprengt sie —
|
||
das Arbeitsverzeichnis muss deshalb auf ein großes Ziel umlegbar sein.
|
||
|
||
Pro Rip wählbar seit 25.07.2026 (Commander-Wunsch): beim „Rippen starten"
|
||
entscheidet man je Disc, wo die Rohdaten landen. Das Setting bleibt der
|
||
Standard — und ist damit der Wert, der bei Vollautomatik-Rips greift, bei
|
||
denen niemand gefragt wird.
|
||
"""
|
||
wurzel, vorgabe, frei = wurzeln or _betriebs_wurzeln()
|
||
for kandidat in (job_wahl, einstellungen.get("workDir")):
|
||
wert = (kandidat or "").strip()
|
||
if not wert:
|
||
continue
|
||
normalisiert = _normalisiert(wert)
|
||
# Nativ zählt die Wahl des Nutzers — dort IST sein Laufwerk die
|
||
# Grenze. Im Container bleibt die Wurzelprüfung: Ein Pfad ausserhalb
|
||
# von /app/media wäre dort ein Pfad ins Nichts.
|
||
if frei or unter_wurzel(normalisiert, wurzel):
|
||
return normalisiert
|
||
return vorgabe
|
||
|
||
|
||
def _frei_bytes(pfad: str) -> int:
|
||
"""Freier Platz am Pfad (nächster existierender Elternordner zählt).
|
||
|
||
## Warum die Suche nach oben NICHT hier steht (Befund 30.08.2026)
|
||
|
||
An dieser Stelle stand sie als eigene Schleife:
|
||
|
||
while kandidat and not os.path.exists(kandidat):
|
||
kandidat = os.path.dirname(kandidat)
|
||
|
||
Unter Windows gibt `os.path.dirname("Q:\\")` **sich selbst** zurueck — an
|
||
einem freien Laufwerksbuchstaben nachgemessen. Zeigt das
|
||
Arbeitsverzeichnis oder ein Ablageziel auf ein Laufwerk, das gerade
|
||
nicht da ist (abgezogene USB-Platte, getrennte Netzlaufwerks-
|
||
Zuordnung), dreht diese Schleife **fuer immer**. Und zwar in
|
||
`_platz_pruefen`, also VOR dem Rip: Der Job bliebe ohne eine einzige
|
||
Meldung stehen, und im Protokoll stuende nichts, womit man das
|
||
aufklären koennte.
|
||
|
||
`pfade.naechster_vorhandener` macht dasselbe seit V2-1 richtig — mit
|
||
Abbruch bei `eltern == pfad` und einer `gesehen`-Menge gegen Zyklen.
|
||
Es war die ganze Zeit da. Dieselbe Entscheidung an zwei Orten, einer
|
||
davon veraltet: genau das Muster aus dem Kopf von `makemkv_aufruf.py`.
|
||
"""
|
||
from rippy import pfade
|
||
|
||
try:
|
||
return shutil.disk_usage(pfade.naechster_vorhandener(pfad) or "/").free
|
||
except OSError:
|
||
return -1
|
||
|
||
|
||
def _platz_pruefen(pfad: str, benoetigt: int, zweck: str) -> str:
|
||
"""Gibt eine Klartext-Fehlermeldung zurück, wenn der Platz nicht reicht —
|
||
sonst leeren String. VOR dem Rip geprüft: eine volle Platte nach 40 GB
|
||
ist der teuerste Fehlschlag, den es gibt."""
|
||
frei = _frei_bytes(pfad)
|
||
if frei < 0 or benoetigt <= 0:
|
||
return "" # unbekannt → nicht raten, lieber rippen lassen
|
||
if frei >= benoetigt:
|
||
return ""
|
||
return (
|
||
f"Zu wenig Platz für {zweck} in {pfad}: "
|
||
f"{frei / 1024**3:.1f} GB frei, benötigt ~{benoetigt / 1024**3:.1f} GB. "
|
||
"Abhilfe: anderes Ziel wählen oder das Arbeitsverzeichnis unter "
|
||
"Einstellungen → Verarbeitung auf eine große Freigabe legen."
|
||
)
|
||
|
||
|
||
def _makemkv_key_anwenden(einstellungen: dict) -> None:
|
||
"""MakeMKV-Beta-Key aus den UI-Einstellungen aktivieren (schlägt Env).
|
||
|
||
Damit ist der Monats-Key ohne Rebuild/Neustart aktualisierbar
|
||
(Einstellungen → System). Format wie entrypoint.sh: settings.conf.
|
||
|
||
Ergänzend statt überschreibend (Befund 25.07.2026): das Datenverzeichnis
|
||
ist jetzt persistent, und hier stand vorher ein open(..., "w") — das warf
|
||
vor JEDEM Rip alles andere aus der settings.conf, z. B. app_UpdateEnable
|
||
aus dem entrypoint. Beide Schreiber müssen gleich arbeiten, sonst kommt
|
||
der Fehler beim nächsten Rip still zurück.
|
||
"""
|
||
key = (einstellungen.get("makemkvAppKey") or "").strip()
|
||
if not key:
|
||
return
|
||
try:
|
||
# WOHIN, entscheidet der Rechner — unter Windows die Registry, sonst
|
||
# die settings.conf. Begruendung und Messung in
|
||
# `rippy.rip.makemkv_aufruf.key_ablegen`.
|
||
#
|
||
# UND nachsehen, ob MakeMKV ihn annimmt: Ein abgelehnter Schluessel
|
||
# ist schlimmer als gar keiner — ohne las MakeMKV die Disc noch, mit
|
||
# einem abgelehnten verweigerte es alles (gemessen 29.08.2026). Die
|
||
# paar Sekunden Rueckfrage sind nichts gegen einen Rip, der Stunden
|
||
# laeuft und sonst am ersten Titel scheitert.
|
||
from ripping import werkzeug
|
||
|
||
# Dasselbe, was `docker/worker/entrypoint.sh` vor jedem Worker-Start
|
||
# tut — nur in der Windows-Form.
|
||
makemkv_aufruf.einstellungen_sicherstellen()
|
||
urteil = makemkv_aufruf.key_setzen_und_pruefen(key, werkzeug("makemkv"))
|
||
if not urteil["ok"]:
|
||
db.add_log("warning", "worker",
|
||
"MakeMKV nimmt den hinterlegten Beta-Key nicht an: %s"
|
||
% urteil["grund"])
|
||
except (OSError, ImportError) as 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(). 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".
|
||
|
||
## 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:
|
||
# 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:
|
||
# 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"
|
||
|
||
|
||
def _serien_episoden_zuordnen(ausgabe: str, serie: str, staffel, meta: dict) -> list:
|
||
"""Episoden-Zuordnung per Laufzeitabgleich (ARM-Wunde #395, besser gelöst).
|
||
|
||
Laufzeiten der Staffel kommen von der API (TMDB); die der Dateien vom
|
||
HandBrake-Scan. Nur bei EINDEUTIGEM Treffer wird umbenannt. Wirft nie.
|
||
"""
|
||
if meta.get("source") != "tmdb" or meta.get("type") != "tv" or not meta.get("id"):
|
||
return ["Episoden-Zuordnung übersprungen (nur mit TMDB-Serien-Treffer möglich)"]
|
||
try:
|
||
antwort = requests.get(
|
||
f"{API_URL}/metadata/tv/{meta['id']}/season/{int(staffel)}", timeout=30
|
||
)
|
||
if antwort.status_code != 200:
|
||
return [f"Episoden-Laufzeiten nicht abrufbar (HTTP {antwort.status_code})"]
|
||
episoden = [
|
||
(e["episode"], (e.get("runtime") or 0) * 60)
|
||
for e in antwort.json().get("episodes", [])
|
||
]
|
||
except (requests.RequestException, ValueError) as e:
|
||
return [f"Episoden-Laufzeiten nicht abrufbar: {e}"]
|
||
if not episoden:
|
||
return ["Keine Episoden-Laufzeiten bei TMDB hinterlegt — Dateinamen bleiben"]
|
||
|
||
dateien = sorted(
|
||
os.path.join(ausgabe, f) for f in os.listdir(ausgabe) if f.endswith(".mkv")
|
||
)
|
||
if not dateien:
|
||
return []
|
||
dauern = [lies_datei_dauer(p) for p in dateien]
|
||
if 0 in dauern:
|
||
return ["Datei-Laufzeiten nicht lesbar — Dateinamen bleiben"]
|
||
|
||
zuordnung = medien.matche_episoden(dauern, episoden)
|
||
if not zuordnung:
|
||
return [
|
||
"Keine EINDEUTIGE Episoden-Zuordnung über die Laufzeiten — "
|
||
"Dateinamen bleiben (lieber ehrlich als falsch benannt)"
|
||
]
|
||
return medien.episoden_umbenennen(ausgabe, serie, staffel, zuordnung)
|
||
|
||
|
||
def _benachrichtigen(job_id: str, betreff: str, text: str, level: str) -> None:
|
||
"""Webhook-Meldung bei Job-Ende — best effort, nie job-entscheidend."""
|
||
einstellungen = db.get_settings(bei_fehler_leer=True)
|
||
url = (einstellungen.get("notificationWebhook") or "").strip()
|
||
if not url:
|
||
return
|
||
try:
|
||
notify.sende(url, betreff, text, level)
|
||
except Exception as e:
|
||
db.add_log("warning", "notify", f"Job {job_id}: Benachrichtigung fehlgeschlagen: {e}")
|
||
|
||
|
||
def _job_abschliessen(job_id: str, ergebnis: dict) -> None:
|
||
"""Schreibt den Endzustand eines Jobs (completed/failed) nach Postgres,
|
||
bereitet bei Erfolg fürs Medien-System auf (NFO/Poster) und meldet das
|
||
Ende per Webhook (falls konfiguriert)."""
|
||
job = db.get_job(job_id) or {}
|
||
titel = job.get("title") or job_id[:8]
|
||
|
||
if ergebnis.get("status") == "cancelled":
|
||
db.update_job(
|
||
job_id, status="failed", error="Abgebrochen durch Nutzer",
|
||
finished_at=db.utcnow(),
|
||
)
|
||
db.add_log("warning", "worker", f"Job {job_id}: abgebrochen — Rohdaten bleiben erhalten")
|
||
_benachrichtigen(job_id, f'⚠️ Rippy: „{titel}" abgebrochen',
|
||
"Der Job wurde auf Wunsch abgebrochen — Rohdaten bleiben erhalten.",
|
||
"warning")
|
||
return
|
||
if ergebnis.get("status") == "success":
|
||
ausgabe = ergebnis.get("output_dir")
|
||
if ausgabe and (job.get("disc_type") in ("dvd", "bluray", "uhd")):
|
||
einstellungen = db.get_settings(bei_fehler_leer=True)
|
||
try:
|
||
meta = json.loads(job.get("meta") or "{}")
|
||
except ValueError:
|
||
meta = {}
|
||
serie = meta.get("series")
|
||
staffel = meta.get("season")
|
||
|
||
# Serien-Rips: Episoden per Laufzeit zuordnen und umbenennen
|
||
# (Show S01E02.mkv) — nur bei EINDEUTIGER Zuordnung, sonst
|
||
# bleiben die MakeMKV-Namen (ehrlich geloggt).
|
||
if serie and staffel:
|
||
for meldung in _serien_episoden_zuordnen(ausgabe, serie, staffel, meta):
|
||
db.add_log("info", "worker", f"Job {job_id}: {meldung}")
|
||
|
||
# Media-Server-Aufbereitung (Jellyfin/Emby/Kodi: NFO + Poster);
|
||
# bei Serien wandern tvshow.nfo/poster in den Serien-Ordner.
|
||
for meldung in medien.aufbereiten(
|
||
ausgabe, einstellungen.get("mediaServer") or "none",
|
||
serie or job.get("title") or "", meta.get("year"), meta,
|
||
serien_root=os.path.dirname(ausgabe) if serie and staffel else None,
|
||
):
|
||
db.add_log("info", "worker", f"Job {job_id}: {meldung}")
|
||
|
||
# Bibliotheks-Refresh (Jellyfin/Emby): der Server scannt sofort
|
||
meldung = medien.bibliothek_refresh(
|
||
einstellungen.get("mediaServer") or "none",
|
||
(einstellungen.get("jellyfinUrl") or "").strip(),
|
||
(einstellungen.get("jellyfinApiKey") or "").strip(),
|
||
)
|
||
if meldung:
|
||
db.add_log("info", "worker", f"Job {job_id}: {meldung}")
|
||
db.update_job(
|
||
job_id,
|
||
status="completed",
|
||
progress=100,
|
||
output_path=ausgabe,
|
||
finished_at=db.utcnow(),
|
||
)
|
||
db.add_log("success", "worker", f"Job {job_id}: abgeschlossen → {ausgabe}")
|
||
_benachrichtigen(job_id, f'✅ Rippy: „{titel}" ist fertig',
|
||
f"Abgelegt unter: {ausgabe}", "success")
|
||
else:
|
||
fehler = ergebnis.get("error", "unbekannter Fehler")
|
||
db.update_job(
|
||
job_id,
|
||
status="failed",
|
||
error=fehler,
|
||
finished_at=db.utcnow(),
|
||
)
|
||
db.add_log("error", "worker", f"Job {job_id}: {fehler}")
|
||
_benachrichtigen(job_id, f'❌ Rippy: „{titel}" fehlgeschlagen', fehler, "error")
|
||
|
||
|
||
def rippen(device_path: str, job_id: str, target_dir: str = None,
|
||
melde=None, weiterreichen=None):
|
||
"""Stufe 1: Rippt die Disc; bei Video folgt die Kompression als eigener Task.
|
||
|
||
target_dir (optional): vom Nutzer gewähltes Ablageziel unter /app/media —
|
||
dort eingehängte Shares (NFS/SMB) sind damit direkt wählbar.
|
||
"""
|
||
db.init_db()
|
||
|
||
if detect_disc_type is None:
|
||
ergebnis = {
|
||
"status": "error",
|
||
"error": "Dieser Worker kann nur komprimieren (nativer "
|
||
"Transcode-Worker ohne Laufwerks-Zugriff) — gerippt "
|
||
"wird auf der Rippy-Hauptmaschine.",
|
||
}
|
||
_job_abschliessen(job_id, ergebnis)
|
||
return ergebnis
|
||
|
||
if _abbruch_angefordert(job_id):
|
||
_job_abschliessen(job_id, {"status": "cancelled"})
|
||
return {"status": "cancelled"}
|
||
|
||
disc_type = detect_disc_type(device_path)
|
||
|
||
if disc_type in ("no_disc", "unknown"):
|
||
fehler = (
|
||
"Keine Disc im Laufwerk" if disc_type == "no_disc"
|
||
else "Disc-Typ nicht erkennbar"
|
||
)
|
||
db.update_job(job_id, status="failed", error=fehler, finished_at=db.utcnow())
|
||
db.add_log("error", "worker", f"Job {job_id}: {fehler} ({device_path})")
|
||
return {"status": "error", "error": fehler, "disc_type": disc_type}
|
||
|
||
db.update_job(job_id, status="running", disc_type=disc_type)
|
||
# Ab hier gilt: der Rip läuft, ist aber NICHT fertig. Stirbt der Job jetzt,
|
||
# ist jede Roh-Datei ein Bruchstück (siehe RIP_FERTIG oben).
|
||
db.meta_merken(job_id, **{RIP_FERTIG: False})
|
||
db.add_log("info", "worker", f"Job {job_id}: {disc_type}-Rip gestartet ({device_path})")
|
||
|
||
letzter = [-1]
|
||
|
||
def fortschritt(progress: int, message: str = ""):
|
||
# MakeMKV liefert viele PRGV-Zeilen pro Sekunde — DB nur bei Änderung.
|
||
if progress == letzter[0]:
|
||
return
|
||
letzter[0] = progress
|
||
if _abbruch_angefordert(job_id):
|
||
raise RipAbbruch()
|
||
# Frueher: self.update_state(...) — der Zwischenstand ging an Celery.
|
||
# Jetzt ein Rueckruf, den der Aufrufer stellt: im verteilten Betrieb
|
||
# meldet er an Celery, im Standalone-Betrieb an den Ereignis-Bus.
|
||
# Der ABLAUF muss nicht wissen, wer zuhoert.
|
||
if melde:
|
||
melde({"progress": progress, "status": "ripping", "message": message})
|
||
db.update_job(job_id, progress=progress)
|
||
|
||
# MakeMKV-Meldungen ins Log (Befund 25.07.2026): Bis dahin überlebte NUR
|
||
# die letzte Zeile ("Failed to open disc"), und die sagt nichts. Dass
|
||
# MakeMKV bei der UHD-Disc nicht einmal versucht, einen Schlüssel zu
|
||
# holen, war deshalb nur per Hand-Lauf im Container zu sehen.
|
||
# Gedrosselt, weil das UI global nur die letzten 200 Zeilen zeigt: jede
|
||
# Meldung höchstens einmal, insgesamt höchstens MAX_MELDUNGEN je Rip.
|
||
# Code 1003 ist MakeMKVs eigenes DEBUG-Rauschen (am 25.07. beobachtet).
|
||
MAX_MELDUNGEN = 40
|
||
gesehen = set()
|
||
|
||
def melde_makemkv(code: int, text: str):
|
||
if code == 1003 or len(gesehen) >= MAX_MELDUNGEN or text in gesehen:
|
||
return
|
||
gesehen.add(text)
|
||
# Die MSG-NUMMER gehoert dazu (Befund 30.08.2026). MakeMKVs Texte
|
||
# sind uebersetzt, die Nummern nicht — sie sind die einzige
|
||
# verlaessliche Kennung (so arbeitet KRITISCHE_CODES). Ohne sie
|
||
# war an einem Lesefehler-Protokoll nicht abzulesen, WELCHE
|
||
# Meldung MakeMKV geschickt hatte; die Erkennung musste sich
|
||
# ersatzweise an einer URL im Text festhalten.
|
||
db.add_log("info", "makemkv",
|
||
f"Job {job_id}: MSG {code} — {text[:300]}")
|
||
|
||
einstellungen = db.get_settings(bei_fehler_leer=True)
|
||
ist_video = disc_type in ("dvd", "bluray", "uhd")
|
||
# Je Disc-Typ abwählbar (siehe komprimieren_fuer): 4K verlustfrei behalten,
|
||
# DVDs trotzdem schrumpfen — vorher gab es nur alles oder nichts.
|
||
transcode_an = ist_video and komprimieren_fuer(disc_type, einstellungen)
|
||
|
||
if ist_video:
|
||
# UI-Key schlägt Env-Key — Monats-Key ohne Rebuild aktualisierbar
|
||
_makemkv_key_anwenden(einstellungen)
|
||
|
||
# Sprechender Zielordner „<Titel> (Jahr)" statt Job-UUID — Jellyfin & Co.
|
||
# erkennen den Film am Ordnernamen. UUID bleibt Fallback ohne Titel.
|
||
# Serien-Rips landen stattdessen in <Serie>/Season NN (Staffel-Flow).
|
||
job = db.get_job(job_id) or {}
|
||
try:
|
||
meta = json.loads(job.get("meta") or "{}")
|
||
except ValueError:
|
||
meta = {}
|
||
if meta.get("series") and meta.get("season"):
|
||
final_dir = medien.serien_ordner(
|
||
_zielbasis(target_dir, disc_type), meta["series"], meta["season"]
|
||
)
|
||
else:
|
||
final_dir = medien.zielordner(
|
||
_zielbasis(target_dir, disc_type),
|
||
job.get("title") or "", meta.get("year"), job_id,
|
||
)
|
||
|
||
# Titel-Wahl: explizite Auswahl aus der Track-Tabelle schlägt alles;
|
||
# sonst „Nur Hauptfilm" (pro Rip wählbar, Fallback: Setting);
|
||
# bei Serien-Discs ohne Auswahl zählen ALLE Episoden-Titel.
|
||
titel_liste = [int(t) for t in (meta.get("titles") or []) if str(t).isdigit()]
|
||
nur_hauptfilm = meta.get("main_feature_only")
|
||
if nur_hauptfilm is None:
|
||
nur_hauptfilm = bool(einstellungen.get("mainFeatureOnly", False))
|
||
if meta.get("series") or titel_liste:
|
||
nur_hauptfilm = False
|
||
# Geplantes Ziel sofort sichtbar machen (UI-Detail + retry-transcode)
|
||
db.update_job(job_id, output_path=final_dir)
|
||
|
||
# meta["work_dir"] = die Wahl aus dem Rip-Dialog; leer = Setting/Default.
|
||
raw_dir = os.path.join(_arbeitsverzeichnis(einstellungen, meta.get("work_dir")), job_id)
|
||
|
||
# Platz-Check VOR dem Rip: Disc-Größe ist per ioctl bekannt — eine volle
|
||
# Platte nach 40 GB wäre der teuerste Fehlschlag (4K-UHD: bis 100 GB roh).
|
||
try:
|
||
disc_bytes = disc_size_bytes(device_path) if disc_size_bytes else 0
|
||
except OSError:
|
||
disc_bytes = 0
|
||
marge = 1024**3 # 1 GB Sicherheitsabstand
|
||
if ist_video:
|
||
rip_ziel = raw_dir if transcode_an else final_dir
|
||
fehler = _platz_pruefen(rip_ziel, disc_bytes + marge, "den Roh-Rip")
|
||
if not fehler and transcode_an and einstellungen.get("keepOriginal", False):
|
||
fehler = _platz_pruefen(final_dir, disc_bytes + marge, "das Original (keepOriginal)")
|
||
if fehler:
|
||
ergebnis = {"status": "error", "error": fehler}
|
||
_job_abschliessen(job_id, ergebnis)
|
||
return ergebnis
|
||
|
||
if disc_type == "cd":
|
||
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(
|
||
device_path, job_id, disc_type,
|
||
progress_cb=fortschritt,
|
||
output_dir=raw_dir,
|
||
nur_hauptfilm=nur_hauptfilm,
|
||
titel_liste=titel_liste,
|
||
log_cb=melde_makemkv,
|
||
)
|
||
else:
|
||
ergebnis = rip_video(
|
||
device_path, job_id, disc_type,
|
||
progress_cb=fortschritt, output_dir=final_dir,
|
||
nur_hauptfilm=nur_hauptfilm,
|
||
titel_liste=titel_liste,
|
||
log_cb=melde_makemkv,
|
||
)
|
||
|
||
# MakeMKV-Fehler in Klartext übersetzen — "Failed to open disc" allein
|
||
# hilft niemandem.
|
||
if ergebnis.get("status") == "error":
|
||
fehler_text = ergebnis.get("error") or ""
|
||
if "volume key is unknown" in fehler_text:
|
||
# Befund 25.07.2026, auf BEIDEN Maschinen gemessen: Laufwerk und
|
||
# MakeMKV sind in Ordnung. makemkvcon unter Linux ruft die
|
||
# Disc-Schlüssel schlicht nie ab — die Windows-Version tut es
|
||
# (Meldung 3338). Hier stand vorher erst "Disc zu neu" und danach
|
||
# "der Schlüssel-Kanal ist tot"; beides war falsch und hat in die
|
||
# Irre geschickt. Herleitung im Kopf von makemkv_daten.py.
|
||
speicher = makemkv_daten.schluesselspeicher_status()
|
||
anzahl = speicher.get("schluessel", 0)
|
||
ergebnis["error"] += (
|
||
" — Klartext: Laufwerk und Rippy sind in Ordnung, MakeMKV "
|
||
"liest die Disc. Es fehlt nur der Schlüssel dieser Pressung. "
|
||
"Der Grund: makemkvcon holt Schlüssel unter Linux nie selbst "
|
||
"nach — die Windows-Version schon. "
|
||
+ (
|
||
"Dieser Worker kennt aktuell GAR KEINEN Disc-Schlüssel. "
|
||
if not anzahl
|
||
else f"Dieser Worker kennt {anzahl} Disc-Schlüssel, "
|
||
"diese Pressung ist nicht dabei. "
|
||
)
|
||
+ "Abhilfe: MakeMKV auf einem Windows-PC installieren, die "
|
||
"Disc dort einmal öffnen, dann die Datei _private_data.tar "
|
||
"aus dem MakeMKV-Datenverzeichnis unter Einstellungen → "
|
||
"System hochladen. Wirkt ab dem nächsten Rip. Klappt auch "
|
||
"das nicht, kennt MakeMKV die Pressung selbst nicht — dann "
|
||
"hilft nur eine KEYDB.cfg (ebenfalls dort hochladbar) oder "
|
||
"das Einreichen des AACS-Dumps im MakeMKV-Forum, Bereich "
|
||
"'Ultra HD Blu-ray'. Der Dump steht unter Einstellungen → "
|
||
"System zum Download bereit."
|
||
)
|
||
elif disc_type == "uhd" and "Failed to open disc" in fehler_text:
|
||
ergebnis["error"] += (
|
||
" — 4K-UHD erkannt: Das Laufwerk kann UHD-Discs vermutlich "
|
||
"nicht entschlüsseln. Dafür ist eine LibreDrive-Firmware nötig "
|
||
"(MakeMKV-Forum 'Ultimate UHD Drives Flashing Guide'). "
|
||
"Normale BD/DVD gehen weiterhin."
|
||
)
|
||
|
||
# Lesefehler laut sagen — auch (und gerade) wenn der Rip als Erfolg
|
||
# endet. Befund 30.08.2026: MakeMKV sicherte 1 von 2 Titeln, meldete
|
||
# sich mit Code 0, und Rippy schrieb „Rip fertig". Dass ein Titel
|
||
# fehlt, stand nur in den MakeMKV-Zeilen. Wer die nicht liest, haelt
|
||
# eine halbe Disc für eine ganze.
|
||
if ergebnis.get("lesefehler"):
|
||
db.add_log(
|
||
"warning", "worker",
|
||
f"Job {job_id}: Die Disc hat Lesefehler — MakeMKV kam an "
|
||
"mehreren Stellen nicht durch. Es kann sein, dass ein Titel "
|
||
"fehlt oder unvollständig ist. Abhilfe: Disc reinigen (radial "
|
||
"von innen nach außen, nicht kreisend) oder ein anderes "
|
||
"Laufwerk probieren — Laufwerke unterscheiden sich hier stark.",
|
||
)
|
||
|
||
# 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
|
||
# freier Worker, inkl. Remote-GPU).
|
||
db.update_job(job_id, status="transcoding", progress=0)
|
||
# Der Rip ist durch. Ab jetzt ist ein Fehlschlag mit „Neu komprimieren"
|
||
# zu heilen, ohne die Disc noch einmal zu lesen.
|
||
db.meta_merken(job_id, **{RIP_FERTIG: True})
|
||
db.add_log("info", "worker", f"Job {job_id}: Rip fertig, Kompression eingereiht")
|
||
# Wer die Kompression uebernimmt, entscheidet der Aufrufer:
|
||
# Celery-Queue im verteilten Betrieb, LocalQueue im Standalone-Betrieb.
|
||
# OHNE Rueckruf wird gleich hier komprimiert — das ist der Fall
|
||
# "ein Prozess macht alles", also der Windows-Betrieb.
|
||
if weiterreichen:
|
||
weiterreichen(job_id, raw_dir, final_dir, meta.get("transcode_node"))
|
||
return {"status": "ripped", "raw_dir": raw_dir}
|
||
return komprimieren(job_id, raw_dir, final_dir)
|
||
|
||
_job_abschliessen(job_id, ergebnis)
|
||
return ergebnis
|
||
|
||
|
||
def ping_worker():
|
||
"""Winziger Task zum Beweisen des gezielten Routings: gibt Hostname +
|
||
Encoder zurück. Wird über worker_direct an EINEN Worker geschickt — landet
|
||
er beim richtigen, stimmt das Encoder-Routing (Encoder-Auswahl im UI)."""
|
||
import socket
|
||
|
||
import caps
|
||
return {"hostname": socket.gethostname(), "encoders": caps.erkenne_encoder()}
|
||
|
||
|
||
def scan_tracks(device_path: str):
|
||
"""Titel-Liste der eingelegten Disc erfassen (für die Auswahl-Tabelle).
|
||
|
||
Ergebnis landet in der settings-Tabelle (key 'tracks:<device>') — die
|
||
API pollt darauf. Läuft auf der Haupt-Queue (nur der Rip-Worker hat das
|
||
Laufwerk). Ein Info-Lauf dauert je nach Disc 20–120 s.
|
||
"""
|
||
db.init_db()
|
||
key = f"tracks:{device_path}"
|
||
db.save_settings({"status": "running"}, key)
|
||
try:
|
||
# Ein Info-Lauf liefert BEIDES: Titel-Liste und die Sprachen. Die
|
||
# Sprach-Auskunft stand schon immer in derselben Ausgabe und wurde nur
|
||
# weggeworfen (Commander-Anforderung 26.07.2026: vor dem Rip fragen,
|
||
# was man haben will).
|
||
titel, streams = lies_titel_info(device_path)
|
||
sprachen = sprachen_zusammenfassen(streams)
|
||
db.save_settings(
|
||
{"status": "done", "tracks": titel, "sprachen": sprachen}, key)
|
||
db.add_log(
|
||
"info", "worker",
|
||
f"Titel-Scan {device_path}: {len(titel)} Titel, "
|
||
f"{len(sprachen['audio'])} Tonsprache(n), "
|
||
f"{len(sprachen['untertitel'])} Untertitelsprache(n)",
|
||
)
|
||
return {"status": "done", "anzahl": len(titel)}
|
||
except Exception as e:
|
||
db.save_settings({"status": "error", "error": str(e)[:300]}, key)
|
||
db.add_log("error", "worker", f"Titel-Scan {device_path} fehlgeschlagen: {e}")
|
||
return {"status": "error", "error": str(e)}
|
||
|
||
|
||
def _ist_uebersetzt(container_pfad: str, lokal_pfad: str) -> bool:
|
||
"""Hat RIPPY_PATH_MAP diesen Pfad wirklich angefasst? (pure, testbar)"""
|
||
return container_pfad != lokal_pfad
|
||
|
||
|
||
# Wie weit wird nach einem vorhandenen Elternordner gesucht. Begrenzt, damit auf
|
||
# einer toten Freigabe nicht endlos geklopft wird.
|
||
MAX_ELTERN_STUFEN = 12
|
||
|
||
|
||
def erster_vorhandener_ordner(pfad: str, isdir=None, dirname=None) -> str:
|
||
"""Der nächste EXISTIERENDE Ordner oberhalb von `pfad` — oder "".
|
||
|
||
⚠️ Vorfall 26.07.2026, im vollen Durchlauf aufgefallen: Der Rip lief sauber
|
||
durch (40 GB), die Kompression brach sofort ab mit „Dieser Worker erreicht
|
||
das Ziel nicht: \\\\NAS\\rippy\\movies\\Akira (1988)". Erreichbar war die
|
||
Freigabe sehr wohl — es fehlten schlicht ZWEI Ordner: `movies` und der
|
||
Filmordner darin. Die Prüfung ließ nur EINE fehlende Ebene durchgehen (sie
|
||
sah nach `dirname`), obwohl gleich darauf `os.makedirs` die ganze Kette
|
||
anlegt. Ein Ziel, das noch nie beschrieben wurde, war damit systematisch
|
||
unerreichbar — also jeder erste Film in einer neuen Ablage.
|
||
|
||
Es zählt daher, ob IRGENDEIN Vorfahre existiert: Von dem aus kann
|
||
`makedirs` den Rest bauen. Existiert keiner, ist die Freigabe wirklich weg.
|
||
|
||
`isdir`/`dirname` werden BEIM AUFRUF aufgelöst, nicht als Vorgabewert
|
||
gebunden — sonst zeigt die Vorgabe für immer auf die Funktion von damals,
|
||
und ein Ersetzen von `os.path.isdir` (im Test wie im Betrieb) ginge ins
|
||
Leere. Genau darüber bin ich beim Schreiben der Tests gestolpert.
|
||
"""
|
||
isdir = isdir or os.path.isdir
|
||
dirname = dirname or os.path.dirname
|
||
aktuell = dirname(pfad or "")
|
||
for _ in range(MAX_ELTERN_STUFEN):
|
||
if not aktuell:
|
||
return ""
|
||
if isdir(aktuell):
|
||
return aktuell
|
||
naechster = dirname(aktuell)
|
||
if naechster == aktuell: # Wurzel erreicht
|
||
return ""
|
||
aktuell = naechster
|
||
return ""
|
||
|
||
|
||
def _erreichbarkeit_pruefen(raw_container: str, raw_lokal: str,
|
||
final_container: str, final_lokal: str,
|
||
fremd=None) -> str:
|
||
"""Kann DIESER Worker Quelle und Ziel überhaupt sehen? Klartext oder "".
|
||
|
||
Befund 25.07.2026 am Job 95afdc89, live: Das gezielte Routing an den
|
||
Windows-PC des Commanders funktionierte einwandfrei — der Worker nahm die
|
||
Aufgabe an und lehnte sie 182 ms später ab mit „Keine Roh-MKVs in
|
||
/app/media/rippy/… gefunden". Diese Meldung klang nach einem kaputten Rip,
|
||
obwohl der Rip vollständig war (79,6 GB lagen auf der NAS). Der Commander
|
||
schloss daraus, das System sähe den externen Encoder nicht — verständlich
|
||
und falsch.
|
||
|
||
Die eigentliche Ursache: `/app/media/...` sind CONTAINER-Pfade. Ein nativer
|
||
Worker sieht sie nur, wenn RIPPY_PATH_MAP sie auf eine Freigabe übersetzt —
|
||
und dieses Mapping wurde von NIEMANDEM gesetzt. Es war also nicht möglich,
|
||
dass diese Kombination je funktioniert.
|
||
|
||
Zweiter Stolperstein im selben Fall: Die Rohdaten lagen auf der NAS (für den
|
||
PC erreichbar), das ZIEL aber auf der VM-Platte (nicht erreichbar, dort
|
||
läuft kein Samba). Deshalb werden hier BEIDE Pfade geprüft, nicht nur die
|
||
Quelle — sonst scheitert es erst beim Schreiben, nach Stunden Rechenzeit.
|
||
"""
|
||
mapping = os.getenv("RIPPY_PATH_MAP", "")
|
||
# ⚠️ Hier stand `not os.path.isdir("/app")` (Befund 29.08.2026) — dasselbe
|
||
# Muster wie in `caps.py`. Im Image stimmt es; auf einem Windows-PC gibt
|
||
# es `/app` nicht, und der eigenstaendige Rippy hielt sich fuer einen
|
||
# FREMDEN Worker. Er haette sich dann selbst vorgeworfen, die
|
||
# Container-Pfade nicht zu erreichen — auf einer Maschine ohne Container.
|
||
#
|
||
# `fremd` ist einspritzbar, damit BEIDE Faelle ueberall pruefbar sind:
|
||
# Vorher haing der Test daran, ob es einen Ordner `/app` gibt, und war
|
||
# damit an den laufenden Rechner gebunden.
|
||
if fremd is None:
|
||
from caps import _extern
|
||
|
||
fremd = _extern() == "ja"
|
||
fremder_worker = fremd
|
||
|
||
for zweck, container, lokal in (
|
||
("die Quelle (Rohdaten)", raw_container, raw_lokal),
|
||
("das Ziel (fertige Datei)", final_container, final_lokal),
|
||
):
|
||
if os.path.isdir(lokal):
|
||
continue
|
||
# Das ZIEL darf fehlen — es wird gleich mit `os.makedirs` angelegt, und
|
||
# zwar samt aller fehlenden Zwischenebenen. Es genügt also, dass
|
||
# irgendein Vorfahre existiert (Herleitung in erster_vorhandener_ordner).
|
||
if zweck.startswith("das Ziel") and erster_vorhandener_ordner(lokal):
|
||
continue
|
||
|
||
text = [f"Dieser Worker erreicht {zweck} nicht: {lokal}"]
|
||
if fremder_worker and not mapping:
|
||
text.append(
|
||
"Ursache: RIPPY_PATH_MAP ist auf dieser Maschine nicht gesetzt. "
|
||
f'„{container}" ist ein Pfad INNERHALB des Rippy-Containers — ein '
|
||
'externer Worker sieht ihn nur, wenn er auf eine Netzwerk-Freigabe '
|
||
'übersetzt wird.'
|
||
)
|
||
text.append(
|
||
"Abhilfe: Freigabe auf diesem Rechner erreichbar machen und in "
|
||
"start-tray.bat setzen, z. B. "
|
||
"set RIPPY_PATH_MAP=/app/media=\\\\NAS\\rippy-media"
|
||
)
|
||
elif fremder_worker and mapping and not _ist_uebersetzt(container, lokal):
|
||
text.append(
|
||
f"RIPPY_PATH_MAP ist gesetzt ({mapping}), deckt diesen Pfad aber "
|
||
f'nicht ab: „{container}" wurde von keinem Eintrag übersetzt — '
|
||
'fehlt ein Präfix, oder ist es ein anderes Verzeichnis als erwartet?'
|
||
)
|
||
elif fremder_worker and mapping:
|
||
# ⚠️ Hier stand bis zum 26.07.2026 dieselbe Meldung wie oben — „deckt
|
||
# diesen Pfad nicht ab" —, obwohl die Karte ihn nachweislich übersetzt
|
||
# hatte (der UNC-Pfad stand im selben Satz). Wer der Meldung folgte,
|
||
# suchte den Fehler in der Karte, während die Freigabe schlicht nicht
|
||
# verbunden war. Eine falsche Ursache ist teurer als gar keine.
|
||
text.append(
|
||
f'Übersetzt wurde er korrekt ({container} → {lokal}, Karte: '
|
||
f'{mapping}) — dieser Pfad ist auf diesem Rechner nur gerade '
|
||
'nicht erreichbar. Ist die Freigabe verbunden? Anmeldedaten noch '
|
||
'gültig? Von Hand prüfen: den Pfad im Explorer öffnen.'
|
||
)
|
||
else:
|
||
text.append(
|
||
"Der Pfad existiert nicht. Liegt das Arbeitsverzeichnis auf einer "
|
||
"Freigabe, die gerade nicht eingehängt ist?"
|
||
)
|
||
text.append(
|
||
'WICHTIG: Die Rohdaten sind NICHT verloren — nach der Korrektur '
|
||
'genügt „Neu komprimieren", ohne die Disc erneut zu rippen.'
|
||
)
|
||
return " ".join(text)
|
||
return ""
|
||
|
||
|
||
def komprimieren(job_id: str, raw_dir: str, final_dir: str):
|
||
"""Stufe 2: HandBrake komprimiert die Roh-MKVs auf Arbeitsgröße.
|
||
|
||
Erst wenn ALLE Dateien sauber komprimiert sind, wird das Roh-Verzeichnis
|
||
gelöscht — bricht die Kompression ab, bleibt das Original in /app/temp
|
||
liegen (kein Datenverlust wie bei ARMs berüchtigtem Move-Bug #1530).
|
||
Über POST /jobs/{id}/retry-transcode jederzeit neu anstoßbar.
|
||
"""
|
||
db.init_db()
|
||
# Native Worker (Windows) übersetzen Container-Pfade aufs Netzlaufwerk
|
||
raw_container, final_container = raw_dir, final_dir
|
||
raw_dir = pfad_lokal(raw_dir)
|
||
final_dir = pfad_lokal(final_dir)
|
||
|
||
fehler = _erreichbarkeit_pruefen(raw_container, raw_dir, final_container, final_dir)
|
||
if fehler:
|
||
ergebnis = {"status": "error", "error": fehler}
|
||
_job_abschliessen(job_id, ergebnis)
|
||
return ergebnis
|
||
|
||
quellen = sorted(glob.glob(os.path.join(raw_dir, "*.mkv")))
|
||
if not quellen:
|
||
ergebnis = {
|
||
"status": "error",
|
||
"error": (
|
||
f"Das Verzeichnis {raw_dir} ist erreichbar, enthält aber keine "
|
||
"MKV-Datei. Der Rip hat also nichts abgelegt (oder jemand hat die "
|
||
"Datei entfernt). Die Kompression lässt sich nach einem neuen Rip "
|
||
"erneut anstoßen."
|
||
),
|
||
}
|
||
_job_abschliessen(job_id, ergebnis)
|
||
return ergebnis
|
||
|
||
einstellungen = db.get_settings(bei_fehler_leer=True)
|
||
# Preset nach Disc-Typ (Befund 25.07.2026): vorher lief JEDE Quelle durch
|
||
# dasselbe Preset — eine 4K-UHD wurde damit auf 1080p heruntergerechnet.
|
||
job = db.get_job(job_id) or {}
|
||
disc_type = job.get("disc_type") or ""
|
||
preset = preset_fuer(disc_type, einstellungen)
|
||
original_behalten = einstellungen.get("keepOriginal", False)
|
||
|
||
# Sprachauswahl: Wahl für DIESEN Rip (aus meta) schlägt die Einstellung.
|
||
# Leer heißt „alles behalten" — genau das Verhalten von vorher.
|
||
try:
|
||
job_meta = json.loads(job.get("meta") or "{}")
|
||
except ValueError:
|
||
job_meta = {}
|
||
audio_sprachen = sprachliste(
|
||
job_meta.get("audio_sprachen") or einstellungen.get("audioSprachen"))
|
||
untertitel_sprachen = sprachliste(
|
||
job_meta.get("untertitel_sprachen") or einstellungen.get("untertitelSprachen"))
|
||
|
||
os.makedirs(final_dir, exist_ok=True)
|
||
db.update_job(job_id, status="transcoding", progress=0, error=None)
|
||
# Wer hier ankommt, hat vollständige Quelldateien — sonst wäre oben schon
|
||
# abgebrochen worden. Das vermerkt die Marke auch für BESTANDSJOBS, die vor
|
||
# ihrer Einführung gerippt wurden und sie darum noch nicht tragen.
|
||
db.meta_merken(job_id, **{RIP_FERTIG: True})
|
||
db.add_log(
|
||
"info", "worker",
|
||
f"Job {job_id}: Kompression gestartet ({len(quellen)} Datei(en), "
|
||
f"Disc-Typ '{disc_type or 'unbekannt'}', Preset '{preset}'"
|
||
+ (f", Ton: {','.join(audio_sprachen)}" if audio_sprachen else ", Ton: alle")
|
||
+ (f", Untertitel: {','.join(untertitel_sprachen)}"
|
||
if untertitel_sprachen else ", Untertitel: alle")
|
||
+ ")",
|
||
)
|
||
|
||
anzahl = len(quellen)
|
||
letzter = [-1]
|
||
letzte_abbruchpruefung = [0.0]
|
||
|
||
def abbruch_pruefen():
|
||
"""Zeitgesteuert prüfen, ob der Nutzer abgebrochen hat. Wirft RipAbbruch.
|
||
|
||
Befund 25.07.2026 (Commander, am laufenden Akira-Job beobachtet): Der
|
||
Abbruch wurde NUR in `datei_fortschritt` geprüft — und diese Closure
|
||
stieg oben sofort wieder aus, wenn sich die Prozentzahl nicht geändert
|
||
hatte. Bei einem 4K-Encode, der pro Prozent eine halbe Stunde braucht,
|
||
sah „Abbrechen" entsprechend lange wirkungslos aus (gemessen: 3,4 min
|
||
zwischen Anforderung 18:30:17 und Bestätigung 18:33:38 — bei noch
|
||
langsamerem Fortschritt entsprechend mehr).
|
||
|
||
Deshalb hängt die Prüfung jetzt an der Zeit statt am Fortschritt und
|
||
läuft bei JEDER Ausgabezeile von HandBrake — auch während des
|
||
Scan-Durchlaufs, der überhaupt keine Encode-Prozente liefert.
|
||
"""
|
||
jetzt = time.monotonic()
|
||
if jetzt - letzte_abbruchpruefung[0] < ABBRUCH_INTERVALL_SEKUNDEN:
|
||
return
|
||
letzte_abbruchpruefung[0] = jetzt
|
||
if _abbruch_angefordert(job_id):
|
||
raise RipAbbruch()
|
||
|
||
for index, quelle in enumerate(quellen):
|
||
ziel = os.path.join(final_dir, os.path.basename(quelle))
|
||
|
||
def datei_fortschritt(p, _index=index):
|
||
gesamt = int((_index * 100 + p) / anzahl)
|
||
if gesamt == letzter[0]:
|
||
return
|
||
letzter[0] = gesamt
|
||
db.update_job(job_id, progress=min(99, gesamt))
|
||
|
||
hb = run_handbrake(
|
||
quelle, ziel, preset=preset,
|
||
progress_cb=datei_fortschritt,
|
||
abbruch_cb=abbruch_pruefen,
|
||
audio_sprachen=audio_sprachen,
|
||
untertitel_sprachen=untertitel_sprachen,
|
||
)
|
||
if hb.get("status") == "cancelled":
|
||
_job_abschliessen(job_id, hb)
|
||
return hb
|
||
if hb.get("status") != "success":
|
||
ergebnis = {
|
||
"status": "error",
|
||
"error": (
|
||
f"Kompression fehlgeschlagen bei {os.path.basename(quelle)}: "
|
||
# Den ECHTEN Ordner nennen, nicht „/app/temp" (Befund
|
||
# 29.08.2026). Auf Windows gibt es den nicht, und wer die
|
||
# Roh-Datei retten will, sucht sonst am falschen Ort.
|
||
f"{hb.get('error')} — Roh-Datei bleibt unter "
|
||
f"{os.path.dirname(quelle)} erhalten"
|
||
),
|
||
}
|
||
_job_abschliessen(job_id, ergebnis)
|
||
return ergebnis
|
||
|
||
if original_behalten:
|
||
_original_aufheben(job_id, raw_dir, final_dir)
|
||
else:
|
||
shutil.rmtree(raw_dir, ignore_errors=True)
|
||
|
||
ergebnis = {"status": "success", "output_dir": final_dir}
|
||
_job_abschliessen(job_id, ergebnis)
|
||
return ergebnis
|