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.
This commit is contained in:
imSp4rky
2026-10-06 12:45:52 -06:00
parent 9fc25e53d5
commit 3477d09cc2
17 changed files with 426 additions and 26 deletions
+2
View File
@@ -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. |
+3 -2
View File
@@ -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
+2
View File
@@ -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
+22
View File
@@ -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
```
+12
View File
@@ -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
+3
View File
@@ -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. |
+10
View File
@@ -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("<AdaptationSet/>")) is False
def test_descriptive_role_element() -> None:
# some manifests mark audio description with a Role and no Accessibility element
element = ET.fromstring(
'<AdaptationSet><Role schemeIdUri="urn:mpeg:dash:role:2011" value="description"/></AdaptationSet>'
)
assert DASH.is_descriptive(element) is True
main = ET.fromstring('<AdaptationSet><Role schemeIdUri="urn:mpeg:dash:role:2011" value="main"/></AdaptationSet>')
assert DASH.is_descriptive(main) is False
@@ -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
@@ -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
+9 -8
View File
@@ -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(
@@ -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
+80 -12
View File
@@ -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"
+2
View File
@@ -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,
+10 -1
View File
@@ -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)
+6
View File
@@ -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
+1 -1
View File
@@ -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
+24 -1
View File
@@ -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.