mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-12 20:15:10 +00:00
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:
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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. |
|
||||||
|
|||||||
@@ -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. |
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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", []),
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user