mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-15 13:35:05 +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:
@@ -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
|
||||
Reference in New Issue
Block a user