"""Unit tests for module-level helpers in unshackle.core.remote_service.""" from __future__ import annotations from enum import Enum import pytest from unshackle.core.api.handlers import serialize_audio_track, serialize_subtitle_track, serialize_video_track from unshackle.core.manifests.snapshot import match_track from unshackle.core.remote_service import ( RemoteService, build_title, build_tracks, deserialize_audio, deserialize_subtitle, deserialize_video, enum_get, reconstruct_drm, ) from unshackle.core.titles.episode import Episode from unshackle.core.titles.movie import Movie from unshackle.core.tracks import Audio, Subtitle, Video pytestmark = pytest.mark.unit class _Color(Enum): RED = 1 BLUE = 2 def test_enum_get_known() -> None: assert enum_get(_Color, "RED") is _Color.RED def test_enum_get_unknown_returns_default() -> None: assert enum_get(_Color, "PURPLE", default=_Color.BLUE) is _Color.BLUE def test_enum_get_none_returns_default() -> None: assert enum_get(_Color, None, default=_Color.RED) is _Color.RED def test_deserialize_video_minimal() -> None: v = deserialize_video({"id": "video-1", "codec": "AVC", "width": 1920, "height": 1080, "bitrate": 5000}) assert isinstance(v, Video) assert v.id == "video-1" assert v.codec is Video.Codec.AVC assert v.bitrate == 5_000_000 # kbps -> bps assert v.width == 1920 assert v.height == 1080 assert v.range is Video.Range.SDR def test_a_dv_track_keeps_its_hdr10_base_label_over_remote() -> None: v = Video(url="https://x/v.mp4", language="en", codec=Video.Codec.HEVC, range_=Video.Range.DV, hdr10_base=True) assert deserialize_video(serialize_video_track(v)).range_label == "DV+HDR10" def test_a_track_note_shows_over_remote_and_export_and_is_not_an_edition() -> None: from unshackle.core.tracks.track import Track v = Video(url="https://x/v.mp4", language="ja", codec=Video.Codec.AVC, note="h264mpl40-dash") a = Audio(url="https://x/a.mp4", language="ja", codec=Audio.Codec.AAC, note="heaac-2-dash") s = Subtitle(url="https://x/s.vtt", language="en", codec=Subtitle.Codec.WebVTT, note="webvtt-lssdh") copies = [deserialize_video(serialize_video_track(v)), deserialize_audio(serialize_audio_track(a))] copies += [deserialize_subtitle(serialize_subtitle_track(s))] copies += [Track.from_dict(t.to_dict()) for t in (v, a, s)] for copy in copies: assert copy.note and str(copy).endswith(f"| {copy.note}") assert copy.edition == [] def test_a_note_that_is_not_text_is_dropped_not_fatal() -> None: assert deserialize_video({"id": "v1", "codec": "AVC", "width": 1, "height": 1, "note": {"x": 1}}).note is None def test_deserialize_video_unknown_codec_falls_back_to_none() -> None: v = deserialize_video({"id": "v2", "codec": "MADE_UP", "width": 0, "height": 0}) assert v.codec is None def test_deserialize_audio_atmos_flag_without_a_count() -> None: a = deserialize_audio({"id": "a1", "codec": "AAC", "atmos": True, "channels": 6, "bitrate": 256}) assert isinstance(a, Audio) assert a.atmos is True assert a.joc is None assert a.channels == 6 assert a.bitrate == 256_000 def test_deserialize_audio_atmos_keeps_a_real_joc_count() -> None: a = deserialize_audio({"id": "a3", "codec": "EC3", "atmos": True, "joc": 16, "channels": 6}) assert a.atmos is True assert a.joc == 16 def test_deserialize_audio_no_atmos() -> None: a = deserialize_audio({"id": "a2", "codec": "AAC", "channels": 2}) assert a.atmos is False assert not a.joc def test_deserialize_subtitle_forced_flag() -> None: s = deserialize_subtitle({"id": "s1", "codec": "WebVTT", "language": "en", "forced": True}) assert isinstance(s, Subtitle) assert s.forced is True assert s.sdh is False def test_deserialize_subtitle_sdh_flag() -> None: s = deserialize_subtitle({"id": "s2", "codec": "WebVTT", "language": "en", "sdh": True}) assert s.sdh is True assert s.forced is False def test_reconstruct_drm_empty() -> None: assert reconstruct_drm(None) == [] assert reconstruct_drm([]) == [] def test_reconstruct_drm_skips_entries_without_pssh() -> None: assert reconstruct_drm([{"type": "widevine"}]) == [] def test_reconstruct_drm_invalid_pssh_silently_dropped() -> None: assert reconstruct_drm([{"type": "widevine", "pssh": "not-real-pssh"}]) == [] def test_build_tracks_aggregates() -> None: data = { "video": [{"id": "v", "codec": "AVC", "width": 1280, "height": 720, "bitrate": 2500}], "audio": [{"id": "a", "codec": "AAC", "channels": 2, "bitrate": 128}], "subtitles": [{"id": "s", "codec": "WebVTT", "language": "en"}], "attachments": [], } t = build_tracks(data) assert len(t.videos) == 1 assert len(t.audio) == 1 assert len(t.subtitles) == 1 def test_build_tracks_restores_drm_preference() -> None: data = { "video": [{"id": "v", "codec": "AVC", "width": 1280, "height": 720, "drm_preference": "playready"}], "audio": [{"id": "a", "codec": "AAC", "channels": 2, "drm_preference": "wv"}], "subtitles": [], } t = build_tracks(data) assert t.videos[0].drm_preference == "playready" assert t.audio[0].drm_preference == "wv" def test_build_tracks_ignores_unknown_drm_preference() -> None: data = {"video": [{"id": "v", "codec": "AVC", "width": 1, "height": 1, "drm_preference": "fairplay"}]} t = build_tracks(data) assert t.videos[0].drm_preference is None def test_match_track_by_id() -> None: a = deserialize_video({"id": "v1", "codec": "AVC", "width": 1920, "height": 1080}) b = deserialize_video({"id": "v2", "codec": "AVC", "width": 1280, "height": 720}) remote = deserialize_video({"id": "v2", "codec": "AVC", "width": 1280, "height": 720}) assert match_track(remote, [a, b]) is b def test_match_track_by_attributes_when_id_missing() -> None: local = deserialize_video({"id": "X", "codec": "AVC", "width": 1920, "height": 1080, "language": "en"}) remote = deserialize_video({"id": "Y", "codec": "AVC", "width": 1920, "height": 1080, "language": "en"}) assert match_track(remote, [local]) is local def test_match_track_by_attributes_keeps_range_and_bitrate_apart() -> None: sdr = deserialize_video( {"id": "a", "codec": "HEVC", "width": 1920, "height": 960, "language": "en", "range": "SDR", "bitrate": 8000000} ) hdr = deserialize_video( { "id": "b", "codec": "HEVC", "width": 1920, "height": 960, "language": "en", "range": "HDR10", "bitrate": 5000000, } ) remote = deserialize_video( { "id": "z", "codec": "HEVC", "width": 1920, "height": 960, "language": "en", "range": "HDR10", "bitrate": 5000000, } ) assert match_track(remote, [sdr, hdr]) is hdr def test_match_track_by_attributes_matches_probed_hdr10p_to_local_hdr10() -> None: common = {"codec": "HEVC", "width": 3840, "height": 2160, "language": "en", "bitrate": 15000000} sdr = deserialize_video({**common, "id": "a", "range": "SDR"}) hdr10 = deserialize_video({**common, "id": "b", "range": "HDR10"}) remote = deserialize_video({**common, "id": "z", "range": "HDR10P"}) assert match_track(remote, [sdr, hdr10]) is hdr10 def test_match_track_no_candidates_returns_none() -> None: remote = deserialize_video({"id": "X", "codec": "AVC", "width": 1, "height": 1}) assert match_track(remote, []) is None def test_build_title_movie() -> None: info = {"type": "movie", "id": "movie-0001", "name": "Foo", "year": 2024, "language": "en"} title = build_title(info, "EXAMPLE", "fallback") assert isinstance(title, Movie) assert title.id == "movie-0001" assert title.name == "Foo" def test_build_title_episode() -> None: info = { "type": "episode", "id": "ep-00001", "series_title": "Show", "season": 1, "number": 2, "name": "Pilot", "year": 2024, "language": "en", } title = build_title(info, "EXAMPLE", "fallback") assert isinstance(title, Episode) assert title.season == 1 assert title.number == 2 assert title.name == "Pilot" def test_build_title_falls_back_to_id_when_missing() -> None: title = build_title({"type": "movie", "name": "x"}, "EXAMPLE", "fallback-id") assert title.id == "fallback-id" def test_credential_cache_digests_cover_common_key_schemes() -> None: import hashlib from unshackle.core.credential import Credential from unshackle.core.remote_service import credential_cache_digests cred = Credential("user@example.com", "hunter2") digests = credential_cache_digests(cred) assert cred.sha1 in digests assert hashlib.sha1(cred.username.encode()).hexdigest() in digests assert hashlib.md5(cred.username.encode()).hexdigest() in digests def test_cache_stem_relevance_filters_foreign_digests_and_profiles() -> None: import hashlib from unshackle.core.credential import Credential from unshackle.core.remote_service import cache_stem_is_relevant, credential_cache_digests active = Credential("us@example.com", "pw1") other = Credential("uk@example.com", "pw2") allowed = credential_cache_digests(active) foreign = {"uk"} assert cache_stem_is_relevant(f"tokens_{active.sha1}", allowed, "us", foreign) assert not cache_stem_is_relevant(f"tokens_{other.sha1}", allowed, "us", foreign) username_hash = hashlib.sha1(active.username.encode()).hexdigest() assert cache_stem_is_relevant(f"device_id_{username_hash}", allowed, "us", foreign) # Service-global state carries no identity marker, so the client sends it assert cache_stem_is_relevant("session_guid", allowed, "us", foreign) assert cache_stem_is_relevant("tokens", allowed, "us", foreign) # Files keyed by profile name: only the active profile's file goes assert cache_stem_is_relevant("tokens_us", allowed, "us", foreign) assert not cache_stem_is_relevant("tokens_uk", allowed, "us", foreign) # Region prefix plus a matching digest (the region is not a profile name here) assert cache_stem_is_relevant(f"tokens_intl_{active.sha1}", allowed, "us", foreign) # An active-credential digest takes priority over a colliding foreign-profile token assert cache_stem_is_relevant(f"tokens_uk_{active.sha1}", allowed, "us", foreign) def test_cache_stem_relevance_active_profile_beats_region_collision() -> None: from unshackle.core.remote_service import cache_stem_is_relevant # tokens_{profile}_{region}_{device}_{6hex} style key, where the region "us" # collides with a foreign profile named "us" assert cache_stem_is_relevant("tokens_default_us_androidtv_abc123", set(), "default", {"us", "de"}) assert not cache_stem_is_relevant("tokens_us_us_androidtv_abc123", set(), "default", {"us", "de"}) assert cache_stem_is_relevant("web_device_id_default_us", set(), "default", {"us"}) def test_cache_stem_relevance_without_credential_withholds_hex_stems() -> None: from unshackle.core.credential import Credential from unshackle.core.remote_service import cache_stem_is_relevant stray = Credential("someone@example.com", "pw") assert not cache_stem_is_relevant(f"tokens_{stray.sha1}", set(), "default", set()) assert cache_stem_is_relevant("tokens_guest", set(), "default", set()) def test_cache_stem_relevance_separator_and_case_profile_names() -> None: from unshackle.core.remote_service import cache_stem_is_relevant # A foreign profile name with separators still matches assert not cache_stem_is_relevant("tokens_us-east", set(), "eu", {"us-east"}) assert not cache_stem_is_relevant("tokens_us_east", set(), "eu", {"us-east"}) # Case variants of a foreign profile name still match assert not cache_stem_is_relevant("tokens_UK", set(), "us", {"uk"}) # The active profile matches through case and separator variants too assert cache_stem_is_relevant("tokens_US-EAST", set(), "us-east", {"eu"}) # A longer matching foreign name beats an active name that is its prefix assert not cache_stem_is_relevant("tokens_us-east", set(), "us", {"us-east"}) # ...but a shorter foreign match does not override the active name assert cache_stem_is_relevant("tokens_default_us_androidtv_abc123", set(), "default", {"us"}) def test_load_cache_files_wiring(tmp_path, monkeypatch) -> None: import json import logging from types import SimpleNamespace from unshackle.core.config import config from unshackle.core.credential import Credential from unshackle.core.remote_service import RemoteService tag = "TESTSVC" cache_dir = tmp_path / tag cache_dir.mkdir() other = Credential("other@example.com", "pw2") for stem in ("tokens_us", "tokens_uk", "tokens_default", "session_guid", f"tokens_{other.sha1}", "titles_abc"): (cache_dir / f"{stem}.json").write_text(json.dumps({"k": stem})) monkeypatch.setattr(config.directories, "cache", tmp_path) monkeypatch.setattr( config, "credentials", {tag: {"default": "a@example.com:pw", "us": "b@example.com:pw", "uk": "c@example.com:pw"}}, ) stub = SimpleNamespace(service_tag=tag, log=logging.getLogger("test")) sent = RemoteService.load_cache_files(stub, "us") assert set(sent) == {"tokens_us", "session_guid"} # A profile with no credentials entry falls back to the default credential sent = RemoteService.load_cache_files(stub, "missing") assert set(sent) == {"tokens_default", "session_guid"} def test_load_cache_files_forwards_nested_keys(tmp_path, monkeypatch) -> None: """A service that keeps its cache in subfolders (session_web/.json) must have those files forwarded under the Cacher key, and the credential filter must still apply to the basename of a nested file.""" import base64 import json import logging import zlib from types import SimpleNamespace from unshackle.core.config import config from unshackle.core.credential import Credential from unshackle.core.remote_service import RemoteService tag = "TESTSVC" cache_dir = tmp_path / tag active = Credential("a@example.com", "pw") other = Credential("other@example.com", "pw2") for rel in ( "tokens", f"session_web/{active.sha1}", f"session_web/{other.sha1}", "session_androidtv/default", "MSL/web/keys", "MSL/web/keys.v2", f"{other.sha1}/state", "titles_abc", "sub/titles_abc", ): path = cache_dir / f"{rel}.json" path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps({"k": rel})) monkeypatch.setattr(config.directories, "cache", tmp_path) monkeypatch.setattr(config, "credentials", {tag: {"default": f"{active.username}:{active.password}"}}) stub = SimpleNamespace(service_tag=tag, log=logging.getLogger("test")) sent = RemoteService.load_cache_files(stub, None) assert set(sent) == { "tokens", f"session_web/{active.sha1}", "session_androidtv/default", "MSL/web/keys", "MSL/web/keys.v2", } payload = json.loads(zlib.decompress(base64.b64decode(sent[f"session_web/{active.sha1}"]))) assert payload == {"k": f"session_web/{active.sha1}"} def test_save_returned_cache_writes_nested_keys_and_rejects_escapes(tmp_path, monkeypatch) -> None: import base64 import logging import zlib from types import SimpleNamespace from unshackle.core.config import config from unshackle.core.remote_service import RemoteService tag = "TESTSVC" monkeypatch.setattr(config.directories, "cache", tmp_path) stub = SimpleNamespace(service_tag=tag, log=logging.getLogger("test")) def blob(text: str) -> str: return base64.b64encode(zlib.compress(text.encode())).decode("ascii") RemoteService.save_returned_cache( stub, { "tokens": blob('"flat"'), "session_web/abc": blob('"nested"'), "session_web\\def": blob('"windows"'), "MSL/keys.v2": blob('"dotted"'), "../escape": blob('"bad"'), "/abs": blob('"bad"'), "C:/x": blob('"bad"'), }, ) assert (tmp_path / tag / "tokens.json").read_text() == '"flat"' assert (tmp_path / tag / "session_web" / "abc.json").read_text() == '"nested"' assert (tmp_path / tag / "session_web" / "def.json").read_text() == '"windows"' assert (tmp_path / tag / "MSL" / "keys.v2.json").read_text() == '"dotted"' assert not (tmp_path / "escape.json").exists() written = sorted(p.relative_to(tmp_path).as_posix() for p in tmp_path.rglob("*.json")) assert written == [ f"{tag}/MSL/keys.v2.json", f"{tag}/session_web/abc.json", f"{tag}/session_web/def.json", f"{tag}/tokens.json", ] def test_save_returned_cache_drops_other_profiles_files(tmp_path, monkeypatch) -> None: """A returned file for another profile must not overwrite that profile's local file.""" import base64 import logging import zlib from types import SimpleNamespace from unshackle.core.config import config from unshackle.core.remote_service import RemoteService tag = "TESTSVC" monkeypatch.setattr(config.directories, "cache", tmp_path) monkeypatch.setattr(config, "credentials", {tag: {"default": "a@example.com:pw", "work": "b@example.com:pw"}}) stub = SimpleNamespace(service_tag=tag, log=logging.getLogger("test")) blob = base64.b64encode(zlib.compress(b'"x"')).decode("ascii") RemoteService.save_returned_cache(stub, {"tokens_work": blob, "tokens_default": blob, "tokens": blob}, "work") assert sorted(p.name for p in (tmp_path / tag).iterdir()) == ["tokens.json", "tokens_work.json"] def test_save_returned_cache_ignores_oversized_payload(tmp_path, monkeypatch) -> None: import logging from types import SimpleNamespace from unshackle.core.api.sanitize import MAX_SESSION_CACHE_KEYS from unshackle.core.config import config from unshackle.core.remote_service import RemoteService monkeypatch.setattr(config.directories, "cache", tmp_path) stub = SimpleNamespace(service_tag="TESTSVC", log=logging.getLogger("test")) RemoteService.save_returned_cache(stub, {f"k{i}": "x" for i in range(MAX_SESSION_CACHE_KEYS + 1)}) assert not list(tmp_path.rglob("*.json")) @pytest.mark.asyncio async def test_session_delete_returns_nested_cache_keys(tmp_path, monkeypatch) -> None: """The server harvests a session's whole cache tree and keys each file by its path relative to the session cache dir, so it round-trips to the same Cacher key.""" import base64 import json import zlib from types import SimpleNamespace from unshackle.core.api import handlers, session_store cache_tag = "_sessions/abc/sid/TESTSVC" cache_dir = tmp_path / cache_tag for rel in ("tokens", "session_web/abc", "titles_x", "MSL/web/keys", "MSL/keys.v2"): path = cache_dir / f"{rel}.json" path.parent.mkdir(parents=True, exist_ok=True) path.write_text(rel) monkeypatch.setattr(handlers.config.directories, "cache", tmp_path) session = SimpleNamespace(cache_tag=cache_tag, client_auth=True, input_bridge=None) async def fake_validated(session_id, request): return session class FakeStore: async def delete(self, session_id): return True monkeypatch.setattr(handlers, "get_validated_session", fake_validated) monkeypatch.setattr(session_store, "get_session_store", lambda: FakeStore()) response = await handlers.session_delete_handler("sid", None) body = json.loads(response.body) assert set(body["cache"]) == {"tokens", "session_web/abc", "MSL/web/keys", "MSL/keys.v2"} assert zlib.decompress(base64.b64decode(body["cache"]["session_web/abc"])) == b"session_web/abc" def test_resolve_manifest_data_matches_across_per_range_manifests() -> None: """One ISM manifest per range. The HDR10 track must take the quality level from its own manifest, not the same-sized one in the SDR manifest, in any manifest order.""" import base64 import zlib from tests.core.test_ism_init import VIDEO_HEVC_PQ_CPD, VIDEO_HEVC_SDR_CPD from tests.core.test_ism_range import manifest_xml from unshackle.core.manifests import ISM from unshackle.core.manifests.snapshot import restore urls = {"sdr": "https://x/sdr/manifest", "hdr": "https://x/hdr/manifest"} xml = {"sdr": manifest_xml(VIDEO_HEVC_SDR_CPD), "hdr": manifest_xml(VIDEO_HEVC_PQ_CPD)} local = {k: ISM.from_text(xml[k], url=urls[k]).to_tracks(language="en").videos[0] for k in xml} assert local["sdr"].range == Video.Range.SDR and local["hdr"].range == Video.Range.HDR10 remote_hdr = deserialize_video( { "id": str(local["hdr"].id), "codec": "HEVC", "width": 3840, "height": 2160, "language": "en", "range": "HDR10", "bitrate_bps": 15000000, } ) tracks = build_tracks({"video": [], "audio": [], "subtitles": []}) tracks.videos.append(remote_hdr) manifests = [ {"type": "ism", "url": urls[k], "data": base64.b64encode(zlib.compress(xml[k].encode())).decode()} for k in ("sdr", "hdr") ] restore(tracks, {"manifests": manifests}) assert remote_hdr.data["ism"]["quality_level"].get("CodecPrivateData") == VIDEO_HEVC_PQ_CPD def test_session_create_writes_nested_cache_and_skips_colliding_keys(tmp_path, monkeypatch) -> None: """The server writes a nested key under the session cache dir with the ``.json`` suffix appended, not substituted, and a key that collides with an existing file (``x`` then ``x.json/y``) is skipped instead of failing the whole create.""" import asyncio import base64 import logging import zlib from types import SimpleNamespace from unshackle.core.api import handlers class Stop(Exception): pass def blob(text: str) -> str: return base64.b64encode(zlib.compress(text.encode())).decode("ascii") monkeypatch.setattr(handlers.config.directories, "cache", tmp_path) monkeypatch.setattr(handlers, "validate_service", lambda service, request=None: service) monkeypatch.setattr(handlers, "resolve_handler_proxy", lambda *args: (None, [])) monkeypatch.setattr(handlers, "server_account_for", lambda *args: False) monkeypatch.setattr(handlers.Services, "load", lambda service: type("Svc", (), {})) monkeypatch.setattr( handlers, "create_service_instance", lambda *args, **kwargs: (SimpleNamespace(log=logging.getLogger("Svc")), None, None), ) monkeypatch.setattr("unshackle.core.api.session_store.refuse_while_draining", lambda: None) monkeypatch.setattr("unshackle.core.api.session_store.get_session_store", lambda: (_ for _ in ()).throw(Stop())) cache = {"x": blob('"flat"'), "x.json/y": blob('"collides"'), "MSL/keys.v2": blob('"dotted"')} response = asyncio.run(handlers.session_create_handler({"service": "TESTSVC", "title_id": "t", "cache": cache})) assert response.status == 422 # the Stop stand-in fired after the cache write written = {p.name: p.read_text() for p in tmp_path.rglob("*.json") if p.is_file()} assert written == {"x.json": '"flat"', "keys.v2.json": '"dotted"'} class _SentCreate(Exception): pass def _remote_service(parent_params: dict, service_params: dict) -> RemoteService: """A RemoteService whose session create raises _SentCreate with the request body.""" import logging from types import SimpleNamespace import requests def post(path: str, data: dict) -> dict: raise _SentCreate(data) svc = object.__new__(RemoteService) svc.ctx = SimpleNamespace(parent=SimpleNamespace(params={"no_proxy": True, **parent_params}), obj=None) svc.service_tag = "EXAMPLE" svc.title_id = "t1" svc._server_accounts = {"global": True} svc._server_cdm = True svc._service_params = service_params svc._session = requests.Session() svc.log = logging.getLogger("test") svc.client = SimpleNamespace(post=post, server_url="https://server.example") return svc def _sent_create_data(parent_params: dict, service_params: dict) -> dict: with pytest.raises(_SentCreate) as exc: _remote_service(parent_params, service_params).authenticate() return exc.value.args[0] @pytest.mark.parametrize("no_proxy, expected", [(False, {"all": "http://proxy.example:8080"}), (True, {})]) def test_the_client_session_downloads_through_the_proxy_it_sends( monkeypatch: pytest.MonkeyPatch, no_proxy: bool, expected: dict ) -> None: """The client fetches playlists and segments itself, so --proxy must reach its own session too.""" from unshackle.core import remote_service monkeypatch.setattr(remote_service, "resolve_remote_proxy_arg", lambda proxy, providers=None: proxy) monkeypatch.setattr("unshackle.core.utils.ip_info.get_ip_info", lambda *args, **kwargs: None) svc = _remote_service({"no_proxy": no_proxy, "proxy": "http://proxy.example:8080"}, {}) with pytest.raises(_SentCreate) as exc: svc.authenticate() assert dict(svc._session.proxies) == expected assert svc._session.trust_env is no_proxy # a proxied session ignores HTTP(S)_PROXY assert exc.value.args[0].get("proxy") == (None if no_proxy else "http://proxy.example:8080") def test_dl_selection_travels_nested_and_a_service_option_cannot_override_it() -> None: parent_params = { "lang": ["orig", "es-419"], "v_lang": [], "a_lang": [], "s_lang": ["ko"], "acodec": [Audio.Codec.EC3], "forced_subs": True, "exact_lang": True, } data = _sent_create_data(parent_params, {"lang": 3, "a_lang": "x"}) assert data["dl_params"] == { "lang": ["orig", "es-419"], "v_lang": [], "a_lang": [], "s_lang": ["ko"], "acodec": ["EC3"], "forced_subs": True, "exact_lang": True, } assert "lang" not in data and "a_lang" not in data assert data["service_params"] == {"lang": 3, "a_lang": "x"} def test_a_service_profile_option_travels_nested_and_keeps_the_credential_profile() -> None: data = _sent_create_data({"profile": "work"}, {"profile": "QC"}) assert data["profile"] == "work" assert data["service_params"] == {"profile": "QC"} def test_no_dl_params_when_the_ctx_has_no_selection() -> None: assert "dl_params" not in _sent_create_data({}, {}) def test_resolve_manifest_data_keeps_the_periods_the_server_filtered() -> None: """A service drops periods (a mid-roll ad) on the server only. The client must skip the same periods after it re-parses the manifest, or a remote download joins them back in.""" from unshackle.core.manifests import DASH from unshackle.core.manifests.snapshot import capture, restore periods = "".join( f""" """ for pid in ("main1", "ad1", "main2") ) mpd = f"""https://x/{periods}""" server = DASH.from_text(mpd, url="https://x/manifest.mpd").to_tracks(language="en") for track in server: track.data["dash"]["filtered_period_ids"] = ["ad1"] remote = deserialize_video(serialize_video_track(server.videos[0])) tracks = build_tracks({"video": [], "audio": [], "subtitles": []}) tracks.videos.append(remote) restore(tracks, capture(server)) assert remote.data["dash"]["filtered_period_ids"] == ["ad1"]