feat(dl): add --require-audio/--require-video and --warn-only alias

Gate a title on audio or video languages that must exist, independent of what -l/-al/-vl select. --require-subs joins the same gate: its --s-lang conflict and download-all side effect are gone, so -sl now decides what to keep. Requiring a language that is also excluded fails at parse time.
This commit is contained in:
imSp4rky
2026-08-23 12:24:45 -06:00
parent 2d005fcc1c
commit d8739b3561
12 changed files with 355 additions and 36 deletions
+4 -2
View File
@@ -388,7 +388,9 @@ Make a download job. It requires `service` and `title_id`. Every other field is
| `wanted` | string / string[] | `[]` | Episode/season selectors, as a list or as one comma-separated string. Accepts the part form, `"S01E01.2"`, and the air-date form, `"2026-08-11"` or `"2026-08-01:2026-08-31"`. For a music release, a selector is a track number, `"1-5"` or `"1,3,7"`, or `"{disc}x{track}"` such as `"2x3"`. |
| `latest_episode` | boolean | `false` | Only the newest episode. |
| `lang` / `v_lang` / `a_lang` / `s_lang` | string[] | `["orig"]` / `[]` / `[]` / `["all"]` | Language filters. |
| `require_subs` | string[] | `[]` | Required subtitle languages. |
| `require_audio` | string[] | `[]` | Audio languages that must exist. The job fails if one is missing, even with `best_available`. |
| `require_video` | string[] | `[]` | Video languages that must exist. The job fails if one is missing, even with `best_available`. |
| `require_subs` | string[] | `[]` | Subtitle languages that must exist. The job fails if one is missing. `s_lang` still decides what to keep. |
| `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. |
@@ -418,7 +420,7 @@ Make a download job. It requires `service` and `title_id`. Every other field is
| `output_dir` | string | `null` | Override output directory. |
| `no_cache` / `reset_cache` | boolean | `false` | Title cache controls. |
**Validation.** Invalid values return `400 INVALID_PARAMETERS`. `vcodec` must be one of H264/H265/H.264/H.265/AVC/HEVC/VC1/VC-1/VP8/VP9/AV1. `acodec` must be one of AAC/AC3/EC3/EAC3/DD/DD+/AC4/OPUS/FLAC/ALAC/VORBIS/OGG/DTS. `range` must be one of SDR/HDR10/HDR10P/DV/HLG/HYBRID, and `HDR10+` is also valid. The bitrate, download worker, and download counts must be positive integers. You may set at most one of the `*_only` flags. You cannot combine `no_subs` with `subs_only`, `no_audio` with `audio_only`, or `s_lang` with `require_subs`.
**Validation.** Invalid values return `400 INVALID_PARAMETERS`. `vcodec` must be one of H264/H265/H.264/H.265/AVC/HEVC/VC1/VC-1/VP8/VP9/AV1. `acodec` must be one of AAC/AC3/EC3/EAC3/DD/DD+/AC4/OPUS/FLAC/ALAC/VORBIS/OGG/DTS. `range` must be one of SDR/HDR10/HDR10P/DV/HLG/HYBRID, and `HDR10+` is also valid. The bitrate, download worker, and download counts must be positive integers. You may set at most one of the `*_only` flags. You cannot combine `no_subs` with `subs_only`, or `no_audio` with `audio_only`.
!!! danger "Rejected parameters"
`postscript`, `post_script` and `post_scripts` are never accepted. A body containing any
+5 -3
View File
@@ -79,7 +79,7 @@ unshackle dl [OPTIONS] SERVICE [SERVICE ARGS...]
| `-c`, `--channels` | - | Audio channels; matches sub-layouts (5.1 ≈ 6.0). |
| `-naa`, `--noatmos` | off | Exclude Dolby Atmos audio tracks. |
| `--worst` | off | Pick the lowest bitrate within the requested quality. **Requires `-q`.** |
| `--best-available` | off | Continue with the best available if a requested resolution/language is absent, instead of failing. |
| `--best-available`, `--warn-only` | off | Warn and continue when a requested resolution, range, or language is absent, instead of failing. |
| `-rvb`, `--real-video-bitrate` | off | Probe real media size for true video bitrates, overriding the manifest. |
| `-rab`, `--real-audio-bitrate` | off | Same for audio (slower). |
@@ -94,7 +94,9 @@ unshackle dl [OPTIONS] SERVICE [SERVICE ARGS...]
| `-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`.** |
| `--require-audio` | - | Audio langs that must exist. The title fails even with `--best-available`. Does not select tracks. |
| `--require-video` | - | Video langs that must exist. The title fails even with `--best-available`. Does not select tracks. |
| `--require-subs` | - | Subtitle langs that must exist. Fails the title if one is missing. `--s-lang` still decides what to keep. |
| `-fs`, `--forced-subs` | off | Include forced subtitle tracks. |
| `-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. |
@@ -212,7 +214,7 @@ order the service used, then renumbers the episodes into the order you asked for
| `--slow` | - | Inter-title delay. Bare `--slow` = 60-120s; `--slow 20-40` = custom range (minimum 20s). |
!!! warning "Some flags cannot be combined"
`--require-subs` and `--s-lang` are mutually exclusive. `--select-titles` and `--wanted` are mutually exclusive. `--worst` needs `--quality`. `--vbitrate` and `--vbitrate-range` (and the audio equivalents) are mutually exclusive.
`--select-titles` and `--wanted` are mutually exclusive. `--worst` needs `--quality`. `--vbitrate` and `--vbitrate-range` (and the audio equivalents) are mutually exclusive.
---
+38 -6
View File
@@ -94,7 +94,7 @@ By default a missing requested resolution is an error. Two flags change that:
| Flag | Behaviour |
| --- | --- |
| `--best-available` | If the requested resolution(s) are not present, continue with the best that *is* available instead of failing. Also softens missing video/audio/subtitle languages and hybrid fallbacks. |
| `--best-available` (`--warn-only`) | If the requested resolution(s) are not present, continue with the best that *is* available instead of failing. Also softens missing video/audio/subtitle languages and hybrid fallbacks. |
| `--worst` | Within the specified quality, pick the **lowest** bitrate rendition. **Requires `-q/--quality`.** |
```shell title="Never fail on a missing resolution"
@@ -364,12 +364,42 @@ except Spanish, forced Spanish subtitles included.
### Requiring subtitles
`--require-subs` takes a list of languages that **must** exist. If they all exist,
unshackle downloads *all* subtitles. If any is missing, the title fails. This is useful
when you want a download only if a specific subtitle track is present.
`--require-subs` takes a list of languages that **must** exist. If any is missing, the
title fails, even under `--best-available` / `--warn-only`. Use it when a download is only
worth keeping if a specific subtitle track is present.
!!! warning "Cannot combine with `--s-lang`"
`--require-subs` and `--s-lang` are mutually exclusive. Use one or the other.
It gates the title. It does not select tracks: `--s-lang` still decides what to keep, and
it defaults to `all`:
```bash
# every subtitle language, but only if English is one of them
unshackle dl --require-subs en EXAMPLE 0ABC123
# English, Korean and Japanese only, and only if English is present
unshackle dl -sl en,ko,ja --require-subs en EXAMPLE 0ABC123
```
### Requiring audio and video languages
`--require-audio` and `--require-video` take a list of languages that **must** exist. If
one is missing, the title fails, even under `--best-available` / `--warn-only`. Like
`--require-subs`, they gate the title without selecting tracks: `-l` / `-al` / `-vl` still
decide what unshackle downloads.
This matters most with `-l best` or `-l all`, which accept whatever the service offers and
so never report a missing language on their own:
```bash
# every audio language except French, but only if English is one of them
unshackle dl -l best,-fr --require-audio en EXAMPLE 0ABC123
# English is required, French is optional
unshackle dl -l en,fr --require-audio en --warn-only EXAMPLE 0ABC123
```
The check runs against the full track list, before any language selection or exclusion.
`orig` resolves to the title language, and `--exact-lang` applies. Audio carried inside the
video track counts. `--no-audio` and `--no-video` drop the matching requirement.
### Forced subtitles and output format
@@ -904,6 +934,8 @@ authoritative list.
| `--video-only` / `--audio-only` / `--subs-only` | `-V` / `-A` / `-S` | Restrict track types. |
| `--no-video` / `--no-audio` / `--no-subs` / `--no-chapters` | `-nv` / `-na` / `-ns` / `-nc` | Skip track types. |
| `--worst` | | Lowest bitrate within `-q`. |
| `--require-audio` | | Audio languages that must exist. The title fails if one is missing. |
| `--require-video` | | Video languages that must exist. The title fails if one is missing. |
| `--best-available` | | Degrade gracefully instead of failing. |
| `--output` | `-o` | Output directory for this run. |
| `--split-audio` / `--merge-video` / `--no-mux` | | Muxing behaviour. |
+1 -1
View File
@@ -48,7 +48,7 @@ Common config keys. This is a useful subset, and every `dl` flag works:
| `workers` | int | *(downloader default)* | Threads per track. |
| `speed_limit` | str | *(unlimited)* | Total download speed cap across all downloads combined, e.g. `500k`, `5M`, `1.5G` or plain bytes/sec. Values are bytes, not bits (`5M` = 5.0 MB/s). |
| `slow` | str | *(unset)* | Inter-title delay, e.g. `"20-40"`. |
| `best_available` | bool | `false` | Fall back to best quality if the request is unavailable. |
| `best_available` | bool | `false` | Warn instead of failing when a requested resolution, range, or language is missing. The CLI flag is `--best-available`, also spelled `--warn-only`. |
| `proxy` | str | *(unset)* | Default proxy URI or 2-letter country. |
| `no_folder` | bool | `false` | Do not create a per-title folder. |
+151
View File
@@ -0,0 +1,151 @@
from __future__ import annotations
import inspect
import pytest
from unshackle.commands.dl import dl
from unshackle.core.api.handlers import DEFAULT_DOWNLOAD_PARAMS
from unshackle.core.utilities import missing_required_langs
@pytest.mark.parametrize(
"required,available,expected",
[
# -l en,fr with --require-audio en: fr may be absent, en may not
(["en"], ["en", "de"], []),
(["en"], ["fr", "de"], ["en"]),
(["en", "fr"], ["en"], ["fr"]),
(["en", "fr"], [], ["en", "fr"]),
([], ["de"], []),
# regional and script variants satisfy the base tag
(["en"], ["en-US"], []),
(["es"], ["es-419"], []),
(["zh"], ["zh-Hans"], []),
# tracks with no language tag satisfy nothing
(["en"], [None], ["en"]),
# a bare exclusion asks for absence, so nothing is required
(["-fr"], ["en"], []),
],
)
def test_missing_required_langs(required, available, expected):
assert missing_required_langs(required, available) == expected
def test_exclusion_token_is_not_a_required_language():
# '-fr' must not be read as a language named "-fr", which could never match
assert missing_required_langs(["en", "-fr"], ["en", "ja"]) == []
assert missing_required_langs(["en", "-fr"], ["ja"]) == ["en"]
def test_orig_resolves_to_the_title_language():
assert missing_required_langs(["orig"], ["ja"], "ja") == []
assert missing_required_langs(["orig"], ["en"], "ja") == ["ja"]
# an unresolvable 'orig' requires nothing rather than failing every title
assert missing_required_langs(["orig"], ["en"], None) == []
def test_exact_lang_separates_a_regional_variant_from_its_base():
assert missing_required_langs(["en-US"], ["en-US"], exact=True) == []
assert missing_required_langs(["en-US"], ["en-GB"], exact=True) == ["en-US"]
assert missing_required_langs(["en-US"], ["en-GB"]) == []
def test_all_and_best_tokens_require_nothing():
# they mean "whatever exists", so they can never be missing
assert missing_required_langs(["all"], []) == []
assert missing_required_langs(["best"], []) == []
@pytest.mark.parametrize("flag", ["--require-audio", "--require-video", "--best-available", "--warn-only"])
def test_flag_is_registered_on_the_dl_command(flag):
assert any(flag in param.opts for param in dl.cli.params)
@pytest.mark.parametrize(
"flag,expected_param",
[
("--require-audio", "require_audio"),
("--require-video", "require_video"),
("--warn-only", "best_available"),
("--best-available", "best_available"),
],
)
def test_flag_maps_to_its_param_name(flag, expected_param):
param = next(p for p in dl.cli.params if flag in p.opts)
assert param.name == expected_param
@pytest.mark.parametrize("flag", ["--require-audio", "--require-video"])
def test_require_flags_parse_a_comma_list(flag):
param = next(p for p in dl.cli.params if flag in p.opts)
assert param.default == []
assert param.type.convert("en,ja", param, None) == ["en", "ja"]
@pytest.mark.parametrize("key", ["require_audio", "require_video"])
def test_api_passes_the_flags_through(key):
# the REST API bypasses click, so the default and the dl.result param must both exist
assert DEFAULT_DOWNLOAD_PARAMS[key] == []
assert key in inspect.signature(dl.result).parameters
def test_api_accepts_the_require_flags():
from unshackle.core.api.handlers import validate_download_parameters
# unlike require_subs, these carry no conflict with a language selection
assert (
validate_download_parameters(
{"service": "EXAMPLE", "title": "x", "require_audio": ["en"], "require_video": ["en"], "lang": ["en", "fr"]}
)
is None
)
@pytest.mark.parametrize("key", ["require_audio", "require_video"])
def test_api_reaches_dl_result(key, monkeypatch):
# download_manager builds the dl.result kwargs by hand, so a missing key is a silent drop
import inspect as _inspect
from unshackle.core.api import download_manager
source = _inspect.getsource(download_manager)
assert f'{key}=params.get("{key}", [])' in source
@pytest.mark.parametrize(
"value",
["en,fr", "en", {"lang": "en"}, [1], ["en", None]],
)
@pytest.mark.parametrize("key", ["require_audio", "require_video"])
def test_api_rejects_a_non_array_require_value(key, value):
# a JSON string iterates character by character and blows up in langcodes, so it must 400 first
from unshackle.core.api.handlers import validate_download_parameters
error = validate_download_parameters({"service": "EXAMPLE", "title": "x", key: value})
assert error == f"{key} must be an array of language strings"
def test_require_subs_no_longer_conflicts_with_s_lang():
# gate and selection are separate concerns: -sl decides what to keep, --require-subs only gates
from unshackle.core.api.handlers import validate_download_parameters
assert (
validate_download_parameters(
{"service": "EXAMPLE", "title": "x", "s_lang": ["en", "ko", "ja"], "require_subs": ["en"]}
)
is None
)
def test_all_three_require_flags_share_one_gate():
import inspect as _inspect
from unshackle.commands import dl as dl_module
source = _inspect.getsource(dl_module.dl.result)
# the sub gate must not short-circuit the s_lang branch any more
assert "downloading all available subtitles" not in source
for kind in ("audio", "video", "subtitle"):
assert f'"{kind}",' in source
assert "missing_required_langs(required, available, title.language, exact=exact_lang)" in source
@@ -0,0 +1,73 @@
"""The dl.result call site of missing_required_langs, and inputs the unit tests skip.
The gate in dl.result is not extractable without executing the whole download loop, so
its wiring is pinned the same way test_required_langs pins download_manager: against the
source. The value inputs cover what the helper's own tests leave out — real tracks carry
langcodes.Language objects, not strings.
"""
from __future__ import annotations
import inspect
from langcodes import Language
from unshackle.commands.dl import dl
from unshackle.core.utilities import missing_required_langs
def test_language_objects_satisfy_a_requirement():
# track.language is a langcodes.Language, never a str, at the real call site
assert missing_required_langs(["en"], [Language.get("en")]) == []
assert missing_required_langs(["en"], [Language.get("en-US")]) == []
assert missing_required_langs(["en"], [Language.get("fr")]) == ["en"]
def test_orig_accepts_a_language_object_title_language():
# title.language is a langcodes.Language at the call site, never a str
assert missing_required_langs(["orig"], ["ja"], Language.get("ja")) == []
assert missing_required_langs(["orig"], ["en"], Language.get("ja")) == ["ja"]
def test_requirement_checks_the_catalogue_not_the_selection():
# -l best,-fr --require-audio en: the gate answers "does en exist", so the
# exclusion of fr from the *selection* must not make en count as missing
assert missing_required_langs(["en"], ["en", "ko", "ja", "fr"]) == []
# requiring a language the selection excludes is still satisfied by the catalogue
assert missing_required_langs(["fr"], ["en", "ko", "ja", "fr"]) == []
class TestGateWiring:
"""Pin the dl.result gate's shape; a silent rewire is otherwise invisible to tests."""
source = inspect.getsource(dl.result)
def test_gate_is_disabled_with_the_track_kind(self):
# --no-audio / -V must not fail on a require_audio the download cannot honour
assert "require_audio if keep_audio else []" in self.source
assert "require_video if keep_videos else []" in self.source
def test_gate_counts_audio_embedded_in_kept_video(self):
assert "embedded_audio_langs(title.tracks.videos, keep_videos)" in self.source
def test_gate_honours_exact_lang(self):
assert "missing_required_langs(required, available, title.language, exact=exact_lang)" in self.source
def test_gate_reads_tracks_before_the_keep_flags_empty_them(self):
# the availability lists must be built before `if not keep_videos:` clears
# title.tracks.videos, or embedded audio could never satisfy a requirement
gate = self.source.index("missing_required_langs(required")
reset = self.source.index("if not keep_videos:")
assert gate < reset
def test_require_and_exclude_clash_is_rejected_per_flag(self):
# --require-audio en with -l all,-en can never be satisfied as the user
# meant it, so it must die at parse time, against each flag's own
# exclusion list (-vl/-al cascade from -l only when unset)
assert "cannot be required and excluded at the same time" in self.source
for pair in (
'("--require-audio", require_audio, audio_excl)',
'("--require-video", require_video, video_excl)',
'("--require-subs", require_subs, s_lang_excl)',
):
assert pair in self.source
+1 -1
View File
@@ -237,7 +237,7 @@ def test_validate_download_params_accepts_defaults() -> None:
({"video_only": True, "audio_only": True}, "exclusive"),
({"no_subs": True, "subs_only": True}, "no_subs and subs_only"),
({"no_audio": True, "audio_only": True}, "no_audio and audio_only"),
({"s_lang": ["en"], "require_subs": ["en"]}, "s_lang and require_subs"),
({"require_subs": "en"}, "require_subs must be an array"),
({"range": "UHD"}, "Invalid range"),
({"range": ["SDR", "UHD"]}, "Invalid range value"),
],
+47 -21
View File
@@ -76,6 +76,7 @@ from unshackle.core.utilities import (
is_exact_match,
keep_forced_subtitle,
log_event,
missing_required_langs,
partition_exclusions,
resolve_sort_langs,
suggest_font_packages,
@@ -636,6 +637,18 @@ class dl:
default=[],
help="Language wanted for Audio, overrides -l/--lang for audio tracks. Prefix a value with '-' to exclude it, e.g. 'all,-es'.",
)
@click.option(
"--require-audio",
type=LANGUAGE_RANGE,
default=[],
help="Audio languages that must exist. The title fails even with --best-available.",
)
@click.option(
"--require-video",
type=LANGUAGE_RANGE,
default=[],
help="Video languages that must exist. The title fails even with --best-available.",
)
@click.option(
"-sl",
"--s-lang",
@@ -647,7 +660,7 @@ class dl:
"--require-subs",
type=LANGUAGE_RANGE,
default=[],
help="Required subtitle languages. Downloads all subtitles only if these languages exist. Cannot be used with --s-lang.",
help="Subtitle languages that must exist. The title fails if one is missing. -sl still decides what to keep.",
)
@click.option("-fs", "--forced-subs", is_flag=True, default=False, help="Include forced subtitle tracks.")
@click.option(
@@ -900,10 +913,11 @@ class dl:
)
@click.option(
"--best-available",
"--warn-only",
"best_available",
is_flag=True,
default=False,
help="Continue with best available quality if requested resolutions are not available.",
help="Warn instead of failing when a requested resolution, range, or language is missing, and continue with what is available.",
)
@click.option(
"--remote",
@@ -1430,6 +1444,8 @@ class dl:
v_lang: list[str],
a_lang: list[str],
s_lang: list[str],
require_audio: list[str],
require_video: list[str],
require_subs: list[str],
forced_subs: bool,
forced_s_lang: list[str],
@@ -1506,6 +1522,16 @@ class dl:
# 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)))
for flag, tokens, excludes in (
("--require-audio", require_audio, audio_excl),
("--require-video", require_video, video_excl),
("--require-subs", require_subs, s_lang_excl),
):
required, own_excl = partition_exclusions(tokens)
clash = own_excl + [t for t in required if any(t.lower() == e.lower() for e in excludes)]
if clash:
self.log.error(f"{flag}: {clash[0]} cannot be required and excluded at the same time.")
sys.exit(1)
ctx = getattr(service, "ctx", None)
parent_params = ctx.parent.params if ctx and ctx.parent else None
@@ -1589,10 +1615,6 @@ class dl:
):
acodec = AUDIO_CODEC_LIST.convert(acodec)
if require_subs and s_lang != ["all"]:
self.log.error("--require-subs and --s-lang cannot be used together")
sys.exit(1)
if worst and not quality:
self.log.error("--worst requires -q/--quality to be specified")
sys.exit(1)
@@ -2196,6 +2218,24 @@ class dl:
if no_proxy_download and any(service.session.proxies.values()):
console.log("Bypassing proxy for downloads as --no-proxy-download was used...")
for kind, required, available in (
(
"audio",
require_audio if keep_audio else [],
[a.language for a in title.tracks.audio] + embedded_audio_langs(title.tracks.videos, keep_videos),
),
("video", require_video if keep_videos else [], [v.language for v in title.tracks.videos]),
(
"subtitle",
require_subs if keep_subtitles else [],
[t.language for t in title.tracks.subtitles],
),
):
missing_required = missing_required_langs(required, available, title.language, exact=exact_lang)
if missing_required:
self.log.error(f"Required {kind} language(s) not found: {', '.join(missing_required)}")
sys.exit(1)
if not keep_videos:
title.tracks.videos = []
if not keep_audio:
@@ -2557,21 +2597,7 @@ class dl:
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
for lang in require_subs
if not any(is_close_match(lang, [sub.language]) for sub in title.tracks.subtitles)
]
if missing_langs:
self.log.error(f"Required subtitle language(s) not found: {', '.join(missing_langs)}")
sys.exit(1)
self.log.info(
f"Required languages found ({', '.join(require_subs)}), downloading all available subtitles"
)
elif keep_subtitles and s_lang and "all" not in s_lang:
if keep_subtitles and s_lang and "all" not in s_lang:
match_func = is_exact_match if exact_lang else is_close_match
missing_langs = find_missing_langs(
+2
View File
@@ -563,6 +563,8 @@ def perform_download(
v_lang=params.get("v_lang", []),
a_lang=params.get("a_lang", []),
s_lang=params.get("s_lang", ["all"]),
require_audio=params.get("require_audio", []),
require_video=params.get("require_video", []),
require_subs=params.get("require_subs", []),
forced_subs=params.get("forced_subs", False),
forced_s_lang=params.get("forced_s_lang", []),
+6 -2
View File
@@ -44,6 +44,8 @@ DEFAULT_DOWNLOAD_PARAMS = {
"v_lang": [],
"a_lang": [],
"s_lang": ["all"],
"require_audio": [],
"require_video": [],
"require_subs": [],
"forced_subs": False,
"forced_s_lang": [],
@@ -1044,8 +1046,10 @@ def validate_download_parameters(data: Dict[str, Any]) -> Optional[str]:
if data.get("no_audio") and data.get("audio_only"):
return "Cannot use both no_audio and audio_only"
if data.get("s_lang") and data.get("require_subs"):
return "Cannot use both s_lang and require_subs"
for key in ("require_audio", "require_video", "require_subs"):
value = data.get(key)
if value is not None and (not isinstance(value, list) or not all(isinstance(v, str) for v in value)):
return f"{key} must be an array of language strings"
if "range" in data and data["range"]:
# "HDR10P" is the canonical range value ("+" is awkward in scripts); "HDR10+" stays valid.
+10
View File
@@ -681,6 +681,16 @@ async def download(request: web.Request) -> web.Response:
items:
type: string
description: Language for subtitle tracks (a '-' prefix excludes, e.g. ["all", "-es"]) (default - ["all"])
require_audio:
type: array
items:
type: string
description: Audio languages that must exist, else the job fails (default - [])
require_video:
type: array
items:
type: string
description: Video languages that must exist, else the job fails (default - [])
require_subs:
type: array
items:
+17
View File
@@ -243,6 +243,23 @@ def find_missing_langs(
return [tok for tok in requested if tok not in skip and not match_func(tok, available)]
def missing_required_langs(
required: Sequence[str],
available: Sequence[Union[str, Language, None]],
original: Optional[Union[str, Language]] = None,
*,
exact: bool = False,
) -> list[str]:
"""
Return the required language tokens that match no available track language.
"orig" becomes the title language, and drops out when the title has none. An exclusion
token ("-fr") asks for a language to be absent, so it is never a required language.
"""
wanted, _ = partition_exclusions(list(required))
return find_missing_langs(resolve_sort_langs(wanted, original), available, exact=exact)
def as_requested(tokens: Sequence[str], orig_token: Optional[str]) -> str:
"""Return language tokens as the user wrote them, so 'orig' reads as 'orig' and not the language it resolved to."""
return ", ".join("orig" if tok == orig_token else tok for tok in tokens)