mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-12 20:15:10 +00:00
fix(cli): make the click param types the one source of codec spellings
--acodec now lists its codecs in --help, --vcodec drops the duplicate entries, and the REST API derives its allowlists from the same types, so AC-4, DDP, VORB and sub_format "original" stop being rejected and H264 stops being silently dropped. Adds shell completion for --acodec and repairs MultipleChoice completion. Raises the click floor to 8.2.
This commit is contained in:
@@ -370,7 +370,7 @@ Make a download job. It requires `service` and `title_id`. Every other field is
|
|||||||
"service": "EXAMPLE",
|
"service": "EXAMPLE",
|
||||||
"title_id": "12345",
|
"title_id": "12345",
|
||||||
"quality": [2160],
|
"quality": [2160],
|
||||||
"vcodec": "H265",
|
"vcodec": "HEVC",
|
||||||
"range": ["HDR10"],
|
"range": ["HDR10"],
|
||||||
"wanted": ["S01E01"]
|
"wanted": ["S01E01"]
|
||||||
}'
|
}'
|
||||||
@@ -434,7 +434,7 @@ Make a download job. It requires `service` and `title_id`. Every other field is
|
|||||||
| `output_dir` | string | `null` | Output directory, relative to the server's downloads directory. The server rejects a path that resolves outside it with `400`. |
|
| `output_dir` | string | `null` | Output directory, relative to the server's downloads directory. The server rejects a path that resolves outside it with `400`. |
|
||||||
| `no_cache` / `reset_cache` | boolean | `false` | Title cache controls. |
|
| `no_cache` / `reset_cache` | boolean | `false` | Title cache controls. |
|
||||||
|
|
||||||
**Validation.** Invalid values return `400 INVALID_PARAMETERS`. `vcodec` must be one of H264/H265/H.264/H.265/AVC/HEVC/VC1/VC-1/VP8/VP9/AV1. `acodec` must be one of AAC/AC3/EC3/EAC3/DD/DD+/AC4/OPUS/FLAC/ALAC/VORBIS/OGG/DTS/DTSX/DTS-X. `range` must be one of SDR/HDR10/HDR10P/DV/HLG/HYBRID, and `HDR10+` is also valid. The bitrate, download worker, and download counts must be positive integers. You may set at most one of the `*_only` flags. You cannot combine `no_subs` with `subs_only`, or `no_audio` with `audio_only`.
|
**Validation.** Invalid values return `400 INVALID_PARAMETERS`. `vcodec`, `acodec` and `sub_format` take the same spellings as the CLI options of the same name (`unshackle dl --help` prints each list). The API and the CLI read one list, so they cannot disagree. `range` must be one of SDR/HDR10/HDR10P/DV/HLG/HYBRID, and `HDR10+` is also valid. The bitrate, download worker, and download counts must be positive integers. You may set at most one of the `*_only` flags. You cannot combine `no_subs` with `subs_only`, or `no_audio` with `audio_only`.
|
||||||
|
|
||||||
**Service options.** A service's own CLI options travel in the same body. Send them in a nested
|
**Service options.** A service's own CLI options travel in the same body. Send them in a nested
|
||||||
`service_params` object, or as flat top-level keys. `service_params` wins over a flat key of the
|
`service_params` object, or as flat top-level keys. `service_params` wins over a flat key of the
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ curl -X POST "$BASE/api/download" \
|
|||||||
"service": "EXAMPLE",
|
"service": "EXAMPLE",
|
||||||
"title_id": "12345",
|
"title_id": "12345",
|
||||||
"quality": [1080],
|
"quality": [1080],
|
||||||
"vcodec": "H265",
|
"vcodec": "HEVC",
|
||||||
"range": ["SDR"],
|
"range": ["SDR"],
|
||||||
"wanted": ["S01E01", "S01E02"]
|
"wanted": ["S01E01", "S01E02"]
|
||||||
}'
|
}'
|
||||||
|
|||||||
@@ -69,8 +69,8 @@ unshackle dl [OPTIONS] SERVICE [SERVICE ARGS...]
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `-p`, `--profile` | - | Profile for credentials and cookies. |
|
| `-p`, `--profile` | - | Profile for credentials and cookies. |
|
||||||
| `-q`, `--quality` | best | Resolution(s), comma-separated, e.g. `1080,720`. `-q 1080` also matches anamorphic tracks by 16:9 canvas. |
|
| `-q`, `--quality` | best | Resolution(s), comma-separated, e.g. `1080,720`. `-q 1080` also matches anamorphic tracks by 16:9 canvas. |
|
||||||
| `-v`, `--vcodec` | any | Video codec(s). Accepts names or values: `AVC`/`H.264`, `HEVC`/`H.265`, `VC1`, `VP8`, `VP9`, `AV1`. |
|
| `-v`, `--vcodec` | any | Video codec(s). Accepts names or values: `AVC`/`H.264`/`H264`, `HEVC`/`H.265`/`H265`, `VC1`/`VC-1`, `VP8`, `VP9`, `AV1`. |
|
||||||
| `-a`, `--acodec` | any | Audio codec(s), comma-separated. Accepts `AAC`, `AC3`/`DD`, `EC3`/`DD+`/`eac3`/`ddp`, `AC4`, `OPUS`, `OGG`/`vorbis`, `DTS`, `DTSX`/`DTS-X`, `ALAC`, `FLAC`. |
|
| `-a`, `--acodec` | any | Audio codec(s), comma-separated. Accepts `AAC`, `AC3`/`DD`, `EC3`/`DD+`/`eac3`/`ddp`, `AC4`/`AC-4`, `OPUS`, `OGG`/`VORB`/`vorbis`, `DTS`, `DTSX`/`DTS-X`, `ALAC`, `FLAC`. |
|
||||||
| `-vb`, `--vbitrate` | highest | Exact video bitrate in kbps. |
|
| `-vb`, `--vbitrate` | highest | Exact video bitrate in kbps. |
|
||||||
| `-ab`, `--abitrate` | highest | Exact audio bitrate in kbps. |
|
| `-ab`, `--abitrate` | highest | Exact audio bitrate in kbps. |
|
||||||
| `-vb-range`, `--vbitrate-range` | - | Video bitrate range in kbps, e.g. `6000-7000`; picks highest within. Mutually exclusive with `--vbitrate`. |
|
| `-vb-range`, `--vbitrate-range` | - | Video bitrate range in kbps, e.g. `6000-7000`; picks highest within. Mutually exclusive with `--vbitrate`. |
|
||||||
|
|||||||
@@ -120,6 +120,8 @@ either enum **names** or their **values**, comma-separated.
|
|||||||
| `VP9` | `VP9` |
|
| `VP9` | `VP9` |
|
||||||
| `AV1` | `AV1` |
|
| `AV1` | `AV1` |
|
||||||
|
|
||||||
|
`H264` and `H265` are aliases of `AVC` and `HEVC`.
|
||||||
|
|
||||||
```shell title="Either spelling works"
|
```shell title="Either spelling works"
|
||||||
unshackle dl -v HEVC EXAMPLE 81234567
|
unshackle dl -v HEVC EXAMPLE 81234567
|
||||||
unshackle dl -v H.265 EXAMPLE 81234567
|
unshackle dl -v H.265 EXAMPLE 81234567
|
||||||
|
|||||||
+1
-1
@@ -28,7 +28,7 @@ classifiers = [
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"appdirs>=1.4.4,<2",
|
"appdirs>=1.4.4,<2",
|
||||||
"Brotli>=1.1.0,<2",
|
"Brotli>=1.1.0,<2",
|
||||||
"click>=8.1.8,<9",
|
"click>=8.2,<9",
|
||||||
"construct>=2.8.8,<3",
|
"construct>=2.8.8,<3",
|
||||||
"crccheck>=1.3.0,<2",
|
"crccheck>=1.3.0,<2",
|
||||||
"filelock>=3.20.3,<4",
|
"filelock>=3.20.3,<4",
|
||||||
|
|||||||
@@ -7,10 +7,15 @@ import click
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from unshackle.core.tracks.subtitle import Subtitle
|
from unshackle.core.tracks.subtitle import Subtitle
|
||||||
|
from unshackle.core.tracks.video import Video
|
||||||
from unshackle.core.utils.click_types import (
|
from unshackle.core.utils.click_types import (
|
||||||
|
AUDIO_CODEC_LIST,
|
||||||
LANGUAGE_RANGE,
|
LANGUAGE_RANGE,
|
||||||
QUALITY_LIST,
|
QUALITY_LIST,
|
||||||
SLOW_DELAY_RANGE,
|
SLOW_DELAY_RANGE,
|
||||||
|
SUBTITLE_CODEC,
|
||||||
|
VIDEO_CODEC_LIST,
|
||||||
|
MultipleChoice,
|
||||||
SeasonRange,
|
SeasonRange,
|
||||||
SubtitleCodecChoice,
|
SubtitleCodecChoice,
|
||||||
)
|
)
|
||||||
@@ -186,3 +191,56 @@ def test_comma_separated_dates_convert():
|
|||||||
def test_bad_date_tokens_fail(token):
|
def test_bad_date_tokens_fail(token):
|
||||||
with pytest.raises(click.UsageError):
|
with pytest.raises(click.UsageError):
|
||||||
parse(token)
|
parse(token)
|
||||||
|
|
||||||
|
|
||||||
|
def test_audio_metavar_lists_every_accepted_spelling():
|
||||||
|
"""--help must not advertise fewer codecs than convert() takes, aliases included."""
|
||||||
|
metavar = AUDIO_CODEC_LIST.get_metavar()
|
||||||
|
assert metavar.startswith("[") and metavar.endswith("]")
|
||||||
|
listed = metavar[1:-1].split("|")
|
||||||
|
assert listed == AUDIO_CODEC_LIST.choices
|
||||||
|
for spelling in ("ddp", "eac3", "vorbis", "ac-4", "vorb"):
|
||||||
|
assert spelling in listed
|
||||||
|
assert AUDIO_CODEC_LIST.convert(spelling)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("param_type", [AUDIO_CODEC_LIST, VIDEO_CODEC_LIST, SUBTITLE_CODEC])
|
||||||
|
def test_no_duplicate_choices(param_type):
|
||||||
|
"""A codec whose name and value are the same word (VP9, AV1) is listed once."""
|
||||||
|
lowered = [choice.lower() for choice in param_type.choices]
|
||||||
|
assert len(lowered) == len(set(lowered))
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_codec_lists_come_from_the_param_types():
|
||||||
|
from unshackle.core.api.handlers import VALID_ACODECS, VALID_SUB_FORMATS, VALID_VCODECS
|
||||||
|
|
||||||
|
assert VALID_ACODECS == [c.upper() for c in AUDIO_CODEC_LIST.choices]
|
||||||
|
assert VALID_VCODECS == [c.upper() for c in VIDEO_CODEC_LIST.choices]
|
||||||
|
assert VALID_SUB_FORMATS == [c.upper() for c in SUBTITLE_CODEC.choices]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", ["avc,,hevc", ["avc", "", "hevc"], " avc , hevc "])
|
||||||
|
def test_empty_video_codec_tokens_are_skipped(value):
|
||||||
|
"""A None in the list reaches track filtering and crashes it, so blanks must drop out."""
|
||||||
|
assert VIDEO_CODEC_LIST.convert(value) == [Video.Codec.AVC, Video.Codec.HEVC]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("spelling", ["H264", "h265", "H.265", "hevc"])
|
||||||
|
def test_video_codec_aliases_resolve(spelling):
|
||||||
|
"""The API advertised H264/H265 while dropping them; both paths now resolve the same."""
|
||||||
|
assert VIDEO_CODEC_LIST.convert(spelling)[0] in (Video.Codec.AVC, Video.Codec.HEVC)
|
||||||
|
|
||||||
|
|
||||||
|
def test_audio_codec_list_shell_complete():
|
||||||
|
"""--acodec offered no completion at all; it now completes the segment after the last comma."""
|
||||||
|
assert [c.value for c in AUDIO_CODEC_LIST.shell_complete(None, None, "fl")] == ["flac"]
|
||||||
|
assert [c.value for c in AUDIO_CODEC_LIST.shell_complete(None, None, "AAC,e")] == ["AAC,ec3", "AAC,eac3"]
|
||||||
|
assert AUDIO_CODEC_LIST.shell_complete(None, None, "zzz") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiple_choice_shell_complete():
|
||||||
|
"""super(self) raised TypeError, so every --range completion crashed instead of completing."""
|
||||||
|
range_choice = MultipleChoice(Video.Range, case_sensitive=False)
|
||||||
|
assert [c.value for c in range_choice.shell_complete(None, None, "hd")] == ["hdr10", "hdr10p"]
|
||||||
|
assert [c.value for c in range_choice.shell_complete(None, None, "sdr,hd")] == ["sdr,hdr10", "sdr,hdr10p"]
|
||||||
|
assert range_choice.shell_complete(None, None, "zzz") == []
|
||||||
|
|||||||
@@ -314,3 +314,17 @@ async def test_search_unknown_service_raises_invalid_service() -> None:
|
|||||||
with pytest.raises(APIError) as exc:
|
with pytest.raises(APIError) as exc:
|
||||||
await search_handler({"service": "NOPE_THIS_IS_NOT_REAL_", "query": "x"}, None)
|
await search_handler({"service": "NOPE_THIS_IS_NOT_REAL_", "query": "x"}, None)
|
||||||
assert exc.value.error_code == APIErrorCode.INVALID_SERVICE
|
assert exc.value.error_code == APIErrorCode.INVALID_SERVICE
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_vcodec_rejects_junk_as_invalid_input() -> None:
|
||||||
|
"""Session routes skip validate_download_parameters, so a bad codec must be a 400, not a 500."""
|
||||||
|
from unshackle.core.api.errors import APIErrorCode
|
||||||
|
from unshackle.core.api.handlers import resolve_vcodec
|
||||||
|
from unshackle.core.tracks import Video
|
||||||
|
|
||||||
|
assert resolve_vcodec(["H265"]) == [Video.Codec.HEVC]
|
||||||
|
assert resolve_vcodec(None) is None
|
||||||
|
for junk in ("XVID", 5, [5]):
|
||||||
|
with pytest.raises(APIError) as exc:
|
||||||
|
resolve_vcodec(junk)
|
||||||
|
assert exc.value.error_code == APIErrorCode.INVALID_INPUT
|
||||||
|
|||||||
@@ -93,10 +93,10 @@ from unshackle.core.utils.click_types import (
|
|||||||
QUALITY_LIST,
|
QUALITY_LIST,
|
||||||
SEASON_RANGE,
|
SEASON_RANGE,
|
||||||
SLOW_DELAY_RANGE,
|
SLOW_DELAY_RANGE,
|
||||||
|
SUBTITLE_CODEC,
|
||||||
|
VIDEO_CODEC_LIST,
|
||||||
ContextData,
|
ContextData,
|
||||||
MultipleChoice,
|
MultipleChoice,
|
||||||
MultipleVideoCodecChoice,
|
|
||||||
SubtitleCodecChoice,
|
|
||||||
)
|
)
|
||||||
from unshackle.core.utils.collections import ci_get, merge_dict
|
from unshackle.core.utils.collections import ci_get, merge_dict
|
||||||
from unshackle.core.utils.post_scripts import NO_POST_SCRIPTS, build_context, dispatch, season_context
|
from unshackle.core.utils.post_scripts import NO_POST_SCRIPTS, build_context, dispatch, season_context
|
||||||
@@ -534,7 +534,7 @@ class dl:
|
|||||||
@click.option(
|
@click.option(
|
||||||
"-v",
|
"-v",
|
||||||
"--vcodec",
|
"--vcodec",
|
||||||
type=MultipleVideoCodecChoice(Video.Codec),
|
type=VIDEO_CODEC_LIST,
|
||||||
default=[],
|
default=[],
|
||||||
help="Video Codec(s) to download, defaults to any codec.",
|
help="Video Codec(s) to download, defaults to any codec.",
|
||||||
)
|
)
|
||||||
@@ -775,7 +775,7 @@ class dl:
|
|||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
"--sub-format",
|
"--sub-format",
|
||||||
type=SubtitleCodecChoice(Subtitle.Codec),
|
type=SUBTITLE_CODEC,
|
||||||
default=None,
|
default=None,
|
||||||
help="Set Output Subtitle Format, only converting if necessary. Use 'original' to keep source format.",
|
help="Set Output Subtitle Format, only converting if necessary. Use 'original' to keep source format.",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from typing import Any, Callable, Dict, List, Optional
|
|||||||
|
|
||||||
from unshackle.core.api.events import bus, publish_service_event
|
from unshackle.core.api.events import bus, publish_service_event
|
||||||
from unshackle.core.api.sanitize import sanitize_log
|
from unshackle.core.api.sanitize import sanitize_log
|
||||||
|
from unshackle.core.utils.click_types import VIDEO_CODEC_LIST
|
||||||
from unshackle.core.utils.redact import REDACTED, URL_USERINFO_RE, redact_path, redact_text
|
from unshackle.core.utils.redact import REDACTED, URL_USERINFO_RE, redact_path, redact_text
|
||||||
|
|
||||||
log = logging.getLogger("download_manager")
|
log = logging.getLogger("download_manager")
|
||||||
@@ -387,10 +388,7 @@ def perform_download(
|
|||||||
|
|
||||||
vcodec_raw = params.get("vcodec")
|
vcodec_raw = params.get("vcodec")
|
||||||
if vcodec_raw:
|
if vcodec_raw:
|
||||||
if isinstance(vcodec_raw, str):
|
params["vcodec"] = VIDEO_CODEC_LIST.convert(vcodec_raw)
|
||||||
vcodec_raw = [vcodec_raw]
|
|
||||||
if isinstance(vcodec_raw, list) and vcodec_raw and not isinstance(vcodec_raw[0], Video.Codec):
|
|
||||||
params["vcodec"] = to_enum(vcodec_raw, Video.Codec)
|
|
||||||
else:
|
else:
|
||||||
params["vcodec"] = []
|
params["vcodec"] = []
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from http.cookiejar import CookieJar, MozillaCookieJar
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, Iterable, List, Optional
|
from typing import Any, Dict, Iterable, List, Optional
|
||||||
|
|
||||||
|
import click
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
from unshackle.core.api.compression import safe_inflate
|
from unshackle.core.api.compression import safe_inflate
|
||||||
@@ -33,6 +34,7 @@ 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
|
||||||
|
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
|
||||||
|
|
||||||
@@ -1513,24 +1515,23 @@ async def list_tracks_handler(data: Dict[str, Any], request: Optional[web.Reques
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
VALID_VCODECS = ["H264", "H265", "H.264", "H.265", "AVC", "HEVC", "VC1", "VC-1", "VP8", "VP9", "AV1"]
|
VALID_VCODECS = [choice.upper() for choice in VIDEO_CODEC_LIST.choices]
|
||||||
VALID_ACODECS = [
|
VALID_ACODECS = [choice.upper() for choice in AUDIO_CODEC_LIST.choices]
|
||||||
"AAC",
|
VALID_SUB_FORMATS = [choice.upper() for choice in SUBTITLE_CODEC.choices]
|
||||||
"AC3",
|
|
||||||
"EC3",
|
|
||||||
"EAC3",
|
def resolve_vcodec(value: Any) -> Optional[list]:
|
||||||
"DD",
|
"""Map a client's vcodec field to codec enums.
|
||||||
"DD+",
|
|
||||||
"AC4",
|
Session routes do not run validate_download_parameters, so this must answer junk with a 400
|
||||||
"OPUS",
|
instead of letting click's UsageError surface as a 500.
|
||||||
"FLAC",
|
"""
|
||||||
"ALAC",
|
if not value:
|
||||||
"VORBIS",
|
return None
|
||||||
"OGG",
|
try:
|
||||||
"DTS",
|
return VIDEO_CODEC_LIST.convert(value) or None
|
||||||
"DTSX",
|
except click.UsageError as e:
|
||||||
"DTS-X",
|
raise APIError(APIErrorCode.INVALID_INPUT, f"Invalid vcodec: {e.format_message()}")
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def check_codec(value: Any, allowed: List[str], name: str) -> Optional[str]:
|
def check_codec(value: Any, allowed: List[str], name: str) -> Optional[str]:
|
||||||
@@ -1590,9 +1591,8 @@ def validate_download_parameters(data: Dict[str, Any]) -> Optional[str]:
|
|||||||
return err
|
return err
|
||||||
|
|
||||||
if "sub_format" in data and data["sub_format"]:
|
if "sub_format" in data and data["sub_format"]:
|
||||||
valid_sub_formats = ["SRT", "VTT", "ASS", "SSA", "TTML", "STPP", "WVTT", "SMI", "SUB", "MPL2", "TMP"]
|
if str(data["sub_format"]).upper() not in VALID_SUB_FORMATS:
|
||||||
if data["sub_format"].upper() not in valid_sub_formats:
|
return f"Invalid sub_format: {data['sub_format']}. Must be one of: {', '.join(VALID_SUB_FORMATS)}"
|
||||||
return f"Invalid sub_format: {data['sub_format']}. Must be one of: {', '.join(valid_sub_formats)}"
|
|
||||||
|
|
||||||
if "vbitrate" in data and data["vbitrate"] is not None:
|
if "vbitrate" in data and data["vbitrate"] is not None:
|
||||||
if not isinstance(data["vbitrate"], int) or data["vbitrate"] <= 0:
|
if not isinstance(data["vbitrate"], int) or data["vbitrate"] <= 0:
|
||||||
@@ -2884,16 +2884,7 @@ def create_service_instance(
|
|||||||
pass
|
pass
|
||||||
range_values = range_values or None
|
range_values = range_values or None
|
||||||
|
|
||||||
vcodec_names = data.get("vcodec")
|
vcodec_values = resolve_vcodec(data.get("vcodec"))
|
||||||
vcodec_values: Optional[list] = None
|
|
||||||
if vcodec_names:
|
|
||||||
vcodec_values = []
|
|
||||||
for name in vcodec_names:
|
|
||||||
try:
|
|
||||||
vcodec_values.append(Video.Codec[name])
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
vcodec_values = vcodec_values or None
|
|
||||||
|
|
||||||
extra_params = {
|
extra_params = {
|
||||||
"range_": range_values,
|
"range_": range_values,
|
||||||
|
|||||||
@@ -675,14 +675,14 @@ async def download(request: web.Request) -> web.Response:
|
|||||||
- type: array
|
- type: array
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
description: Video codec(s) to download (e.g., "H265" or ["H264", "H265"]) - accepts H264, H265, AVC, HEVC, VP8, VP9, AV1, VC1 (default - None)
|
description: Video codec(s) to download (e.g., "HEVC" or ["AVC", "HEVC"]) - accepts AVC, H.264, H264, HEVC, H.265, H265, VC1, VC-1, VP8, VP9, AV1 (default - None)
|
||||||
acodec:
|
acodec:
|
||||||
oneOf:
|
oneOf:
|
||||||
- type: string
|
- type: string
|
||||||
- type: array
|
- type: array
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
description: Audio codec(s) to download (e.g., "AAC" or ["AAC", "EC3"]) - accepts AAC, AC3, EC3, AC4, OPUS, FLAC, ALAC, DTS, DTSX, DTS-X, OGG (default - None)
|
description: Audio codec(s) to download (e.g., "AAC" or ["AAC", "EC3"]) - accepts AAC, AC3, DD, EC3, DD+, EAC3, DDP, AC4, AC-4, OPUS, OGG, VORB, VORBIS, DTS, DTSX, DTS-X, ALAC, FLAC (default - None)
|
||||||
vbitrate:
|
vbitrate:
|
||||||
type: integer
|
type: integer
|
||||||
description: Video bitrate in kbps (default - None)
|
description: Video bitrate in kbps (default - None)
|
||||||
@@ -756,7 +756,7 @@ async def download(request: web.Request) -> web.Response:
|
|||||||
description: Use exact language matching (no variants) (default - false)
|
description: Use exact language matching (no variants) (default - false)
|
||||||
sub_format:
|
sub_format:
|
||||||
type: string
|
type: string
|
||||||
description: Output subtitle format such as SRT or VTT (default - None)
|
description: Output subtitle format such as SRT or VTT, or "original" to keep the source format (default - None)
|
||||||
video_only:
|
video_only:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: Only download video tracks (default - false)
|
description: Only download video tracks (default - false)
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ from click.shell_completion import CompletionItem
|
|||||||
from pywidevine.cdm import Cdm as WidevineCdm
|
from pywidevine.cdm import Cdm as WidevineCdm
|
||||||
|
|
||||||
from unshackle.core.tracks.audio import Audio
|
from unshackle.core.tracks.audio import Audio
|
||||||
|
from unshackle.core.tracks.subtitle import Subtitle
|
||||||
|
from unshackle.core.tracks.video import Video
|
||||||
|
|
||||||
|
|
||||||
class VideoCodecChoice(click.Choice):
|
class VideoCodecChoice(click.Choice):
|
||||||
@@ -18,27 +20,31 @@ class VideoCodecChoice(click.Choice):
|
|||||||
- Enum values: H.264, H.265, VC-1, VP8, VP9, AV1
|
- Enum values: H.264, H.265, VC-1, VP8, VP9, AV1
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, codec_enum):
|
def __init__(self, codec_enum: type[Video.Codec]) -> None:
|
||||||
self.codec_enum = codec_enum
|
self.codec_enum = codec_enum
|
||||||
choices = []
|
self._name_to_codec: dict[str, Video.Codec] = {}
|
||||||
for codec in codec_enum:
|
for codec in codec_enum:
|
||||||
choices.append(codec.name.lower()) # e.g., "avc", "hevc"
|
for choice in (codec.name.lower(), codec.value.lower()):
|
||||||
choices.append(codec.value) # e.g., "H.264", "H.265"
|
self._name_to_codec[choice] = codec
|
||||||
super().__init__(choices, case_sensitive=False)
|
|
||||||
|
|
||||||
def convert(self, value: Any, param: Optional[click.Parameter] = None, ctx: Optional[click.Context] = None):
|
aliases = {"h264": "AVC", "h265": "HEVC"}
|
||||||
|
for alias, target in aliases.items():
|
||||||
|
if target in codec_enum.__members__:
|
||||||
|
self._name_to_codec[alias] = codec_enum[target]
|
||||||
|
|
||||||
|
super().__init__(list(self._name_to_codec), case_sensitive=False)
|
||||||
|
|
||||||
|
def convert(self, value: Any, param: Optional[click.Parameter] = None, ctx: Optional[click.Context] = None) -> Any:
|
||||||
if not value:
|
if not value:
|
||||||
return None
|
return None
|
||||||
|
if isinstance(value, self.codec_enum):
|
||||||
|
return value
|
||||||
|
|
||||||
converted_value = super().convert(value, param, ctx)
|
converted_value = super().convert(value, param, ctx)
|
||||||
|
codec = self._name_to_codec.get(str(converted_value).lower())
|
||||||
for codec in self.codec_enum:
|
if codec is None:
|
||||||
if converted_value.lower() == codec.name.lower():
|
self.fail(f"'{value}' is not a valid video codec", param, ctx)
|
||||||
return codec
|
return codec
|
||||||
if converted_value == codec.value:
|
|
||||||
return codec
|
|
||||||
|
|
||||||
self.fail(f"'{value}' is not a valid video codec", param, ctx)
|
|
||||||
|
|
||||||
|
|
||||||
class MultipleVideoCodecChoice(VideoCodecChoice):
|
class MultipleVideoCodecChoice(VideoCodecChoice):
|
||||||
@@ -64,7 +70,10 @@ class MultipleVideoCodecChoice(VideoCodecChoice):
|
|||||||
|
|
||||||
chosen_values: list[Any] = []
|
chosen_values: list[Any] = []
|
||||||
for v in values:
|
for v in values:
|
||||||
chosen_values.append(super().convert(v.strip(), param, ctx))
|
token = v.strip() if isinstance(v, str) else str(v).strip()
|
||||||
|
if not token:
|
||||||
|
continue
|
||||||
|
chosen_values.append(super().convert(token, param, ctx))
|
||||||
return chosen_values
|
return chosen_values
|
||||||
|
|
||||||
|
|
||||||
@@ -351,12 +360,12 @@ class AudioCodecList(click.ParamType):
|
|||||||
|
|
||||||
name = "audio_codec_list"
|
name = "audio_codec_list"
|
||||||
|
|
||||||
def __init__(self, codec_enum):
|
def __init__(self, codec_enum: type[Audio.Codec]) -> None:
|
||||||
self.codec_enum = codec_enum
|
self.codec_enum = codec_enum
|
||||||
self._name_to_codec: dict[str, Audio.Codec] = {}
|
self._name_to_codec: dict[str, Audio.Codec] = {}
|
||||||
for codec in codec_enum:
|
for codec in codec_enum:
|
||||||
self._name_to_codec[codec.name.lower()] = codec
|
for choice in (codec.name.lower(), codec.value.lower()):
|
||||||
self._name_to_codec[codec.value.lower()] = codec
|
self._name_to_codec[choice] = codec
|
||||||
|
|
||||||
aliases = {
|
aliases = {
|
||||||
"eac3": "EC3",
|
"eac3": "EC3",
|
||||||
@@ -367,6 +376,14 @@ class AudioCodecList(click.ParamType):
|
|||||||
if target in codec_enum.__members__:
|
if target in codec_enum.__members__:
|
||||||
self._name_to_codec[alias] = codec_enum[target]
|
self._name_to_codec[alias] = codec_enum[target]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def choices(self) -> list[str]:
|
||||||
|
"""Every spelling convert() accepts, in declaration order. Mirrors click.Choice.choices."""
|
||||||
|
return list(self._name_to_codec)
|
||||||
|
|
||||||
|
def get_metavar(self, *args: Any, **kwargs: Any) -> str:
|
||||||
|
return f"[{'|'.join(self.choices)}]"
|
||||||
|
|
||||||
def convert(self, value: Any, param: Optional[click.Parameter] = None, ctx: Optional[click.Context] = None) -> list:
|
def convert(self, value: Any, param: Optional[click.Parameter] = None, ctx: Optional[click.Context] = None) -> list:
|
||||||
if not value:
|
if not value:
|
||||||
return []
|
return []
|
||||||
@@ -387,10 +404,21 @@ class AudioCodecList(click.ParamType):
|
|||||||
if key in self._name_to_codec:
|
if key in self._name_to_codec:
|
||||||
codecs.append(self._name_to_codec[key])
|
codecs.append(self._name_to_codec[key])
|
||||||
else:
|
else:
|
||||||
valid = sorted(set(self._name_to_codec.keys()))
|
self.fail(f"'{val}' is not valid. Choices: {', '.join(sorted(self.choices))}", param, ctx)
|
||||||
self.fail(f"'{val}' is not valid. Choices: {', '.join(valid)}", param, ctx)
|
|
||||||
return list(dict.fromkeys(codecs)) # Remove duplicates, preserve order
|
return list(dict.fromkeys(codecs)) # Remove duplicates, preserve order
|
||||||
|
|
||||||
|
def shell_complete(self, ctx: click.Context, param: click.Parameter, incomplete: str) -> list[CompletionItem]:
|
||||||
|
"""
|
||||||
|
Complete the codec after the last comma.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
ctx: Invocation context for this command.
|
||||||
|
param: The parameter that requests completion.
|
||||||
|
incomplete: The value to complete. Can be empty.
|
||||||
|
"""
|
||||||
|
prefix, sep, last = incomplete.rpartition(",")
|
||||||
|
return [CompletionItem(f"{prefix}{sep}{choice}") for choice in self.choices if choice.startswith(last.lower())]
|
||||||
|
|
||||||
|
|
||||||
class MultipleChoice(click.Choice):
|
class MultipleChoice(click.Choice):
|
||||||
"""
|
"""
|
||||||
@@ -425,15 +453,21 @@ class MultipleChoice(click.Choice):
|
|||||||
|
|
||||||
def shell_complete(self, ctx: click.Context, param: click.Parameter, incomplete: str) -> list[CompletionItem]:
|
def shell_complete(self, ctx: click.Context, param: click.Parameter, incomplete: str) -> list[CompletionItem]:
|
||||||
"""
|
"""
|
||||||
Complete choices that start with the incomplete value.
|
Complete the choice after the last comma.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
ctx: Invocation context for this command.
|
ctx: Invocation context for this command.
|
||||||
param: The parameter that requests completion.
|
param: The parameter that requests completion.
|
||||||
incomplete: The value to complete. Can be empty.
|
incomplete: The value to complete. Can be empty.
|
||||||
"""
|
"""
|
||||||
incomplete = incomplete.rsplit(",")[-1]
|
prefix, sep, last = incomplete.rpartition(",")
|
||||||
return super(self).shell_complete(ctx, param, incomplete)
|
if not self.case_sensitive:
|
||||||
|
last = last.casefold()
|
||||||
|
return [
|
||||||
|
CompletionItem(f"{prefix}{sep}{choice}")
|
||||||
|
for choice in (self.normalize_choice(c, ctx) for c in self.choices)
|
||||||
|
if choice.startswith(last)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class SlowDelayRange(click.ParamType):
|
class SlowDelayRange(click.ParamType):
|
||||||
@@ -466,4 +500,6 @@ SEASON_RANGE = SeasonRange()
|
|||||||
LANGUAGE_RANGE = LanguageRange()
|
LANGUAGE_RANGE = LanguageRange()
|
||||||
QUALITY_LIST = QualityList()
|
QUALITY_LIST = QualityList()
|
||||||
AUDIO_CODEC_LIST = AudioCodecList(Audio.Codec)
|
AUDIO_CODEC_LIST = AudioCodecList(Audio.Codec)
|
||||||
|
VIDEO_CODEC_LIST = MultipleVideoCodecChoice(Video.Codec)
|
||||||
|
SUBTITLE_CODEC = SubtitleCodecChoice(Subtitle.Codec)
|
||||||
SLOW_DELAY_RANGE = SlowDelayRange()
|
SLOW_DELAY_RANGE = SlowDelayRange()
|
||||||
|
|||||||
@@ -2211,7 +2211,7 @@ requires-dist = [
|
|||||||
{ name = "bandit", specifier = ">=1.9.4" },
|
{ name = "bandit", specifier = ">=1.9.4" },
|
||||||
{ name = "brotli", specifier = ">=1.1.0,<2" },
|
{ name = "brotli", specifier = ">=1.1.0,<2" },
|
||||||
{ name = "chardet", specifier = ">=5.2.0,<6" },
|
{ name = "chardet", specifier = ">=5.2.0,<6" },
|
||||||
{ name = "click", specifier = ">=8.1.8,<9" },
|
{ name = "click", specifier = ">=8.2,<9" },
|
||||||
{ name = "construct", specifier = ">=2.8.8,<3" },
|
{ name = "construct", specifier = ">=2.8.8,<3" },
|
||||||
{ name = "crccheck", specifier = ">=1.3.0,<2" },
|
{ name = "crccheck", specifier = ">=1.3.0,<2" },
|
||||||
{ name = "cryptography", specifier = ">=45.0.0,<47" },
|
{ name = "cryptography", specifier = ">=45.0.0,<47" },
|
||||||
|
|||||||
Reference in New Issue
Block a user