From 4e1ccf32303b03c037f47929b2744fd606de5ddc Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Mon, 3 Aug 2026 09:27:40 -0600 Subject: [PATCH] feat(dl): add -fsl/--forced-s-lang to filter forced subtitles by language --- docs/dev/rest-api/endpoints.md | 1 + docs/guide/cli-reference.md | 1 + docs/guide/downloading.md | 4 ++ docs/guide/subtitles.md | 13 +++++ docs/reference/configuration.md | 1 + .../test_forced_sub_selection.py | 25 +++++++++ unshackle/commands/dl.py | 51 ++++++++++++++++--- unshackle/core/api/download_manager.py | 1 + unshackle/core/api/handlers.py | 1 + unshackle/core/api/routes.py | 5 ++ unshackle/core/utilities.py | 15 ++++++ 11 files changed, 112 insertions(+), 6 deletions(-) create mode 100644 tests/lang_selection/test_forced_sub_selection.py diff --git a/docs/dev/rest-api/endpoints.md b/docs/dev/rest-api/endpoints.md index 206f7532..8fc42797 100644 --- a/docs/dev/rest-api/endpoints.md +++ b/docs/dev/rest-api/endpoints.md @@ -366,6 +366,7 @@ Create a download job. Requires `service` and `title_id`; every other field is a | `lang` / `v_lang` / `a_lang` / `s_lang` | string[] | `["orig"]` / `[]` / `[]` / `["all"]` | Language filters. | | `require_subs` | string[] | `[]` | Required subtitle languages. | | `forced_subs` | boolean | `false` | Include forced subtitles. | +| `forced_s_lang` | string[] | `[]` | Forced subtitle language(s); implies `forced_subs`. | | `exact_lang` | boolean | `false` | Exact language matching. | | `sub_format` | string | `null` | Output subtitle format. | | `video_only` / `audio_only` / `subs_only` / `chapters_only` | boolean | `false` | Track-type restriction (at most one). | diff --git a/docs/guide/cli-reference.md b/docs/guide/cli-reference.md index a5d1daec..d3dba5cd 100644 --- a/docs/guide/cli-reference.md +++ b/docs/guide/cli-reference.md @@ -96,6 +96,7 @@ unshackle dl [OPTIONS] SERVICE [SERVICE ARGS...] | `-sl`, `--s-lang` | `all` | Subtitle language(s). | | `--require-subs` | - | Required subtitle langs; keeps **all** subs only if these exist. **Cannot combine with `--s-lang`.** | | `-fs`, `--forced-subs` | off | Include forced subtitle tracks. | +| `-fsl`, `--forced-s-lang` | none | Language(s) wanted for forced subtitles; implies `-fs`. | | `--exact-lang` | off | Exact matching only: `-l es-419` matches `es-419`, not `es-ES`. | | `--sub-format` | - | Output subtitle format (`SRT`/`srt`, `VTT`/`webvtt`, `ASS`/`ssa`, `TTML`, `SMI`, ...), or `original` to keep the source format. | diff --git a/docs/guide/downloading.md b/docs/guide/downloading.md index 11d26828..8959e208 100644 --- a/docs/guide/downloading.md +++ b/docs/guide/downloading.md @@ -289,6 +289,9 @@ download on the presence of a specific subtitle track. - `-fs` / `--forced-subs`: include forced subtitle tracks (signs/foreign dialogue). Without this flag, forced subs are dropped from selection. +- `-fsl` / `--forced-s-lang`: keep forced subtitles only in these languages (implies + `-fs`). Works independently of `--s-lang`, so `-sl all -fsl en` grabs every full + subtitle but only the English forced track. - `--sub-format`: set the output subtitle format, converting only when necessary. Accepts codec names/values and common aliases (`srt`, `vtt`, `ass`, `ssa`, `ttml`, etc.), or the literal `original` to keep the source format. @@ -578,6 +581,7 @@ authoritative list. | `--v-lang` | `-vl` | Video-only language override. | | `--s-lang` | `-sl` | Subtitle language(s); default `all`. | | `--forced-subs` | `-fs` | Include forced subtitles. | +| `--forced-s-lang` | `-fsl` | Forced subtitle language(s); implies `-fs`. | | `--sub-format` | | Output subtitle format. | | `--wanted` | `-w` | Episode/season range. | | `--select-titles` | | Interactively pick episodes or films. | diff --git a/docs/guide/subtitles.md b/docs/guide/subtitles.md index 8823be19..d272445a 100644 --- a/docs/guide/subtitles.md +++ b/docs/guide/subtitles.md @@ -70,6 +70,7 @@ Subtitle selection happens through `dl` flags. The most important is language se | `-sl`, `--s-lang` | Language(s) wanted for subtitles. Defaults to `all`. | | `--require-subs` | Require these languages to exist; if present, download **all** subtitles. Cannot be combined with `--s-lang`. | | `-fs`, `--forced-subs` | Include forced subtitle tracks (excluded by default). | +| `-fsl`, `--forced-s-lang` | Language(s) wanted for forced subtitles; implies `-fs`. | | `--exact-lang` | Exact language matching, with no regional variants. | | `-S`, `--subs-only` | Download only subtitle tracks. | | `-ns`, `--no-subs` | Do not download subtitle tracks at all. | @@ -121,6 +122,18 @@ default**. Add `-fs` / `--forced-subs` to include them: unshackle dl --s-lang en --forced-subs EXAMPLE 81234567 ``` +`-fs` keeps forced tracks in every selected language. If you only want forced tracks +in certain languages, use `-fsl` / `--forced-s-lang` instead. It implies `-fs`, so +you do not need both: + +```shell title="All full subtitles, but only the English forced track" +unshackle dl --s-lang all --forced-s-lang en EXAMPLE 81234567 +``` + +If you pass both flags, `-fsl` wins and keeps only forced tracks in its languages. +It also understands `orig` (the title's original language) and `all` (every forced +track, same as plain `-fs`). + ## Track types: forced, SDH, and CC Every subtitle track carries flags describing what kind of captions it holds. These diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index eec8fb91..4ededb9c 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -320,6 +320,7 @@ Common keys (a useful subset; every `dl` flag works): | `channels` | float | *(unset)* | Audio channels, e.g. `6` for 5.1. | | `sub_format` | str | *(unset)* | Convert subtitles to this format (`srt`, `vtt`, `original`, ...). | | `forced_subs` | bool | `false` | Include forced subtitle tracks. | +| `forced_s_lang` | list | `[]` | Forced subtitle language(s); implies `forced_subs`. | | `no_subs` / `no_audio` / `no_chapters` | bool | `false` | Skip that track type. | | `downloads` | int | `1` | Tracks downloaded concurrently. | | `workers` | int | *(downloader default)* | Threads per track. | diff --git a/tests/lang_selection/test_forced_sub_selection.py b/tests/lang_selection/test_forced_sub_selection.py new file mode 100644 index 00000000..379c9bf3 --- /dev/null +++ b/tests/lang_selection/test_forced_sub_selection.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import pytest + +from unshackle.core.utilities import keep_forced_subtitle + + +@pytest.mark.parametrize( + "forced,language,forced_s_lang,exact,expected", + [ + (False, "en", [], False, True), + (False, "de", ["en"], False, True), + (True, "en", [], False, False), + (True, "en", ["en"], False, True), + (True, "en-US", ["en"], False, True), + (True, "de", ["en"], False, False), + (True, "de", ["en", "de"], False, True), + # CLDR rates paradigm-regional pairs (en/en-US) distance 0, so exact still matches + (True, "en-US", ["en"], True, True), + (True, "en", ["en"], True, True), + (True, "es-419", ["es-ES"], True, False), + ], +) +def test_keep_forced_subtitle(forced: bool, language: str, forced_s_lang: list[str], exact: bool, expected: bool): + assert keep_forced_subtitle(forced, language, forced_s_lang, exact) is expected diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 0902518b..b4b31193 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -80,6 +80,8 @@ from unshackle.core.utilities import ( get_system_fonts, init_debug_logger, is_close_match, + is_exact_match, + keep_forced_subtitle, log_event, suggest_font_packages, time_elapsed_since, @@ -551,6 +553,13 @@ class dl: help="Required subtitle languages. Downloads all subtitles only if these languages exist. Cannot be used with --s-lang.", ) @click.option("-fs", "--forced-subs", is_flag=True, default=False, help="Include forced subtitle tracks.") + @click.option( + "-fsl", + "--forced-s-lang", + type=LANGUAGE_RANGE, + default=[], + help="Languages wanted for forced subtitles, implies -fs. Keeps forced subs only in these languages.", + ) @click.option( "--exact-lang", is_flag=True, @@ -1270,6 +1279,7 @@ class dl: s_lang: list[str], require_subs: list[str], forced_subs: bool, + forced_s_lang: list[str], exact_lang: bool, sub_format: Optional[Union[Subtitle.Codec, str]], video_only: bool, @@ -1312,6 +1322,15 @@ class dl: self._remote_service = service if self.server_cdm else None start_time = time.time() + if forced_s_lang: + # services read forced_subs from raw ctx params, so the implication must be pushed there too + forced_subs = True + if "all" in forced_s_lang: + forced_s_lang = [] + ctx = getattr(service, "ctx", None) + if ctx and ctx.parent: + ctx.parent.params["forced_subs"] = True + if skip_dl: DOWNLOAD_LICENCE_ONLY.set() @@ -2782,6 +2801,9 @@ class dl: sys.exit(1) # filter subtitle tracks + fsl = [t for t in forced_s_lang if t != "orig"] + if "orig" in forced_s_lang and title.language: + fsl.append(str(title.language)) if keep_subtitles and require_subs: missing_langs = [ lang @@ -2797,8 +2819,6 @@ class dl: f"Required languages found ({', '.join(require_subs)}), downloading all available subtitles" ) elif keep_subtitles and s_lang and "all" not in s_lang: - from unshackle.core.utilities import is_exact_match - match_func = is_exact_match if exact_lang else is_close_match missing_langs = find_missing_langs( @@ -2819,19 +2839,38 @@ class dl: self.log.warning( f"{missing_str} not found in subtitle tracks, continuing without subtitles" ) - title.tracks.subtitles = [] + title.tracks.select_subtitles( + lambda x: ( + x.forced and keep_forced_subtitle(x.forced, x.language, fsl, exact_lang) + ) + ) else: self.log.error(missing_str + " not found in tracks") sys.exit(1) if s_lang and title.tracks.subtitles: - title.tracks.select_subtitles(lambda x: match_func(x.language, s_lang)) + title.tracks.select_subtitles( + lambda x: ( + match_func(x.language, s_lang) + or bool(x.forced and fsl and match_func(x.language, fsl)) + ) + ) if not title.tracks.subtitles and not best_available: self.log.error(f"There's no {s_lang} Subtitle Track...") sys.exit(1) - if keep_subtitles and not forced_subs: - title.tracks.select_subtitles(lambda x: not x.forced) + if keep_subtitles: + # fsl, not forced_s_lang: unresolvable orig must keep all forced, not drop them + if fsl: + title.tracks.select_subtitles( + lambda x: keep_forced_subtitle(x.forced, x.language, fsl, exact_lang) + ) + if not any(x.forced for x in title.tracks.subtitles): + self.log.warning( + f"No forced subtitle tracks matched --forced-s-lang ({', '.join(forced_s_lang)})" + ) + elif not forced_subs: + title.tracks.select_subtitles(lambda x: not x.forced) # filter audio tracks # might have no audio tracks if part of the video, e.g. transport stream hls diff --git a/unshackle/core/api/download_manager.py b/unshackle/core/api/download_manager.py index 180ed7e5..56fc0d99 100644 --- a/unshackle/core/api/download_manager.py +++ b/unshackle/core/api/download_manager.py @@ -536,6 +536,7 @@ def _perform_download( s_lang=params.get("s_lang", ["all"]), require_subs=params.get("require_subs", []), forced_subs=params.get("forced_subs", False), + forced_s_lang=params.get("forced_s_lang", []), exact_lang=params.get("exact_lang", False), sub_format=params.get("sub_format"), video_only=params.get("video_only", False), diff --git a/unshackle/core/api/handlers.py b/unshackle/core/api/handlers.py index f22d9065..569dafb2 100644 --- a/unshackle/core/api/handlers.py +++ b/unshackle/core/api/handlers.py @@ -44,6 +44,7 @@ DEFAULT_DOWNLOAD_PARAMS = { "s_lang": ["all"], "require_subs": [], "forced_subs": False, + "forced_s_lang": [], "exact_lang": False, "sub_format": None, "video_only": False, diff --git a/unshackle/core/api/routes.py b/unshackle/core/api/routes.py index 21fffadd..3af50bf4 100644 --- a/unshackle/core/api/routes.py +++ b/unshackle/core/api/routes.py @@ -689,6 +689,11 @@ async def download(request: web.Request) -> web.Response: forced_subs: type: boolean description: Include forced subtitle tracks (default - false) + forced_s_lang: + type: array + items: + type: string + description: Languages wanted for forced subtitles, implies forced_subs (default - []) exact_lang: type: boolean description: Use exact language matching (no variants) (default - false) diff --git a/unshackle/core/utilities.py b/unshackle/core/utilities.py index d101ed35..3b008ce3 100644 --- a/unshackle/core/utilities.py +++ b/unshackle/core/utilities.py @@ -163,6 +163,21 @@ def is_exact_match(language: Union[str, Language], languages: Sequence[Union[str return closest_match(language, list(map(str, languages)))[1] <= LANGUAGE_EXACT_DISTANCE +def keep_forced_subtitle( + forced: bool, + language: Union[str, Language], + forced_s_lang: Sequence[str], + exact: bool = False, +) -> bool: + """The -fsl filter: non-forced tracks always pass, forced tracks only if they match forced_s_lang.""" + if not forced: + return True + if not forced_s_lang: + return False + match_func = is_exact_match if exact else is_close_match + return match_func(language, forced_s_lang) + + def find_missing_langs( requested: Sequence[str], available: Sequence[Union[str, Language, None]],