From 9017ab95b709d55e62d2c3d3589a78cb22ff9f5a Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Tue, 27 Oct 2026 13:06:48 -0600 Subject: [PATCH] feat(tracks): add Video.hdr10_base and forward s_lang over remote and REST A DV track with an HDR10 base (profile 8.1) lists as DV+HDR10; dl_params now carry s_lang. --- docs/dev/rest-api/endpoints.md | 7 ++++--- docs/dev/rest-api/remote-sessions.md | 2 +- docs/dev/service-architecture.md | 1 + tests/core/test_hdr10plus_probe.py | 6 ++++++ tests/remote/unit/test_download_manager.py | 8 +++++--- tests/remote/unit/test_remote_service_helpers.py | 8 ++++++++ tests/remote/unit/test_server_accounts.py | 12 +++++++++++- unshackle/core/api/download_manager.py | 1 + unshackle/core/api/handlers.py | 3 ++- unshackle/core/api/routes.py | 7 ++++++- unshackle/core/remote_service.py | 3 ++- unshackle/core/tracks/video.py | 9 +++++++-- 12 files changed, 54 insertions(+), 13 deletions(-) diff --git a/docs/dev/rest-api/endpoints.md b/docs/dev/rest-api/endpoints.md index 4501a4f2..f221963a 100644 --- a/docs/dev/rest-api/endpoints.md +++ b/docs/dev/rest-api/endpoints.md @@ -317,7 +317,7 @@ Show the video, audio, and subtitle tracks for a title. For series, you can scop | `part` | int/string | no | Part index of a split episode (combined with `season` and `episode`). | | `profile`, `proxy`, `no_proxy`, `cdm_type` | - | no | As on `list-titles`. | | `credentials`, `cookies`, `cache` | - | no | As on `list-titles`. | -| `dl_params` | object | no | The `dl` track selection that the service reads from `ctx.parent.params`: `lang`, `v_lang`, `a_lang`, `acodec`, `forced_subs` and `exact_lang`, as on [`POST /api/session/create`](#post-apisessioncreate). Some services select the manifests they fetch from these values. | +| `dl_params` | object | no | The `dl` track selection that the service reads from `ctx.parent.params`: `lang`, `v_lang`, `a_lang`, `s_lang`, `acodec`, `forced_subs` and `exact_lang`, as on [`POST /api/session/create`](#post-apisessioncreate). Some services select the manifests they fetch from these values. | When you give both `season` and `episode`, the server combines them into a `"{season}x{episode}"` selector. `part` narrows that to `"{season}x{episode}.{part}"`, one part of a [split episode](../creating-a-service.md#split-episodes). Without `part` you get every part of the episode. The server reads `part` only when you give both `season` and `episode`, and `wanted` takes precedence over all three. To scope a part by range, put it in `wanted` instead (`"S01E01.2"`). @@ -332,7 +332,8 @@ When you give both `season` and `episode`, the server combines them into a `"{se "bitrate": 8000, "bitrate_bps": 8000000, "width": 3840, "height": 2160, "resolution": "2160p", "fps": "24", "range": "HDR10", "range_display": "HDR10", "scan_type": null, "closed_captions": [], - "dv_compatible_bitstream": false, "hdr10plus": false, "language": "en", "is_original_lang": true, + "dv_compatible_bitstream": false, "hdr10plus": false, "hdr10_base": false, "language": "en", + "is_original_lang": true, "name": null, "track_name": "English", "needs_repack": false, "edition": [], "data": null, "drm": [], "descriptor": "DASH" } @@ -961,7 +962,7 @@ the newlines must go out at once. Make a remote session for a service and title. Authentication runs asynchronously in the background. This call returns immediately with `status: "authenticating"`, and you then poll the prompt endpoint. The body accepts `service` and `title_id` (both required). It also accepts a broad set of optional keys, because the body allows `additionalProperties`. These are `credentials` (`{username, password, extra?}`), `cookies` (base64 of zlib-compressed Netscape cookie file), `proxy`, `no_proxy`, `profile`, `cache` (map of forwarded cache files, keyed by the file path relative to the service cache directory with `/` separators and no `.json` suffix, so a nested key such as `session_web/` lands in a subdirectory), `client_region`, `proxy_region`, `cdm_type`, `range_`, `vcodec`, `quality`, `best_available`, `dl_params`, `client`, and any service CLI options. `proxy_region` is the country code the client resolved `proxy` from; the server matches it against its own accounts. `client` is a freeform object the dashboard shows as sent (the CLI puts `version`, `code_hash`, `platform` and a redacted `argv` in it); the server ignores it above 4096 bytes of JSON. -`dl_params` is an object with the `dl` track selection: `lang`, `v_lang` and `a_lang` (arrays of language strings), `acodec` (an array of audio codec names or `dl -a` aliases such as `ddp`), and `forced_subs` and `exact_lang` (booleans). The service reads these values from `ctx.parent.params`. The server uses the `dl` default for each absent or malformed value, and drops an unknown codec name. +`dl_params` is an object with the `dl` track selection: `lang`, `v_lang`, `a_lang` and `s_lang` (arrays of language strings), `acodec` (an array of audio codec names or `dl -a` aliases such as `ddp`), and `forced_subs` and `exact_lang` (booleans). The service reads these values from `ctx.parent.params`. The server uses the `dl` default for each absent or malformed value, and drops an unknown codec name. Service CLI options also travel in a nested `service_params` object, which wins over a flat key with the same name. `profile` at the top level always means the credentials profile, never a service's own `--profile` option. diff --git a/docs/dev/rest-api/remote-sessions.md b/docs/dev/rest-api/remote-sessions.md index 81508d24..9f930fa4 100644 --- a/docs/dev/rest-api/remote-sessions.md +++ b/docs/dev/rest-api/remote-sessions.md @@ -467,7 +467,7 @@ object the dashboard shows as sent), and the track-selection hints `range_`, `vc `quality`, `best_available`, `generate_dv`, plus arbitrary service CLI options (`additionalProperties: true`). -`dl_params` holds the `dl` track selection: `lang`, `v_lang` and `a_lang` +`dl_params` holds the `dl` track selection: `lang`, `v_lang`, `a_lang` and `s_lang` (arrays of language strings), `acodec` (an array of audio codec names, such as `EC3`), and `forced_subs` and `exact_lang` (booleans). The server gives these values to the service in `ctx.parent.params`, never as service options, so a service option diff --git a/docs/dev/service-architecture.md b/docs/dev/service-architecture.md index f13f305f..e38b3379 100644 --- a/docs/dev/service-architecture.md +++ b/docs/dev/service-architecture.md @@ -457,6 +457,7 @@ the API surface. Two DV mechanisms sit on top of the track model. **DV fixup** re-exposes DV signalling on HLS and DASH streams that carry a valid DV RPU but advertise plain HEVC, with DV only in the supplemental codecs (`dv_compatible_bitstream`). `--list` shows such a track as `HDR10+DV` or `HDR10P+DV`. +A service sets `hdr10_base` on a DV track that has an HDR10 base layer (DV profile 8.1), and `--list` shows it as `DV+HDR10`. **Hybrid** processing (`Video.Range.HYBRID`) selects an HDR10(+) base and a DV donor, extracts the RPU with `dovi_tool`, and injects it onto the base layer, to make a single track that carries both HDR10 metadata and the DV RPU. Both are automatic once the diff --git a/tests/core/test_hdr10plus_probe.py b/tests/core/test_hdr10plus_probe.py index e07990f8..438fe0b7 100644 --- a/tests/core/test_hdr10plus_probe.py +++ b/tests/core/test_hdr10plus_probe.py @@ -215,3 +215,9 @@ def test_a_quoted_dl_workers_value_works_like_the_cli(monkeypatch: pytest.Monkey service = SimpleNamespace(get_tracks=lambda title: SimpleNamespace(videos=[hevc("v", 2160)]), session=None) monkeypatch.setitem(config.dl, "workers", "8") assert probe_calls(monkeypatch, service) == [8] + + +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 diff --git a/tests/remote/unit/test_download_manager.py b/tests/remote/unit/test_download_manager.py index b08d8174..943d5ebe 100644 --- a/tests/remote/unit/test_download_manager.py +++ b/tests/remote/unit/test_download_manager.py @@ -192,17 +192,18 @@ class _CapturedCtx(Exception): @pytest.mark.parametrize( ("params", "expected"), [ - ({}, (["orig"], [], [], [], False, False)), + ({}, (["orig"], [], [], ["all"], [], False, False)), ( { "lang": ["es-419"], "v_lang": ["en"], "a_lang": ["ja"], + "s_lang": ["ko"], "acodec": "AAC,EC3", "forced_subs": True, "exact_lang": True, }, - (["es-419"], ["en"], ["ja"], ["AAC", "EC3"], True, True), + (["es-419"], ["en"], ["ja"], ["ko"], ["AAC", "EC3"], True, True), ), ], ) @@ -231,7 +232,8 @@ def test_perform_download_puts_the_lang_selection_in_the_service_ctx( ctx = exc.value.args[0] p = ctx.params - selection = (p["lang"], p["v_lang"], p["a_lang"], [c.name for c in p["acodec"]], p["forced_subs"], p["exact_lang"]) + acodec = [c.name for c in p["acodec"]] + selection = (p["lang"], p["v_lang"], p["a_lang"], p["s_lang"], acodec, p["forced_subs"], p["exact_lang"]) assert selection == expected diff --git a/tests/remote/unit/test_remote_service_helpers.py b/tests/remote/unit/test_remote_service_helpers.py index 3ee60c05..96740917 100644 --- a/tests/remote/unit/test_remote_service_helpers.py +++ b/tests/remote/unit/test_remote_service_helpers.py @@ -6,6 +6,7 @@ from enum import Enum import pytest +from unshackle.core.api.handlers import serialize_video_track from unshackle.core.remote_service import ( RemoteService, build_title, @@ -52,6 +53,11 @@ def test_deserialize_video_minimal() -> None: 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_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 @@ -624,6 +630,7 @@ def test_dl_selection_travels_nested_and_a_service_option_cannot_override_it() - "lang": ["orig", "es-419"], "v_lang": [], "a_lang": [], + "s_lang": ["ko"], "acodec": [Audio.Codec.EC3], "forced_subs": True, "exact_lang": True, @@ -633,6 +640,7 @@ def test_dl_selection_travels_nested_and_a_service_option_cannot_override_it() - "lang": ["orig", "es-419"], "v_lang": [], "a_lang": [], + "s_lang": ["ko"], "acodec": ["EC3"], "forced_subs": True, "exact_lang": True, diff --git a/tests/remote/unit/test_server_accounts.py b/tests/remote/unit/test_server_accounts.py index 5ea57f5e..fb3cf1b2 100644 --- a/tests/remote/unit/test_server_accounts.py +++ b/tests/remote/unit/test_server_accounts.py @@ -162,7 +162,15 @@ def _create_with_real_ctx(monkeypatch, data): return svc -DL_DEFAULTS = {"lang": ["orig"], "v_lang": [], "a_lang": [], "acodec": [], "forced_subs": False, "exact_lang": False} +DL_DEFAULTS = { + "lang": ["orig"], + "v_lang": [], + "a_lang": [], + "s_lang": ["all"], + "acodec": [], + "forced_subs": False, + "exact_lang": False, +} def test_nested_dl_params_reach_the_parent_ctx(monkeypatch): @@ -173,6 +181,7 @@ def test_nested_dl_params_reach_the_parent_ctx(monkeypatch): "lang": ["es-419", "en"], "v_lang": ["ja"], "a_lang": ["es-419"], + "s_lang": ["ko"], "acodec": ["EC3", "NOPE", "AAC"], "forced_subs": True, "exact_lang": True, @@ -182,6 +191,7 @@ def test_nested_dl_params_reach_the_parent_ctx(monkeypatch): assert svc.parent_params["lang"] == ["es-419", "en"] assert svc.parent_params["v_lang"] == ["ja"] assert svc.parent_params["a_lang"] == ["es-419"] + assert svc.parent_params["s_lang"] == ["ko"] assert svc.parent_params["acodec"] == [Audio.Codec.EC3, Audio.Codec.AAC] assert svc.parent_params["forced_subs"] is True assert svc.parent_params["exact_lang"] is True diff --git a/unshackle/core/api/download_manager.py b/unshackle/core/api/download_manager.py index 4322294e..e468e53b 100644 --- a/unshackle/core/api/download_manager.py +++ b/unshackle/core/api/download_manager.py @@ -511,6 +511,7 @@ def perform_download( "lang": params.get("lang", ["orig"]), "v_lang": params.get("v_lang", []), "a_lang": params.get("a_lang", []), + "s_lang": params.get("s_lang", ["all"]), "forced_subs": params.get("forced_subs", False), "exact_lang": params.get("exact_lang", False), # dl skips the local service config and guards the proxy for a remote_services server diff --git a/unshackle/core/api/handlers.py b/unshackle/core/api/handlers.py index a98b1e67..9a936e38 100644 --- a/unshackle/core/api/handlers.py +++ b/unshackle/core/api/handlers.py @@ -1752,6 +1752,7 @@ def serialize_video_track(track: Video, include_url: bool = False) -> Dict[str, "closed_captions": list(getattr(track, "closed_captions", None) or []), "dv_compatible_bitstream": bool(getattr(track, "dv_compatible_bitstream", False)), "hdr10plus": bool(getattr(track, "hdr10plus", False)), + "hdr10_base": bool(getattr(track, "hdr10_base", False)), } result.update(serialize_track_common(track)) if include_url and hasattr(track, "url") and track.url: @@ -3653,7 +3654,7 @@ def forwarded_dl_params(data: Dict[str, Any]) -> Dict[str, Any]: return None params: Dict[str, Any] = {} - for key in ("lang", "v_lang", "a_lang"): + for key in ("lang", "v_lang", "a_lang", "s_lang"): langs = str_list(key) params[key] = langs if langs is not None else list(cast(List[str], DEFAULT_DOWNLOAD_PARAMS[key])) # the dl CLI default is [], and some services test membership in it diff --git a/unshackle/core/api/routes.py b/unshackle/core/api/routes.py index e5802489..51e7e30e 100644 --- a/unshackle/core/api/routes.py +++ b/unshackle/core/api/routes.py @@ -795,7 +795,7 @@ async def list_tracks(request: web.Request) -> web.Response: dl_params: type: object description: | - The dl track selection (lang, v_lang, a_lang, acodec, forced_subs, exact_lang), in the same + The dl track selection (lang, v_lang, a_lang, s_lang, acodec, forced_subs, exact_lang), in the same form as on `/api/session/create`. The service reads it from `ctx.parent.params`. credentials: type: object @@ -1916,6 +1916,11 @@ async def session_create(request: web.Request) -> web.Response: items: type: string default: [] + s_lang: + type: array + items: + type: string + default: ["all"] forced_subs: type: boolean default: false diff --git a/unshackle/core/remote_service.py b/unshackle/core/remote_service.py index 2584c11a..72ac77bf 100644 --- a/unshackle/core/remote_service.py +++ b/unshackle/core/remote_service.py @@ -287,6 +287,7 @@ def deserialize_video(data: Dict[str, Any]) -> Video: closed_captions=data.get("closed_captions") or None, dv_compatible_bitstream=bool(data.get("dv_compatible_bitstream", False)), hdr10plus=bool(data.get("hdr10plus", False)), + hdr10_base=bool(data.get("hdr10_base", False)), ) @@ -1329,7 +1330,7 @@ class RemoteService: params = self.ctx.parent.params dl_params: dict[str, Any] = { key: [str(x) for x in params[key]] - for key in ("lang", "v_lang", "a_lang") + for key in ("lang", "v_lang", "a_lang", "s_lang") if params.get(key) is not None } if params.get("acodec") is not None: diff --git a/unshackle/core/tracks/video.py b/unshackle/core/tracks/video.py index 45dd81e6..022b0eac 100644 --- a/unshackle/core/tracks/video.py +++ b/unshackle/core/tracks/video.py @@ -237,6 +237,7 @@ class Video(Track): closed_captions: Optional[list[dict[str, Any]]] = None, dv_compatible_bitstream: bool = False, hdr10plus: bool = False, + hdr10_base: bool = False, **kwargs: Any, ) -> None: """ @@ -256,6 +257,7 @@ class Video(Track): Strings may represent numbers, floats, or a fraction (num/den). All strings will be cast to either a number or float. hdr10plus: The DV track also carries HDR10+ metadata. The HDR10+ probe sets it. + hdr10_base: The DV track has an HDR10 base layer, as DV profile 8.1 does. Note: If codec, bitrate, width, height, or fps is not specified, unshackle can skip some checks or assume a value. Give as much information as possible. @@ -305,6 +307,7 @@ class Video(Track): self.needs_duration_fix = False self.dv_compatible_bitstream = dv_compatible_bitstream self.hdr10plus = hdr10plus + self.hdr10_base = hdr10_base self.hybrid_base_only = False self.native_hybrid = False @@ -321,6 +324,7 @@ class Video(Track): "scan_type": self.scan_type.name if self.scan_type else None, "dv_compatible_bitstream": self.dv_compatible_bitstream, "hdr10plus": self.hdr10plus, + "hdr10_base": self.hdr10_base, } ) return data @@ -339,6 +343,7 @@ class Video(Track): scan_type=Video.ScanType[data["scan_type"]] if data.get("scan_type") else None, dv_compatible_bitstream=data.get("dv_compatible_bitstream", False), hdr10plus=data.get("hdr10plus", False), + hdr10_base=data.get("hdr10_base", False), ) @staticmethod @@ -377,8 +382,8 @@ class Video(Track): @property def range_label(self) -> str: - """The range that ``-r`` selects the track by, then any second format it carries: HDR10+DV, DV+HDR10P.""" - label = self.range.name + ("+HDR10P" if self.hdr10plus else "") + """The range that ``-r`` selects the track by, then any second format it carries: HDR10+DV, DV+HDR10, DV+HDR10P.""" + label = self.range.name + ("+HDR10P" if self.hdr10plus else "+HDR10" if self.hdr10_base else "") return f"{label}+DV" if self.dv_compatible_bitstream and self.range != Video.Range.DV else label def __str__(self) -> str: