From 4fbaf2a61ad8caef09863a1a7a9a996bcc295a95 Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Tue, 13 Oct 2026 15:07:01 -0600 Subject: [PATCH] feat(api): forward exact_lang to the service context The remote-dl client sends exact_lang in dl_params, and REST download jobs set it in the service ctx, so a service can skip the manifests of close-match languages. --- docs/dev/rest-api/endpoints.md | 4 ++-- docs/dev/rest-api/remote-sessions.md | 4 ++-- tests/remote/unit/test_download_manager.py | 16 ++++++++++++---- .../remote/unit/test_remote_service_helpers.py | 2 ++ tests/remote/unit/test_server_accounts.py | 17 +++++++++++------ unshackle/core/api/download_manager.py | 1 + unshackle/core/api/handlers.py | 1 + unshackle/core/api/routes.py | 5 ++++- unshackle/core/remote_service.py | 5 +++-- 9 files changed, 38 insertions(+), 17 deletions(-) diff --git a/docs/dev/rest-api/endpoints.md b/docs/dev/rest-api/endpoints.md index e48f049b..9500c98d 100644 --- a/docs/dev/rest-api/endpoints.md +++ b/docs/dev/rest-api/endpoints.md @@ -316,7 +316,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` and `forced_subs`, 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`, `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"`). @@ -958,7 +958,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` (boolean). 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` 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. 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 36648128..4e2b6464 100644 --- a/docs/dev/rest-api/remote-sessions.md +++ b/docs/dev/rest-api/remote-sessions.md @@ -432,11 +432,11 @@ object the dashboard shows as sent), and the track-selection hints `range_`, `vc `dl_params` holds the `dl` track selection: `lang`, `v_lang` and `a_lang` (arrays of language strings), `acodec` (an array of audio codec names, such as -`EC3`), and `forced_subs` (boolean). 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 with the same name, such as an own `--lang`, does not get them. The server uses the `dl` default for each absent or malformed value: `lang` is `["orig"]`, -`v_lang`, `a_lang` and `acodec` are `[]`, and `forced_subs` is `false`. The +`v_lang`, `a_lang` and `acodec` are `[]`, and `forced_subs` and `exact_lang` are `false`. The server drops an unknown codec name. An empty array is a valid value and stays empty. diff --git a/tests/remote/unit/test_download_manager.py b/tests/remote/unit/test_download_manager.py index 33969046..b08d8174 100644 --- a/tests/remote/unit/test_download_manager.py +++ b/tests/remote/unit/test_download_manager.py @@ -192,10 +192,17 @@ class _CapturedCtx(Exception): @pytest.mark.parametrize( ("params", "expected"), [ - ({}, (["orig"], [], [], [], False)), + ({}, (["orig"], [], [], [], False, False)), ( - {"lang": ["es-419"], "v_lang": ["en"], "a_lang": ["ja"], "acodec": "AAC,EC3", "forced_subs": True}, - (["es-419"], ["en"], ["ja"], ["AAC", "EC3"], True), + { + "lang": ["es-419"], + "v_lang": ["en"], + "a_lang": ["ja"], + "acodec": "AAC,EC3", + "forced_subs": True, + "exact_lang": True, + }, + (["es-419"], ["en"], ["ja"], ["AAC", "EC3"], True, True), ), ], ) @@ -224,7 +231,8 @@ def test_perform_download_puts_the_lang_selection_in_the_service_ctx( ctx = exc.value.args[0] p = ctx.params - assert (p["lang"], p["v_lang"], p["a_lang"], [c.name for c in p["acodec"]], p["forced_subs"]) == expected + selection = (p["lang"], p["v_lang"], p["a_lang"], [c.name for c in p["acodec"]], p["forced_subs"], p["exact_lang"]) + assert selection == expected def test_perform_download_passes_latest_episodes_to_dl_result(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/tests/remote/unit/test_remote_service_helpers.py b/tests/remote/unit/test_remote_service_helpers.py index 2b46cfbe..ee2e076a 100644 --- a/tests/remote/unit/test_remote_service_helpers.py +++ b/tests/remote/unit/test_remote_service_helpers.py @@ -616,6 +616,7 @@ def test_dl_selection_travels_nested_and_a_service_option_cannot_override_it() - "a_lang": [], "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"] == { @@ -624,6 +625,7 @@ def test_dl_selection_travels_nested_and_a_service_option_cannot_override_it() - "a_lang": [], "acodec": ["EC3"], "forced_subs": True, + "exact_lang": True, } assert data["lang"] == 3 assert data["service_params"] == {"lang": 3, "a_lang": "x"} diff --git a/tests/remote/unit/test_server_accounts.py b/tests/remote/unit/test_server_accounts.py index df45e741..5ea57f5e 100644 --- a/tests/remote/unit/test_server_accounts.py +++ b/tests/remote/unit/test_server_accounts.py @@ -162,6 +162,9 @@ def _create_with_real_ctx(monkeypatch, data): return svc +DL_DEFAULTS = {"lang": ["orig"], "v_lang": [], "a_lang": [], "acodec": [], "forced_subs": False, "exact_lang": False} + + def test_nested_dl_params_reach_the_parent_ctx(monkeypatch): from unshackle.core.tracks import Audio @@ -172,6 +175,7 @@ def test_nested_dl_params_reach_the_parent_ctx(monkeypatch): "a_lang": ["es-419"], "acodec": ["EC3", "NOPE", "AAC"], "forced_subs": True, + "exact_lang": True, } } svc = _create_with_real_ctx(monkeypatch, data) @@ -180,12 +184,13 @@ def test_nested_dl_params_reach_the_parent_ctx(monkeypatch): assert svc.parent_params["a_lang"] == ["es-419"] 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 def test_absent_dl_params_fall_back_to_the_dl_defaults(monkeypatch): svc = _create_with_real_ctx(monkeypatch, {}) - selection = {k: svc.parent_params[k] for k in ("lang", "v_lang", "a_lang", "acodec", "forced_subs")} - assert selection == {"lang": ["orig"], "v_lang": [], "a_lang": [], "acodec": [], "forced_subs": False} + selection = {k: svc.parent_params[k] for k in (*DL_DEFAULTS,)} + assert selection == DL_DEFAULTS def test_an_empty_lang_list_is_kept(monkeypatch): @@ -204,14 +209,14 @@ def test_a_service_lang_option_never_gets_the_dl_lang(monkeypatch): "dl_params", [ "en", - {"lang": "en", "v_lang": "ja", "a_lang": {"x": 1}, "acodec": "EC3", "forced_subs": "yes"}, - {"lang": [1], "v_lang": [None], "a_lang": ["en", None], "acodec": [3], "forced_subs": 1}, + {"lang": "en", "v_lang": "ja", "a_lang": {"x": 1}, "acodec": "EC3", "forced_subs": "yes", "exact_lang": "yes"}, + {"lang": [1], "v_lang": [None], "a_lang": ["en", None], "acodec": [3], "forced_subs": 1, "exact_lang": 1}, ], ) def test_malformed_dl_params_are_ignored(monkeypatch, dl_params): svc = _create_with_real_ctx(monkeypatch, {"dl_params": dl_params}) - selection = {k: svc.parent_params[k] for k in ("lang", "v_lang", "a_lang", "acodec", "forced_subs")} - assert selection == {"lang": ["orig"], "v_lang": [], "a_lang": [], "acodec": [], "forced_subs": False} + selection = {k: svc.parent_params[k] for k in (*DL_DEFAULTS,)} + assert selection == DL_DEFAULTS def test_profile_reaches_the_parent_ctx_params(monkeypatch): diff --git a/unshackle/core/api/download_manager.py b/unshackle/core/api/download_manager.py index 366b2e04..56aa90c7 100644 --- a/unshackle/core/api/download_manager.py +++ b/unshackle/core/api/download_manager.py @@ -500,6 +500,7 @@ def perform_download( "v_lang": params.get("v_lang", []), "a_lang": params.get("a_lang", []), "forced_subs": params.get("forced_subs", False), + "exact_lang": params.get("exact_lang", False), } # Hand-built context: record parameter sources so service dl overrides # apply to defaults but never clobber client-sent values. diff --git a/unshackle/core/api/handlers.py b/unshackle/core/api/handlers.py index 2a52f66d..bb288176 100644 --- a/unshackle/core/api/handlers.py +++ b/unshackle/core/api/handlers.py @@ -3277,6 +3277,7 @@ def forwarded_dl_params(data: Dict[str, Any]) -> Dict[str, Any]: except click.BadParameter: log.warning(f"Ignoring unknown dl_params.acodec value: {sanitize_log(name)}") params["forced_subs"] = raw.get("forced_subs") is True + params["exact_lang"] = raw.get("exact_lang") is True return params diff --git a/unshackle/core/api/routes.py b/unshackle/core/api/routes.py index dee6ea2a..fc607217 100644 --- a/unshackle/core/api/routes.py +++ b/unshackle/core/api/routes.py @@ -726,7 +726,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), in the same + The dl track selection (lang, v_lang, a_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 @@ -1831,6 +1831,9 @@ async def session_create(request: web.Request) -> web.Response: forced_subs: type: boolean default: false + exact_lang: + type: boolean + default: false server_cdm: type: boolean description: | diff --git a/unshackle/core/remote_service.py b/unshackle/core/remote_service.py index 42c655c6..e4af1964 100644 --- a/unshackle/core/remote_service.py +++ b/unshackle/core/remote_service.py @@ -1073,8 +1073,9 @@ class RemoteService: } if params.get("acodec") is not None: dl_params["acodec"] = [c.name for c in params["acodec"]] - if "forced_subs" in params: - dl_params["forced_subs"] = bool(params["forced_subs"]) + for key in ("forced_subs", "exact_lang"): + if key in params: + dl_params[key] = bool(params[key]) if dl_params: create_data["dl_params"] = dl_params