mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-11 19:45:06 +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",
|
||||
"title_id": "12345",
|
||||
"quality": [2160],
|
||||
"vcodec": "H265",
|
||||
"vcodec": "HEVC",
|
||||
"range": ["HDR10"],
|
||||
"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`. |
|
||||
| `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_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",
|
||||
"title_id": "12345",
|
||||
"quality": [1080],
|
||||
"vcodec": "H265",
|
||||
"vcodec": "HEVC",
|
||||
"range": ["SDR"],
|
||||
"wanted": ["S01E01", "S01E02"]
|
||||
}'
|
||||
|
||||
@@ -69,8 +69,8 @@ unshackle dl [OPTIONS] SERVICE [SERVICE ARGS...]
|
||||
|---|---|---|
|
||||
| `-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. |
|
||||
| `-v`, `--vcodec` | any | Video codec(s). Accepts names or values: `AVC`/`H.264`, `HEVC`/`H.265`, `VC1`, `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`. |
|
||||
| `-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`/`AC-4`, `OPUS`, `OGG`/`VORB`/`vorbis`, `DTS`, `DTSX`/`DTS-X`, `ALAC`, `FLAC`. |
|
||||
| `-vb`, `--vbitrate` | highest | Exact video 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`. |
|
||||
|
||||
@@ -120,6 +120,8 @@ either enum **names** or their **values**, comma-separated.
|
||||
| `VP9` | `VP9` |
|
||||
| `AV1` | `AV1` |
|
||||
|
||||
`H264` and `H265` are aliases of `AVC` and `HEVC`.
|
||||
|
||||
```shell title="Either spelling works"
|
||||
unshackle dl -v HEVC EXAMPLE 81234567
|
||||
unshackle dl -v H.265 EXAMPLE 81234567
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ classifiers = [
|
||||
dependencies = [
|
||||
"appdirs>=1.4.4,<2",
|
||||
"Brotli>=1.1.0,<2",
|
||||
"click>=8.1.8,<9",
|
||||
"click>=8.2,<9",
|
||||
"construct>=2.8.8,<3",
|
||||
"crccheck>=1.3.0,<2",
|
||||
"filelock>=3.20.3,<4",
|
||||
|
||||
@@ -7,10 +7,15 @@ import click
|
||||
import pytest
|
||||
|
||||
from unshackle.core.tracks.subtitle import Subtitle
|
||||
from unshackle.core.tracks.video import Video
|
||||
from unshackle.core.utils.click_types import (
|
||||
AUDIO_CODEC_LIST,
|
||||
LANGUAGE_RANGE,
|
||||
QUALITY_LIST,
|
||||
SLOW_DELAY_RANGE,
|
||||
SUBTITLE_CODEC,
|
||||
VIDEO_CODEC_LIST,
|
||||
MultipleChoice,
|
||||
SeasonRange,
|
||||
SubtitleCodecChoice,
|
||||
)
|
||||
@@ -186,3 +191,56 @@ def test_comma_separated_dates_convert():
|
||||
def test_bad_date_tokens_fail(token):
|
||||
with pytest.raises(click.UsageError):
|
||||
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:
|
||||
await search_handler({"service": "NOPE_THIS_IS_NOT_REAL_", "query": "x"}, None)
|
||||
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,
|
||||
SEASON_RANGE,
|
||||
SLOW_DELAY_RANGE,
|
||||
SUBTITLE_CODEC,
|
||||
VIDEO_CODEC_LIST,
|
||||
ContextData,
|
||||
MultipleChoice,
|
||||
MultipleVideoCodecChoice,
|
||||
SubtitleCodecChoice,
|
||||
)
|
||||
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
|
||||
@@ -534,7 +534,7 @@ class dl:
|
||||
@click.option(
|
||||
"-v",
|
||||
"--vcodec",
|
||||
type=MultipleVideoCodecChoice(Video.Codec),
|
||||
type=VIDEO_CODEC_LIST,
|
||||
default=[],
|
||||
help="Video Codec(s) to download, defaults to any codec.",
|
||||
)
|
||||
@@ -775,7 +775,7 @@ class dl:
|
||||
)
|
||||
@click.option(
|
||||
"--sub-format",
|
||||
type=SubtitleCodecChoice(Subtitle.Codec),
|
||||
type=SUBTITLE_CODEC,
|
||||
default=None,
|
||||
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.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
|
||||
|
||||
log = logging.getLogger("download_manager")
|
||||
@@ -387,10 +388,7 @@ def perform_download(
|
||||
|
||||
vcodec_raw = params.get("vcodec")
|
||||
if vcodec_raw:
|
||||
if isinstance(vcodec_raw, str):
|
||||
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)
|
||||
params["vcodec"] = VIDEO_CODEC_LIST.convert(vcodec_raw)
|
||||
else:
|
||||
params["vcodec"] = []
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from http.cookiejar import CookieJar, MozillaCookieJar
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List, Optional
|
||||
|
||||
import click
|
||||
from aiohttp import web
|
||||
|
||||
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.tracks import Audio, Subtitle, Tracks, Video
|
||||
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.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_ACODECS = [
|
||||
"AAC",
|
||||
"AC3",
|
||||
"EC3",
|
||||
"EAC3",
|
||||
"DD",
|
||||
"DD+",
|
||||
"AC4",
|
||||
"OPUS",
|
||||
"FLAC",
|
||||
"ALAC",
|
||||
"VORBIS",
|
||||
"OGG",
|
||||
"DTS",
|
||||
"DTSX",
|
||||
"DTS-X",
|
||||
]
|
||||
VALID_VCODECS = [choice.upper() for choice in VIDEO_CODEC_LIST.choices]
|
||||
VALID_ACODECS = [choice.upper() for choice in AUDIO_CODEC_LIST.choices]
|
||||
VALID_SUB_FORMATS = [choice.upper() for choice in SUBTITLE_CODEC.choices]
|
||||
|
||||
|
||||
def resolve_vcodec(value: Any) -> Optional[list]:
|
||||
"""Map a client's vcodec field to codec enums.
|
||||
|
||||
Session routes do not run validate_download_parameters, so this must answer junk with a 400
|
||||
instead of letting click's UsageError surface as a 500.
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return VIDEO_CODEC_LIST.convert(value) or None
|
||||
except click.UsageError as e:
|
||||
raise APIError(APIErrorCode.INVALID_INPUT, f"Invalid vcodec: {e.format_message()}")
|
||||
|
||||
|
||||
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
|
||||
|
||||
if "sub_format" in data and data["sub_format"]:
|
||||
valid_sub_formats = ["SRT", "VTT", "ASS", "SSA", "TTML", "STPP", "WVTT", "SMI", "SUB", "MPL2", "TMP"]
|
||||
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)}"
|
||||
if str(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)}"
|
||||
|
||||
if "vbitrate" in data and data["vbitrate"] is not None:
|
||||
if not isinstance(data["vbitrate"], int) or data["vbitrate"] <= 0:
|
||||
@@ -2884,16 +2884,7 @@ def create_service_instance(
|
||||
pass
|
||||
range_values = range_values or None
|
||||
|
||||
vcodec_names = 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
|
||||
vcodec_values = resolve_vcodec(data.get("vcodec"))
|
||||
|
||||
extra_params = {
|
||||
"range_": range_values,
|
||||
|
||||
@@ -675,14 +675,14 @@ async def download(request: web.Request) -> web.Response:
|
||||
- type: array
|
||||
items:
|
||||
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:
|
||||
oneOf:
|
||||
- type: string
|
||||
- type: array
|
||||
items:
|
||||
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:
|
||||
type: integer
|
||||
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)
|
||||
sub_format:
|
||||
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:
|
||||
type: boolean
|
||||
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 unshackle.core.tracks.audio import Audio
|
||||
from unshackle.core.tracks.subtitle import Subtitle
|
||||
from unshackle.core.tracks.video import Video
|
||||
|
||||
|
||||
class VideoCodecChoice(click.Choice):
|
||||
@@ -18,27 +20,31 @@ class VideoCodecChoice(click.Choice):
|
||||
- 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
|
||||
choices = []
|
||||
self._name_to_codec: dict[str, Video.Codec] = {}
|
||||
for codec in codec_enum:
|
||||
choices.append(codec.name.lower()) # e.g., "avc", "hevc"
|
||||
choices.append(codec.value) # e.g., "H.264", "H.265"
|
||||
super().__init__(choices, case_sensitive=False)
|
||||
for choice in (codec.name.lower(), codec.value.lower()):
|
||||
self._name_to_codec[choice] = codec
|
||||
|
||||
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:
|
||||
return None
|
||||
if isinstance(value, self.codec_enum):
|
||||
return value
|
||||
|
||||
converted_value = super().convert(value, param, ctx)
|
||||
|
||||
for codec in self.codec_enum:
|
||||
if converted_value.lower() == codec.name.lower():
|
||||
return codec
|
||||
if converted_value == codec.value:
|
||||
return codec
|
||||
|
||||
self.fail(f"'{value}' is not a valid video codec", param, ctx)
|
||||
codec = self._name_to_codec.get(str(converted_value).lower())
|
||||
if codec is None:
|
||||
self.fail(f"'{value}' is not a valid video codec", param, ctx)
|
||||
return codec
|
||||
|
||||
|
||||
class MultipleVideoCodecChoice(VideoCodecChoice):
|
||||
@@ -64,7 +70,10 @@ class MultipleVideoCodecChoice(VideoCodecChoice):
|
||||
|
||||
chosen_values: list[Any] = []
|
||||
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
|
||||
|
||||
|
||||
@@ -351,12 +360,12 @@ class AudioCodecList(click.ParamType):
|
||||
|
||||
name = "audio_codec_list"
|
||||
|
||||
def __init__(self, codec_enum):
|
||||
def __init__(self, codec_enum: type[Audio.Codec]) -> None:
|
||||
self.codec_enum = codec_enum
|
||||
self._name_to_codec: dict[str, Audio.Codec] = {}
|
||||
for codec in codec_enum:
|
||||
self._name_to_codec[codec.name.lower()] = codec
|
||||
self._name_to_codec[codec.value.lower()] = codec
|
||||
for choice in (codec.name.lower(), codec.value.lower()):
|
||||
self._name_to_codec[choice] = codec
|
||||
|
||||
aliases = {
|
||||
"eac3": "EC3",
|
||||
@@ -367,6 +376,14 @@ class AudioCodecList(click.ParamType):
|
||||
if target in codec_enum.__members__:
|
||||
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:
|
||||
if not value:
|
||||
return []
|
||||
@@ -387,10 +404,21 @@ class AudioCodecList(click.ParamType):
|
||||
if key in self._name_to_codec:
|
||||
codecs.append(self._name_to_codec[key])
|
||||
else:
|
||||
valid = sorted(set(self._name_to_codec.keys()))
|
||||
self.fail(f"'{val}' is not valid. Choices: {', '.join(valid)}", param, ctx)
|
||||
self.fail(f"'{val}' is not valid. Choices: {', '.join(sorted(self.choices))}", param, ctx)
|
||||
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):
|
||||
"""
|
||||
@@ -425,15 +453,21 @@ class MultipleChoice(click.Choice):
|
||||
|
||||
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:
|
||||
ctx: Invocation context for this command.
|
||||
param: The parameter that requests completion.
|
||||
incomplete: The value to complete. Can be empty.
|
||||
"""
|
||||
incomplete = incomplete.rsplit(",")[-1]
|
||||
return super(self).shell_complete(ctx, param, incomplete)
|
||||
prefix, sep, last = incomplete.rpartition(",")
|
||||
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):
|
||||
@@ -466,4 +500,6 @@ SEASON_RANGE = SeasonRange()
|
||||
LANGUAGE_RANGE = LanguageRange()
|
||||
QUALITY_LIST = QualityList()
|
||||
AUDIO_CODEC_LIST = AudioCodecList(Audio.Codec)
|
||||
VIDEO_CODEC_LIST = MultipleVideoCodecChoice(Video.Codec)
|
||||
SUBTITLE_CODEC = SubtitleCodecChoice(Subtitle.Codec)
|
||||
SLOW_DELAY_RANGE = SlowDelayRange()
|
||||
|
||||
@@ -2211,7 +2211,7 @@ requires-dist = [
|
||||
{ name = "bandit", specifier = ">=1.9.4" },
|
||||
{ name = "brotli", specifier = ">=1.1.0,<2" },
|
||||
{ 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 = "crccheck", specifier = ">=1.3.0,<2" },
|
||||
{ name = "cryptography", specifier = ">=45.0.0,<47" },
|
||||
|
||||
Reference in New Issue
Block a user