From 47b3390bd0fd16ba341533f7fdd8bdf695e03aab Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Mon, 30 Mar 2026 16:25:29 -0600 Subject: [PATCH] feat(dl): allow --slow to accept custom delay range --slow now supports an optional MIN-MAX range (e.g., --slow 20-40) for custom delay between title downloads. Bare --slow retains the original 60-120s default. Minimum delay enforced at 20 seconds. --- unshackle/commands/dl.py | 15 ++++++++------- unshackle/core/api/download_manager.py | 2 +- unshackle/core/api/handlers.py | 2 +- unshackle/core/services.py | 18 ++++++++++++++++++ unshackle/core/utils/click_types.py | 23 +++++++++++++++++++++++ 5 files changed, 51 insertions(+), 9 deletions(-) diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 9dc5a131..46a6dd90 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -58,7 +58,7 @@ from unshackle.core.utilities import (find_font_with_fallbacks, get_debug_logger is_close_match, suggest_font_packages, time_elapsed_since) from unshackle.core.utils import tags from unshackle.core.utils.click_types import (AUDIO_CODEC_LIST, LANGUAGE_RANGE, QUALITY_LIST, SEASON_RANGE, - ContextData, MultipleChoice, MultipleVideoCodecChoice, + SLOW_DELAY_RANGE, ContextData, MultipleChoice, MultipleVideoCodecChoice, SubtitleCodecChoice) from unshackle.core.utils.collections import merge_dict from unshackle.core.utils.selector import select_multiple @@ -460,10 +460,11 @@ class dl: @click.option("-ad", "--audio-description", is_flag=True, default=False, help="Download audio description tracks.") @click.option( "--slow", - is_flag=True, - default=False, - help="Add a 60-120 second delay between each Title download to act more like a real device. " - "This is recommended if you are downloading high-risk titles or streams.", + type=SLOW_DELAY_RANGE, + default=None, + help="Add a delay between each Title download to act more like a real device. " + "Use --slow for a 60-120s delay, or --slow MIN-MAX (e.g., --slow 20-40) for a custom range. " + "Minimum delay is 20 seconds.", ) @click.option( "--list", @@ -1017,7 +1018,7 @@ class dl: no_chapters: bool, no_video: bool, audio_description: bool, - slow: bool, + slow: Optional[tuple[int, int]], list_: bool, list_titles: bool, skip_dl: bool, @@ -1393,7 +1394,7 @@ class dl: ) if slow and i != 0: - delay = random.randint(60, 120) + delay = random.randint(slow[0], slow[1]) with console.status(f"Delaying by {delay} seconds..."): time.sleep(delay) diff --git a/unshackle/core/api/download_manager.py b/unshackle/core/api/download_manager.py index beadf4e2..87e445c3 100644 --- a/unshackle/core/api/download_manager.py +++ b/unshackle/core/api/download_manager.py @@ -289,7 +289,7 @@ def _perform_download( no_chapters=params.get("no_chapters", False), no_video=params.get("no_video", False), audio_description=params.get("audio_description", False), - slow=params.get("slow", False), + slow=params.get("slow", None), list_=False, list_titles=False, skip_dl=params.get("skip_dl", False), diff --git a/unshackle/core/api/handlers.py b/unshackle/core/api/handlers.py index dc5c52c0..0fadd23e 100644 --- a/unshackle/core/api/handlers.py +++ b/unshackle/core/api/handlers.py @@ -44,7 +44,7 @@ DEFAULT_DOWNLOAD_PARAMS = { "no_chapters": False, "no_video": False, "audio_description": False, - "slow": False, + "slow": None, "split_audio": None, "skip_dl": False, "export": None, diff --git a/unshackle/core/services.py b/unshackle/core/services.py index 140a767e..c013d0da 100644 --- a/unshackle/core/services.py +++ b/unshackle/core/services.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re from pathlib import Path import click @@ -29,6 +30,23 @@ class Services(click.MultiCommand): # Click-specific methods + def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]: + """Preprocess --slow to support optional range value before Click parses args.""" + processed = [] + i = 0 + while i < len(args): + if args[i] == "--slow": + if i + 1 < len(args) and re.match(r"^\d+-\d+$", args[i + 1]): + processed.append(f"--slow={args[i + 1]}") + i += 2 + else: + processed.append("--slow=60-120") + i += 1 + else: + processed.append(args[i]) + i += 1 + return super().parse_args(ctx, processed) + def list_commands(self, ctx: click.Context) -> list[str]: """Returns a list of all available Services as command names for Click. diff --git a/unshackle/core/utils/click_types.py b/unshackle/core/utils/click_types.py index 890ce253..3ccd5a97 100644 --- a/unshackle/core/utils/click_types.py +++ b/unshackle/core/utils/click_types.py @@ -360,9 +360,32 @@ class MultipleChoice(click.Choice): return super(self).shell_complete(ctx, param, incomplete) +class SlowDelayRange(click.ParamType): + """Parses a delay range string like '20-40' into a tuple of (min, max) seconds.""" + + name = "delay_range" + + def convert(self, value: Any, param: Optional[click.Parameter], ctx: Optional[click.Context]) -> tuple[int, int]: + if isinstance(value, tuple): + return value + + match = re.match(r"^(\d+)-(\d+)$", str(value)) + if not match: + self.fail(f"'{value}' is not a valid range. Use format: MIN-MAX (e.g., 20-40)", param, ctx) + + low, high = int(match.group(1)), int(match.group(2)) + if low < 20: + self.fail(f"Minimum delay must be at least 20 seconds, got {low}", param, ctx) + if low > high: + self.fail(f"Min ({low}) cannot be greater than max ({high})", param, ctx) + + return (low, high) + + SEASON_RANGE = SeasonRange() LANGUAGE_RANGE = LanguageRange() QUALITY_LIST = QualityList() AUDIO_CODEC_LIST = AudioCodecList(Audio.Codec) +SLOW_DELAY_RANGE = SlowDelayRange() # VIDEO_CODEC_CHOICE will be created dynamically when imported