From d8739b356117ce62be2b90824af2b23c91b7d71c Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Sun, 23 Aug 2026 12:24:45 -0600 Subject: [PATCH] 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. --- docs/dev/rest-api/endpoints.md | 6 +- docs/guide/cli-reference.md | 8 +- docs/guide/downloading.md | 44 ++++- docs/reference/configuration/download.md | 2 +- tests/lang_selection/test_required_langs.py | 151 ++++++++++++++++++ .../test_required_langs_gate.py | 73 +++++++++ tests/remote/unit/test_handlers_serialize.py | 2 +- unshackle/commands/dl.py | 68 +++++--- unshackle/core/api/download_manager.py | 2 + unshackle/core/api/handlers.py | 8 +- unshackle/core/api/routes.py | 10 ++ unshackle/core/utilities.py | 17 ++ 12 files changed, 355 insertions(+), 36 deletions(-) create mode 100644 tests/lang_selection/test_required_langs.py create mode 100644 tests/lang_selection/test_required_langs_gate.py diff --git a/docs/dev/rest-api/endpoints.md b/docs/dev/rest-api/endpoints.md index 3f3dc07e..c9e420ed 100644 --- a/docs/dev/rest-api/endpoints.md +++ b/docs/dev/rest-api/endpoints.md @@ -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 diff --git a/docs/guide/cli-reference.md b/docs/guide/cli-reference.md index 33071cda..46cec04c 100644 --- a/docs/guide/cli-reference.md +++ b/docs/guide/cli-reference.md @@ -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. --- diff --git a/docs/guide/downloading.md b/docs/guide/downloading.md index 64d1949e..efdb6888 100644 --- a/docs/guide/downloading.md +++ b/docs/guide/downloading.md @@ -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. | diff --git a/docs/reference/configuration/download.md b/docs/reference/configuration/download.md index de5f599d..85682d0c 100644 --- a/docs/reference/configuration/download.md +++ b/docs/reference/configuration/download.md @@ -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. | diff --git a/tests/lang_selection/test_required_langs.py b/tests/lang_selection/test_required_langs.py new file mode 100644 index 00000000..9bd8afcf --- /dev/null +++ b/tests/lang_selection/test_required_langs.py @@ -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 diff --git a/tests/lang_selection/test_required_langs_gate.py b/tests/lang_selection/test_required_langs_gate.py new file mode 100644 index 00000000..ed9a395b --- /dev/null +++ b/tests/lang_selection/test_required_langs_gate.py @@ -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 diff --git a/tests/remote/unit/test_handlers_serialize.py b/tests/remote/unit/test_handlers_serialize.py index e04959ca..3fa06ea3 100644 --- a/tests/remote/unit/test_handlers_serialize.py +++ b/tests/remote/unit/test_handlers_serialize.py @@ -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"), ], diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 15da8124..7d2884e2 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -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( diff --git a/unshackle/core/api/download_manager.py b/unshackle/core/api/download_manager.py index bcda3e73..d1fda532 100644 --- a/unshackle/core/api/download_manager.py +++ b/unshackle/core/api/download_manager.py @@ -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", []), diff --git a/unshackle/core/api/handlers.py b/unshackle/core/api/handlers.py index de4115fe..49f477ab 100644 --- a/unshackle/core/api/handlers.py +++ b/unshackle/core/api/handlers.py @@ -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. diff --git a/unshackle/core/api/routes.py b/unshackle/core/api/routes.py index ef839e29..19c2f4d7 100644 --- a/unshackle/core/api/routes.py +++ b/unshackle/core/api/routes.py @@ -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: diff --git a/unshackle/core/utilities.py b/unshackle/core/utilities.py index 41b5c001..c1cbe3f3 100644 --- a/unshackle/core/utilities.py +++ b/unshackle/core/utilities.py @@ -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)