"""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 laeuft 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 fuer die Regex (Stand 25.07.2026): vorher stand hier line.split(",", 4)[3]. Das schnitt jede Meldung ab, die selbst ein Komma enthaelt — 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 ausser 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 fuer alles — eine 4K-UHD wurde # damit auf 1080p heruntergerechnet, und beim ersten echten UHD-Rip waere # 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 aendern. 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 zaehlt 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 genuegten — es musste NICHT auf eine Encode-Zeile gewartet # werden. Genau das war der Fehler. assert len(gesehen) == 2