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:
imSp4rky
2026-09-17 19:22:19 -06:00
parent 372eb5ccf1
commit cf2906f120
13 changed files with 171 additions and 72 deletions
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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"]
}' }'
+2 -2
View File
@@ -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`. |
+2
View File
@@ -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
View File
@@ -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",
+58
View File
@@ -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
+4 -4
View File
@@ -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.",
) )
+2 -4
View File
@@ -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"] = []
+22 -31
View File
@@ -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,
+3 -3
View File
@@ -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)
+59 -23
View File
@@ -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()
Generated
+1 -1
View File
@@ -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" },