mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-15 21:45:10 +00:00
Audio Vivid skips the mux and its .ts is saved beside the output; a user's avs3Decoder adds a PCM copy. wide_output prints dl listings at the terminal width; cfg saves lowercase true/false as booleans.
114 lines
5.0 KiB
Python
114 lines
5.0 KiB
Python
from __future__ import annotations
|
|
|
|
import logging
|
|
import stat
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from tests.core.test_hdr10plus_probe import ts
|
|
from unshackle.commands.dl import take_unmuxable_audio, track_file_suffix
|
|
from unshackle.core.tracks import Audio, Tracks, Video
|
|
from unshackle.core.utils import audio_vivid
|
|
|
|
LOG = logging.getLogger("test")
|
|
|
|
|
|
def media_packet(pid: int, payload: bytes, start: bool) -> bytes:
|
|
"""One TS packet carrying ``payload``, padded with adaptation-field stuffing as packagers do."""
|
|
stuffing = 184 - len(payload)
|
|
flags = 0x40 if start else 0x00
|
|
if stuffing:
|
|
field = bytes([stuffing - 1]) + (b"\x00" + b"\xff" * (stuffing - 2) if stuffing > 1 else b"")
|
|
return bytes([0x47, flags | pid >> 8, pid & 0xFF, 0x30]) + field + payload
|
|
return bytes([0x47, flags | pid >> 8, pid & 0xFF, 0x10]) + payload
|
|
|
|
|
|
def audio_vivid_ts(frames: list[bytes]) -> bytes:
|
|
"""A PAT, a PMT with one Audio Vivid stream on PID 0x100, and one PES per frame."""
|
|
data = ts((0xD5, b""))
|
|
for frame in frames:
|
|
pes = b"\x00\x00\x01\xc0\x00\x00\x80\x80\x05" + b"\x21\x00\x01\x00\x01" + frame
|
|
data += b"".join(media_packet(0x100, pes[i : i + 184], i == 0) for i in range(0, len(pes), 184))
|
|
return data
|
|
|
|
|
|
def test_the_elementary_stream_is_every_pes_payload_in_order(tmp_path: Path) -> None:
|
|
frames = [bytes([n]) * size for n, size in ((1, 40), (2, 400), (3, 175))]
|
|
source, out = tmp_path / "a.ts", tmp_path / "a.av3a"
|
|
source.write_bytes(audio_vivid_ts(frames))
|
|
assert audio_vivid.extract_audio_vivid(source, out)
|
|
assert out.read_bytes() == b"".join(frames)
|
|
|
|
|
|
def test_a_ts_without_audio_vivid_extracts_nothing(tmp_path: Path) -> None:
|
|
source = tmp_path / "a.ts"
|
|
source.write_bytes(ts((0x0F, b"")))
|
|
assert not audio_vivid.extract_audio_vivid(source, tmp_path / "a.av3a")
|
|
|
|
|
|
def fake_decoder(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, *, fail: bool = False) -> None:
|
|
"""A stand-in decoder that copies its input to its output, or exits 3; a .cmd on Windows."""
|
|
if sys.platform == "win32":
|
|
decoder = tmp_path / "avs3Decoder.cmd"
|
|
decoder.write_text("@exit /b 3\r\n" if fail else '@copy /y "%~1" "%~2" >nul\r\n')
|
|
else:
|
|
decoder = tmp_path / "avs3Decoder"
|
|
decoder.write_text("#!/bin/sh\nexit 3\n" if fail else '#!/bin/sh\ncp "$1" "$2"\n')
|
|
decoder.chmod(decoder.stat().st_mode | stat.S_IEXEC)
|
|
monkeypatch.setattr(audio_vivid.binaries, "avs3Decoder", decoder, raising=False)
|
|
|
|
|
|
def test_the_decoder_gets_the_stream_and_writes_the_wav(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
fake_decoder(tmp_path, monkeypatch)
|
|
source = tmp_path / "a.ts"
|
|
source.write_bytes(audio_vivid_ts([b"frame"]))
|
|
wav = audio_vivid.decode_audio_vivid(source, LOG)
|
|
assert wav == tmp_path / "a.wav" and wav.read_bytes() == b"frame"
|
|
assert not (tmp_path / "a.av3a").exists()
|
|
|
|
|
|
def test_a_failing_decoder_warns_once_and_leaves_no_files(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
|
) -> None:
|
|
fake_decoder(tmp_path, monkeypatch, fail=True)
|
|
source = tmp_path / "a.ts"
|
|
source.write_bytes(audio_vivid_ts([b"frame"]))
|
|
with caplog.at_level(logging.WARNING):
|
|
assert audio_vivid.decode_audio_vivid(source, LOG) is None
|
|
assert caplog.messages == ["Audio Vivid decode of a.ts failed"]
|
|
assert sorted(p.name for p in tmp_path.iterdir() if not p.name.startswith("avs3")) == ["a.ts"]
|
|
|
|
|
|
def tracks_with(tmp_path: Path, *codecs: Any) -> Tracks:
|
|
tracks = Tracks()
|
|
tracks.videos = [Video(url="https://x.example/v.m3u8", language="zh", codec=Video.Codec.HEVC)]
|
|
for n, codec in enumerate(codecs):
|
|
audio = Audio(url=f"https://x.example/a{n}.m3u8", language="zh", codec=codec, id_=f"a{n}")
|
|
audio.path = tmp_path / f"a{n}.ts"
|
|
tracks.audio.append(audio)
|
|
return tracks
|
|
|
|
|
|
def test_without_a_decoder_audio_vivid_leaves_the_mux(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr("unshackle.commands.dl.decode_audio_vivid", lambda path, log: None)
|
|
tracks = tracks_with(tmp_path, Audio.Codec.EC3, Audio.Codec.AV3A)
|
|
kept = take_unmuxable_audio(tracks, LOG)
|
|
assert [a.id for a in kept] == ["a1"]
|
|
assert [a.codec for a in tracks.audio] == [Audio.Codec.EC3]
|
|
|
|
|
|
def test_with_a_decoder_a_wav_copy_joins_the_mux(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr("unshackle.commands.dl.decode_audio_vivid", lambda path, log: path.with_suffix(".wav"))
|
|
tracks = tracks_with(tmp_path, Audio.Codec.AV3A)
|
|
kept = take_unmuxable_audio(tracks, LOG)
|
|
assert [a.id for a in kept] == ["a0"] and kept[0].codec == Audio.Codec.AV3A
|
|
assert [(a.id, a.codec, a.path) for a in tracks.audio] == [("a0", Audio.Codec.PCM, tmp_path / "a0.wav")]
|
|
|
|
|
|
def test_a_kept_track_is_named_like_a_no_mux_track(tmp_path: Path) -> None:
|
|
tracks = tracks_with(tmp_path, Audio.Codec.AV3A)
|
|
assert [track_file_suffix(t) for t in (tracks.videos[0], tracks.audio[0])] == [".HEVC", ".zh.AV3A"]
|