feat(tracks): support HDR Vivid and Audio Vivid

Add Video.Range.VIVID and Audio.Codec.AV3A, found by the first-segment probe (CUVA SEI, MPEG-TS cuvv/AVSA/0xD5); sort videos by standard resolution, then bitrate; drop service names left in core and tests. Closes #179
This commit is contained in:
imSp4rky
2026-11-14 22:03:13 -07:00
parent 7a5ad5024a
commit 856a193061
26 changed files with 365 additions and 96 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ def adaptation_set(scheme: str, value: str) -> ET.Element:
@pytest.mark.parametrize(
"scheme,value",
[
# "description" is the value the DASH role scheme defines. Stan writes it, and reading only
# "description" is the value the DASH role scheme defines. A service writes it, and reading only
# "descriptive" left every audio-description track unflagged.
("urn:mpeg:dash:role:2011", "description"),
("urn:mpeg:dash:role:2011", "descriptive"),
+5 -1
View File
@@ -11,7 +11,7 @@ import pytest
from unshackle.commands.dl import dl
from unshackle.core.config import config
from unshackle.core.export_name import ExportNamer, export_name, move_export, season_episodes
from unshackle.core.export_name import ExportNamer, export_name, move_export, quality_parts, season_episodes
from unshackle.core.titles import Episode, Movie, Song
from unshackle.core.tracks import Audio, Subtitle, Video
@@ -251,3 +251,7 @@ def test_summary_when_nothing_was_exported(tmp_path: Path, caplog: pytest.LogCap
with caplog.at_level(logging.INFO, logger="download"):
instance.log_export_summary()
assert "Nothing was exported" in caplog.messages
def test_a_range_value_with_a_space_has_none_in_the_name() -> None:
assert "hdrvivid" in quality_parts([video(codec=Video.Codec.HEVC, range_=Video.Range.VIVID)])
+132 -10
View File
@@ -5,11 +5,14 @@ from typing import Any
import pytest
from tests.core.test_export_name import video
from unshackle.core.config import Config, config
from unshackle.core.import_service import video_range
from unshackle.core.manifests.snapshot import match_track
from unshackle.core.remote_service import RemoteService
from unshackle.core.tracks import Video
from unshackle.core.tracks import Audio, Video
from unshackle.core.utils import hdr10plus
from unshackle.core.utils.hdr10plus import HDR10PLUS_T35, fragment_has_hdr10plus
from unshackle.core.utils.hdr10plus import HDR10PLUS_T35, TS_PACKET, fragment_has_hdr10plus
def box(kind: bytes, body: bytes) -> bytes:
@@ -76,8 +79,10 @@ def probe(monkeypatch: pytest.MonkeyPatch, videos: list[Any]) -> list[str]:
return video.id[-1].encode()
monkeypatch.setattr(hdr10plus, "first_fragment", fake_fragment)
monkeypatch.setattr(hdr10plus, "fragment_has_hdr10plus", lambda data: data == b"+")
hdr10plus.probe_hdr10plus(videos, None, log=logging.getLogger("test"), workers=4) # type: ignore[arg-type]
monkeypatch.setattr(
hdr10plus, "fragment_has_hdr10plus", lambda data, t35=HDR10PLUS_T35: t35 == HDR10PLUS_T35 and data == b"+"
)
hdr10plus.probe_dynamic_hdr(videos, None, log=logging.getLogger("test"), workers=4) # type: ignore[arg-type]
return probed
@@ -132,7 +137,7 @@ def test_on_start_runs_only_when_there_is_a_track_to_probe(monkeypatch: pytest.M
started: list[int] = []
sdr = SimpleNamespace(id="s", range=Video.Range.SDR, codec=Video.Codec.AVC, bitrate=1, session=None)
for videos in ([sdr], [hevc("v", 2160)]):
hdr10plus.probe_hdr10plus(
hdr10plus.probe_dynamic_hdr(
videos, # type: ignore[arg-type]
None, # type: ignore[arg-type]
log=logging.getLogger("test"),
@@ -165,14 +170,16 @@ def test_first_segment_of_an_hls_playlist_keeps_its_byte_offset() -> None:
def probe_calls(monkeypatch: pytest.MonkeyPatch, service: Any, **kwargs: Any) -> list[int]:
calls: list[int] = []
monkeypatch.setattr(
hdr10plus, "probe_hdr10plus", lambda videos, session, *, log, workers, on_start: calls.append(workers)
hdr10plus, "probe_dynamic_hdr", lambda videos, session, *, log, workers, on_start: calls.append(workers)
)
hdr10plus.probed_tracks(service, None, **kwargs)
return calls
def test_probed_tracks_follows_the_config_switch_and_the_workers_count(monkeypatch: pytest.MonkeyPatch) -> None:
service = SimpleNamespace(get_tracks=lambda title: SimpleNamespace(videos=[hevc("v", 2160)]), session=None)
service = SimpleNamespace(
get_tracks=lambda title: SimpleNamespace(videos=[hevc("v", 2160)], audio=[]), session=None
)
assert probe_calls(monkeypatch, service, workers=3) == [3]
monkeypatch.setattr(config, "hdr10plus_probe", False)
assert probe_calls(monkeypatch, service, workers=3) == []
@@ -180,13 +187,15 @@ def test_probed_tracks_follows_the_config_switch_and_the_workers_count(monkeypat
def test_probed_tracks_leaves_a_remote_service_to_its_server(monkeypatch: pytest.MonkeyPatch) -> None:
remote = RemoteService.__new__(RemoteService)
remote.get_tracks = lambda title: SimpleNamespace(videos=[hevc("v", 2160)]) # type: ignore[method-assign]
remote.get_tracks = lambda title: SimpleNamespace(videos=[hevc("v", 2160)], audio=[]) # type: ignore[method-assign]
assert probe_calls(monkeypatch, remote, workers=3) == []
@pytest.mark.parametrize("workers", [-1, "8"])
def test_a_bad_workers_count_is_an_error(monkeypatch: pytest.MonkeyPatch, workers: Any) -> None:
service = SimpleNamespace(get_tracks=lambda title: SimpleNamespace(videos=[hevc("v", 2160)]), session=None)
service = SimpleNamespace(
get_tracks=lambda title: SimpleNamespace(videos=[hevc("v", 2160)], audio=[]), session=None
)
with pytest.raises(ValueError, match="workers"):
probe_calls(monkeypatch, service, workers=workers)
@@ -212,7 +221,9 @@ def test_a_fragment_inside_the_first_window_needs_one_request(monkeypatch: pytes
def test_a_quoted_dl_workers_value_works_like_the_cli(monkeypatch: pytest.MonkeyPatch) -> None:
service = SimpleNamespace(get_tracks=lambda title: SimpleNamespace(videos=[hevc("v", 2160)]), session=None)
service = SimpleNamespace(
get_tracks=lambda title: SimpleNamespace(videos=[hevc("v", 2160)], audio=[]), session=None
)
monkeypatch.setitem(config.dl, "workers", "8")
assert probe_calls(monkeypatch, service) == [8]
@@ -221,3 +232,114 @@ def test_a_dv_track_with_an_hdr10_base_lists_as_dv_plus_hdr10() -> None:
video = Video(url="https://x/v.mp4", language="en", codec=Video.Codec.HEVC, range_=Video.Range.DV, hdr10_base=True)
assert "[H.265, DV+HDR10]" in str(video)
assert Video.from_dict(video.to_dict()).hdr10_base
def ts_packet(pid: int, section: bytes) -> bytes:
body = b"\x00" + section
return bytes([0x47, 0x40 | (pid >> 8), pid & 0xFF, 0x10]) + body + b"\xff" * (184 - len(body))
def ts(*streams: tuple[int, bytes]) -> bytes:
"""A PAT and a PMT that list each (stream type, registration) on its own PID."""
pat = b"\x00\xb0\x0d\x00\x01\xc1\x00\x00\x00\x01\xf0\x00" + b"\x00" * 4
entries = b""
for index, (stream_type, registration) in enumerate(streams):
descriptors = b"\x05\x04" + registration if registration else b""
entries += bytes([stream_type, 0xE1, index, 0xF0, len(descriptors)]) + descriptors
body = b"\x00\x01\xc1\x00\x00\xe1\x00\xf0\x00" + entries + b"\x00" * 4
pmt = b"\x02" + bytes([0xB0 | (len(body) >> 8), len(body) & 0xFF]) + body
return ts_packet(0, pat) + ts_packet(0x1000, pmt)
def test_ts_streams_reads_the_pmt_stream_types_and_registrations() -> None:
assert hdr10plus.ts_streams(ts((0x24, b"cuvv"), (0x0F, b""))) == [(0x24, b"cuvv"), (0x0F, b"")]
assert hdr10plus.ts_streams(ts_packet(0, b"\x00" * 16)) is None
assert hdr10plus.is_ts(ts((0xD5, b""))) and not hdr10plus.is_ts(fragment(nal(1, b"")))
def serve_segments(monkeypatch: pytest.MonkeyPatch, segments: dict[str, bytes]) -> None:
"""Serve each track's first segment through the real first_fragment, keyed by track id."""
monkeypatch.setattr(hdr10plus, "first_segment", lambda track, session: (track.id, 0))
monkeypatch.setattr(hdr10plus, "ranged_get", lambda url, session, byte_range: segments[url])
def test_a_cuvv_registered_ts_track_becomes_vivid(monkeypatch: pytest.MonkeyPatch) -> None:
serve_segments(
monkeypatch,
{"vivid": ts((0x24, b"cuvv"), (0x0F, b"")), "plain": ts((0x24, b"")), "dv": ts((0x24, b"cuvv"))},
)
videos = [hevc("vivid", 2160), hevc("plain", 1080), hevc("dv", 2160, range_=Video.Range.DV)]
hdr10plus.probe_dynamic_hdr(videos, None, log=logging.getLogger("test"), workers=4) # type: ignore[arg-type]
assert [v.range for v in videos] == [Video.Range.VIVID, Video.Range.HDR10, Video.Range.DV]
assert not videos[2].hdr10plus
def test_cuva_sei_makes_an_fmp4_hdr10_or_hlg_track_vivid(monkeypatch: pytest.MonkeyPatch) -> None:
cuva = fragment(nal(32, b"vps"), sei(4, hdr10plus.CUVA_T35 + b"\x00" * 8), nal(1, b"\xff" * 64))
plus = fragment(nal(32, b"vps"), sei(4, HDR10PLUS_T35 + b"\x00" * 8), nal(1, b"\xff" * 64))
serve_segments(monkeypatch, {"pq": cuva, "hlg": cuva, "hlg+": plus})
videos = [
hevc(i, 2160 - n, range_=r)
for n, (i, r) in enumerate([("pq", Video.Range.HDR10), ("hlg", Video.Range.HLG), ("hlg+", Video.Range.HLG)])
]
hdr10plus.probe_dynamic_hdr(videos, None, log=logging.getLogger("test"), workers=4) # type: ignore[arg-type]
assert [v.range for v in videos] == [Video.Range.VIVID, Video.Range.VIVID, Video.Range.HLG]
def test_ts_streams_skips_program_zero_and_survives_bad_lengths() -> None:
pat = b"\x00\xb0\x11\x00\x01\xc1\x00\x00" + b"\x00\x00\xe0\x10" + b"\x00\x01\xf0\x00" + b"\x00" * 4
data = ts_packet(0, pat) + ts((0x24, b"cuvv"))[TS_PACKET:]
assert hdr10plus.ts_streams(data) == [(0x24, b"cuvv")]
pmt = bytearray(ts((0x24, b"cuvv"))[TS_PACKET:])
pmt[5 + 12 + 4] = 0xFF
pmt[28:] = bytes(len(pmt) - 28)
assert hdr10plus.ts_streams(ts((0x24, b""))[:TS_PACKET] + bytes(pmt)) == [(0x24, b"cuvv")]
def test_audio_without_a_codec_takes_it_from_the_ts_stream_type(monkeypatch: pytest.MonkeyPatch) -> None:
fetched: list[str] = []
def fake_fragment(track: Any, session: Any) -> bytes:
fetched.append(track.id)
return ts((0xD5, b""))
monkeypatch.setattr(hdr10plus, "first_fragment", fake_fragment)
vivid = SimpleNamespace(id="a1", codec=None, session=None)
named = SimpleNamespace(id="a2", codec=Audio.Codec.AAC, session=None)
hdr10plus.probe_audio_codecs([vivid, named], None, log=logging.getLogger("test"), workers=4) # type: ignore[arg-type,list-item]
assert (vivid.codec, named.codec, fetched) == (Audio.Codec.AV3A, Audio.Codec.AAC, ["a1"])
assert "[Audio Vivid]" in str(Audio(url="https://x/a.m3u8", language="zh", codec=Audio.Codec.AV3A))
def test_an_avsa_registration_names_audio_vivid(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(hdr10plus, "first_fragment", lambda track, session: ts((0x06, b"AVSA")))
track = SimpleNamespace(id="a", codec=None, session=None)
hdr10plus.probe_audio_codecs([track], None, log=logging.getLogger("test"), workers=1) # type: ignore[arg-type,list-item]
assert track.codec == Audio.Codec.AV3A
def test_vivid_imports_by_value_and_matches_its_unprobed_reparse() -> None:
assert video_range("HDR Vivid") == video_range("vivid") == Video.Range.VIVID
for local, remote in ((Video.Range.HDR10, Video.Range.VIVID), (Video.Range.HLG, Video.Range.VIVID)):
assert match_track(video("r", range_=remote), [video("l", range_=local)]) is not None
assert match_track(video("r", range_=Video.Range.VIVID), [video("l", range_=Video.Range.HDR10P)]) is None
def test_audio_vivid_has_no_music_container(tmp_path: Any) -> None:
path = tmp_path / "a.ts"
path.write_bytes(b"\x47")
track = Audio(url="https://x/a.m3u8", language="zh", codec=Audio.Codec.AV3A)
track.path = path
with pytest.raises(ValueError, match="Audio Vivid"):
track.to_music_container()
def test_probed_tracks_probes_codecless_audio_without_video(monkeypatch: pytest.MonkeyPatch) -> None:
calls: list[int] = []
monkeypatch.setattr(
hdr10plus, "probe_audio_codecs", lambda audio, session, *, log, workers: calls.append(len(audio))
)
service = SimpleNamespace(get_tracks=lambda title: SimpleNamespace(videos=[], audio=[SimpleNamespace(codec=None)]))
service.session = None
hdr10plus.probed_tracks(service, None, workers=2) # type: ignore[arg-type]
assert calls == [1]
+2 -2
View File
@@ -215,7 +215,7 @@ def test_a_quiet_remote_login_shows_its_last_server_lines_as_a_notice(monkeypatc
shown = []
monkeypatch.setattr(remote_service, "show_notice", shown.append)
monkeypatch.setattr(remote_service.time, "sleep", lambda s: None)
logs = iter([["+ IP Region: CA"], ["+ Open URL: https://disneyplus.com/start\n+ Enter pair code: 9835-2503"], []])
logs = iter([["+ IP Region: CA"], ["+ Open URL: https://example.test/start\n+ Enter pair code: 9835-2503"], []])
statuses = iter(["authenticating", "authenticating", "authenticating", "authenticated"])
class Client:
@@ -236,7 +236,7 @@ def test_a_quiet_remote_login_shows_its_last_server_lines_as_a_notice(monkeypatc
remote.poll_auth_completion(poll_interval=0)
# silent while the remote was still logging; the pair code once it went quiet; cleared when done
assert shown == ["+ IP Region: CA\n+ Open URL: https://disneyplus.com/start\n+ Enter pair code: 9835-2503", None]
assert shown == ["+ IP Region: CA\n+ Open URL: https://example.test/start\n+ Enter pair code: 9835-2503", None]
assert remote.auth_notice is None
@@ -103,6 +103,20 @@ def test_sort_videos_resolution_outranks_bitrate() -> None:
assert [t.id for t in tracks.videos] == ["lo-bitrate-2160", "hi-bitrate-720"]
def test_sort_videos_orders_crops_of_one_resolution_by_bitrate() -> None:
tracks = Tracks()
tracks.videos = [
make_video("1610-lo", height=1610, bitrate=4_000_000, width=3840),
make_video("1606-hi", height=1606, bitrate=15_000_000, width=3840),
make_video("1610-hi", height=1610, bitrate=16_000_000, width=3840),
make_video("804", height=804, bitrate=20_000_000, width=1920),
]
tracks.sort_videos()
assert [t.id for t in tracks.videos] == ["1610-hi", "1606-hi", "1610-lo", "804"]
def test_sort_videos_unknown_bitrate_still_wins_on_resolution() -> None:
tracks = Tracks()
tracks.videos = [