Files
rippy/docker/worker/ripping.py
T
Hitonabi 8eb5653848
Ampel / ampel (push) Successful in 55s
Restefeger: Auth komplett raus, Serien-Flow + Episoden-Matching, Jellyfin-Refresh, Duplikat-Warnung, echtes Nur-Hauptfilm
AUTH ENTFERNT (Commander-Entscheid 24.07., KONZEPT §10): /token- und
/api-keys-Endpoints, auth.py, test_auth.py, passlib/bcrypt/PyJWT/
python-multipart, JWT_SECRET_KEY-Pflicht. Heimnetz-only, das UI hatte nie
einen Login — die Auth-Oberflaeche war Placebo und die passlib/bcrypt-
Falle brach die Ampel. Rate-Limit pro IP bleibt. Schnellstart laeuft
jetzt ganz ohne .env-Pflichtwerte.

Serien-Flow (Etappe-12-Kern, ARM-Wunde #395):
- Rip-Dialog: Serienname + Staffel -> Ablage <Serie>/Season NN
  (jellyfin.org/docs Naming-Schema); tvshow.nfo + poster.jpg im
  Serien-Ordner, bei Staffel 2 nicht ueberschrieben.
- Episoden-Matching per Laufzeitabgleich: HandBrakeCLI --scan
  ('+ duration:', handbrake.fr/docs) je MKV gegen TMDB-Staffel-Laufzeiten
  (GET /metadata/tv/{id}/season/{n}; tv-season-details-API).
  Ordnungserhaltend; komplette Staffel auf einer Disc klappt auch bei
  uniformen Anime-Laufzeiten (Sequenz-Stufe). Umbenannt wird NUR bei
  eindeutiger Zuordnung — sonst ehrliches Log. Mit Tests.

Weitere Punkte:
- Jellyfin/Emby-Bibliotheks-Refresh nach jedem fertigen Rip
  (POST /Library/Refresh, X-Emby-Token lt. jellyfin.org/docs) —
  URL/Key + Test-Knopf in Einstellungen -> Ripping.
- Duplikat-Warnung: Disc-Fingerabdruck (jetzt Teil des Prescan-Ergebnisses
  + der Job-Metadaten) gegen die Historie; Karte zeigt 'bereits gerippt',
  Vollautomatik ueberspringt Duplikate.
- 'Nur Hauptfilm' ECHT: makemkvcon info -> TINFO-Attr-9-Laufzeiten
  (usage.txt) -> laengster Titel -> mkv dev:X <nr>. Vorher wirkungsloses
  Setting; pro Rip im Dialog uebersteuerbar. Mit Tests.
- OMDb-Treffer eingedeutscht via TMDB /find (external_source=imdb_id,
  de-DE; find-by-id-API).
- Dashboard: Speicherplatz-Anzeige (amber < 60 GB) + CSV-Export
  (GET /jobs/export, Semikolon+BOM fuer deutsches Excel).
- Metadaten-Seite entfernt (Abnahme durch Commander-Auftrag) inkl.
  Placebo-Endpoints /metadata/lookup (scannte Dummy-Device) und
  /metadata/confirm (schrieb nie gelesenen Cache-Key).
- Doppel-Jahr-Fix: 'X (2009) (2009)' in Log und Ordnernamen.
- Remote-Worker-Blocker: redis (6379) + postgres (5432) waren NIE
  veroeffentlicht — kein Remote-Worker konnte sich je verbinden. Ports
  jetzt offen (Heimnetz-Kompromiss, kommentiert) + API_URL fuer Worker.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 14:22:19 +02:00

402 lines
14 KiB
Python

"""Ripping-Tasks: DVD/Blu-ray verlustfrei via MakeMKV (Etappe 10), CD via abcde.
Etappe 10 (23.07.2026): HandBrake ist aus dem Ripp-Pfad entfernt — das KONZEPT
verlangt verlustfreies Sichern (MakeMKV als Muss-Feature). HandBrake war ohnehin
nie im Worker-Image installiert; jeder Rip endete sofort mit "nicht installiert".
MakeMKV-Aufruf und Fortschritts-Format sind dokumentiert unter
https://www.makemkv.com/developers/usage.txt (Robot-Mode `-r`):
PRGV:current,total,max → Fortschritt gesamt = total/max
MSG:code,flags,... → Meldungen
(AGENTS Regel D: externe Schnittstellen nie aus dem Kopf.)
"""
import os
import re
import shutil
import subprocess
import tempfile
RIP_OUTPUT_DIR = os.getenv("RIP_OUTPUT_DIR", "/app/media")
class RipAbbruch(Exception):
"""Kooperativer Abbruch: vom Fortschritts-Callback geworfen, wenn der
Nutzer den Job abgebrochen hat (Status 'canceling' in der DB)."""
def check_makemkv_installed() -> bool:
"""Prüft, ob makemkvcon installiert ist."""
return shutil.which("makemkvcon") is not None
def check_abcde_installed() -> bool:
"""Prüft, ob abcde installiert ist."""
return shutil.which("abcde") is not None
def check_cdparanoia_installed() -> bool:
"""Prüft, ob cdparanoia installiert ist."""
return shutil.which("cdparanoia") is not None
def build_makemkv_cmd(device_path: str, output_dir: str, titel: str = "all") -> 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> <titel> <ziel> 'all' oder eine Titel-Nummer (Hauptfilm)
"""
return [
"makemkvcon",
"-r",
"--noscan",
"--progress=-same",
"mkv",
f"dev:{device_path}",
str(titel),
output_dir,
]
def parse_tinfo_dauern(ausgabe: str) -> dict:
"""TINFO-Zeilen → {titel_nr: dauer_sekunden} (pure Funktion, testbar).
makemkvcon-Robot-Format laut makemkv.com/developers/usage.txt:
TINFO:<titel>,<attr_id>,<code>,"<wert>" — Attribut 9 ist die Laufzeit
als "H:MM:SS" (ap_iaDuration).
"""
dauern = {}
for zeile in ausgabe.splitlines():
treffer = re.match(r'TINFO:(\d+),9,\d+,"(\d+):(\d{2}):(\d{2})"', zeile.strip())
if treffer:
nr, h, m, s = (int(g) for g in treffer.groups())
dauern[nr] = h * 3600 + m * 60 + s
return dauern
def laengster_titel(dauern: dict):
"""Titel-Nummer mit der längsten Laufzeit — der Hauptfilm (pure Funktion)."""
if not dauern:
return None
return max(dauern, key=dauern.get)
def lies_titel_dauern(device_path: str, timeout: int = 300) -> dict:
"""Fragt die Titel-Laufzeiten der Disc ab (makemkvcon info, Robot-Mode)."""
try:
ergebnis = subprocess.run(
["makemkvcon", "-r", "--noscan", "info", f"dev:{device_path}"],
capture_output=True, text=True, timeout=timeout,
)
except (OSError, subprocess.TimeoutExpired):
return {}
return parse_tinfo_dauern(ergebnis.stdout or "")
def parse_scan_dauer(ausgabe: str) -> int:
"""HandBrake-Scan-Ausgabe → Dauer in Sekunden (pure Funktion, testbar).
HandBrakeCLI --scan schreibt je Titel "+ duration: HH:MM:SS"
(handbrake.fr/docs, Scan-Ausgabe); bei einer MKV-Datei gibt es genau
einen Titel.
"""
treffer = re.search(r"\+ duration: (\d+):(\d{2}):(\d{2})", ausgabe)
if not treffer:
return 0
h, m, s = (int(g) for g in treffer.groups())
return h * 3600 + m * 60 + s
def lies_datei_dauer(pfad: str, timeout: int = 120) -> int:
"""Laufzeit einer MKV-Datei in Sekunden (HandBrakeCLI --scan)."""
if not check_handbrake_installed():
return 0
try:
ergebnis = subprocess.run(
["HandBrakeCLI", "--scan", "-i", pfad],
capture_output=True, text=True, timeout=timeout,
)
except (OSError, subprocess.TimeoutExpired):
return 0
return parse_scan_dauer((ergebnis.stdout or "") + (ergebnis.stderr or ""))
def get_progress_from_prgv(line: str) -> int:
"""Extrahiert Gesamt-Fortschritt (0-100) aus einer PRGV-Zeile.
Format laut MakeMKV-Doku: PRGV:current,total,max
`total` ist der Gesamtfortschritt, `max` die Skala (65536).
"""
match = re.match(r"PRGV:(\d+),(\d+),(\d+)", line.strip())
if not match:
return -1
total, maximum = int(match.group(2)), int(match.group(3))
if maximum <= 0:
return -1
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
)
try:
for line in process.stdout:
progress = get_progress_from_line(line)
if progress > 0 and progress_cb:
progress_cb(progress)
except RipAbbruch:
process.kill()
process.wait()
return {"status": "cancelled", "error": "Abgebrochen durch Nutzer"}
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).
-o = Ausgabeformat (flac), -N = nicht-interaktiv, -x = Eject am Ende,
-c = Config-Datei (enthält OUTPUTDIR). NIE ein Verzeichnis an -o geben.
"""
return [
"abcde",
"-d", device_path,
"-o", "flac",
"-N",
"-x",
"-c", config_path
]
def write_abcde_config(output_dir: str) -> str:
"""Schreibt eine minimale abcde-Config mit dem Zielverzeichnis, gibt Pfad zurück."""
tmp = tempfile.NamedTemporaryFile(
"w", suffix=".abcde.conf", delete=False, encoding="utf-8"
)
tmp.write(f"OUTPUTDIR='{output_dir}'\nINTERACTIVE=n\n")
tmp.close()
return tmp.name
def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str = "all") -> dict:
"""Rippt eine DVD/Blu-ray verlustfrei mit makemkvcon; meldet Fortschritt."""
if not check_makemkv_installed():
return {"status": "error", "error": "makemkvcon ist nicht installiert"}
os.makedirs(output_dir, exist_ok=True)
try:
process = subprocess.Popen(
build_makemkv_cmd(device_path, output_dir, titel),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1
)
letzte_meldung = ""
# Kritische Meldungen einsammeln: die LETZTE Zeile ist fast immer nur
# "Failed to open disc" — die URSACHE ("volume key is unknown", Key
# abgelaufen) steht Zeilen davor und ging im Fehlertext verloren
# (Befund 24.07., Summer-Wars-UHD).
kritische_meldungen = []
KRITISCH = (
"volume key is unknown",
"evaluation period has expired",
"registration key",
"Too old version",
)
try:
for line in process.stdout:
progress = get_progress_from_prgv(line)
if progress >= 0 and progress_cb:
progress_cb(progress)
elif line.startswith("MSG:"):
# MSG:code,flags,count,"message",... — Klartext ist Feld 4
teile = line.split(",", 4)
if len(teile) >= 4:
letzte_meldung = teile[3].strip('"')
if any(muster in letzte_meldung for muster in KRITISCH):
kritische_meldungen.append(letzte_meldung)
except RipAbbruch:
process.kill()
process.wait()
return {"status": "cancelled", "error": "Abgebrochen durch Nutzer"}
process.wait()
mkv_dateien = [
os.path.join(output_dir, f)
for f in sorted(os.listdir(output_dir))
if f.endswith(".mkv")
]
if process.returncode == 0 and mkv_dateien:
return {
"status": "success",
"output_dir": output_dir,
"files": mkv_dateien,
"return_code": process.returncode,
}
return {
"status": "error",
"error": (
f"makemkvcon endete mit Code {process.returncode}"
+ (f" — Ursache: {'; '.join(kritische_meldungen)}" if kritische_meldungen else "")
+ (f" — letzte Meldung: {letzte_meldung}" if letzte_meldung else "")
+ ("" if mkv_dateien else " — keine MKV-Datei entstanden")
),
"return_code": process.returncode,
}
except Exception as e:
return {"status": "error", "error": str(e)}
def rip_video(device_path: str, disc_id: str, disc_type: str = "dvd", progress_cb=None,
output_dir: str = None, nur_hauptfilm: bool = False) -> 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. output_dir
überschreibt das Ziel (Transcode-Fall: Roh-Rip nach /app/temp).
nur_hauptfilm: Vorab-Info-Lauf, dann nur den LÄNGSTEN Titel rippen —
das löst das eingestellte (vorher wirkungslose) mainFeatureOnly ein.
"""
if output_dir is None:
output_dir = os.path.join(RIP_OUTPUT_DIR, disc_type, disc_id)
titel = "all"
if nur_hauptfilm:
dauern = lies_titel_dauern(device_path)
haupt = laengster_titel(dauern)
if haupt is not None:
titel = str(haupt)
# Kein Titel ermittelbar → ehrlich auf 'all' zurückfallen statt raten
return run_makemkv(device_path, output_dir, progress_cb=progress_cb, titel=titel)
def rip_cd(device_path: str, disc_id: str, progress_cb=None, output_dir: str = None) -> dict:
"""Rippt eine CD mit abcde (FLAC)."""
if not check_abcde_installed():
return {
"status": "error",
"error": "abcde ist nicht installiert. Installiere abcde und cdparanoia für CD-Ripping."
}
if not check_cdparanoia_installed():
return {
"status": "error",
"error": "cdparanoia ist nicht installiert. Installiere abcde und cdparanoia für CD-Ripping."
}
if output_dir is None:
output_dir = os.path.join(RIP_OUTPUT_DIR, "cd", disc_id)
os.makedirs(output_dir, exist_ok=True)
def melde(progress: int, message: str = ""):
if progress_cb:
progress_cb(progress, message)
config_path = write_abcde_config(output_dir)
try:
process = subprocess.Popen(
build_abcde_cmd(device_path, config_path),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1
)
try:
for line in process.stdout:
melde(50, line.strip()[:200])
except RipAbbruch:
process.kill()
process.wait()
return {"status": "cancelled", "error": "Abgebrochen durch Nutzer"}
process.wait()
if process.returncode == 0:
melde(90, "MusicBrainz Lookup abgeschlossen")
return {
"status": "success",
"output_dir": output_dir,
"return_code": process.returncode
}
return {
"status": "error",
"error": f"abcde failed with code {process.returncode}",
"return_code": process.returncode
}
except Exception as e:
return {"status": "error", "error": str(e)}
finally:
try:
os.unlink(config_path)
except OSError:
pass