Files
rippy/docker/worker/tasks.py
T
Hitonabi e84afc1718
Ampel / ampel (push) Successful in 29s
feat(transcode): ein HandBrake-Preset je Disc-Typ statt eines fuer alles
Rueckfrage des Commanders beim ersten echten UHD-Rip: "merkt Rippy, dass es
eine UHD ist, und nimmt direkt das 4K-Preset?" Antwort war nein. Die
Kompression fragte den Disc-Typ ueberhaupt nicht:

    preset = einstellungen.get("transcodePreset") or DEFAULT_HB_PRESET

Live eingestellt war "HQ 1080p30 Surround". Der gerade laufende Akira-Rip
waere also verlustfrei in 4K gerippt und danach auf 1080p heruntergerechnet
worden - und mit keepOriginal=False waere der 4K-Rohschnitt danach geloescht
worden. Umgekehrt wurde eine DVD auf 1080p hochskaliert, was nichts bringt.

- preset_fuer(disc_type, einstellungen) in ripping.py, pure und getestet.
  Reihenfolge: Preset des Disc-Typs -> allgemeines transcodePreset ->
  DEFAULT_HB_PRESET. Bestandsinstallationen aendern ihr Verhalten NICHT,
  solange die neuen Felder nicht gespeichert sind.
- Drei Einstellungen: transcodePresetDvd / transcodePresetBluray /
  transcodePresetUhd. transcodePreset bleibt als Rueckfall bestehen.
- transcode_files holt den Disc-Typ aus dem Job-Datensatz und schreibt ihn
  mit ins Log ("Disc-Typ 'uhd', Preset '...'").
- UI: drei Auswahlfelder statt einem, mit Klartext dazu, warum eine 4K-UHD
  auf ein 2160p-Preset gehoert.

Preset-Namen stammen aus "HandBrakeCLI --preset-list" im Worker-Image
(HandBrake 1.6.1) - nicht aus dem Kopf (AGENTS Regel D):
H.265 MKV 2160p60 4K, HQ 2160p60 4K HEVC Surround,
Super HQ 2160p60 4K HEVC Surround, H.265 MKV 1080p30, HQ 1080p30 Surround,
Super HQ 1080p30 Surround, H.265 MKV 576p25, H.265 MKV 480p30,
HQ 576p25 Surround.

Sofortmassnahme am laufenden Job (auf Ansage des Commanders): keepOriginal
auf True gesetzt - nur dieses eine Feld, gegengeprueft dass kein anderer
Schluessel veraendert wurde. Damit ueberlebt der 4K-Rohschnitt die
Kompression.

ACHTUNG - Deploy bewusst NICHT ausgefuehrt: docker compose up -d --build
wuerde den Worker-Container neu erstellen und den laufenden Akira-Rip
abbrechen. Erst nach Abschluss des Jobs deployen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 11:38:52 +02:00

622 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Rip- und Transcode-Tasks — bewusst GETRENNT (23.07.2026, Basis für Etappe 12/20).
Warum zwei Tasks: Rippen braucht das Laufwerk (läuft immer lokal), Kompression
braucht nur CPU/GPU + Zugriff auf die Rohdatei. Als eigener Celery-Task auf der
Queue "transcode" kann die Kompression damit auch ein Remote-Worker mit GPU
übernehmen (optionales Add-on) — und fehlgeschlagene Kompressionen lassen sich
neu anstoßen, ohne die Disc neu zu rippen (POST /jobs/{id}/retry-transcode).
Zwei Stufen (Commander-Entscheid 23.07.): MakeMKV rippt verlustfrei (einziger
Weg durch AACS — HandBrake kann verschlüsselte Discs nicht lesen), HandBrake
komprimiert danach auf Arbeitsgröße. Die Rohdatei liegt nur temporär in
/app/temp und wird nach Erfolg gelöscht (Setting keepOriginal behält sie).
"""
import glob
import json
import os
import shutil
import requests
import db
import makemkv_daten
import medien
import notify
from celery_app import celery_app
# fcntl gibt es nur unter Linux — der NATIVE Windows-Transcode-Worker
# (deploy/worker-windows) lädt dieses Modul auch, bedient aber nur die
# transcode-Queue. Rippen ohne detection ist unten hart verriegelt.
try:
from detection import detect_disc_type, disc_size_bytes
except ImportError: # Windows: kein fcntl
detect_disc_type = None
disc_size_bytes = None
from ripping import (
RIP_OUTPUT_DIR,
RipAbbruch,
lies_datei_dauer,
lies_titel_info,
preset_fuer,
rip_cd,
rip_video,
run_handbrake,
)
API_URL = os.getenv("API_URL", "http://api:8000")
def _transcode_queue(node: str):
"""Ziel-Queue für die Kompression (siehe celery_client.transcode_queue):
gewählter Worker via worker_direct, wenn online — sonst geteilte Queue."""
if not node:
return "transcode"
try:
from celery.utils import worker_direct
antworten = celery_app.control.ping(timeout=1.0) or []
online = {k for antwort in antworten for k in antwort.keys()}
if node in online:
return worker_direct(node)
except Exception:
pass
return "transcode"
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"
def _zielbasis(target_dir, disc_type: str) -> str:
"""Ablagebasis: vom Nutzer gewähltes Ziel (validiert) oder Standard."""
if target_dir:
normalisiert = os.path.normpath(target_dir)
if normalisiert.startswith(MEDIA_ROOT):
return normalisiert
return os.path.join(RIP_OUTPUT_DIR, disc_type)
def _arbeitsverzeichnis(einstellungen: dict) -> str:
"""Basis für Roh-Rips: UI-Setting `workDir` (unter /app/media, z. B. eine
NAS-Freigabe) schlägt den Container-Default /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.
"""
work_dir = (einstellungen.get("workDir") or "").strip()
if work_dir:
normalisiert = os.path.normpath(work_dir)
if normalisiert.startswith(MEDIA_ROOT):
return normalisiert
return RAW_DIR
def _frei_bytes(pfad: str) -> int:
"""Freier Platz am Pfad (nächster existierender Elternordner zählt)."""
kandidat = pfad
while kandidat and not os.path.exists(kandidat):
kandidat = os.path.dirname(kandidat)
try:
return shutil.disk_usage(kandidat 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
pfad = os.path.join(makemkv_daten.DATEN_DIR, "settings.conf")
try:
os.makedirs(makemkv_daten.DATEN_DIR, exist_ok=True)
try:
with open(pfad, encoding="utf-8", errors="replace") as f:
alt = f.read()
except OSError:
alt = ""
with open(pfad, "w", encoding="utf-8", newline="\n") as f:
f.write(makemkv_daten.settings_conf_zusammenfuehren(alt, key))
except OSError as e:
db.add_log("warning", "worker", f"MakeMKV-Key konnte nicht gesetzt werden: {e}")
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()
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()
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")
@celery_app.task(bind=True, name="worker.tasks.rip_disc")
def rip_disc(self, device_path: str, job_id: str, target_dir: str = 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)
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()
self.update_state(
state="PROGRESS",
meta={"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)
db.add_log("info", "makemkv", f"Job {job_id}: {text[:300]}")
einstellungen = db.get_settings()
ist_video = disc_type in ("dvd", "bluray", "uhd")
transcode_an = ist_video and einstellungen.get("transcodeEnabled", True)
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)
raw_dir = os.path.join(_arbeitsverzeichnis(einstellungen), 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)
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."
)
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).
ziel_queue = _transcode_queue(meta.get("transcode_node"))
db.update_job(job_id, status="transcoding", progress=0)
gezielt = meta.get("transcode_node") and ziel_queue != "transcode"
db.add_log("info", "worker",
f"Job {job_id}: Rip fertig, Kompression eingereiht"
+ (f" → gezielt an {meta.get('transcode_node')}" if gezielt else ""))
transcode_files.apply_async(
args=[job_id, raw_dir, final_dir],
queue=ziel_queue,
)
return {"status": "ripped", "raw_dir": raw_dir}
_job_abschliessen(job_id, ergebnis)
return ergebnis
@celery_app.task(name="worker.tasks.ping_worker")
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()}
@celery_app.task(name="worker.tasks.scan_tracks")
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 20120 s.
"""
db.init_db()
key = f"tracks:{device_path}"
db.save_settings({"status": "running"}, key)
try:
titel = lies_titel_info(device_path)
db.save_settings({"status": "done", "tracks": titel}, key)
db.add_log("info", "worker", f"Titel-Scan {device_path}: {len(titel)} Titel gefunden")
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)}
@celery_app.task(bind=True, name="worker.tasks.transcode_files")
def transcode_files(self, 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_dir = pfad_lokal(raw_dir)
final_dir = pfad_lokal(final_dir)
quellen = sorted(glob.glob(os.path.join(raw_dir, "*.mkv")))
if not quellen:
ergebnis = {"status": "error", "error": f"Keine Roh-MKVs in {raw_dir} gefunden"}
_job_abschliessen(job_id, ergebnis)
return ergebnis
einstellungen = db.get_settings()
# 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)
os.makedirs(final_dir, exist_ok=True)
db.update_job(job_id, status="transcoding", progress=0, error=None)
db.add_log(
"info", "worker",
f"Job {job_id}: Kompression gestartet ({len(quellen)} Datei(en), "
f"Disc-Typ '{disc_type or 'unbekannt'}', Preset '{preset}')",
)
anzahl = len(quellen)
letzter = [-1]
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
if _abbruch_angefordert(job_id):
raise RipAbbruch()
db.update_job(job_id, progress=min(99, gesamt))
hb = run_handbrake(quelle, ziel, preset=preset, progress_cb=datei_fortschritt)
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)}: "
f"{hb.get('error')} — Roh-Datei bleibt in /app/temp erhalten"
),
}
_job_abschliessen(job_id, ergebnis)
return ergebnis
if original_behalten:
ziel_original = os.path.join(final_dir, "original")
shutil.move(raw_dir, ziel_original)
db.add_log("info", "worker", f"Job {job_id}: Original behalten unter {ziel_original}")
else:
shutil.rmtree(raw_dir, ignore_errors=True)
ergebnis = {"status": "success", "output_dir": final_dir}
_job_abschliessen(job_id, ergebnis)
return ergebnis