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"`. | | `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. | | `latest_episode` | boolean | `false` | Only the newest episode. |
| `lang` / `v_lang` / `a_lang` / `s_lang` | string[] | `["orig"]` / `[]` / `[]` / `["all"]` | Language filters. | | `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_subs` | boolean | `false` | Include forced subtitles. |
| `forced_s_lang` | string[] | `[]` | Forced subtitle language(s); implies `forced_subs`. | | `forced_s_lang` | string[] | `[]` | Forced subtitle language(s); implies `forced_subs`. |
| `exact_lang` | boolean | `false` | Exact language matching. | | `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. | | `output_dir` | string | `null` | Override output directory. |
| `no_cache` / `reset_cache` | boolean | `false` | Title cache controls. | | `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" !!! danger "Rejected parameters"
`postscript`, `post_script` and `post_scripts` are never accepted. A body containing any `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). | | `-c`, `--channels` | - | Audio channels; matches sub-layouts (5.1 ≈ 6.0). |
| `-naa`, `--noatmos` | off | Exclude Dolby Atmos audio tracks. | | `-naa`, `--noatmos` | off | Exclude Dolby Atmos audio tracks. |
| `--worst` | off | Pick the lowest bitrate within the requested quality. **Requires `-q`.** | | `--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. | | `-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). | | `-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. | | `-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. | | `-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`. | | `-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. | | `-fs`, `--forced-subs` | off | Include forced subtitle tracks. |
| `-fsl`, `--forced-s-lang` | none | Language(s) wanted for forced subtitles; implies `-fs`. A `-` prefix excludes. | | `-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. | | `--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). | | `--slow` | - | Inter-title delay. Bare `--slow` = 60-120s; `--slow 20-40` = custom range (minimum 20s). |
!!! warning "Some flags cannot be combined" !!! 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 | | 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`.** | | `--worst` | Within the specified quality, pick the **lowest** bitrate rendition. **Requires `-q/--quality`.** |
```shell title="Never fail on a missing resolution" ```shell title="Never fail on a missing resolution"
@@ -364,12 +364,42 @@ except Spanish, forced Spanish subtitles included.
### Requiring subtitles ### Requiring subtitles
`--require-subs` takes a list of languages that **must** exist. If they all exist, `--require-subs` takes a list of languages that **must** exist. If any is missing, the
unshackle downloads *all* subtitles. If any is missing, the title fails. This is useful title fails, even under `--best-available` / `--warn-only`. Use it when a download is only
when you want a download only if a specific subtitle track is present. worth keeping if a specific subtitle track is present.
!!! warning "Cannot combine with `--s-lang`" It gates the title. It does not select tracks: `--s-lang` still decides what to keep, and
`--require-subs` and `--s-lang` are mutually exclusive. Use one or the other. 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 ### Forced subtitles and output format
@@ -904,6 +934,8 @@ authoritative list.
| `--video-only` / `--audio-only` / `--subs-only` | `-V` / `-A` / `-S` | Restrict track types. | | `--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. | | `--no-video` / `--no-audio` / `--no-subs` / `--no-chapters` | `-nv` / `-na` / `-ns` / `-nc` | Skip track types. |
| `--worst` | | Lowest bitrate within `-q`. | | `--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. | | `--best-available` | | Degrade gracefully instead of failing. |
| `--output` | `-o` | Output directory for this run. | | `--output` | `-o` | Output directory for this run. |
| `--split-audio` / `--merge-video` / `--no-mux` | | Muxing behaviour. | | `--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. | | `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). | | `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"`. | | `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. | | `proxy` | str | *(unset)* | Default proxy URI or 2-letter country. |
| `no_folder` | bool | `false` | Do not create a per-title folder. | | `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"), ({"video_only": True, "audio_only": True}, "exclusive"),
({"no_subs": True, "subs_only": True}, "no_subs and subs_only"), ({"no_subs": True, "subs_only": True}, "no_subs and subs_only"),
({"no_audio": True, "audio_only": True}, "no_audio and audio_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": "UHD"}, "Invalid range"),
({"range": ["SDR", "UHD"]}, "Invalid range value"), ({"range": ["SDR", "UHD"]}, "Invalid range value"),
], ],
+47 -21
View File
@@ -76,6 +76,7 @@ from unshackle.core.utilities import (
is_exact_match, is_exact_match,
keep_forced_subtitle, keep_forced_subtitle,
log_event, log_event,
missing_required_langs,
partition_exclusions, partition_exclusions,
resolve_sort_langs, resolve_sort_langs,
suggest_font_packages, suggest_font_packages,
@@ -636,6 +637,18 @@ class dl:
default=[], default=[],
help="Language wanted for Audio, overrides -l/--lang for audio tracks. Prefix a value with '-' to exclude it, e.g. 'all,-es'.", 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( @click.option(
"-sl", "-sl",
"--s-lang", "--s-lang",
@@ -647,7 +660,7 @@ class dl:
"--require-subs", "--require-subs",
type=LANGUAGE_RANGE, type=LANGUAGE_RANGE,
default=[], 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("-fs", "--forced-subs", is_flag=True, default=False, help="Include forced subtitle tracks.")
@click.option( @click.option(
@@ -900,10 +913,11 @@ class dl:
) )
@click.option( @click.option(
"--best-available", "--best-available",
"--warn-only",
"best_available", "best_available",
is_flag=True, is_flag=True,
default=False, 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( @click.option(
"--remote", "--remote",
@@ -1430,6 +1444,8 @@ class dl:
v_lang: list[str], v_lang: list[str],
a_lang: list[str], a_lang: list[str],
s_lang: list[str], s_lang: list[str],
require_audio: list[str],
require_video: list[str],
require_subs: list[str], require_subs: list[str],
forced_subs: bool, forced_subs: bool,
forced_s_lang: list[str], 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 # 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))) 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))) 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) ctx = getattr(service, "ctx", None)
parent_params = ctx.parent.params if ctx and ctx.parent else 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) 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: if worst and not quality:
self.log.error("--worst requires -q/--quality to be specified") self.log.error("--worst requires -q/--quality to be specified")
sys.exit(1) sys.exit(1)
@@ -2196,6 +2218,24 @@ class dl:
if no_proxy_download and any(service.session.proxies.values()): if no_proxy_download and any(service.session.proxies.values()):
console.log("Bypassing proxy for downloads as --no-proxy-download was used...") 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: if not keep_videos:
title.tracks.videos = [] title.tracks.videos = []
if not keep_audio: if not keep_audio:
@@ -2557,21 +2597,7 @@ class dl:
s_excl_r, [t.language for t in title.tracks.subtitles], exact_lang 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) title.tracks.select_subtitles(lambda x: str(x.language) not in drop)
if keep_subtitles and require_subs: if keep_subtitles and s_lang and "all" not in s_lang:
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:
match_func = is_exact_match if exact_lang else is_close_match match_func = is_exact_match if exact_lang else is_close_match
missing_langs = find_missing_langs( missing_langs = find_missing_langs(
+2
View File
@@ -563,6 +563,8 @@ def perform_download(
v_lang=params.get("v_lang", []), v_lang=params.get("v_lang", []),
a_lang=params.get("a_lang", []), a_lang=params.get("a_lang", []),
s_lang=params.get("s_lang", ["all"]), 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", []), require_subs=params.get("require_subs", []),
forced_subs=params.get("forced_subs", False), forced_subs=params.get("forced_subs", False),
forced_s_lang=params.get("forced_s_lang", []), forced_s_lang=params.get("forced_s_lang", []),
+6 -2
View File
@@ -44,6 +44,8 @@ DEFAULT_DOWNLOAD_PARAMS = {
"v_lang": [], "v_lang": [],
"a_lang": [], "a_lang": [],
"s_lang": ["all"], "s_lang": ["all"],
"require_audio": [],
"require_video": [],
"require_subs": [], "require_subs": [],
"forced_subs": False, "forced_subs": False,
"forced_s_lang": [], "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"): if data.get("no_audio") and data.get("audio_only"):
return "Cannot use both no_audio and audio_only" return "Cannot use both no_audio and audio_only"
if data.get("s_lang") and data.get("require_subs"): for key in ("require_audio", "require_video", "require_subs"):
return "Cannot use both s_lang and 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"]: if "range" in data and data["range"]:
# "HDR10P" is the canonical range value ("+" is awkward in scripts); "HDR10+" stays valid. # "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: items:
type: string type: string
description: Language for subtitle tracks (a '-' prefix excludes, e.g. ["all", "-es"]) (default - ["all"]) 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: require_subs:
type: array type: array
items: 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)] 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: 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 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) return ", ".join("orig" if tok == orig_token else tok for tok in tokens)