Files
rippy/docker/worker/test_ripping_helpers.py
T
Hitonabi 148ac494c2
Ampel / ampel (push) Successful in 29s
feat(sprachen): Rippy fragt vor dem Rip, welche Sprachen du willst
Commander-Anforderung: "Die Disc hat Material in X Sprachen und X Untertiteln -
Rippy muss VOR dem Rip fragen: Was genau willst du haben? In der Automatik muss
das ebenfalls einstellbar sein."

Die Auskunft lag laengst vor und wurde weggeworfen: Derselbe Titel-Scan, der die
Titel-Tabelle fuellt, liefert in derselben makemkvcon-Ausgabe die Streams mit.
Ein zweiter Info-Lauf haette eine Minute Wartezeit gekostet - jetzt kommt beides
aus einem Aufruf.

Format an der Akira-Blu-ray im Laufwerk gemessen (AGENTS Regel D):

  SINFO:<titel>,<stream>,<attribut>,<code>,"<wert>"
  1 = Typ ("Audio"/"Subtitles"), 3 = Sprachcode, 4 = Sprachname,
  6 = Codec, 14 = Kanaele, 30 = Beschreibung

DIE FALLE dabei: Die Sprache steht in 3/4, NICHT in 28/29. Die tragen auf JEDEM
Stream "eng"/"English" - auch auf einer deutschen Tonspur und auf dem
Videostream; das ist MakeMKVs eigene Anzeigesprache. Wer 28 nimmt, haelt jede
Disc fuer englisch. Ein Test haelt das fest.

Angewendet wird die Wahl bei der KOMPRESSION, nicht beim Rippen - drei Gruende:
der Rip bleibt vollstaendig und verlustfrei (Muss-Feature laut KONZEPT); HandBrake
hat dafuer dokumentierte Schalter (--audio-lang-list / --subtitle-lang-list, die
genau die ISO-639-2-Codes nehmen, die MakeMKV liefert - beides gegengeprueft);
und wer spaeter andere Sprachen will, komprimiert neu statt die Disc wieder
einzulegen. Genau das sagt der Dialog auch, sonst glaubt man, es werde
unvollstaendig gerippt.

Nichts angeklickt heisst "alles behalten" - das Verhalten von vorher. Auch die
Einstellung ist bewusst LEER vorbelegt: Ein stilles "deu" wuerde bei einem
japanischen Original die Originaltonspur wegwerfen, ohne dass jemand gefragt hat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 15:10:21 +02:00

595 lines
24 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")
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
# 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"
# --- Auswurf: das ioctl meldet Erfolg und tut nichts (Befund 26.07.2026) -----
def _laufwerk(verriegelt=True, kennt_lockdoor=True):
"""Ein nachgebautes Laufwerk, das sich wie das echte verhaelt.
Gemessen am BU40N der Rippy-VM: Nach einem MakeMKV-Rip ist die Tuer
verriegelt. CDROMEJECT wird dann ANGENOMMEN und tut nichts - der Status
bleibt auf 4 (Disc drin). Erst CDROM_LOCKDOOR 0 macht den Auswurf wirksam.
"""
import ripping
zustand = {"verriegelt": verriegelt, "status": ripping.CDS_DISC_OK,
"aufrufe": []}
def ioctl_fn(fd, befehl, arg=0):
zustand["aufrufe"].append(befehl)
if befehl == ripping.CDROM_LOCKDOOR:
if not kennt_lockdoor:
raise OSError("ioctl unbekannt")
zustand["verriegelt"] = bool(arg)
return 0
if befehl == ripping.CDROMEJECT:
if not zustand["verriegelt"]:
zustand["status"] = ripping.CDS_TRAY_OPEN
return 0 # <- auch verriegelt: ERFOLG, aber ohne Wirkung
if befehl == ripping.CDROM_DRIVE_STATUS:
return zustand["status"]
raise OSError("unerwartetes ioctl")
return zustand, ioctl_fn
def test_auswurf_entriegelt_zuerst_und_klappt_dann():
import ripping
zustand, ioctl_fn = _laufwerk(verriegelt=True)
ok = ripping.wirf_disc_aus(
"/dev/sr0", ioctl_fn=ioctl_fn, oeffnen=lambda p: 42,
schliessen=lambda fd: None, warten=lambda s: None)
assert ok is True
assert zustand["status"] == ripping.CDS_TRAY_OPEN
# Reihenfolge: entriegeln VOR auswerfen
assert zustand["aufrufe"][0] == ripping.CDROM_LOCKDOOR
assert zustand["aufrufe"][1] == ripping.CDROMEJECT
def test_auswurf_meldet_fehlschlag_wenn_die_disc_drin_bleibt():
"""Der eigentliche Fehler. Vorher gab wirf_disc_aus True zurueck, weil das
ioctl nicht geworfen hatte - und ins Log kam "Disc ausgeworfen", waehrend
die Schublade zu blieb. Ein ioctl-Rueckgabewert beweist nichts."""
import ripping
# Ein Laufwerk, das LOCKDOOR nicht kennt und verriegelt bleibt
zustand, ioctl_fn = _laufwerk(verriegelt=True, kennt_lockdoor=False)
ok = ripping.wirf_disc_aus(
"/dev/sr0", ioctl_fn=ioctl_fn, oeffnen=lambda p: 42,
schliessen=lambda fd: None, warten=lambda s: None)
assert ok is False
assert zustand["status"] == ripping.CDS_DISC_OK # nie aufgegangen
def test_auswurf_bei_slot_laufwerk_ohne_schublade():
"""Ein Slot-Laufwerk hat keine Schublade und meldet nach dem Auswerfen
CDS_NO_DISC. Das muss als Erfolg zaehlen."""
import ripping
assert ripping._auswurf_geglueckt(ripping.CDS_NO_DISC) is True
assert ripping._auswurf_geglueckt(ripping.CDS_TRAY_OPEN) is True
assert ripping._auswurf_geglueckt(ripping.CDS_DISC_OK) is False
assert ripping._auswurf_geglueckt(ripping.CDS_DRIVE_NOT_READY) is False
def test_auswurf_ohne_laufwerk_wirft_nicht():
import ripping
def oeffnen_kaputt(p):
raise OSError("kein Laufwerk")
assert ripping.wirf_disc_aus(
"/dev/sr9", ioctl_fn=lambda *a: 0, oeffnen=oeffnen_kaputt,
schliessen=lambda fd: None, warten=lambda s: None) is False
# --- 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 "--all-audio" in cmd and "--all-subtitles" in cmd
def test_handbrake_kommando_mit_sprachauswahl():
"""--audio-lang-list zusammen mit --all-audio heisst laut HandBrake-Hilfe:
alle Tonspuren, 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 "--all-audio" in cmd and "--all-subtitles" in cmd