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.