mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-12 20:15:10 +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_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. |
|
| `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` | 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`. |
|
| `forced_s_lang` | string[] | `[]` | Forced subtitle language(s); implies `forced_subs`. |
|
||||||
| `exact_lang` | boolean | `false` | Exact language matching. |
|
| `exact_lang` | boolean | `false` | Exact language matching. |
|
||||||
| `sub_format` | string | `null` | Output subtitle format. |
|
| `sub_format` | string | `null` | Output subtitle format. |
|
||||||
| `video_only` / `audio_only` / `subs_only` / `chapters_only` | boolean | `false` | Track-type restriction (at most one). |
|
| `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. |
|
| `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` | 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. |
|
| `slow` | boolean/string | `null` | Randomized delay between downloads. |
|
||||||
| `split_audio` | boolean | `null` | Separate files per audio codec. |
|
| `split_audio` | boolean | `null` | Separate files per audio codec. |
|
||||||
| `skip_dl` | boolean | `false` | Only fetch keys, do not download. |
|
| `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.
|
empty.
|
||||||
|
|
||||||
The server does not forward the `dl` mode flags: `video_only`, `audio_only`,
|
The server does not forward the `dl` mode flags: `video_only`, `audio_only`,
|
||||||
`subs_only`, `chapters_only`, `list_`, `skip_dl`, `sub_format` and
|
`subs_only`, `chapters_only`, `list_`, `skip_dl`, `sub_format`,
|
||||||
`no_attachments`. A service that reads one of them to skip work fetches more on
|
`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.
|
the server, but the result is the same.
|
||||||
|
|
||||||
In a client-login session (not a server account), the server gives the service
|
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-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. |
|
| `--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. |
|
| `-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. |
|
| `-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. |
|
| `--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. |
|
| `--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 |
|
| Flag | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `-ad`, `--audio-description` | Include descriptive (audio-description) tracks. |
|
| `-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. |
|
| `--skip-subtitle-errors` | Skip a failed subtitle instead of aborting the title. Video/audio failures remain fatal. |
|
||||||
|
|
||||||
### Output, muxing & files
|
### 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
|
`-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`
|
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.
|
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.
|
- `--sub-format`: set the output subtitle format, converting only when necessary.
|
||||||
Accepts codec names/values and common aliases (`srt`, `vtt`, `ass`, `ssa`, `ttml`,
|
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.
|
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
|
- `-ad` / `--audio-description`: include descriptive (audio-description) tracks, which
|
||||||
unshackle drops by default.
|
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
|
- `--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.
|
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"
|
```shell title="Subtitles only"
|
||||||
unshackle dl -S -sl en EXAMPLE 81234567
|
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`. |
|
| `-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`. |
|
| `--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). |
|
| `-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. |
|
| `-fsl`, `--forced-s-lang` | Language(s) wanted for forced subtitles; implies `-fs`. A `-` prefix excludes. |
|
||||||
| `--exact-lang` | Exact language matching, with no regional variants. |
|
| `--exact-lang` | Exact language matching, with no regional variants. |
|
||||||
| `-S`, `--subs-only` | Download only subtitle tracks. |
|
| `-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
|
It also understands `orig` (the title's original language) and `all` (every forced
|
||||||
track, same as plain `-fs`).
|
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
|
## Track types: forced, SDH, and CC
|
||||||
|
|
||||||
Every subtitle track carries flags describing what kind of captions it holds. These
|
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`, ...). |
|
| `sub_format` | str | *(unset)* | Convert subtitles to this format (`srt`, `vtt`, `original`, ...). |
|
||||||
| `forced_subs` | bool | `false` | Include forced subtitle tracks. |
|
| `forced_subs` | bool | `false` | Include forced subtitle tracks. |
|
||||||
| `forced_s_lang` | list | `[]` | Forced subtitle language(s); implies `forced_subs`. A `-` prefix excludes. |
|
| `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. |
|
| `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. |
|
| `downloads` | int | `1` | Tracks downloaded concurrently. |
|
||||||
| `workers` | int | *(downloader default)* | Threads per track. |
|
| `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:
|
def test_no_accessibility_element() -> None:
|
||||||
assert DASH.is_descriptive(ET.fromstring("<AdaptationSet/>")) is False
|
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
|
import pytest
|
||||||
|
|
||||||
from unshackle.commands.dl import dl
|
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.api.handlers import DEFAULT_DOWNLOAD_PARAMS
|
||||||
from unshackle.core.utilities import missing_required_langs
|
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"])
|
@pytest.mark.parametrize(
|
||||||
def test_api_reaches_dl_result(key, monkeypatch):
|
"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
|
# download_manager builds the dl.result kwargs by hand, so a missing key is a silent drop
|
||||||
import inspect as _inspect
|
assert DEFAULT_DOWNLOAD_PARAMS[key] == default
|
||||||
|
assert key in inspect.signature(dl.result).parameters
|
||||||
from unshackle.core.api import download_manager
|
assert f'{key}=params.get("{key}", {default!r})' in inspect.getsource(download_manager)
|
||||||
|
|
||||||
source = _inspect.getsource(download_manager)
|
|
||||||
assert f'{key}=params.get("{key}", [])' in source
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ class TestGateWiring:
|
|||||||
assert "require_video if keep_videos else []" in self.source
|
assert "require_video if keep_videos else []" in self.source
|
||||||
|
|
||||||
def test_gate_counts_audio_embedded_in_kept_video(self):
|
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):
|
def test_gate_honours_exact_lang(self):
|
||||||
assert "missing_required_langs(required, available, title.language, exact=exact_lang)" in self.source
|
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_close_match,
|
||||||
is_exact_match,
|
is_exact_match,
|
||||||
keep_forced_subtitle,
|
keep_forced_subtitle,
|
||||||
|
kind_only_conflict,
|
||||||
log_event,
|
log_event,
|
||||||
missing_required_langs,
|
missing_required_langs,
|
||||||
partition_exclusions,
|
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.",
|
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("-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(
|
@click.option(
|
||||||
"-fsl",
|
"-fsl",
|
||||||
"--forced-s-lang",
|
"--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).",
|
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("-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(
|
@click.option(
|
||||||
"--slow",
|
"--slow",
|
||||||
type=SLOW_DELAY_RANGE,
|
type=SLOW_DELAY_RANGE,
|
||||||
@@ -1523,6 +1538,7 @@ class dl:
|
|||||||
require_video: list[str],
|
require_video: list[str],
|
||||||
require_subs: list[str],
|
require_subs: list[str],
|
||||||
forced_subs: bool,
|
forced_subs: bool,
|
||||||
|
forced_subs_only: bool,
|
||||||
forced_s_lang: list[str],
|
forced_s_lang: list[str],
|
||||||
exact_lang: bool,
|
exact_lang: bool,
|
||||||
sub_format: Optional[Union[Subtitle.Codec, str]],
|
sub_format: Optional[Union[Subtitle.Codec, str]],
|
||||||
@@ -1537,6 +1553,7 @@ class dl:
|
|||||||
no_video: bool,
|
no_video: bool,
|
||||||
no_attachments: bool,
|
no_attachments: bool,
|
||||||
audio_description: bool,
|
audio_description: bool,
|
||||||
|
audio_description_only: bool,
|
||||||
slow: Optional[tuple[int, int]],
|
slow: Optional[tuple[int, int]],
|
||||||
list_: bool,
|
list_: bool,
|
||||||
list_titles: bool,
|
list_titles: bool,
|
||||||
@@ -1639,7 +1656,27 @@ class dl:
|
|||||||
if excludes and name in parent_params:
|
if excludes and name in parent_params:
|
||||||
parent_params[name] = includes
|
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
|
# services read forced_subs from raw ctx params, so the implication must be pushed there too
|
||||||
forced_subs = True
|
forced_subs = True
|
||||||
if "all" in forced_s_lang:
|
if "all" in forced_s_lang:
|
||||||
@@ -2044,6 +2081,27 @@ class dl:
|
|||||||
post_script_last: dict[Any, dict[Path, dict[str, str]]] = {}
|
post_script_last: dict[Any, dict[Path, dict[str, str]]] = {}
|
||||||
post_script_folders: list[Path] = []
|
post_script_folders: list[Path] = []
|
||||||
post_script_sample: dict[str, str] = {}
|
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):
|
for i, title in enumerate(titles):
|
||||||
v_lang, a_lang, s_lang, range_ = base_selection
|
v_lang, a_lang, s_lang, range_ = base_selection
|
||||||
@@ -2316,13 +2374,14 @@ class dl:
|
|||||||
(
|
(
|
||||||
"audio",
|
"audio",
|
||||||
require_audio if keep_audio else [],
|
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]),
|
("video", require_video if keep_videos else [], [v.language for v in title.tracks.videos]),
|
||||||
(
|
(
|
||||||
"subtitle",
|
"subtitle",
|
||||||
require_subs if keep_subtitles else [],
|
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)
|
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
|
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))
|
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
|
# 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 keep_audio and len(title.tracks.audio) > 0:
|
||||||
if not audio_description:
|
if not audio_description:
|
||||||
@@ -2818,7 +2887,10 @@ class dl:
|
|||||||
if a_orig_token in audio_languages:
|
if a_orig_token in audio_languages:
|
||||||
a_orig_token = None
|
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")):
|
if not any(tok in processed_lang for tok in ("best", "all")):
|
||||||
missing_a_langs = find_missing_langs(
|
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]
|
remaining = [tok for tok in processed_lang if tok not in missing_a_langs]
|
||||||
if remaining:
|
if remaining:
|
||||||
self.log.warning(
|
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)}"
|
f"continuing with: {as_requested(remaining, a_orig_token)}"
|
||||||
)
|
)
|
||||||
processed_lang = remaining
|
processed_lang = remaining
|
||||||
else:
|
else:
|
||||||
self.log.error(
|
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)
|
sys.exit(1)
|
||||||
else:
|
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)
|
sys.exit(1)
|
||||||
|
|
||||||
title.tracks.audio = select_best_audio(
|
title.tracks.audio = select_best_audio(
|
||||||
@@ -3700,11 +3772,7 @@ class dl:
|
|||||||
post_script_folders.append(final_path.parent)
|
post_script_folders.append(final_path.parent)
|
||||||
|
|
||||||
if not no_mux:
|
if not no_mux:
|
||||||
group_key = post_script_group(title)
|
settle_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)
|
|
||||||
|
|
||||||
title_dl_time = time_elapsed_since(dl_start_time)
|
title_dl_time = time_elapsed_since(dl_start_time)
|
||||||
downloaded_label = "Track" if isinstance(title, Song) else "Title"
|
downloaded_label = "Track" if isinstance(title, Song) else "Title"
|
||||||
|
|||||||
@@ -601,6 +601,7 @@ def perform_download(
|
|||||||
require_video=params.get("require_video", []),
|
require_video=params.get("require_video", []),
|
||||||
require_subs=params.get("require_subs", []),
|
require_subs=params.get("require_subs", []),
|
||||||
forced_subs=ctx.params["forced_subs"],
|
forced_subs=ctx.params["forced_subs"],
|
||||||
|
forced_subs_only=params.get("forced_subs_only", False),
|
||||||
forced_s_lang=params.get("forced_s_lang", []),
|
forced_s_lang=params.get("forced_s_lang", []),
|
||||||
exact_lang=params.get("exact_lang", False),
|
exact_lang=params.get("exact_lang", False),
|
||||||
sub_format=params.get("sub_format"),
|
sub_format=params.get("sub_format"),
|
||||||
@@ -615,6 +616,7 @@ def perform_download(
|
|||||||
no_video=params.get("no_video", False),
|
no_video=params.get("no_video", False),
|
||||||
no_attachments=params.get("no_attachments", False),
|
no_attachments=params.get("no_attachments", False),
|
||||||
audio_description=params.get("audio_description", False),
|
audio_description=params.get("audio_description", False),
|
||||||
|
audio_description_only=params.get("audio_description_only", False),
|
||||||
slow=params.get("slow", None),
|
slow=params.get("slow", None),
|
||||||
list_=False,
|
list_=False,
|
||||||
list_titles=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.services import Services
|
||||||
from unshackle.core.titles import Episode, Movie, Song, Title_T
|
from unshackle.core.titles import Episode, Movie, Song, Title_T
|
||||||
from unshackle.core.tracks import Audio, Subtitle, Tracks, Video
|
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.click_types import AUDIO_CODEC_LIST, SUBTITLE_CODEC, VIDEO_CODEC_LIST
|
||||||
from unshackle.core.utils.collections import ci_get
|
from unshackle.core.utils.collections import ci_get
|
||||||
from unshackle.core.utils.redact import REDACTED, URL_USERINFO_RE, redact_all, redact_secrets, redact_text
|
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_video": [],
|
||||||
"require_subs": [],
|
"require_subs": [],
|
||||||
"forced_subs": False,
|
"forced_subs": False,
|
||||||
|
"forced_subs_only": False,
|
||||||
"forced_s_lang": [],
|
"forced_s_lang": [],
|
||||||
"exact_lang": False,
|
"exact_lang": False,
|
||||||
"sub_format": None,
|
"sub_format": None,
|
||||||
@@ -77,6 +78,7 @@ DEFAULT_DOWNLOAD_PARAMS = {
|
|||||||
"no_video": False,
|
"no_video": False,
|
||||||
"no_attachments": False,
|
"no_attachments": False,
|
||||||
"audio_description": False,
|
"audio_description": False,
|
||||||
|
"audio_description_only": False,
|
||||||
"slow": None,
|
"slow": None,
|
||||||
"split_audio": None,
|
"split_audio": None,
|
||||||
"skip_dl": False,
|
"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"
|
return "Cannot use both no_subs and subs_only"
|
||||||
if data.get("no_audio") and data.get("audio_only"):
|
if data.get("no_audio") and data.get("audio_only"):
|
||||||
return "Cannot use both no_audio and 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"):
|
for key in ("require_audio", "require_video", "require_subs"):
|
||||||
value = data.get(key)
|
value = data.get(key)
|
||||||
|
|||||||
@@ -895,6 +895,9 @@ async def download(request: web.Request) -> web.Response:
|
|||||||
forced_subs:
|
forced_subs:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: Include forced subtitle tracks (default - false)
|
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:
|
forced_s_lang:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
@@ -936,6 +939,9 @@ async def download(request: web.Request) -> web.Response:
|
|||||||
audio_description:
|
audio_description:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: Download audio description tracks (default - false)
|
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:
|
slow:
|
||||||
oneOf:
|
oneOf:
|
||||||
- type: boolean
|
- type: boolean
|
||||||
|
|||||||
@@ -1209,7 +1209,7 @@ class DASH:
|
|||||||
("urn:mpeg:dash:role:2011", "descriptive"),
|
("urn:mpeg:dash:role:2011", "descriptive"),
|
||||||
("urn:tva:metadata:cs:AudioPurposeCS:2007", "1"),
|
("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
|
@staticmethod
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from collections import defaultdict
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import ModuleType
|
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 urllib.parse import ParseResult, urlparse
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
@@ -235,6 +235,29 @@ def keep_forced_subtitle(
|
|||||||
return match_func(language, forced_s_lang)
|
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]:
|
def embedded_audio_langs(videos: Sequence[Any], keep_videos: bool) -> list[str]:
|
||||||
"""
|
"""
|
||||||
Return the audio languages carried inside video tracks rather than beside them.
|
Return the audio languages carried inside video tracks rather than beside them.
|
||||||
|
|||||||
Reference in New Issue
Block a user