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