feat(dl): add -fsl/--forced-s-lang to filter forced subtitles by language

This commit is contained in:
imSp4rky
2026-08-03 09:27:40 -06:00
parent b580ccdd42
commit 4e1ccf3230
11 changed files with 112 additions and 6 deletions
+1
View File
@@ -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). |
+1
View File
@@ -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. |
+4
View File
@@ -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. |
+13
View File
@@ -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
+1
View File
@@ -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. |
@@ -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
+45 -6
View File
@@ -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
+1
View File
@@ -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),
+1
View File
@@ -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,
+5
View File
@@ -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)
+15
View File
@@ -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]],