feat(dl): exclude languages with a - prefix on -l, -vl, -al, -sl and -fsl

This commit is contained in:
imSp4rky
2026-08-10 12:18:30 -06:00
parent 8a7022a091
commit 79ad70a9cf
10 changed files with 355 additions and 27 deletions
+1
View File
@@ -10,6 +10,7 @@ This changelog is automatically generated using [git-cliff](https://git-cliff.or
### Features
- *dl*: Exclude a language with a `-` prefix on -l, -vl, -al, -sl and -fsl
- *api*: Report per-episode and per-track progress on download jobs
- *api*: Add job retry/priority/clear, history, config and maintenance endpoints
- *api*: Expose choice values and multiple flag in service cli_params
+5 -5
View File
@@ -90,13 +90,13 @@ unshackle dl [OPTIONS] SERVICE [SERVICE ARGS...]
| Flag | Default | Description |
|---|---|---|
| `-l`, `--lang` | `orig` | Language(s) for **both** video and audio. `orig` = the title's original language; e.g. `orig,en`. |
| `-vl`, `--v-lang` | - | Video-only language (overrides `-l` for video). |
| `-al`, `--a-lang` | - | Audio-only language (overrides `-l` for audio). |
| `-sl`, `--s-lang` | `all` | Subtitle language(s). |
| `-l`, `--lang` | `orig` | Language(s) for **both** video and audio. `orig` = the title's original language; e.g. `orig,en`. A `-` prefix excludes, e.g. `all,-es`. |
| `-vl`, `--v-lang` | - | Video-only language (overrides `-l` for video). A `-` prefix excludes. |
| `-al`, `--a-lang` | - | Audio-only language (overrides `-l` for audio). A `-` prefix excludes. |
| `-sl`, `--s-lang` | `all` | Subtitle language(s). A `-` prefix excludes, e.g. `all,-es`. |
| `--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`. |
| `-fsl`, `--forced-s-lang` | none | Language(s) wanted for forced subtitles; implies `-fs`. A `-` prefix excludes. |
| `--exact-lang` | off | Exact matching only: `-l es-419` matches `es-419`, not `es-ES`. Applies to selection and to sort order. |
| `--sub-format` | - | Output subtitle format (`SRT`/`srt`, `VTT`/`webvtt`, `ASS`/`ssa`, `TTML`, `SMI`, ...), or `original` to keep the source format. |
+52 -6
View File
@@ -254,6 +254,47 @@ it is used. You can override each stream type independently:
unshackle dl -al en EXAMPLE 81234567
```
### Excluding languages
Put a `-` in front of a language to remove it from the selection. This works on `-l`,
`-vl`, `-al`, `-sl`, and `-fsl`, in the same way as it works on `-w` / `--wanted`.
```shell title="Every subtitle language except Spanish"
unshackle dl -sl all,-es EXAMPLE 81234567
```
The exclusions are subtracted from the languages the flag would otherwise select, and the
order of the values does not matter. If you give only exclusions, the flag keeps its own
default: `-sl -es` is the same as `-sl all,-es`, and `-l -es` is the same as `-l orig,-es`.
```shell title="Only exclusions"
# every subtitle language except Spanish and French
unshackle dl -sl -es,-fr EXAMPLE 81234567
# the original audio language, unless it is Spanish
unshackle dl -l -es EXAMPLE 81234567
```
`-vl` and `-al` have no default of their own: they cascade to `-l`. An override that names
a language replaces `-l` for that stream type, exclusions included. An override that gives
only exclusions adds them to the ones from `-l`.
```shell title="Cascade"
# video keeps the Japanese track; only the audio drops Spanish
unshackle dl -l en,-es -vl ja EXAMPLE 81234567
```
!!! note "Rules for exclusion tokens"
- `orig` is resolved per title, so `-sl -orig` drops the subtitles in the title's
original language.
- `--exact-lang` applies to exclusions with the same matching rules as selection:
`-es` removes exactly the tracks that `es` would select. Without it, `-es` also
removes `es-419` and `es-ES`.
- Tracks with no language tag are never excluded.
- `-all` is rejected. Name the languages you do not want instead.
- The comma form is the documented style. `-sl -es` also works, but a shell can read
a lone `-es` as another option.
### Sort order
`-l` and `-sl` **select** languages: naming some removes the rest. To keep every language
@@ -296,6 +337,10 @@ every available subtitle language is downloaded.
unshackle dl -sl en,es EXAMPLE 81234567
```
To keep the default and drop one language, use the `-` prefix described in
[Excluding languages](#excluding-languages). `-sl all,-es` keeps every subtitle language
except Spanish, forced Spanish subtitles included.
### Requiring subtitles
`--require-subs` takes a list of languages that **must** exist. If they all exist, *all*
@@ -311,7 +356,8 @@ download on the presence of a specific subtitle track.
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.
subtitle but only the English forced track. It accepts exclusions too: `-fsl all,-es`
keeps every forced subtitle except the Spanish one, and `-fsl -es` means the same.
- `--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.
@@ -665,12 +711,12 @@ authoritative list.
| `--range` | `-r` | Color range(s); default `SDR`. |
| `--channels` | `-c` | Audio channel layout. |
| `--noatmos` | `-naa` | Exclude Atmos audio. |
| `--lang` | `-l` | Video + audio language(s); default `orig`. |
| `--a-lang` | `-al` | Audio-only language override. |
| `--v-lang` | `-vl` | Video-only language override. |
| `--s-lang` | `-sl` | Subtitle language(s); default `all`. |
| `--lang` | `-l` | Video + audio language(s); default `orig`. `-` excludes, e.g. `all,-es`. |
| `--a-lang` | `-al` | Audio-only language override. `-` excludes. |
| `--v-lang` | `-vl` | Video-only language override. `-` excludes. |
| `--s-lang` | `-sl` | Subtitle language(s); default `all`. `-` excludes, e.g. `all,-es`. |
| `--forced-subs` | `-fs` | Include forced subtitles. |
| `--forced-s-lang` | `-fsl` | Forced subtitle language(s); implies `-fs`. |
| `--forced-s-lang` | `-fsl` | Forced subtitle language(s); implies `-fs`. `-` excludes. |
| `--sub-format` | | Output subtitle format. |
| `--wanted` | `-w` | Episode/season range. |
| `--select-titles` | | Interactively pick episodes or films. |
+2 -2
View File
@@ -67,10 +67,10 @@ Subtitle selection happens through `dl` flags. The most important is language se
| Flag | Purpose |
| --- | --- |
| `-sl`, `--s-lang` | Language(s) wanted for subtitles. Defaults to `all`. |
| `-sl`, `--s-lang` | Language(s) wanted for subtitles. Defaults to `all`. A `-` prefix excludes, e.g. `all,-es`. |
| `--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`. |
| `-fsl`, `--forced-s-lang` | Language(s) wanted for forced subtitles; implies `-fs`. A `-` prefix excludes. |
| `--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. |
+16 -4
View File
@@ -31,9 +31,9 @@ Common keys (a useful subset; every `dl` flag works):
| Key | Type | Default | Sets |
|-----|------|---------|------|
| `lang` | list | `["orig"]` | Video/audio language(s); `orig` = original language. |
| `a_lang` / `v_lang` | list | `[]` | Audio- / video-only language override. |
| `s_lang` | list | `["all"]` | Subtitle language(s). |
| `lang` | list | `["orig"]` | Video/audio language(s); `orig` = original language. A `-` prefix excludes, e.g. `[all, -es]`. |
| `a_lang` / `v_lang` | list | `[]` | Audio- / video-only language override. A `-` prefix excludes. |
| `s_lang` | list | `["all"]` | Subtitle language(s). A `-` prefix excludes, e.g. `[all, -es]`. |
| `quality` | list | `[]` (best) | Resolution(s), e.g. `[1080]`. |
| `vcodec` | list | `[]` (any) | Video codec(s), e.g. `[H265]`. |
| `acodec` | list | `[]` (any) | Audio codec(s), e.g. `[EC3]`. |
@@ -41,7 +41,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`. |
| `forced_s_lang` | list | `[]` | Forced subtitle language(s); implies `forced_subs`. A `-` prefix excludes. |
| `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. |
@@ -61,6 +61,18 @@ dl:
downloads: 2
```
!!! note "Language exclusions in YAML"
The language keys accept the same `-` exclusion tokens as the command line. Give them
as a list or as one comma-separated string.
```yaml
dl:
s_lang: [all, -es]
a_lang: "orig,-es"
```
See [Excluding languages](../../guide/downloading.md#excluding-languages) for the rules.
!!! note "Per-service overrides"
A `dl` block nested under a service in [`services`](services.md) takes the same keys and
applies to that service only.
+24 -1
View File
@@ -7,7 +7,13 @@ import click
import pytest
from unshackle.core.tracks.subtitle import Subtitle
from unshackle.core.utils.click_types import QUALITY_LIST, SLOW_DELAY_RANGE, SeasonRange, SubtitleCodecChoice
from unshackle.core.utils.click_types import (
LANGUAGE_RANGE,
QUALITY_LIST,
SLOW_DELAY_RANGE,
SeasonRange,
SubtitleCodecChoice,
)
choice = SubtitleCodecChoice(Subtitle.Codec)
@@ -60,6 +66,23 @@ def test_slow_delay_range_accepts_bool(value, expected):
assert SLOW_DELAY_RANGE.convert(value, None, None) == expected
# --- LanguageRange ----------------------------------------------------------
@pytest.mark.parametrize(
"value,expected",
[
("all,-es", ["all", "-es"]),
("-es", ["-es"]),
("all, -es ; -fr", ["all", "-es", "-fr"]),
(["all", "-es"], ["all", "-es"]),
],
)
def test_language_range_passes_exclusion_tokens_through(value, expected):
"""The '-' prefix is resolved at filter time, so the type must keep the token intact."""
assert LANGUAGE_RANGE.convert(value) == expected
# --- SeasonRange ------------------------------------------------------------
+80
View File
@@ -0,0 +1,80 @@
"""Tests for the `-` exclusion prefix on the language flags (-l, -vl, -al, -sl, -fsl)."""
from __future__ import annotations
import pytest
from langcodes import Language
from unshackle.core.utilities import excluded_language_tags, partition_exclusions
from unshackle.core.utils.click_types import LANGUAGE_RANGE
# --- partition_exclusions ---------------------------------------------------
@pytest.mark.parametrize(
"tokens,expected",
[
(["all", "-es"], (["all"], ["es"])),
(["-es"], ([], ["es"])),
(["en", "ja"], (["en", "ja"], [])),
(["-es", "-fr", "en"], (["en"], ["es", "fr"])),
([], ([], [])),
(None, ([], [])),
],
)
def test_partition_splits_on_the_leading_dash(tokens, expected):
assert partition_exclusions(tokens) == expected
def test_partition_keeps_the_written_order_and_drops_repeats():
assert partition_exclusions(["ja", "en", "ja", "-es", "-es"]) == (["ja", "en"], ["es"])
@pytest.mark.parametrize("token", ["-", " - ", "", " "])
def test_partition_skips_tokens_with_no_value(token):
assert partition_exclusions(["en", token]) == (["en"], [])
def test_partition_trims_whitespace_around_a_token():
assert partition_exclusions([" en ", "- es "]) == (["en"], ["es"])
def test_partition_reads_the_tokens_language_range_produces():
assert partition_exclusions(LANGUAGE_RANGE.convert("all,-es;-fr")) == (["all"], ["es", "fr"])
# --- excluded_language_tags ---------------------------------------------------
def test_close_match_excludes_the_regional_variants():
assert excluded_language_tags(["es"], ["es-419", "es-ES", "en-US"]) == {"es-419", "es-ES"}
def test_exact_match_prefers_the_string_equal_tag():
# same string preference as selection: an exact 'es' only takes 'es' when it exists
assert excluded_language_tags(["es"], ["es", "es-ES", "es-419"], exact=True) == {"es"}
def test_exact_match_falls_back_to_the_paradigm_variant():
# no literal 'es' available, so 'es' resolves the way an exact '-sl es' selection would
assert excluded_language_tags(["es"], ["es-ES", "es-419"], exact=True) == {"es-ES"}
def test_exact_match_keeps_an_unnamed_regional_variant():
assert excluded_language_tags(["es-ES"], ["es-ES", "es-419"], exact=True) == {"es-ES"}
def test_language_objects_are_accepted():
assert excluded_language_tags(["es"], [Language.get("es")]) == {"es"}
def test_an_untagged_track_is_never_excluded():
assert excluded_language_tags(["es"], [None, "en-US"]) == set()
def test_no_exclusions_excludes_nothing():
assert excluded_language_tags([], ["es-419", "en-US"]) == set()
def test_an_unmatched_language_is_kept():
assert excluded_language_tags(["es", "fr"], ["en-US", "ja-JP"]) == set()
+21
View File
@@ -150,3 +150,24 @@ def test_language_range_yaml_shapes(value, expected):
ctx = make_ctx()
apply_service_dl_overrides(ctx, {"v_lang": value}, log)
assert ctx.params["v_lang"] == expected
@pytest.mark.parametrize(
"value,expected",
[
("all,-es", ["all", "-es"]),
("-es", ["-es"]),
(["all", "-es"], ["all", "-es"]),
],
)
def test_language_exclusion_tokens_survive_the_override_convert(value, expected):
"""A service dl override is re-converted, so its exclusion tokens must reach the filter stage intact."""
ctx = make_ctx()
apply_service_dl_overrides(ctx, {"v_lang": value}, log)
assert ctx.params["v_lang"] == expected
def test_cli_exclusion_beats_service_config():
ctx = make_ctx(["-vl", "-es"])
apply_service_dl_overrides(ctx, {"v_lang": ["en"]}, log)
assert ctx.params["v_lang"] == ["-es"]
+111 -9
View File
@@ -74,6 +74,7 @@ from unshackle.core.tracks.hybrid import Hybrid
from unshackle.core.tracks.track import assert_fragments_decrypted, has_encrypted_sample_entry
from unshackle.core.utilities import (
as_requested,
excluded_language_tags,
find_font_with_fallbacks,
find_missing_langs,
get_debug_logger,
@@ -83,6 +84,7 @@ from unshackle.core.utilities import (
is_exact_match,
keep_forced_subtitle,
log_event,
partition_exclusions,
resolve_sort_langs,
suggest_font_packages,
time_elapsed_since,
@@ -532,7 +534,7 @@ class dl:
"--lang",
type=LANGUAGE_RANGE,
default="orig",
help="Language(s) wanted for Video and Audio (comma-separated). Use 'orig' to select the original language, e.g. 'orig,en' for both original and English.",
help="Language(s) wanted for Video and Audio (comma-separated). Use 'orig' to select the original language, e.g. 'orig,en' for both original and English. Prefix a value with '-' to exclude it, e.g. 'all,-es'.",
)
@click.option(
"--latest-episode",
@@ -545,16 +547,22 @@ class dl:
"--v-lang",
type=LANGUAGE_RANGE,
default=[],
help="Language wanted for Video. You would use this if the video language doesn't match the audio.",
help="Language wanted for Video. You would use this if the video language doesn't match the audio. Prefix a value with '-' to exclude it, e.g. 'all,-es'.",
)
@click.option(
"-al",
"--a-lang",
type=LANGUAGE_RANGE,
default=[],
help="Language wanted for Audio, overrides -l/--lang for audio tracks.",
help="Language wanted for Audio, overrides -l/--lang for audio tracks. Prefix a value with '-' to exclude it, e.g. 'all,-es'.",
)
@click.option(
"-sl",
"--s-lang",
type=LANGUAGE_RANGE,
default=["all"],
help="Language wanted for Subtitles. Prefix a value with '-' to exclude it, e.g. 'all,-es'.",
)
@click.option("-sl", "--s-lang", type=LANGUAGE_RANGE, default=["all"], help="Language wanted for Subtitles.")
@click.option(
"--require-subs",
type=LANGUAGE_RANGE,
@@ -567,7 +575,7 @@ class dl:
"--forced-s-lang",
type=LANGUAGE_RANGE,
default=[],
help="Languages wanted for forced subtitles, implies -fs. Keeps forced subs only in these languages.",
help="Languages wanted for forced subtitles, implies -fs. Keeps forced subs only in these languages. Prefix a value with '-' to exclude it, e.g. 'all,-es'.",
)
@click.option(
"--exact-lang",
@@ -1340,14 +1348,51 @@ class dl:
self._remote_service = service if self.server_cdm else None
start_time = time.time()
if forced_s_lang:
lang, lang_excl = partition_exclusions(lang)
v_lang, v_lang_excl = partition_exclusions(v_lang)
a_lang, a_lang_excl = partition_exclusions(a_lang)
s_lang, s_lang_excl = partition_exclusions(s_lang)
forced_s_lang, fsl_excl = partition_exclusions(forced_s_lang)
for flag, excludes in (
("-l/--lang", lang_excl),
("-vl/--v-lang", v_lang_excl),
("-al/--a-lang", a_lang_excl),
("-sl/--s-lang", s_lang_excl),
("-fsl/--forced-s-lang", fsl_excl),
):
if any(token.lower() == "all" for token in excludes):
self.log.error(f"{flag}: 'all' cannot be excluded. Name the languages to exclude instead.")
sys.exit(1)
# exclusion-only use falls back to the flag's own default; -vl/-al have none, they cascade to -l
if lang_excl and not lang:
lang = ["orig"]
if s_lang_excl and not s_lang:
s_lang = ["all"]
# an override that names languages replaces -l entirely, so it must not inherit its exclusions
video_excl = list(dict.fromkeys(v_lang_excl + ([] if v_lang else lang_excl)))
audio_excl = list(dict.fromkeys(a_lang_excl + ([] if a_lang else lang_excl)))
ctx = getattr(service, "ctx", None)
parent_params = ctx.parent.params if ctx and ctx.parent else None
if parent_params is not None:
# services read the raw ctx params, they must never see a '-xx' token
for name, includes, excludes in (
("lang", lang, lang_excl),
("v_lang", v_lang, v_lang_excl),
("a_lang", a_lang, a_lang_excl),
("s_lang", s_lang, s_lang_excl),
("forced_s_lang", forced_s_lang, fsl_excl),
):
if excludes and name in parent_params:
parent_params[name] = includes
if forced_s_lang or fsl_excl:
# 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 parent_params is not None:
parent_params["forced_subs"] = True
if skip_dl:
DOWNLOAD_LICENCE_ONLY.set()
@@ -1849,6 +1894,18 @@ class dl:
)
sys.exit(1)
song_excl = [
str(song.language) if t == "orig" else t for t in audio_excl if t != "orig" or song.language
]
if song_excl:
drop = excluded_language_tags(song_excl, [t.language for t in song.tracks.audio], exact_lang)
song.tracks.select_audio(lambda x: str(x.language) not in drop)
if not song.tracks.audio:
self.log.error(
f"Every Audio Track for {song.name} was excluded by -{', -'.join(song_excl)}..."
)
sys.exit(1)
audio_languages = a_lang or lang
if audio_languages:
processed_lang = []
@@ -2500,6 +2557,23 @@ class dl:
title.tracks.chapters = []
with console.status("Selecting tracks...", spinner="dots"):
def resolve_excludes(excludes: list[str], _title: Title_T = title) -> list[str]:
"""Resolve 'orig' against this title. An unresolvable 'orig' excludes nothing."""
resolved: list[str] = []
for token in excludes:
value = str(_title.language) if token == "orig" else token
if token == "orig" and not _title.language:
continue
if value not in resolved:
resolved.append(value)
return resolved
video_excl_r = resolve_excludes(video_excl)
audio_excl_r = resolve_excludes(audio_excl)
s_excl_r = resolve_excludes(s_lang_excl)
fsl_excl_r = resolve_excludes(fsl_excl)
if isinstance(title, (Movie, Episode)):
# filter video tracks
if keep_videos and vcodec:
@@ -2546,6 +2620,15 @@ class dl:
self.log.error(f"No Video Track in {vbitrate_min}-{vbitrate_max}kbps range...")
sys.exit(1)
if keep_videos and video_excl_r:
drop = excluded_language_tags(
video_excl_r, [t.language for t in title.tracks.videos], exact_lang
)
title.tracks.select_video(lambda x: str(x.language) not in drop)
if not title.tracks.videos:
self.log.error(f"Every Video Track was excluded by -{', -'.join(video_excl_r)}...")
sys.exit(1)
effective_video_lang = v_lang or lang
video_languages = [lang for lang in effective_video_lang if lang != "best"]
video_multi_lang = (
@@ -2826,6 +2909,12 @@ class dl:
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 s_excl_r:
# applies to forced subs too: an excluded language is unwanted in any form
drop = excluded_language_tags(
s_excl_r, [t.language for t in title.tracks.subtitles], exact_lang
)
title.tracks.select_subtitles(lambda x: str(x.language) not in drop)
if keep_subtitles and require_subs:
missing_langs = [
lang
@@ -2893,6 +2982,11 @@ class dl:
)
elif not forced_subs:
title.tracks.select_subtitles(lambda x: not x.forced)
if fsl_excl_r:
drop = excluded_language_tags(
fsl_excl_r, [t.language for t in title.tracks.subtitles if t.forced], exact_lang
)
title.tracks.select_subtitles(lambda x: not (x.forced and str(x.language) in drop))
# filter audio tracks
# might have no audio tracks if part of the video, e.g. transport stream hls
@@ -2929,6 +3023,14 @@ class dl:
if not title.tracks.audio:
self.log.error(f"No Audio Track in {abitrate_min}-{abitrate_max}kbps range...")
sys.exit(1)
if audio_excl_r:
drop = excluded_language_tags(
audio_excl_r, [t.language for t in title.tracks.audio], exact_lang
)
title.tracks.select_audio(lambda x: str(x.language) not in drop)
if not title.tracks.audio:
self.log.error(f"Every Audio Track was excluded by -{', -'.join(audio_excl_r)}...")
sys.exit(1)
audio_languages = a_lang or lang
if audio_languages:
processed_lang = []
+43
View File
@@ -164,6 +164,49 @@ 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 partition_exclusions(tokens: Optional[Sequence[str]]) -> tuple[list[str], list[str]]:
"""
Split selection tokens into wanted values and values excluded with a leading '-'.
Both sides keep the order they were written in and drop later repeats. A bare '-'
carries no value and is skipped.
Example:
>>> partition_exclusions(["all", "-es"])
(['all'], ['es'])
"""
includes: list[str] = []
excludes: list[str] = []
for raw in tokens or []:
token = str(raw).strip()
if not token or token == "-":
continue
target = includes
if token.startswith("-"):
token = token[1:].strip()
if not token:
continue
target = excludes
if token not in target:
target.append(token)
return includes, excludes
def excluded_language_tags(
excludes: Sequence[str],
available: Sequence[Union[str, Language, None]],
exact: bool = False,
) -> set[str]:
"""
The language tags out of `available` that the exclusion tokens remove.
Matches through matching_languages so an exclusion drops exactly the tracks the same
token would select, including its exact-mode string preference. Untagged tracks are
never excluded.
"""
return {tag for token in excludes for tag in matching_languages(token, available, exact)}
def keep_forced_subtitle(
forced: bool,
language: Union[str, Language],