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`). |
| `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/<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.
+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
(`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
+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
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
+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)
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
+5 -3
View File
@@ -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
@@ -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,
+11 -1
View File
@@ -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
+1
View File
@@ -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
+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 []),
"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
+6 -1
View File
@@ -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
+2 -1
View File
@@ -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:
+7 -2
View File
@@ -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: