Ampel / ampel (push) Failing after 48s
WAS: Der Ablauf ist aus tasks.py heraus (ablauf.py, ohne Celery), die
LocalQueue wird bedient, ein Laeufer arbeitet Auftraege im selben Prozess
ab, und Rippy meldet sich mit gemessenen Faehigkeiten selbst als Arbeiter.
WARUM: "Rippy fuer Windows soll standalone funktionieren" (Commander). Bis
hierher konnte die Windows-App alles ANZEIGEN und nichts TUN — ein Rip waere
eingereiht worden und fuer immer liegengeblieben, weil niemand ihn holt.
DIE TRENNUNG: rip_disc hing an GENAU DREI Celery-Stellen in 234 Zeilen —
self.update_state, _transcode_queue, transcode_files.apply_async. Alle drei
sind Fragen der ZUSTELLUNG, nicht des Ablaufs. Sie sind jetzt Rueckrufe:
tasks.py reicht die Celery-Fassung herein, standalone.py die lokale. OHNE
Rueckruf komprimiert derselbe Prozess weiter — genau das, was ein
Ein-Prozess-Rippy braucht. Der Ablauf selbst ist Zeile fuer Zeile derselbe;
der Docker-Betrieb merkt vom Umbau nichts (Task-Namen, Argumente, Queues
unveraendert).
EINE ZUSTELL-STELLE statt drei: celery_client.abschicken() bedient alle
Auftragsarten. Vorher rief jede Stelle send_task selbst auf — der
Standalone-Betrieb haette an drei Stellen umgebogen werden muessen, beim
naechsten Auftragstyp an einer vierten.
WEITERER BLOCKER GEFUNDEN: ablauf.py holte detect_disc_type fest aus dem
LINUX-Treiber, in einem try/except. Unter Windows waere es damit IMMER None
gewesen und Rippen "hart verriegelt" — Rippy haette alles angezeigt und
nichts gerippt, ohne dass irgendwo ein Fehler stuende. Jetzt fragt es den
Treiber-Port.
WERKZEUGE: ripping.py und caps.py suchten nur im PATH. Auf dem Commander-PC
gemessen, vorher/nachher:
vorher check_makemkv_installed() -> False (obwohl installiert)
erkenne_encoder() -> nur CPU
nachher MakeMKV 1.18.4 C:\Program Files (x86)\MakeMKV\makemkvcon64.exe
HandBrake 1.11.2 ueber die API geholt, in 2,7 s
Encoder cpu-x264, cpu-x265, cpu-av1, VCE, VCE-AV1
107 Presets, Ryzen 7 9700X, 16 Kerne, avx512f
VCE ist die Hardwarebeschleunigung der Radeon — die hat Rippy auf diesem
Rechner vorher nie gesehen, weil es HandBrake gar nicht fand.
NEUE ROUTEN: GET /system/werkzeuge (was liegt wo, in welcher Fassung, gibt
es Neueres) und POST /system/werkzeuge/{name}/holen. HandBrake kommt
vollautomatisch von GitHub. MakeMKV wird NICHT mitgeliefert — Rippy laedt
die offizielle Datei und startet sie (Black-Box-Trennung, KONZEPT.md § 6).
makemkv.com antwortete beim Bauen mit HTTP 525; das wird im Klartext
gemeldet, und eine selbst geholte Datei bleibt moeglich.
HERZSCHLAG: /capabilities las die workers-Tabelle, die bisher nur der
Celery-Herzschlag fuellte. Im Standalone-Betrieb stand dort "0 Worker" und
die Encoder-Auswahl im UI blieb LEER — auf einem Rechner, der alles kann.
Jetzt meldet sich der Prozess selbst, mit dem, was caps.py MISST.
GEMESSEN, aus der fertigen EXE (29,0 MB):
bereit nach 1 s, keine Fehler im Log
Werkzeuge: beide gefunden, mit Version und Pfad
Worker: 1 (TobisNicerPC), 5 Encoder, 107 Presets
Die ganze Kette ist als Test festgehalten (test_kette.py): zustellen ->
einreihen -> Laeufer -> ablauf -> Job endet in einem EHRLICHEN Zustand.
Ohne Laufwerk geprueft, und das ist der wichtigere Fall: Ein Rip auf ein
totes Geraet muss zuegig scheitern, nicht auf "pending" haengenbleiben.
GEMESSEN: ruff sauber, 512 Tests gruen + 15 uebersprungen (vorher 489).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
522 lines
21 KiB
Python
522 lines
21 KiB
Python
"""Tests für ripping.py: Kommando-Bau und Fortschritts-Parsing.
|
|
|
|
Deckt genau die Stellen ab, an denen Reviews erfundene Schnittstellen fanden
|
|
(abcde-Flags, Celery-API, HandBrake-Regex) — damit so etwas nie wieder still liegt.
|
|
Das PRGV-Format stammt aus der MakeMKV-Doku (makemkv.com/developers/usage.txt).
|
|
"""
|
|
|
|
import os
|
|
|
|
from ripping import (
|
|
build_abcde_cmd,
|
|
build_handbrake_cmd,
|
|
build_makemkv_cmd,
|
|
get_progress_from_line,
|
|
get_progress_from_prgv,
|
|
parse_msg,
|
|
write_abcde_config,
|
|
)
|
|
|
|
|
|
def test_makemkv_cmd_vollstaendig():
|
|
cmd = build_makemkv_cmd("/dev/sr0", "/app/media/dvd/x")
|
|
# Seit V2-4 steht hier der GEFUNDENE Pfad statt des blossen Namens: Unter
|
|
# Windows liegt makemkvcon in Program Files und nicht im PATH — mit dem
|
|
# nackten Namen faende `subprocess` es dort nie. Im Container bleibt es
|
|
# der blosse Name (der Katalog findet nichts, der Rueckfall greift), auf
|
|
# dem Entwicklungsrechner ist es der volle Pfad. Beides ist richtig;
|
|
# gepruefet wird deshalb, WORAUF der Befehl zeigt.
|
|
assert "makemkvcon" in cmd[0].lower()
|
|
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")
|
|
# Wie beim MakeMKV-Befehl: seit V2-4 steht hier der GEFUNDENE Pfad. Unter
|
|
# Windows liegt HandBrakeCLI in Program Files oder in Rippys eigenem
|
|
# Werkzeug-Ordner und nicht im PATH — mit dem nackten Namen faende
|
|
# `subprocess` es dort nie.
|
|
assert "handbrakecli" in cmd[0].lower()
|
|
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 "--first-audio" in cmd # beste Spur pro Sprache behalten
|
|
assert "--audio-codec" in cmd
|
|
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
|
|
# Echte Zeile mit fps-Anhang, wie sie im Binary steht
|
|
assert get_progress_from_line(
|
|
"Encoding: task 1 of 1, 12.34 % (5.67 fps, avg 4.32 fps, ETA 00h12m34s)"
|
|
) == 12
|
|
# Ein echtes 0 % ist eine ANGABE, keine Leermeldung
|
|
assert get_progress_from_line("Encoding: task 1 of 1, 0.00 %") == 0
|
|
|
|
|
|
def test_handbrake_progress_ignoriert_scan_durchlauf():
|
|
"""Befund 25.07.2026 (Akira-UHD, live gemessen): HandBrake läuft VOR dem
|
|
Encodieren einen Scan-Durchlauf, der ebenfalls Prozente ausgibt und dabei
|
|
bis 100 % steigt. Die alte Regex nahm jede Zahl vor einem Prozentzeichen
|
|
und meldete deshalb 99 %, während der Encode bei 1 % stand.
|
|
|
|
-1 heißt „keine Encode-Fortschrittszeile" — dasselbe Muster wie bei
|
|
get_progress_from_prgv. Fremde Zeilen dürfen NIE als 0 % gelten.
|
|
"""
|
|
assert get_progress_from_line("Scanning title 1 of 1, preview 3, 30.00 %") == -1
|
|
assert get_progress_from_line("Scanning title 1 of 1, preview 10, 100.00 %") == -1
|
|
# Vorlauf-Phase: Prozente beziehen sich auf die Suche, nicht auf den Encode
|
|
assert get_progress_from_line(
|
|
"Encoding: task 1 of 1, Searching for start time, 42.00 %"
|
|
) == -1
|
|
assert get_progress_from_line("kein Fortschritt hier") == -1
|
|
assert get_progress_from_line("Muxing: this may take awhile...") == -1
|
|
assert get_progress_from_line("") == -1
|
|
|
|
|
|
def test_handbrake_progress_rechnet_zwei_durchlaeufe_zusammen():
|
|
"""Presets mit zwei Durchläufen zählen die Prozente je Durchlauf neu.
|
|
Ohne Verrechnung sprang die Anzeige mitten in der Datei zurück auf 0."""
|
|
assert get_progress_from_line("Encoding: task 1 of 2, 50.00 %") == 25
|
|
assert get_progress_from_line("Encoding: task 2 of 2, 0.00 %") == 50
|
|
assert get_progress_from_line("Encoding: task 2 of 2, 100.00 %") == 100
|
|
|
|
|
|
def test_prgv_parsing():
|
|
# PRGV:current,total,max — total/max ist der Gesamtfortschritt
|
|
assert get_progress_from_prgv("PRGV:100,32768,65536") == 50
|
|
assert get_progress_from_prgv("PRGV:0,65536,65536") == 100
|
|
assert get_progress_from_prgv("PRGV:0,0,65536") == 0
|
|
|
|
|
|
def test_prgv_parsing_ignoriert_fremde_zeilen():
|
|
# -1 heißt „keine Fortschrittszeile" — MSG-Zeilen dürfen NIE als 0% gelten,
|
|
# sonst springt die Anzeige ständig auf null zurück.
|
|
assert get_progress_from_prgv('MSG:1005,0,1,"MakeMKV gestartet","%1","x"') == -1
|
|
assert get_progress_from_prgv("irgendwas") == -1
|
|
assert get_progress_from_prgv("PRGV:kaputt") == -1
|
|
|
|
|
|
def test_parse_msg_trennt_code_und_klartext():
|
|
"""Echte Zeilen aus einem makemkvcon-Lauf vom 25.07.2026 (Akira UHD).
|
|
|
|
Feld 4 ist laut https://www.makemkv.com/developers/usage.txt der fertig
|
|
zusammengesetzte Klartext — genau der landet im Rippy-Log.
|
|
"""
|
|
assert parse_msg(
|
|
'MSG:1005,0,1,"MakeMKV v1.18.4 linux(x64-release) started","%1 started","MakeMKV v1.18.4 linux(x64-release)"'
|
|
) == (1005, "MakeMKV v1.18.4 linux(x64-release) started")
|
|
assert parse_msg(
|
|
'MSG:1011,0,1,"Using LibreDrive mode (v06.3 id=866A98CB9C4E)","%1","Using LibreDrive mode (v06.3 id=866A98CB9C4E)"'
|
|
) == (1011, "Using LibreDrive mode (v06.3 id=866A98CB9C4E)")
|
|
|
|
|
|
def test_parse_msg_liest_die_uhd_fehlermeldung():
|
|
"""3303 ist der Befund, um den es beim ganzen KEYDB-Thema geht: das
|
|
Laufwerk läuft im LibreDrive-Modus, MakeMKV kennt nur den Schluessel
|
|
DIESER Pressung nicht. Ohne diese Zeile im Log raet der Commander."""
|
|
assert parse_msg(
|
|
'MSG:3303,16777216,0,"The volume key is unknown for this disc - video can\'t be decrypted","The volume key is unknown for this disc - video can\'t be decrypted"'
|
|
) == (3303, "The volume key is unknown for this disc - video can't be decrypted")
|
|
assert parse_msg('MSG:5010,0,0,"Failed to open disc","Failed to open disc"') == (
|
|
5010,
|
|
"Failed to open disc",
|
|
)
|
|
|
|
|
|
def test_parse_msg_schneidet_meldungen_mit_komma_nicht_ab():
|
|
"""DER Grund für die Regex (Stand 25.07.2026): vorher stand hier
|
|
line.split(",", 4)[3]. Das schnitt jede Meldung ab, die selbst ein Komma
|
|
enthält — und MakeMKV schreibt solche laufend. Im Log stand dann nur noch
|
|
ein Satzfragment, das mehr verwirrt als hilft."""
|
|
zeile = (
|
|
'MSG:3025,0,3,"Title #1 has length of 12 seconds, which is less than '
|
|
'minimum title length of 120 seconds and was therefore skipped",'
|
|
'"Title #%1 has length of %2 seconds which is less than minimum title '
|
|
'length of %3 seconds and was therefore skipped","1","12","120"'
|
|
)
|
|
code, text = parse_msg(zeile)
|
|
assert code == 3025
|
|
assert text.endswith("and was therefore skipped")
|
|
assert "which is less than" in text
|
|
|
|
|
|
def test_parse_msg_ignoriert_fremde_zeilen():
|
|
# Alles außer MSG muss None liefern, sonst landet Fortschritts-Rauschen
|
|
# (PRGV kommt mehrmals pro Sekunde) als Log-Eintrag in der Datenbank.
|
|
assert parse_msg("PRGV:100,32768,65536") is None
|
|
assert parse_msg('DRV:0,2,999,12,"BD-RE ASUS BW-16D1HT","AKIRA","/dev/sr0"') is None
|
|
assert parse_msg("TCOUNT:5") is None
|
|
assert parse_msg("") is None
|
|
assert parse_msg("irgendwelcher Muell ohne Struktur") is None
|
|
|
|
|
|
def test_abcde_cmd_hat_genau_ein_ausgabeformat():
|
|
"""Review-Fund 22.07.: '-o' stand doppelt (Format UND Verzeichnis) — abcde
|
|
parste das Verzeichnis als Format, CD-Ripping war nie funktionsfähig."""
|
|
cmd = build_abcde_cmd("/dev/sr0", "/tmp/test.abcde.conf")
|
|
assert cmd.count("-o") == 1
|
|
assert cmd[cmd.index("-o") + 1] == "flac"
|
|
assert "-c" in cmd
|
|
assert cmd[cmd.index("-c") + 1] == "/tmp/test.abcde.conf"
|
|
assert "-N" in cmd # nicht-interaktiv, sonst hängt der Worker
|
|
|
|
|
|
def test_abcde_auswurf_folgt_der_einstellung():
|
|
"""Befund 25.07.2026: `-x` (Auswurf) stand fest verdrahtet drin. Eine
|
|
Audio-CD warf damit IMMER aus, eine DVD/Blu-ray NIE — und die Einstellung
|
|
„Automatischer Auswurf" regelte keines von beidem, weil sie nirgends
|
|
gelesen wurde."""
|
|
assert "-x" in build_abcde_cmd("/dev/sr0", "/tmp/c.conf", auswerfen=True)
|
|
assert "-x" not in build_abcde_cmd("/dev/sr0", "/tmp/c.conf", auswerfen=False)
|
|
# Standard bleibt „auswerfen" — so war das Verhalten bisher
|
|
assert "-x" in build_abcde_cmd("/dev/sr0", "/tmp/c.conf")
|
|
# Die Config darf durch das weggefallene -x nicht verrutschen
|
|
ohne = build_abcde_cmd("/dev/sr0", "/tmp/c.conf", auswerfen=False)
|
|
assert ohne[ohne.index("-c") + 1] == "/tmp/c.conf"
|
|
|
|
|
|
def test_abcde_config_enthaelt_zielverzeichnis():
|
|
pfad = write_abcde_config("/app/media/cd/test123")
|
|
try:
|
|
with open(pfad, encoding="utf-8") as f:
|
|
inhalt = f.read()
|
|
assert "OUTPUTDIR='/app/media/cd/test123'" in inhalt
|
|
assert "INTERACTIVE=n" in inhalt
|
|
finally:
|
|
os.unlink(pfad)
|
|
|
|
|
|
def test_preset_fuer_nimmt_das_preset_des_disc_typs():
|
|
# Befund 25.07.2026: vorher galt EIN Preset für alles — eine 4K-UHD wurde
|
|
# damit auf 1080p heruntergerechnet, und beim ersten echten UHD-Rip wäre
|
|
# die 4K-Aufloesung still verlorengegangen.
|
|
from ripping import preset_fuer
|
|
|
|
einstellungen = {
|
|
"transcodePreset": "HQ 1080p30 Surround",
|
|
"transcodePresetDvd": "H.265 MKV 576p25",
|
|
"transcodePresetBluray": "H.265 MKV 1080p30",
|
|
"transcodePresetUhd": "H.265 MKV 2160p60 4K",
|
|
}
|
|
assert preset_fuer("uhd", einstellungen) == "H.265 MKV 2160p60 4K"
|
|
assert preset_fuer("bluray", einstellungen) == "H.265 MKV 1080p30"
|
|
assert preset_fuer("dvd", einstellungen) == "H.265 MKV 576p25"
|
|
|
|
|
|
def test_preset_fuer_faellt_auf_das_allgemeine_preset_zurueck():
|
|
# Bestandsinstallationen kennen die drei neuen Felder nicht. Solange der
|
|
# Nutzer sie nicht speichert, MUSS sich sein Verhalten nicht ändern.
|
|
from ripping import preset_fuer
|
|
|
|
alt = {"transcodePreset": "HQ 1080p30 Surround"}
|
|
assert preset_fuer("uhd", alt) == "HQ 1080p30 Surround"
|
|
assert preset_fuer("dvd", alt) == "HQ 1080p30 Surround"
|
|
# Leere Zeichenkette zählt als "nicht gesetzt" (leeres Select-Feld im UI)
|
|
assert preset_fuer("uhd", {"transcodePresetUhd": " ", "transcodePreset": "X"}) == "X"
|
|
|
|
|
|
def test_preset_fuer_ohne_einstellungen_nimmt_den_eingebauten_standard():
|
|
# Unbekannter Disc-Typ, leere oder fehlende Einstellungen: nie None, nie
|
|
# Absturz — sonst stirbt die Kompression an einem leeren --preset-Argument.
|
|
from ripping import DEFAULT_HB_PRESET, preset_fuer
|
|
|
|
assert preset_fuer("uhd", {}) == DEFAULT_HB_PRESET
|
|
assert preset_fuer("", None) == DEFAULT_HB_PRESET
|
|
assert preset_fuer(None, {}) == DEFAULT_HB_PRESET
|
|
assert preset_fuer("cd", {"transcodePresetUhd": "egal"}) == DEFAULT_HB_PRESET
|
|
|
|
|
|
def test_handbrake_prueft_abbruch_bei_jeder_zeile_nicht_nur_bei_fortschritt():
|
|
"""Befund 25.07.2026 (am laufenden Akira-Job beobachtet): Der Abbruch hing
|
|
am Fortschritts-Callback, und der stieg bei unveraenderter Prozentzahl
|
|
sofort aus. Bei einem 4K-Encode mit einem Prozent je halber Stunde sah
|
|
„Abbrechen" minutenlang wirkungslos aus (gemessen: 3,4 min).
|
|
|
|
Der Abbruch-Kanal muss deshalb JEDE Ausgabezeile sehen — auch die des
|
|
Scan-Durchlaufs, der gar keine Encode-Prozente liefert.
|
|
"""
|
|
import ripping
|
|
|
|
zeilen = [
|
|
"Scanning title 1 of 1, preview 1, 10.00 %\n",
|
|
"Scanning title 1 of 1, preview 2, 20.00 %\n",
|
|
"Encoding: task 1 of 1, 0.00 %\n",
|
|
"Encoding: task 1 of 1, 0.00 %\n",
|
|
]
|
|
gesehen = []
|
|
|
|
class FakeProcess:
|
|
def __init__(self):
|
|
self.stdout = iter(zeilen)
|
|
self.returncode = 0
|
|
self.getoetet = False
|
|
|
|
def kill(self):
|
|
self.getoetet = True
|
|
|
|
def wait(self):
|
|
return 0
|
|
|
|
prozess = FakeProcess()
|
|
|
|
def abbruch_cb():
|
|
gesehen.append(1)
|
|
if len(gesehen) == 2: # beim zweiten Mal abbrechen
|
|
raise ripping.RipAbbruch()
|
|
|
|
ergebnis = ripping._handbrake_schleife(prozess, "/x.mkv", abbruch_cb, None)
|
|
|
|
assert ergebnis["status"] == "cancelled"
|
|
assert prozess.getoetet is True
|
|
# Zwei Scan-Zeilen genügten — es musste NICHT auf eine Encode-Zeile gewartet
|
|
# werden. Genau das war der Fehler.
|
|
assert len(gesehen) == 2
|
|
|
|
|
|
def test_komprimieren_fuer_globaler_schalter_schlaegt_alles():
|
|
from ripping import komprimieren_fuer
|
|
|
|
assert komprimieren_fuer("uhd", {"transcodeEnabled": False}) is False
|
|
assert komprimieren_fuer("dvd", {"transcodeEnabled": False}) is False
|
|
# Standard ist AN - Bestandsinstallationen ändern ihr Verhalten nicht
|
|
assert komprimieren_fuer("bluray", {}) is True
|
|
assert komprimieren_fuer("bluray", None) is True
|
|
|
|
|
|
def test_komprimieren_fuer_einzelner_disc_typ_abwaehlbar():
|
|
"""Der Grund dafür (Befund 25.07.2026): 4K-HEVC brauchte auf der Rippy-VM
|
|
gemessene 28-55 Stunden, weil die CPU kein AVX2 hat. Vorher gab es nur
|
|
einen globalen Schalter - wer 4K verlustfrei behalten und DVDs trotzdem
|
|
schrumpfen wollte, hatte keine Möglichkeit."""
|
|
from ripping import PRESET_KEINE, komprimieren_fuer
|
|
|
|
nur_uhd_aus = {"transcodePresetUhd": PRESET_KEINE,
|
|
"transcodePresetDvd": "H.265 MKV 576p25"}
|
|
assert komprimieren_fuer("uhd", nur_uhd_aus) is False
|
|
assert komprimieren_fuer("dvd", nur_uhd_aus) is True
|
|
assert komprimieren_fuer("bluray", nur_uhd_aus) is True
|
|
|
|
|
|
def test_preset_fuer_gibt_den_reservewert_nie_als_preset_zurueck():
|
|
"""Sonst bekaeme HandBrake `--preset keine` und würde scheitern. Wer über
|
|
"Neu komprimieren" ausdruecklich doch komprimieren will, soll ein
|
|
brauchbares Preset bekommen statt eines Fehlers."""
|
|
from ripping import DEFAULT_HB_PRESET, PRESET_KEINE, preset_fuer
|
|
|
|
assert preset_fuer("uhd", {"transcodePresetUhd": PRESET_KEINE,
|
|
"transcodePreset": "HQ 1080p30 Surround"}) == "HQ 1080p30 Surround"
|
|
# Auch ohne allgemeines Preset kommt ein gültiger Name heraus
|
|
assert preset_fuer("uhd", {"transcodePresetUhd": PRESET_KEINE}) == DEFAULT_HB_PRESET
|
|
|
|
|
|
def test_reservewert_kollidiert_mit_keinem_echten_preset():
|
|
"""Gegengeprueft gegen alle 90 Presets aus `HandBrakeCLI --preset-list`
|
|
im Worker-Image (25.07.2026) - keines heißt so."""
|
|
from ripping import PRESET_KEINE
|
|
|
|
assert PRESET_KEINE == "keine"
|
|
assert " " not in PRESET_KEINE # echte Preset-Namen haben durchweg Leerzeichen
|
|
|
|
|
|
# --- Unbekanntes Preset: aus „Code 3" wird eine handelbare Meldung -----------
|
|
|
|
|
|
def test_unbekanntes_preset_wird_erkannt():
|
|
"""Wortlaut echt gemessen (26.07.2026, Worker-Image): HandBrake antwortet
|
|
„Invalid preset <Name>" und listet danach die gültigen auf."""
|
|
from ripping import unbekanntes_preset
|
|
|
|
assert unbekanntes_preset("Invalid preset Gibt Es Nicht 999p") == "Gibt Es Nicht 999p"
|
|
assert unbekanntes_preset(" Invalid preset H.265 XYZ 4K ") == "H.265 XYZ 4K"
|
|
# Alles andere ist kein Preset-Fehler
|
|
assert unbekanntes_preset("Valid presets are:") == ""
|
|
assert unbekanntes_preset("Encoding: task 1 of 1, 5.00 %") == ""
|
|
assert unbekanntes_preset("") == ""
|
|
assert unbekanntes_preset(None) == ""
|
|
|
|
|
|
def test_falsches_preset_erklaert_den_fehlschlag_statt_nur_den_code():
|
|
"""Vorher stand im UI nur „HandBrake endete mit Code 3" — daraus war nicht
|
|
zu erraten, dass der Preset-NAME das Problem ist. Der Fall wird häufiger,
|
|
weil ein Remote-Worker eine andere HandBrake-Version haben kann."""
|
|
import ripping
|
|
|
|
class FakeProcess:
|
|
def __init__(self):
|
|
self.stdout = iter([
|
|
"Invalid preset H.265 VCN 2160p 4K\n",
|
|
"Valid presets are:\n",
|
|
])
|
|
self.returncode = 3
|
|
|
|
def kill(self):
|
|
pass
|
|
|
|
def wait(self):
|
|
return 3
|
|
|
|
ergebnis = ripping._handbrake_schleife(FakeProcess(), "/gibt-es-nicht.mkv")
|
|
|
|
assert ergebnis["status"] == "error"
|
|
assert "H.265 VCN 2160p 4K" in ergebnis["error"]
|
|
assert "Bestes wählen" in ergebnis["error"]
|
|
assert ergebnis["return_code"] == 3
|
|
|
|
|
|
def test_fehler_ohne_preset_problem_bleibt_der_alte():
|
|
import ripping
|
|
|
|
class FakeProcess:
|
|
def __init__(self):
|
|
self.stdout = iter(["irgendwas ganz anderes\n"])
|
|
self.returncode = 1
|
|
|
|
def kill(self):
|
|
pass
|
|
|
|
def wait(self):
|
|
return 1
|
|
|
|
ergebnis = ripping._handbrake_schleife(FakeProcess(), "/gibt-es-nicht.mkv")
|
|
assert ergebnis["error"] == "HandBrake endete mit Code 1"
|
|
|
|
|
|
# --- Sprachen der Disc: gemessen an der Akira-Blu-ray (26.07.2026) -----------
|
|
#
|
|
# Woertlich aus `makemkvcon -r --noscan info dev:/dev/sr0` im Worker-Container
|
|
# der Rippy-VM, gekuerzt auf die Attribute, die Rippy liest (AGENTS Regel D).
|
|
|
|
SINFO_ECHT = """SINFO:0,0,1,6201,"Video"
|
|
SINFO:0,0,19,0,"1920x1080"
|
|
SINFO:0,0,28,0,"eng"
|
|
SINFO:0,0,29,0,"English"
|
|
SINFO:0,1,1,6202,"Audio"
|
|
SINFO:0,1,3,0,"deu"
|
|
SINFO:0,1,4,0,"German"
|
|
SINFO:0,1,6,0,"DTS-HD MA"
|
|
SINFO:0,1,14,0,"2"
|
|
SINFO:0,1,28,0,"eng"
|
|
SINFO:0,1,29,0,"English"
|
|
SINFO:0,1,30,0,"DTS-HD MA Stereo German"
|
|
SINFO:0,2,1,6202,"Audio"
|
|
SINFO:0,2,3,0,"jpn"
|
|
SINFO:0,2,4,0,"Japanese"
|
|
SINFO:0,2,6,0,"DTS-HD MA"
|
|
SINFO:0,2,14,0,"6"
|
|
SINFO:0,2,30,0,"DTS-HD MA 5.1 Japanese"
|
|
SINFO:0,3,1,6203,"Subtitles"
|
|
SINFO:0,3,3,0,"deu"
|
|
SINFO:0,3,4,0,"German"
|
|
SINFO:0,3,6,0,"PGS"
|
|
SINFO:1,0,1,6202,"Audio"
|
|
SINFO:1,0,3,0,"eng"
|
|
SINFO:1,0,4,0,"English"
|
|
SINFO:1,0,6,0,"AC3"
|
|
"""
|
|
|
|
|
|
def test_streams_werden_nach_typ_getrennt():
|
|
from ripping import parse_stream_info
|
|
|
|
je_titel = parse_stream_info(SINFO_ECHT)
|
|
assert set(je_titel) == {0, 1}
|
|
assert [a["lang"] for a in je_titel[0]["audio"]] == ["deu", "jpn"]
|
|
assert [u["lang"] for u in je_titel[0]["untertitel"]] == ["deu"]
|
|
assert je_titel[0]["audio"][1]["kanaele"] == "6"
|
|
assert je_titel[0]["audio"][0]["codec"] == "DTS-HD MA"
|
|
|
|
|
|
def test_die_sprache_steht_in_attribut_3_nicht_28():
|
|
"""DIE Falle: 28/29 tragen auf JEDEM Stream "eng"/"English" - auch auf einer
|
|
deutschen Tonspur und auf dem Videostream. Das ist MakeMKVs Anzeigesprache.
|
|
Wer 28 nimmt, haelt jede Disc fuer englisch."""
|
|
from ripping import parse_stream_info
|
|
|
|
deutsche_spur = parse_stream_info(SINFO_ECHT)[0]["audio"][0]
|
|
assert deutsche_spur["lang"] == "deu"
|
|
assert deutsche_spur["sprache"] == "German"
|
|
|
|
|
|
def test_video_taucht_nicht_als_tonspur_auf():
|
|
from ripping import parse_stream_info
|
|
|
|
je_titel = parse_stream_info(SINFO_ECHT)
|
|
assert all(a["typ"] == "Audio" for a in je_titel[0]["audio"])
|
|
|
|
|
|
def test_sprachen_werden_ueber_alle_titel_vereinigt():
|
|
"""Vor dem Rip zaehlt nicht "Titel 3 Stream 7", sondern was die DISC hat."""
|
|
from ripping import parse_stream_info, sprachen_zusammenfassen
|
|
|
|
s = sprachen_zusammenfassen(parse_stream_info(SINFO_ECHT))
|
|
assert [a["lang"] for a in s["audio"]] == ["deu", "jpn", "eng"]
|
|
assert [a["sprache"] for a in s["audio"]] == ["German", "Japanese", "English"]
|
|
assert [u["lang"] for u in s["untertitel"]] == ["deu"]
|
|
# deu kommt in Titel 0 einmal vor
|
|
assert s["audio"][0]["spuren"] == 1
|
|
|
|
|
|
def test_leere_ausgabe_gibt_leere_sprachen():
|
|
from ripping import parse_stream_info, sprachen_zusammenfassen
|
|
|
|
assert parse_stream_info("") == {}
|
|
assert sprachen_zusammenfassen({}) == {"audio": [], "untertitel": []}
|
|
assert sprachen_zusammenfassen(None) == {"audio": [], "untertitel": []}
|
|
|
|
|
|
# --- Sprachliste aus den Einstellungen --------------------------------------
|
|
|
|
|
|
def test_sprachliste_raeumt_auf():
|
|
from ripping import sprachliste
|
|
|
|
assert sprachliste("deu, eng , ,DEU") == ["deu", "eng"]
|
|
assert sprachliste(["deu", "deu", "jpn"]) == ["deu", "jpn"]
|
|
assert sprachliste("") == []
|
|
assert sprachliste(None) == []
|
|
|
|
|
|
def test_grossschreibung_macht_die_auswahl_nicht_unwirksam():
|
|
"""HandBrake will ISO-639-2 klein. Ein "DEU" aus einem Eingabefeld darf nicht
|
|
still die ganze Auswahl aushebeln."""
|
|
from ripping import sprachliste
|
|
|
|
assert sprachliste("DEU,ENG") == ["deu", "eng"]
|
|
|
|
|
|
# --- Das HandBrake-Kommando -------------------------------------------------
|
|
|
|
|
|
def test_handbrake_kommando_ohne_auswahl_bleibt_wie_bisher():
|
|
from ripping import build_handbrake_cmd
|
|
|
|
cmd = build_handbrake_cmd("/a.mkv", "/b.mkv", "H.265 MKV 1080p30")
|
|
assert "--audio-lang-list" not in cmd
|
|
assert "--subtitle-lang-list" not in cmd
|
|
assert "--first-audio" in cmd and "--all-subtitles" in cmd
|
|
|
|
|
|
def test_handbrake_kommando_mit_sprachauswahl():
|
|
"""--audio-lang-list zusammen mit --first-audio heisst laut HandBrake-Hilfe:
|
|
nur die erste (beste) Tonspur, die zu einer der Sprachen passen."""
|
|
from ripping import build_handbrake_cmd
|
|
|
|
cmd = build_handbrake_cmd("/a.mkv", "/b.mkv", "H.265 MKV 1080p30",
|
|
audio_sprachen=["deu", "jpn"],
|
|
untertitel_sprachen=["deu"])
|
|
assert cmd[cmd.index("--audio-lang-list") + 1] == "deu,jpn"
|
|
assert cmd[cmd.index("--subtitle-lang-list") + 1] == "deu"
|
|
assert "--first-audio" in cmd and "--all-subtitles" in cmd
|