From 3477d09cc2fde53218b46e99d1aff391ec375867 Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Tue, 6 Oct 2026 12:45:52 -0600 Subject: [PATCH] feat(dl): add --audio-description-only and --forced-subs-only Each keeps one kind of track inside its track type. A run of more than one title skips a title that has no such track. The REST API rejects a non-boolean value for a boolean dl param, and the DASH parser reads audio description from Role elements. --- docs/dev/rest-api/endpoints.md | 2 + docs/dev/rest-api/remote-sessions.md | 5 +- docs/guide/cli-reference.md | 2 + docs/guide/downloading.md | 22 +++ docs/guide/subtitles.md | 12 ++ docs/reference/configuration/download.md | 3 + tests/core/test_dash_descriptive_audio.py | 10 ++ tests/lang_selection/test_kind_only_flags.py | 90 +++++++++++ .../test_kind_only_selection.py | 149 ++++++++++++++++++ tests/lang_selection/test_required_langs.py | 17 +- .../test_required_langs_gate.py | 2 +- unshackle/commands/dl.py | 92 +++++++++-- unshackle/core/api/download_manager.py | 2 + unshackle/core/api/handlers.py | 11 +- unshackle/core/api/routes.py | 6 + unshackle/core/manifests/dash.py | 2 +- unshackle/core/utilities.py | 25 ++- 17 files changed, 426 insertions(+), 26 deletions(-) create mode 100644 tests/lang_selection/test_kind_only_flags.py create mode 100644 tests/lang_selection/test_kind_only_selection.py diff --git a/docs/dev/rest-api/endpoints.md b/docs/dev/rest-api/endpoints.md index 2d9f7b7a..a4e117c1 100644 --- a/docs/dev/rest-api/endpoints.md +++ b/docs/dev/rest-api/endpoints.md @@ -435,12 +435,14 @@ Make a download job. It requires `service` and `title_id`. Every other field is | `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_only` | boolean | `false` | Download forced subtitles and no other subtitle; implies `forced_subs`. Not valid with `no_subs`, `video_only`, `audio_only` or `chapters_only`. | | `forced_s_lang` | string[] | `[]` | Forced subtitle language(s); implies `forced_subs`. | | `exact_lang` | boolean | `false` | Exact language matching. | | `sub_format` | string | `null` | Output subtitle format. | | `video_only` / `audio_only` / `subs_only` / `chapters_only` | boolean | `false` | Track-type restriction (at most one). | | `no_subs` / `no_audio` / `no_chapters` / `no_video` / `no_attachments` | boolean | `false` | Skip a track type. | | `audio_description` | boolean | `false` | Download audio description. | +| `audio_description_only` | boolean | `false` | Download audio description and no standard audio. Not valid with `no_audio`, `video_only`, `subs_only` or `chapters_only`. | | `slow` | boolean/string | `null` | Randomized delay between downloads. | | `split_audio` | boolean | `null` | Separate files per audio codec. | | `skip_dl` | boolean | `false` | Only fetch keys, do not download. | diff --git a/docs/dev/rest-api/remote-sessions.md b/docs/dev/rest-api/remote-sessions.md index 3792b75f..6ef54c39 100644 --- a/docs/dev/rest-api/remote-sessions.md +++ b/docs/dev/rest-api/remote-sessions.md @@ -438,8 +438,9 @@ server drops an unknown codec name. An empty array is a valid value and stays empty. The server does not forward the `dl` mode flags: `video_only`, `audio_only`, -`subs_only`, `chapters_only`, `list_`, `skip_dl`, `sub_format` and -`no_attachments`. A service that reads one of them to skip work fetches more on +`subs_only`, `chapters_only`, `list_`, `skip_dl`, `sub_format`, +`no_attachments`, `audio_description`, `audio_description_only` and +`forced_subs_only`. A service that reads one of them to skip work fetches more on the server, but the result is the same. In a client-login session (not a server account), the server gives the service diff --git a/docs/guide/cli-reference.md b/docs/guide/cli-reference.md index 38b3025b..214f3d39 100644 --- a/docs/guide/cli-reference.md +++ b/docs/guide/cli-reference.md @@ -102,6 +102,7 @@ unshackle dl [OPTIONS] SERVICE [SERVICE ARGS...] | `--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. | +| `-fso`, `--forced-subs-only` | off | Keep only forced subtitle tracks; drop the other subtitles. Implies `-fs`. | | `-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. | | `--sub-format` | - | Output subtitle format (`SRT`/`srt`, `VTT`/`webvtt`, `ASS`/`ssa`, `TTML`, `SMI`, ...), or `original` to keep the source format. | @@ -139,6 +140,7 @@ Keep only certain track types, or skip certain track types. | Flag | Description | |---|---| | `-ad`, `--audio-description` | Include descriptive (audio-description) tracks. | +| `-ado`, `--audio-description-only` | Keep only descriptive (audio-description) tracks; drop the standard audio. Does not need `-ad`. | | `--skip-subtitle-errors` | Skip a failed subtitle instead of aborting the title. Video/audio failures remain fatal. | ### Output, muxing & files diff --git a/docs/guide/downloading.md b/docs/guide/downloading.md index 7a3caeaa..e710ee02 100644 --- a/docs/guide/downloading.md +++ b/docs/guide/downloading.md @@ -416,6 +416,8 @@ video track counts. `--no-audio` and `--no-video` drop the matching requirement. `-fs`). Works independently of `--s-lang`, so `-sl all -fsl en` grabs every full subtitle but only the English forced track. It accepts exclusions too: `-fsl all,-es` keeps every forced subtitle except the Spanish one, and `-fsl -es` means the same. +- `-fso` / `--forced-subs-only`: keep only the forced subtitle tracks and drop every + other subtitle (implies `-fs`). `--s-lang` and `-fsl` still select the languages. - `--sub-format`: set the output subtitle format, converting only when necessary. Accepts codec names/values and common aliases (`srt`, `vtt`, `ass`, `ssa`, `ttml`, and the other codec aliases), or the literal `original` to keep the source format. @@ -637,9 +639,29 @@ Additional track-type flags: - `-ad` / `--audio-description`: include descriptive (audio-description) tracks, which unshackle drops by default. +- `-ado` / `--audio-description-only`: keep only the descriptive tracks and drop the + standard audio. It does not need `-ad`. Add `-A` to download no video or subtitles. - `--skip-subtitle-errors`: if a subtitle fails to download, skip it and continue rather than aborting the whole title. Video and audio failures remain fatal. +!!! note "The `-only` kind flags" + `-ado` and `-fso` each keep one kind of track inside its track type. They do not drop + the other track types: `-ado` still downloads the video, and `-fso` still downloads + video and audio. Add `-A` or `-S` for that. + + - The language flags still apply. With the default `-l orig`, `-ado` fails when the + descriptive track is not in the original language. Select the language with `-al`. + - A flag that drops the same tracks is an error, for example `-ado` with `--no-audio` + or `-fso` with `-A`. + - A title with no track of that kind stops a run of one title. A run of more than one + title skips that title with a warning and continues. + - unshackle reads the kind from the manifest or from the service. A track that + neither of them marks as descriptive or forced does not match. + +```shell title="Audio description only, in English" +unshackle dl -A -ado -al en EXAMPLE 81234567 +``` + ```shell title="Subtitles only" unshackle dl -S -sl en EXAMPLE 81234567 ``` diff --git a/docs/guide/subtitles.md b/docs/guide/subtitles.md index 319f29c1..637057c2 100644 --- a/docs/guide/subtitles.md +++ b/docs/guide/subtitles.md @@ -74,6 +74,7 @@ Subtitle selection happens through `dl` flags. The most important is language se | `-sl`, `--s-lang` | Language(s) wanted for subtitles. Defaults to `all`. A `-` prefix excludes, e.g. `all,-es`. | | `--require-subs` | Require these languages to exist; if present, download **all** subtitles. Cannot be combined with `--s-lang`. | | `-fs`, `--forced-subs` | Include forced subtitle tracks (excluded by default). | +| `-fso`, `--forced-subs-only` | Keep only forced subtitle tracks and drop the other subtitles; implies `-fs`. | | `-fsl`, `--forced-s-lang` | Language(s) wanted for forced subtitles; implies `-fs`. A `-` prefix excludes. | | `--exact-lang` | Exact language matching, with no regional variants. | | `-S`, `--subs-only` | Download only subtitle tracks. | @@ -138,6 +139,17 @@ If you pass both flags, `-fsl` wins and keeps only forced tracks in its language It also understands `orig` (the title's original language) and `all` (every forced track, same as plain `-fs`). +To download the forced tracks and no other subtitle, use `-fso` / `--forced-subs-only`. +It implies `-fs`, and `--s-lang` and `-fsl` still select the languages: + +```shell title="Only the English forced track" +unshackle dl --s-lang en --forced-subs-only EXAMPLE 81234567 +``` + +`-fso` does not drop video or audio. Add `-S` for a subtitle-only download. A title with +no forced track stops a run of one title. A run of more than one title skips that title +with a warning. + ## Track types: forced, SDH, and CC Every subtitle track carries flags describing what kind of captions it holds. These diff --git a/docs/reference/configuration/download.md b/docs/reference/configuration/download.md index e5dc2bcb..2267c02e 100644 --- a/docs/reference/configuration/download.md +++ b/docs/reference/configuration/download.md @@ -43,6 +43,9 @@ Common config keys. This is a useful subset, and every `dl` flag works: | `sub_format` | str | *(unset)* | Convert subtitles to this format (`srt`, `vtt`, `original`, ...). | | `forced_subs` | bool | `false` | Include forced subtitle tracks. | | `forced_s_lang` | list | `[]` | Forced subtitle language(s); implies `forced_subs`. A `-` prefix excludes. | +| `forced_subs_only` | bool | `false` | Keep only forced subtitle tracks; implies `forced_subs`. | +| `audio_description` | bool | `false` | Include descriptive (audio-description) tracks. | +| `audio_description_only` | bool | `false` | Keep only descriptive (audio-description) tracks. | | `no_subs` / `no_audio` / `no_chapters` / `no_attachments` | bool | `false` | Skip that track type. `no_attachments` also skips attaching subtitle fonts. | | `downloads` | int | `1` | Tracks downloaded concurrently. | | `workers` | int | *(downloader default)* | Threads per track. | diff --git a/tests/core/test_dash_descriptive_audio.py b/tests/core/test_dash_descriptive_audio.py index 06da4976..dfd05f1b 100644 --- a/tests/core/test_dash_descriptive_audio.py +++ b/tests/core/test_dash_descriptive_audio.py @@ -37,3 +37,13 @@ def test_non_descriptive_accessibility_values(scheme: str, value: str) -> None: def test_no_accessibility_element() -> None: assert DASH.is_descriptive(ET.fromstring("")) is False + + +def test_descriptive_role_element() -> None: + # some manifests mark audio description with a Role and no Accessibility element + element = ET.fromstring( + '' + ) + assert DASH.is_descriptive(element) is True + main = ET.fromstring('') + assert DASH.is_descriptive(main) is False diff --git a/tests/lang_selection/test_kind_only_flags.py b/tests/lang_selection/test_kind_only_flags.py new file mode 100644 index 00000000..8e41cb27 --- /dev/null +++ b/tests/lang_selection/test_kind_only_flags.py @@ -0,0 +1,90 @@ +"""The kind-only flags: --audio-description-only and --forced-subs-only. + +The filters in dl.result are not extractable without executing the whole download loop, so +their wiring is pinned against the source, the same way test_required_langs_gate does it. +""" + +from __future__ import annotations + +import inspect + +import pytest + +from unshackle.commands.dl import dl +from unshackle.core.api.handlers import validate_download_parameters +from unshackle.core.utilities import kind_only_conflict + +KEYS = ["audio_description_only", "forced_subs_only"] +BASE = {"service": "EXAMPLE", "title": "x"} + + +@pytest.mark.parametrize( + "args,only_key,base_key,expected", + [ + (["-ado"], "audio_description_only", "audio_description", (True, False)), + (["-ad"], "audio_description_only", "audio_description", (False, True)), + ([], "audio_description_only", "audio_description", (False, False)), + (["-fso"], "forced_subs_only", "forced_subs", (True, False)), + (["-fs"], "forced_subs_only", "forced_subs", (False, True)), + ], +) +def test_only_flag_does_not_parse_as_its_base_flag(args, only_key, base_key, expected): + params = dl.cli.make_context("dl", args, resilient_parsing=True).params + assert (params[only_key], params[base_key]) == expected + + +@pytest.mark.parametrize( + "params,expected", + [ + ({"audio_description_only": True, "audio_only": True}, None), + ({"audio_description_only": True}, None), + ({"audio_description_only": True, "no_audio": True}, ("audio_description_only", "no_audio")), + ({"audio_description_only": True, "video_only": True}, ("audio_description_only", "video_only")), + ({"forced_subs_only": True, "subs_only": True}, None), + ({"forced_subs_only": True, "no_subs": True}, ("forced_subs_only", "no_subs")), + ({"forced_subs_only": True, "audio_only": True}, ("forced_subs_only", "audio_only")), + ({"no_audio": True, "video_only": True}, None), + # the *_only track-type flags combine: -A -S keeps audio, so -ado is not dropped + ({"audio_description_only": True, "audio_only": True, "subs_only": True}, None), + ({"forced_subs_only": True, "subs_only": True, "audio_only": True}, None), + ({"audio_description_only": True, "subs_only": True}, ("audio_description_only", "subs_only")), + ], +) +def test_kind_only_conflict(params, expected): + assert kind_only_conflict(params) == expected + + +def test_api_rejects_a_conflict(): + assert validate_download_parameters({**BASE, "audio_description_only": True, "no_audio": True}) == ( + "Cannot use both audio_description_only and no_audio" + ) + assert validate_download_parameters({**BASE, "audio_description_only": True, "audio_only": True}) is None + + +@pytest.mark.parametrize("key", [*KEYS, "audio_description", "audio_only"]) +def test_api_rejects_a_non_boolean_flag(key): + # the string "false" is truthy in dl.result, so it would turn the flag on + assert validate_download_parameters({**BASE, key: "false"}) == f"{key} must be a boolean" + assert validate_download_parameters({**BASE, key: None}) is None + + +class TestFilterWiring: + source = inspect.getsource(dl.result) + + def test_ado_runs_when_the_title_has_no_separate_audio(self): + # embedded audio is standard audio: the filter must sit before the `len(...) > 0` guard + ado = self.source.index("if keep_audio and audio_description_only:") + guard = self.source.index("if keep_audio and len(title.tracks.audio) > 0:") + assert ado < guard + + def test_ado_does_not_count_embedded_audio(self): + # both the --require-audio gate and the missing-language check + assert self.source.count("keep_videos and not audio_description_only") == 2 + + def test_only_filters_keep_one_kind(self): + assert "title.tracks.select_audio(lambda x: x.descriptive)" in self.source + assert "title.tracks.select_subtitles(lambda x: x.forced)" in self.source + + def test_fso_implies_forced_subs(self): + # services read forced_subs from the ctx params to fetch forced tracks at all + assert "if forced_s_lang or fsl_excl or forced_subs_only:" in self.source diff --git a/tests/lang_selection/test_kind_only_selection.py b/tests/lang_selection/test_kind_only_selection.py new file mode 100644 index 00000000..544f358c --- /dev/null +++ b/tests/lang_selection/test_kind_only_selection.py @@ -0,0 +1,149 @@ +"""Run the kind-only filters through the real dl.result() selection loop. + +A fake service hands in-memory tracks to the loop, and the run stops at the first +title that reaches ``Tracks.tree``, the step right after selection. A title with no +track of the kind never gets there, so what the hook records is what the run kept. +""" + +from __future__ import annotations + +import inspect +import logging +from typing import Any, cast + +import pytest + +from unshackle.commands.dl import dl +from unshackle.core.api.handlers import DEFAULT_DOWNLOAD_PARAMS +from unshackle.core.service import Service +from unshackle.core.titles import Episode, Series +from unshackle.core.tracks import Audio, Subtitle, Tracks, Video + + +class Selected(BaseException): + """Carries the kept track ids out of the loop.""" + + def __init__(self, tracks: Tracks) -> None: + self.ids = { + "video": [t.id for t in tracks.videos], + "audio": [t.id for t in tracks.audio], + "subs": [t.id for t in tracks.subtitles], + } + + +class Harness(dl): + def __getattr__(self, name: str) -> None: + # every optional dl attribute (tmdb ids, cdm, vaults, ...) reads as unset + return None + + +class FakeService: + """One episode per entry; ``ad`` adds a descriptive track, ``forced`` a forced subtitle.""" + + def __init__(self, episodes: dict[int, dict[str, bool]]) -> None: + self.episodes = episodes + self.session = None + + def authenticate(self, cookies: Any = None, credential: Any = None) -> None: + pass + + def get_titles_cached(self) -> Series: + return Series( + Episode(id_=f"show-s01-e{n:03}", service=FakeService, title="Show", season=1, number=n, language="en") + for n in self.episodes + ) + + def get_tracks(self, title: Episode) -> Tracks: + kinds = self.episodes[title.number] + tracks = Tracks() + tracks.add(Video(id_="v", url="https://x/v", language="en", codec=Video.Codec.AVC, range_=Video.Range.SDR)) + tracks.add(Audio(id_="a", url="https://x/a", language="en", codec=Audio.Codec.AAC, bitrate=128_000)) + if kinds.get("ad"): + tracks.add( + Audio(id_="ad", url="https://x/ad", language="en", codec=Audio.Codec.AAC, bitrate=1, descriptive=True) + ) + tracks.add(Subtitle(id_="s", url="https://x/s", language="en", codec=Subtitle.Codec.WebVTT)) + if kinds.get("forced"): + tracks.add(Subtitle(id_="sf", url="https://x/sf", language="en", codec=Subtitle.Codec.WebVTT, forced=True)) + return tracks + + def get_chapters(self, title: Episode) -> list: + return [] + + def __getattr__(self, name: str) -> Any: + if name.startswith("on_"): + # the event hooks (on_track_downloaded, ...) the loop subscribes + return lambda *a, **k: None + raise AttributeError(name) + + +def run(monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, episodes: dict, **flags: Any) -> Any: + """Return the Selected ids, the exit code, or None for a run that completes.""" + monkeypatch.setattr(Tracks, "tree", lambda self, *a, **k: (_ for _ in ()).throw(Selected(self))) + cmd = Harness.__new__(Harness) + cmd.log = logging.getLogger("test_kind_only") + cmd.service = "FAKE" + cmd.tmdb_searched = True + cmd.completed_files = [] + kwargs: dict[str, Any] = {} + for name, param in inspect.signature(dl.result).parameters.items(): + if name in DEFAULT_DOWNLOAD_PARAMS: + kwargs[name] = DEFAULT_DOWNLOAD_PARAMS[name] + elif name not in ("self", "service"): + kwargs[name] = None if param.default is inspect.Parameter.empty else param.default + kwargs.update(quality=[], vcodec=[], acodec=[], range_=[], wanted=[], cdm_only=None, downloads=1) + kwargs.update(flags) + caplog.set_level(logging.WARNING, logger="test_kind_only") + try: + cmd.result(cast(Service, FakeService(episodes)), **kwargs) + except Selected as sel: + return sel.ids + except SystemExit as exc: + return exc.code + return None + + +def test_ado_keeps_the_descriptive_track_and_the_other_types( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + ids = run(monkeypatch, caplog, {1: {"ad": True, "forced": True}}, audio_description_only=True) + assert ids == {"video": ["v"], "audio": ["ad"], "subs": ["s"]} + + +def test_fso_keeps_the_forced_track_and_the_other_types( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + ids = run(monkeypatch, caplog, {1: {"ad": True, "forced": True}}, forced_subs_only=True) + assert ids == {"video": ["v"], "audio": ["a"], "subs": ["sf"]} + + +def test_only_flags_with_their_type_flags_give_a_single_kind( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + ids = run(monkeypatch, caplog, {1: {"ad": True, "forced": True}}, audio_description_only=True, audio_only=True) + assert ids == {"video": [], "audio": ["ad"], "subs": []} + ids = run(monkeypatch, caplog, {1: {"ad": True, "forced": True}}, forced_subs_only=True, subs_only=True) + assert ids == {"video": [], "audio": [], "subs": ["sf"]} + + +@pytest.mark.parametrize("flag", ["audio_description_only", "forced_subs_only"]) +def test_a_single_title_with_no_such_track_exits( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, flag: str +) -> None: + assert run(monkeypatch, caplog, {1: {}}, **{flag: True}) == 1 + assert "Skipping" not in caplog.text + + +def test_a_multi_title_run_skips_the_title_and_continues( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + ids = run(monkeypatch, caplog, {1: {}, 2: {"ad": True}}, audio_description_only=True) + assert ids["audio"] == ["ad"] + assert "Skipping Show S01E01" in caplog.text + + +def test_a_multi_title_run_with_every_title_skipped_completes( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + assert run(monkeypatch, caplog, {1: {}, 2: {}}, forced_subs_only=True) is None + assert caplog.text.count("Skipping Show S01E") == 2 diff --git a/tests/lang_selection/test_required_langs.py b/tests/lang_selection/test_required_langs.py index 9bd8afcf..a2016305 100644 --- a/tests/lang_selection/test_required_langs.py +++ b/tests/lang_selection/test_required_langs.py @@ -5,6 +5,7 @@ import inspect import pytest from unshackle.commands.dl import dl +from unshackle.core.api import download_manager from unshackle.core.api.handlers import DEFAULT_DOWNLOAD_PARAMS from unshackle.core.utilities import missing_required_langs @@ -102,15 +103,15 @@ def test_api_accepts_the_require_flags(): ) -@pytest.mark.parametrize("key", ["require_audio", "require_video"]) -def test_api_reaches_dl_result(key, monkeypatch): +@pytest.mark.parametrize( + "key,default", + [("require_audio", []), ("require_video", []), ("audio_description_only", False), ("forced_subs_only", False)], +) +def test_api_reaches_dl_result(key, default): # 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 + assert DEFAULT_DOWNLOAD_PARAMS[key] == default + assert key in inspect.signature(dl.result).parameters + assert f'{key}=params.get("{key}", {default!r})' in inspect.getsource(download_manager) @pytest.mark.parametrize( diff --git a/tests/lang_selection/test_required_langs_gate.py b/tests/lang_selection/test_required_langs_gate.py index ed9a395b..f8c1da0f 100644 --- a/tests/lang_selection/test_required_langs_gate.py +++ b/tests/lang_selection/test_required_langs_gate.py @@ -48,7 +48,7 @@ class TestGateWiring: 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 + assert "embedded_audio_langs(title.tracks.videos, keep_videos and not audio_description_only)" in self.source def test_gate_honours_exact_lang(self): assert "missing_required_langs(required, available, title.language, exact=exact_lang)" in self.source diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 8af8d748..4cd959cc 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -93,6 +93,7 @@ from unshackle.core.utilities import ( is_close_match, is_exact_match, keep_forced_subtitle, + kind_only_conflict, log_event, missing_required_langs, partition_exclusions, @@ -756,6 +757,13 @@ class dl: 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( + "-fso", + "--forced-subs-only", + is_flag=True, + default=False, + help="Download forced subtitle tracks and no other subtitle tracks.", + ) @click.option( "-fsl", "--forced-s-lang", @@ -882,6 +890,13 @@ class dl: help="Do not download or mux attachments (cover art, subtitle fonts, and files the service attaches).", ) @click.option("-ad", "--audio-description", is_flag=True, default=False, help="Download audio description tracks.") + @click.option( + "-ado", + "--audio-description-only", + is_flag=True, + default=False, + help="Download audio description tracks and no standard audio tracks.", + ) @click.option( "--slow", type=SLOW_DELAY_RANGE, @@ -1523,6 +1538,7 @@ class dl: require_video: list[str], require_subs: list[str], forced_subs: bool, + forced_subs_only: bool, forced_s_lang: list[str], exact_lang: bool, sub_format: Optional[Union[Subtitle.Codec, str]], @@ -1537,6 +1553,7 @@ class dl: no_video: bool, no_attachments: bool, audio_description: bool, + audio_description_only: bool, slow: Optional[tuple[int, int]], list_: bool, list_titles: bool, @@ -1639,7 +1656,27 @@ class dl: if excludes and name in parent_params: parent_params[name] = includes - if forced_s_lang or fsl_excl: + conflict = kind_only_conflict( + { + "audio_description_only": audio_description_only, + "forced_subs_only": forced_subs_only, + "no_audio": no_audio, + "no_subs": no_subs, + "video_only": video_only, + "audio_only": audio_only, + "subs_only": subs_only, + "chapters_only": chapters_only, + } + ) + if conflict: + first, second = (f"--{name.replace('_', '-')}" for name in conflict) + self.log.error(f"{first} cannot be used with {second}: {second} drops the tracks that {first} selects.") + sys.exit(1) + + if audio_description_only: + audio_description = True + + if forced_s_lang or fsl_excl or forced_subs_only: # services read forced_subs from raw ctx params, so the implication must be pushed there too forced_subs = True if "all" in forced_s_lang: @@ -2044,6 +2081,27 @@ class dl: post_script_last: dict[Any, dict[Path, dict[str, str]]] = {} post_script_folders: list[Path] = [] post_script_sample: dict[str, str] = {} + wanted_total = sum(post_script_pending.values()) + + def settle_post_script_group(title: Any) -> None: + """Count one title of its group as done; send the season event after the last one.""" + group_key = post_script_group(title) + post_script_pending[group_key] = post_script_pending.get(group_key, 1) - 1 + if post_script_pending[group_key] <= 0: + for folder, context in post_script_last.get(group_key, {}).items(): + dispatch("success", "season", season_context(context, folder), postscript) + + def skip_title(title: Any, reason: str) -> None: + """Skip a title that has no track of the kind an only flag selects. + + A run with one title stops instead, because it has nothing else to download. + """ + if wanted_total <= 1: + self.log.error(reason) + sys.exit(1) + self.log.warning(f"{reason} Skipping {title}") + if not no_mux: + settle_post_script_group(title) for i, title in enumerate(titles): v_lang, a_lang, s_lang, range_ = base_selection @@ -2316,13 +2374,14 @@ class dl: ( "audio", require_audio if keep_audio else [], - [a.language for a in title.tracks.audio] + embedded_audio_langs(title.tracks.videos, keep_videos), + [a.language for a in title.tracks.audio if a.descriptive or not audio_description_only] + + embedded_audio_langs(title.tracks.videos, keep_videos and not audio_description_only), ), ("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], + [t.language for t in title.tracks.subtitles if t.forced or not forced_subs_only], ), ): missing_required = missing_required_langs(required, available, title.language, exact=exact_lang) @@ -2749,7 +2808,17 @@ class dl: fsl_excl_r, [t.language for t in title.tracks.subtitles if t.forced], exact_lang ) title.tracks.select_subtitles(lambda x: not (x.forced and str(x.language) in drop)) + if forced_subs_only: + title.tracks.select_subtitles(lambda x: x.forced) + if not title.tracks.subtitles: + skip_title(title, "There's no forced subtitle track for the selected languages.") + continue + if keep_audio and audio_description_only: + title.tracks.select_audio(lambda x: x.descriptive) + if not title.tracks.audio: + skip_title(title, "There's no audio description track.") + continue # might have no audio tracks if part of the video, e.g. transport stream hls if keep_audio and len(title.tracks.audio) > 0: if not audio_description: @@ -2818,7 +2887,10 @@ class dl: if a_orig_token in audio_languages: a_orig_token = None - embedded_langs = embedded_audio_langs(title.tracks.videos, keep_videos) + embedded_langs = embedded_audio_langs( + title.tracks.videos, keep_videos and not audio_description_only + ) + audio_noun = "audio description tracks" if audio_description_only else "audio tracks" if not any(tok in processed_lang for tok in ("best", "all")): missing_a_langs = find_missing_langs( @@ -2832,17 +2904,17 @@ class dl: remaining = [tok for tok in processed_lang if tok not in missing_a_langs] if remaining: self.log.warning( - f"{missing_str} not found in audio tracks, " + f"{missing_str} not found in {audio_noun}, " f"continuing with: {as_requested(remaining, a_orig_token)}" ) processed_lang = remaining else: self.log.error( - f"{missing_str} not found in audio tracks and no fallback available" + f"{missing_str} not found in {audio_noun} and no fallback available" ) sys.exit(1) else: - self.log.error(missing_str + " not found in audio tracks") + self.log.error(f"{missing_str} not found in {audio_noun}") sys.exit(1) title.tracks.audio = select_best_audio( @@ -3700,11 +3772,7 @@ class dl: post_script_folders.append(final_path.parent) if not no_mux: - group_key = post_script_group(title) - post_script_pending[group_key] = post_script_pending.get(group_key, 1) - 1 - if post_script_pending[group_key] <= 0: - for folder, context in post_script_last.get(group_key, {}).items(): - dispatch("success", "season", season_context(context, folder), postscript) + settle_post_script_group(title) title_dl_time = time_elapsed_since(dl_start_time) downloaded_label = "Track" if isinstance(title, Song) else "Title" diff --git a/unshackle/core/api/download_manager.py b/unshackle/core/api/download_manager.py index fc04a574..7ac2c0e0 100644 --- a/unshackle/core/api/download_manager.py +++ b/unshackle/core/api/download_manager.py @@ -601,6 +601,7 @@ def perform_download( require_video=params.get("require_video", []), require_subs=params.get("require_subs", []), forced_subs=ctx.params["forced_subs"], + forced_subs_only=params.get("forced_subs_only", False), forced_s_lang=params.get("forced_s_lang", []), exact_lang=params.get("exact_lang", False), sub_format=params.get("sub_format"), @@ -615,6 +616,7 @@ def perform_download( no_video=params.get("no_video", False), no_attachments=params.get("no_attachments", False), audio_description=params.get("audio_description", False), + audio_description_only=params.get("audio_description_only", False), slow=params.get("slow", None), list_=False, list_titles=False, diff --git a/unshackle/core/api/handlers.py b/unshackle/core/api/handlers.py index aeace2cb..1a4f343f 100644 --- a/unshackle/core/api/handlers.py +++ b/unshackle/core/api/handlers.py @@ -33,7 +33,7 @@ from unshackle.core.proxies.resolve import initialize_proxy_providers, resolve_p from unshackle.core.services import Services from unshackle.core.titles import Episode, Movie, Song, Title_T from unshackle.core.tracks import Audio, Subtitle, Tracks, Video -from unshackle.core.utilities import declared_kwargs +from unshackle.core.utilities import declared_kwargs, kind_only_conflict from unshackle.core.utils.click_types import AUDIO_CODEC_LIST, SUBTITLE_CODEC, VIDEO_CODEC_LIST from unshackle.core.utils.collections import ci_get from unshackle.core.utils.redact import REDACTED, URL_USERINFO_RE, redact_all, redact_secrets, redact_text @@ -64,6 +64,7 @@ DEFAULT_DOWNLOAD_PARAMS = { "require_video": [], "require_subs": [], "forced_subs": False, + "forced_subs_only": False, "forced_s_lang": [], "exact_lang": False, "sub_format": None, @@ -77,6 +78,7 @@ DEFAULT_DOWNLOAD_PARAMS = { "no_video": False, "no_attachments": False, "audio_description": False, + "audio_description_only": False, "slow": None, "split_audio": None, "skip_dl": False, @@ -1925,6 +1927,13 @@ def validate_download_parameters(data: Dict[str, Any]) -> Optional[str]: return "Cannot use both no_subs and subs_only" if data.get("no_audio") and data.get("audio_only"): return "Cannot use both no_audio and audio_only" + conflict = kind_only_conflict(data) + if conflict: + return f"Cannot use both {conflict[0]} and {conflict[1]}" + + for key, default in DEFAULT_DOWNLOAD_PARAMS.items(): + if isinstance(default, bool) and data.get(key) is not None and not isinstance(data[key], bool): + return f"{key} must be a boolean" for key in ("require_audio", "require_video", "require_subs"): value = data.get(key) diff --git a/unshackle/core/api/routes.py b/unshackle/core/api/routes.py index 8b610e83..509ab021 100644 --- a/unshackle/core/api/routes.py +++ b/unshackle/core/api/routes.py @@ -895,6 +895,9 @@ async def download(request: web.Request) -> web.Response: forced_subs: type: boolean description: Include forced subtitle tracks (default - false) + forced_subs_only: + type: boolean + description: Download forced subtitle tracks and no other subtitle tracks, implies forced_subs (default - false) forced_s_lang: type: array items: @@ -936,6 +939,9 @@ async def download(request: web.Request) -> web.Response: audio_description: type: boolean description: Download audio description tracks (default - false) + audio_description_only: + type: boolean + description: Download audio description tracks and no standard audio tracks (default - false) slow: oneOf: - type: boolean diff --git a/unshackle/core/manifests/dash.py b/unshackle/core/manifests/dash.py index 415776eb..4e5b37af 100644 --- a/unshackle/core/manifests/dash.py +++ b/unshackle/core/manifests/dash.py @@ -1209,7 +1209,7 @@ class DASH: ("urn:mpeg:dash:role:2011", "descriptive"), ("urn:tva:metadata:cs:AudioPurposeCS:2007", "1"), ) - for x in adaptation_set.findall("Accessibility") + for x in (*adaptation_set.findall("Accessibility"), *adaptation_set.findall("Role")) ) @staticmethod diff --git a/unshackle/core/utilities.py b/unshackle/core/utilities.py index 2674363e..03c9ee92 100644 --- a/unshackle/core/utilities.py +++ b/unshackle/core/utilities.py @@ -17,7 +17,7 @@ from collections import defaultdict from datetime import datetime, timezone from pathlib import Path from types import ModuleType -from typing import Any, Callable, Optional, Sequence, Union +from typing import Any, Callable, Mapping, Optional, Sequence, Union from urllib.parse import ParseResult, urlparse from uuid import uuid4 @@ -235,6 +235,29 @@ def keep_forced_subtitle( return match_func(language, forced_s_lang) +def kind_only_conflict(params: Mapping[str, Any]) -> Optional[tuple[str, str]]: + """ + Return the first (kind-only flag, flag that drops its track type) pair that are both set. + + The ``*_only`` track-type flags combine, so another one only drops the kind-only + flag's track type when its own ``*_only`` flag is not set as well. + """ + for flag, own_only, no_flag in ( + ("audio_description_only", "audio_only", "no_audio"), + ("forced_subs_only", "subs_only", "no_subs"), + ): + if not params.get(flag): + continue + if params.get(no_flag): + return flag, no_flag + if params.get(own_only): + continue + for other in ("video_only", "audio_only", "subs_only", "chapters_only"): + if params.get(other): + return flag, other + return None + + def embedded_audio_langs(videos: Sequence[Any], keep_videos: bool) -> list[str]: """ Return the audio languages carried inside video tracks rather than beside them.