Transcode-Stufe: HandBrake komprimiert NACH dem MakeMKV-Rip + --noscan-Fix
Ampel / ampel (push) Successful in 34s

Commander-Entscheid 23.07.: 40-GB-Rohdateien sind kein brauchbares
Endprodukt. Architektur bleibt zweistufig, weil HandBrake AACS nicht
lesen kann: MakeMKV rippt verlustfrei nach /app/temp/raw, HandBrake
macht daraus x265 auf Arbeitsgroesse in /app/media, Roh-Verzeichnis
wird erst NACH komplettem Erfolg geloescht (keepOriginal behaelt es).

- Worker: handbrake-cli im Image, run_handbrake + _komprimiere,
  Job-Status "transcoding", Settings aus Postgres (transcodeEnabled/
  Preset/keepOriginal), Fortschritt je Datei aggregiert
- makemkvcon: --noscan im Kommando verankert — der Geraete-Scan haengt
  (1.18.4) bzw. crasht (1.17.7) im Container, mit --noscan + dev:-Pfad
  laeuft es (Befund 23.07., DRV-Zeile beweist Laufwerks-Erkennung)
- UI: Settings-Tab "Verarbeitung" (Toggle/Preset/Original behalten),
  Status-Badge "Komprimieren", LiveLog erkennt transcoding als aktiv
- KONZEPT/ROADMAP entsprechend aktualisiert; Tests fuer HandBrake-Cmd
  (arbeitet auf DATEI, nie am Geraet) und Progress-Regex

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-23 15:56:37 +02:00
parent 009898efae
commit 2474b683e2
10 changed files with 287 additions and 16 deletions
+3
View File
@@ -59,6 +59,8 @@ RUN cd "makemkv-bin-${MAKEMKV_VERSION}" \
FROM python:3.12-slim-bookworm
# handbrake-cli: Kompressions-Stufe NACH dem verlustfreien MakeMKV-Rip
# (Commander-Entscheid 23.07.: 40-GB-Rohdateien sind nicht arbeitsfähig).
RUN apt-get update && apt-get install -y --no-install-recommends \
libssl3 \
libexpat1 \
@@ -68,6 +70,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
cdparanoia \
cd-discid \
flac \
handbrake-cli \
&& rm -rf /var/lib/apt/lists/*
COPY --from=makemkv-build /usr/local /usr/local
+26
View File
@@ -61,6 +61,32 @@ def init_db() -> None:
metadata.create_all(engine)
settings_table = Table(
"settings",
metadata,
Column("key", String(64), primary_key=True),
Column("value", Text),
)
def get_settings(key: str = "ui") -> dict:
"""UI-Einstellungen lesen (der Worker respektiert Transcode-Optionen)."""
import json
from sqlalchemy import select
try:
with engine.connect() as conn:
zeile = conn.execute(
select(settings_table.c.value).where(settings_table.c.key == key)
).first()
if zeile and zeile[0]:
return json.loads(zeile[0])
except Exception:
pass
return {}
def update_job(job_id: str, **fields) -> None:
with engine.begin() as conn:
conn.execute(jobs.update().where(jobs.c.id == job_id).values(**fields))
+79 -3
View File
@@ -39,12 +39,16 @@ def build_makemkv_cmd(device_path: str, output_dir: str) -> list:
"""Baut das MakeMKV-Kommando (pure Funktion, testbar).
-r Robot-Mode: maschinenlesbare Ausgabe (PRGV/MSG-Zeilen)
--noscan KEIN Scan über alle Geräte — der brachte MakeMKV im
Container zum Hängen (1.18.4) bzw. Segfault (1.17.7):
/sys zeigt dort auch Geräte ohne /dev-Knoten (Befund 23.07.)
--progress=-same Fortschritt in denselben Stream wie die Meldungen
mkv dev:<pfad> all <ziel> alle Titel der Disc verlustfrei als MKV
"""
return [
"makemkvcon",
"-r",
"--noscan",
"--progress=-same",
"mkv",
f"dev:{device_path}",
@@ -68,6 +72,76 @@ def get_progress_from_prgv(line: str) -> int:
return min(100, int(total * 100 / maximum))
def check_handbrake_installed() -> bool:
"""Prüft, ob HandBrakeCLI installiert ist."""
return shutil.which("HandBrakeCLI") is not None
DEFAULT_HB_PRESET = "H.265 MKV 1080p30"
def build_handbrake_cmd(input_path: str, output_path: str, preset: str = DEFAULT_HB_PRESET) -> list:
"""Baut das HandBrake-Kommando für die Kompressions-Stufe (pure Funktion).
Arbeitet auf der MKV-DATEI aus dem MakeMKV-Rip — nie auf dem Laufwerk:
HandBrake kann AACS-verschlüsselte Blu-rays nicht lesen, MakeMKV bleibt
deshalb zwingend die erste Stufe. --all-audio/--all-subtitles behalten
alle Sprachen (Preset-Default wäre nur die erste Tonspur).
"""
return [
"HandBrakeCLI",
"--input", input_path,
"--output", output_path,
"--preset", preset,
"--all-audio",
"--all-subtitles",
]
def get_progress_from_line(line: str) -> int:
"""Extrahiert Fortschritt in Prozent aus HandBrake-Ausgabe.
Testfund 22.07.: echtes HandBrake schreibt „45.50 %" MIT Leerzeichen vor
dem Prozentzeichen — eine Regex ohne \\s* parst NIE echte Ausgabe.
"""
match = re.search(r'(\d+\.\d+)\s*%', line)
if match:
return int(float(match.group(1)))
return 0
def run_handbrake(input_path: str, output_path: str, preset: str = DEFAULT_HB_PRESET, progress_cb=None) -> dict:
"""Komprimiert eine MKV-Datei mit HandBrakeCLI; meldet Fortschritt."""
if not check_handbrake_installed():
return {"status": "error", "error": "HandBrakeCLI ist nicht installiert"}
try:
process = subprocess.Popen(
build_handbrake_cmd(input_path, output_path, preset),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1
)
for line in process.stdout:
progress = get_progress_from_line(line)
if progress > 0 and progress_cb:
progress_cb(progress)
process.wait()
if process.returncode == 0 and os.path.exists(output_path):
return {"status": "success", "output_path": output_path}
return {
"status": "error",
"error": f"HandBrake endete mit Code {process.returncode}",
"return_code": process.returncode,
}
except Exception as e:
return {"status": "error", "error": str(e)}
def build_abcde_cmd(device_path: str, config_path: str) -> list:
"""Baut das abcde-Kommando (pure Funktion, testbar).
@@ -149,13 +223,15 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None) -> dict:
return {"status": "error", "error": str(e)}
def rip_video(device_path: str, disc_id: str, disc_type: str = "dvd", progress_cb=None) -> dict:
def rip_video(device_path: str, disc_id: str, disc_type: str = "dvd", progress_cb=None, output_dir: str = None) -> dict:
"""Rippt eine DVD oder Blu-ray verlustfrei mit MakeMKV.
Bewusst KEIN eigener Celery-Task: der einzige Task ist worker.tasks.rip_disc,
der hier mit seinem eigenen Fortschritts-Callback durchgreift.
der hier mit seinem eigenen Fortschritts-Callback durchgreift. output_dir
überschreibt das Ziel (Transcode-Fall: Roh-Rip nach /app/temp).
"""
output_dir = os.path.join(RIP_OUTPUT_DIR, disc_type, disc_id)
if output_dir is None:
output_dir = os.path.join(RIP_OUTPUT_DIR, disc_type, disc_id)
return run_makemkv(device_path, output_dir, progress_cb=progress_cb)
+86 -4
View File
@@ -1,14 +1,30 @@
"""Zentraler Rip-Task: erkennt den Disc-Typ, rippt und schreibt Status nach Postgres.
"""Zentraler Rip-Task: erkennt den Disc-Typ, rippt, komprimiert, schreibt Status.
Die API legt beim POST /jobs die Job-Zeile an und schickt diesen Task los —
der Worker hält die Zeile aktuell (running → completed/failed) und schreibt
Ereignisse ins Log. Das UI liest beides über die API.
der Worker hält die Zeile aktuell (running → transcoding → completed/failed)
und schreibt Ereignisse ins Log. Das UI liest beides über die API.
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 os
import shutil
import db
from celery_app import celery_app
from detection import detect_disc_type
from ripping import rip_cd, rip_video
from ripping import (
DEFAULT_HB_PRESET,
RIP_OUTPUT_DIR,
rip_cd,
rip_video,
run_handbrake,
)
RAW_DIR = os.getenv("RAW_DIR", "/app/temp/raw")
@celery_app.task(bind=True, name="worker.tasks.rip_disc")
@@ -44,10 +60,27 @@ def rip_disc(self, device_path: str, job_id: str):
)
db.update_job(job_id, progress=progress)
einstellungen = db.get_settings()
transcode_an = (
disc_type in ("dvd", "bluray")
and einstellungen.get("transcodeEnabled", True)
)
if disc_type == "cd":
ergebnis = rip_cd(device_path, job_id, progress_cb=fortschritt)
elif transcode_an:
# Stufe 1: Roh-Rip nach /app/temp (wird nach der Kompression gelöscht)
ergebnis = rip_video(
device_path, job_id, disc_type,
progress_cb=fortschritt,
output_dir=os.path.join(RAW_DIR, job_id),
)
else:
ergebnis = rip_video(device_path, job_id, disc_type, progress_cb=fortschritt)
if ergebnis.get("status") == "success" and transcode_an:
ergebnis = _komprimiere(job_id, disc_type, ergebnis, einstellungen)
if ergebnis.get("status") == "success":
db.update_job(
job_id,
@@ -67,3 +100,52 @@ def rip_disc(self, device_path: str, job_id: str):
db.add_log("error", "worker", f"Job {job_id}: {ergebnis.get('error', 'unbekannter Fehler')}")
return ergebnis
def _komprimiere(job_id: str, disc_type: str, rip_ergebnis: dict, einstellungen: dict) -> dict:
"""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).
"""
quellen = rip_ergebnis.get("files", [])
final_dir = os.path.join(RIP_OUTPUT_DIR, disc_type, job_id)
os.makedirs(final_dir, exist_ok=True)
preset = einstellungen.get("transcodePreset") or DEFAULT_HB_PRESET
original_behalten = einstellungen.get("keepOriginal", False)
db.update_job(job_id, status="transcoding", progress=0)
db.add_log(
"info", "worker",
f"Job {job_id}: Kompression gestartet ({len(quellen)} Datei(en), Preset '{preset}')",
)
anzahl = max(1, len(quellen))
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)
db.update_job(job_id, progress=min(99, gesamt))
hb = run_handbrake(quelle, ziel, preset=preset, progress_cb=datei_fortschritt)
if hb.get("status") != "success":
return {
"status": "error",
"error": (
f"Kompression fehlgeschlagen bei {os.path.basename(quelle)}: "
f"{hb.get('error')} — Roh-Datei bleibt in /app/temp erhalten"
),
}
raw_dir = os.path.dirname(quellen[0]) if quellen else None
if raw_dir:
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)
return {"status": "success", "output_dir": final_dir}
+23
View File
@@ -9,7 +9,9 @@ import os
from ripping import (
build_abcde_cmd,
build_handbrake_cmd,
build_makemkv_cmd,
get_progress_from_line,
get_progress_from_prgv,
write_abcde_config,
)
@@ -19,12 +21,33 @@ def test_makemkv_cmd_vollstaendig():
cmd = build_makemkv_cmd("/dev/sr0", "/app/media/dvd/x")
assert cmd[0] == "makemkvcon"
assert "-r" in cmd # Robot-Mode: maschinenlesbar
assert "--noscan" in cmd # Scan hängt/crasht im Container (23.07.)
assert "--progress=-same" in cmd # Fortschritt im selben Stream
assert "mkv" in cmd
assert "dev:/dev/sr0" in cmd # Geräte-Notation laut Doku
assert cmd[-2:] == ["all", "/app/media/dvd/x"]
def test_handbrake_cmd_arbeitet_auf_datei_nicht_geraet():
"""HandBrake kann AACS nicht — es bekommt IMMER die MKV-Datei aus dem
MakeMKV-Rip, nie das Laufwerk (die alte Direkt-am-Gerät-Pipeline war
für Blu-rays prinzipiell funktionsunfähig)."""
cmd = build_handbrake_cmd("/app/temp/raw/x/t00.mkv", "/app/media/bluray/x/t00.mkv")
assert cmd[0] == "HandBrakeCLI"
assert cmd[cmd.index("--input") + 1] == "/app/temp/raw/x/t00.mkv"
assert cmd[cmd.index("--output") + 1] == "/app/media/bluray/x/t00.mkv"
assert "--preset" in cmd
assert "--all-audio" in cmd # alle Sprachen behalten
assert "--all-subtitles" in cmd
def test_handbrake_progress_parsing():
# 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, 100.00 %") == 100
assert get_progress_from_line("kein Fortschritt hier") == 0
def test_prgv_parsing():
# PRGV:current,total,max — total/max ist der Gesamtfortschritt
assert get_progress_from_prgv("PRGV:100,32768,65536") == 50