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.
This commit is contained in:
imSp4rky
2026-10-27 13:06:48 -06:00
parent 0440f2ba54
commit 9017ab95b7
12 changed files with 54 additions and 13 deletions
+4 -3
View File
@@ -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`). | | `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`. | | `profile`, `proxy`, `no_proxy`, `cdm_type` | - | no | As on `list-titles`. |
| `credentials`, `cookies`, `cache` | - | 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"`). 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, "bitrate": 8000, "bitrate_bps": 8000000, "width": 3840, "height": 2160,
"resolution": "2160p", "fps": "24", "range": "HDR10", "resolution": "2160p", "fps": "24", "range": "HDR10",
"range_display": "HDR10", "scan_type": null, "closed_captions": [], "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, "name": null, "track_name": "English", "needs_repack": false, "edition": [], "data": null,
"drm": [], "descriptor": "DASH" "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/<sha1>` 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. 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/<sha1>` 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. 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.
+1 -1
View File
@@ -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 `quality`, `best_available`, `generate_dv`, plus arbitrary service CLI options
(`additionalProperties: true`). (`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 (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 `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 service in `ctx.parent.params`, never as service options, so a service option
+1
View File
@@ -457,6 +457,7 @@ the API surface.
Two DV mechanisms sit on top of the track model. **DV fixup** re-exposes DV signalling on 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 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`. 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, **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 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 track that carries both HDR10 metadata and the DV RPU. Both are automatic once the
+6
View File
@@ -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) service = SimpleNamespace(get_tracks=lambda title: SimpleNamespace(videos=[hevc("v", 2160)]), session=None)
monkeypatch.setitem(config.dl, "workers", "8") monkeypatch.setitem(config.dl, "workers", "8")
assert probe_calls(monkeypatch, service) == [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
+5 -3
View File
@@ -192,17 +192,18 @@ class _CapturedCtx(Exception):
@pytest.mark.parametrize( @pytest.mark.parametrize(
("params", "expected"), ("params", "expected"),
[ [
({}, (["orig"], [], [], [], False, False)), ({}, (["orig"], [], [], ["all"], [], False, False)),
( (
{ {
"lang": ["es-419"], "lang": ["es-419"],
"v_lang": ["en"], "v_lang": ["en"],
"a_lang": ["ja"], "a_lang": ["ja"],
"s_lang": ["ko"],
"acodec": "AAC,EC3", "acodec": "AAC,EC3",
"forced_subs": True, "forced_subs": True,
"exact_lang": 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] ctx = exc.value.args[0]
p = ctx.params 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 assert selection == expected
@@ -6,6 +6,7 @@ from enum import Enum
import pytest import pytest
from unshackle.core.api.handlers import serialize_video_track
from unshackle.core.remote_service import ( from unshackle.core.remote_service import (
RemoteService, RemoteService,
build_title, build_title,
@@ -52,6 +53,11 @@ def test_deserialize_video_minimal() -> None:
assert v.range is Video.Range.SDR 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: def test_deserialize_video_unknown_codec_falls_back_to_none() -> None:
v = deserialize_video({"id": "v2", "codec": "MADE_UP", "width": 0, "height": 0}) v = deserialize_video({"id": "v2", "codec": "MADE_UP", "width": 0, "height": 0})
assert v.codec is None 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"], "lang": ["orig", "es-419"],
"v_lang": [], "v_lang": [],
"a_lang": [], "a_lang": [],
"s_lang": ["ko"],
"acodec": [Audio.Codec.EC3], "acodec": [Audio.Codec.EC3],
"forced_subs": True, "forced_subs": True,
"exact_lang": 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"], "lang": ["orig", "es-419"],
"v_lang": [], "v_lang": [],
"a_lang": [], "a_lang": [],
"s_lang": ["ko"],
"acodec": ["EC3"], "acodec": ["EC3"],
"forced_subs": True, "forced_subs": True,
"exact_lang": True, "exact_lang": True,
+11 -1
View File
@@ -162,7 +162,15 @@ def _create_with_real_ctx(monkeypatch, data):
return svc 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): 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"], "lang": ["es-419", "en"],
"v_lang": ["ja"], "v_lang": ["ja"],
"a_lang": ["es-419"], "a_lang": ["es-419"],
"s_lang": ["ko"],
"acodec": ["EC3", "NOPE", "AAC"], "acodec": ["EC3", "NOPE", "AAC"],
"forced_subs": True, "forced_subs": True,
"exact_lang": 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["lang"] == ["es-419", "en"]
assert svc.parent_params["v_lang"] == ["ja"] assert svc.parent_params["v_lang"] == ["ja"]
assert svc.parent_params["a_lang"] == ["es-419"] 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["acodec"] == [Audio.Codec.EC3, Audio.Codec.AAC]
assert svc.parent_params["forced_subs"] is True assert svc.parent_params["forced_subs"] is True
assert svc.parent_params["exact_lang"] is True assert svc.parent_params["exact_lang"] is True
+1
View File
@@ -511,6 +511,7 @@ def perform_download(
"lang": params.get("lang", ["orig"]), "lang": params.get("lang", ["orig"]),
"v_lang": params.get("v_lang", []), "v_lang": params.get("v_lang", []),
"a_lang": params.get("a_lang", []), "a_lang": params.get("a_lang", []),
"s_lang": params.get("s_lang", ["all"]),
"forced_subs": params.get("forced_subs", False), "forced_subs": params.get("forced_subs", False),
"exact_lang": params.get("exact_lang", False), "exact_lang": params.get("exact_lang", False),
# dl skips the local service config and guards the proxy for a remote_services server # dl skips the local service config and guards the proxy for a remote_services server
+2 -1
View File
@@ -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 []), "closed_captions": list(getattr(track, "closed_captions", None) or []),
"dv_compatible_bitstream": bool(getattr(track, "dv_compatible_bitstream", False)), "dv_compatible_bitstream": bool(getattr(track, "dv_compatible_bitstream", False)),
"hdr10plus": bool(getattr(track, "hdr10plus", False)), "hdr10plus": bool(getattr(track, "hdr10plus", False)),
"hdr10_base": bool(getattr(track, "hdr10_base", False)),
} }
result.update(serialize_track_common(track)) result.update(serialize_track_common(track))
if include_url and hasattr(track, "url") and track.url: 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 return None
params: Dict[str, Any] = {} 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) langs = str_list(key)
params[key] = langs if langs is not None else list(cast(List[str], DEFAULT_DOWNLOAD_PARAMS[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 # the dl CLI default is [], and some services test membership in it
+6 -1
View File
@@ -795,7 +795,7 @@ async def list_tracks(request: web.Request) -> web.Response:
dl_params: dl_params:
type: object type: object
description: | 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`. form as on `/api/session/create`. The service reads it from `ctx.parent.params`.
credentials: credentials:
type: object type: object
@@ -1916,6 +1916,11 @@ async def session_create(request: web.Request) -> web.Response:
items: items:
type: string type: string
default: [] default: []
s_lang:
type: array
items:
type: string
default: ["all"]
forced_subs: forced_subs:
type: boolean type: boolean
default: false default: false
+2 -1
View File
@@ -287,6 +287,7 @@ def deserialize_video(data: Dict[str, Any]) -> Video:
closed_captions=data.get("closed_captions") or None, closed_captions=data.get("closed_captions") or None,
dv_compatible_bitstream=bool(data.get("dv_compatible_bitstream", False)), dv_compatible_bitstream=bool(data.get("dv_compatible_bitstream", False)),
hdr10plus=bool(data.get("hdr10plus", 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 params = self.ctx.parent.params
dl_params: dict[str, Any] = { dl_params: dict[str, Any] = {
key: [str(x) for x in params[key]] 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(key) is not None
} }
if params.get("acodec") is not None: if params.get("acodec") is not None:
+7 -2
View File
@@ -237,6 +237,7 @@ class Video(Track):
closed_captions: Optional[list[dict[str, Any]]] = None, closed_captions: Optional[list[dict[str, Any]]] = None,
dv_compatible_bitstream: bool = False, dv_compatible_bitstream: bool = False,
hdr10plus: bool = False, hdr10plus: bool = False,
hdr10_base: bool = False,
**kwargs: Any, **kwargs: Any,
) -> None: ) -> None:
""" """
@@ -256,6 +257,7 @@ class Video(Track):
Strings may represent numbers, floats, or a fraction (num/den). Strings may represent numbers, floats, or a fraction (num/den).
All strings will be cast to either a number or float. All strings will be cast to either a number or float.
hdr10plus: The DV track also carries HDR10+ metadata. The HDR10+ probe sets it. 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 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. 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.needs_duration_fix = False
self.dv_compatible_bitstream = dv_compatible_bitstream self.dv_compatible_bitstream = dv_compatible_bitstream
self.hdr10plus = hdr10plus self.hdr10plus = hdr10plus
self.hdr10_base = hdr10_base
self.hybrid_base_only = False self.hybrid_base_only = False
self.native_hybrid = False self.native_hybrid = False
@@ -321,6 +324,7 @@ class Video(Track):
"scan_type": self.scan_type.name if self.scan_type else None, "scan_type": self.scan_type.name if self.scan_type else None,
"dv_compatible_bitstream": self.dv_compatible_bitstream, "dv_compatible_bitstream": self.dv_compatible_bitstream,
"hdr10plus": self.hdr10plus, "hdr10plus": self.hdr10plus,
"hdr10_base": self.hdr10_base,
} }
) )
return data return data
@@ -339,6 +343,7 @@ class Video(Track):
scan_type=Video.ScanType[data["scan_type"]] if data.get("scan_type") else None, scan_type=Video.ScanType[data["scan_type"]] if data.get("scan_type") else None,
dv_compatible_bitstream=data.get("dv_compatible_bitstream", False), dv_compatible_bitstream=data.get("dv_compatible_bitstream", False),
hdr10plus=data.get("hdr10plus", False), hdr10plus=data.get("hdr10plus", False),
hdr10_base=data.get("hdr10_base", False),
) )
@staticmethod @staticmethod
@@ -377,8 +382,8 @@ class Video(Track):
@property @property
def range_label(self) -> str: def range_label(self) -> str:
"""The range that ``-r`` selects the track by, then any second format it carries: HDR10+DV, DV+HDR10P.""" """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 "") 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 return f"{label}+DV" if self.dv_compatible_bitstream and self.range != Video.Range.DV else label
def __str__(self) -> str: def __str__(self) -> str: