[FEATURE] Override variable support for throttle protection ranges (#1315)
Adds support for setting throttle_protection range values using static override variables. `sleep_per_download_s` has additional support for entry variables since it's used per entry.
We can now experiment with scaling this value based on entry attributes, such as duration. Example:
```
throttle_protection:
sleep_per_download_s:
min: >-
{
%mul(5.5, %pow( duration, 0.4 ))
}
max: >-
{
%mul(6.5, %pow( duration, 0.6 ))
}
```
This commit is contained in:
parent
adb08ed5f9
commit
df8b4a1df8
4 changed files with 211 additions and 45 deletions
|
|
@ -1005,6 +1005,9 @@ Provides options to make ytdl-sub look more 'human-like' to protect from throttl
|
||||||
range-based values, a random number will be chosen within the range to avoid sleeps looking
|
range-based values, a random number will be chosen within the range to avoid sleeps looking
|
||||||
scripted.
|
scripted.
|
||||||
|
|
||||||
|
Range min and max values support static override variables within their definitions.
|
||||||
|
``sleep_per_download_s`` supports both static and override variables.
|
||||||
|
|
||||||
:Usage:
|
:Usage:
|
||||||
|
|
||||||
.. code-block:: yaml
|
.. code-block:: yaml
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
import random
|
import random
|
||||||
import time
|
import time
|
||||||
|
from abc import ABC
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
from typing import Type
|
||||||
|
from typing import TypeVar
|
||||||
|
|
||||||
from ytdl_sub.config.overrides import Overrides
|
from ytdl_sub.config.overrides import Overrides
|
||||||
from ytdl_sub.config.plugin.plugin import Plugin
|
from ytdl_sub.config.plugin.plugin import Plugin
|
||||||
|
|
@ -10,68 +13,135 @@ from ytdl_sub.entries.entry import Entry
|
||||||
from ytdl_sub.utils.file_handler import FileMetadata
|
from ytdl_sub.utils.file_handler import FileMetadata
|
||||||
from ytdl_sub.utils.logger import Logger
|
from ytdl_sub.utils.logger import Logger
|
||||||
from ytdl_sub.validators.strict_dict_validator import StrictDictValidator
|
from ytdl_sub.validators.strict_dict_validator import StrictDictValidator
|
||||||
from ytdl_sub.validators.validators import FloatValidator
|
from ytdl_sub.validators.string_formatter_validators import FloatFormatterValidator
|
||||||
|
from ytdl_sub.validators.string_formatter_validators import OverridesFloatFormatterValidator
|
||||||
from ytdl_sub.validators.validators import ProbabilityValidator
|
from ytdl_sub.validators.validators import ProbabilityValidator
|
||||||
from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive
|
from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive
|
||||||
|
|
||||||
logger = Logger.get("throttle-protection")
|
logger = Logger.get("throttle-protection")
|
||||||
|
|
||||||
|
FloatValidatorT = TypeVar("FloatValidatorT", bound=FloatFormatterValidator)
|
||||||
|
|
||||||
class RandomizedRangeValidator(StrictDictValidator):
|
|
||||||
|
class _RandomizedRangeValidator(StrictDictValidator, ABC):
|
||||||
"""
|
"""
|
||||||
Validator to specify a float range between [min, max)
|
Base class for range validation, to support both entry and static overrides.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
_float_validator: Type[FloatValidatorT]
|
||||||
|
|
||||||
_required_keys = {"max"}
|
_required_keys = {"max"}
|
||||||
_optional_keys = {"min"}
|
_optional_keys = {"min"}
|
||||||
|
|
||||||
def __init__(self, name, value):
|
def __init__(self, name, value):
|
||||||
super().__init__(name, value)
|
super().__init__(name, value)
|
||||||
|
|
||||||
self._max = self._validate_key(key="max", validator=FloatValidator).value
|
self._max = self._validate_key(key="max", validator=self._float_validator)
|
||||||
self._min = self._validate_key_if_present(
|
self._min = self._validate_key_if_present(
|
||||||
key="min", validator=FloatValidator, default=0.0
|
key="min", validator=self._float_validator, default=0.0
|
||||||
).value
|
)
|
||||||
|
|
||||||
if self._min < 0:
|
def _randomized_float(self, overrides: Overrides, entry: Optional[Entry] = None) -> float:
|
||||||
raise self._validation_exception("min must be greater than zero")
|
actualized_min = float(overrides.apply_formatter(self._min, entry=entry))
|
||||||
|
actualized_max = float(overrides.apply_formatter(self._max, entry=entry))
|
||||||
|
|
||||||
if self._max < self._min:
|
if actualized_min < 0:
|
||||||
raise self._validation_exception(
|
raise self._validation_exception(
|
||||||
f"max ({self._max}) must be greater than or equal to min ({self._min})"
|
f"min must be greater than zero, received {actualized_min}"
|
||||||
|
)
|
||||||
|
if actualized_max < actualized_min:
|
||||||
|
raise self._validation_exception(
|
||||||
|
f"max ({actualized_max}) must be greater than or equal to min ({actualized_min})"
|
||||||
)
|
)
|
||||||
|
|
||||||
def min_value(self) -> float:
|
return random.uniform(actualized_min, actualized_max)
|
||||||
"""
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
Minimum value
|
|
||||||
"""
|
|
||||||
return self._min
|
|
||||||
|
|
||||||
def max_value(self) -> float:
|
def _randomized_int(self, overrides: Overrides, entry: Optional[Entry] = None) -> int:
|
||||||
"""
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
Maximum value
|
|
||||||
"""
|
|
||||||
return self._max
|
|
||||||
|
|
||||||
def randomized_float(self) -> float:
|
|
||||||
"""
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
A random float within the range
|
|
||||||
"""
|
|
||||||
return random.uniform(self._min, self._max)
|
|
||||||
|
|
||||||
def randomized_int(self) -> int:
|
|
||||||
"""
|
"""
|
||||||
Returns
|
Returns
|
||||||
-------
|
-------
|
||||||
A random float within the range, then cast to an integer (floored)
|
A random float within the range, then cast to an integer (floored)
|
||||||
"""
|
"""
|
||||||
return int(self.randomized_float())
|
return int(self._randomized_float(overrides, entry=entry))
|
||||||
|
|
||||||
|
def _max_value(self, overrides: Overrides, entry: Optional[Entry] = None) -> float:
|
||||||
|
"""
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
Max possible value
|
||||||
|
"""
|
||||||
|
actualized_max = float(overrides.apply_formatter(self._max, entry=entry))
|
||||||
|
if actualized_max < 0:
|
||||||
|
raise self._validation_exception(
|
||||||
|
f"max must be greater than zero, received {actualized_max}"
|
||||||
|
)
|
||||||
|
return actualized_max
|
||||||
|
|
||||||
|
|
||||||
|
class RandomizedRangeValidator(_RandomizedRangeValidator):
|
||||||
|
"""
|
||||||
|
Validator to specify a float range between [min, max) with both
|
||||||
|
override and entry variable support.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_float_validator = FloatFormatterValidator
|
||||||
|
|
||||||
|
def randomized_float(self, overrides: Overrides, entry: Entry) -> float:
|
||||||
|
"""
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
A random float within the range
|
||||||
|
"""
|
||||||
|
return self._randomized_float(overrides=overrides, entry=entry)
|
||||||
|
|
||||||
|
def randomized_int(self, overrides: Overrides, entry: Entry) -> int:
|
||||||
|
"""
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
A random float within the range, then cast to an integer (floored)
|
||||||
|
"""
|
||||||
|
return self._randomized_int(overrides=overrides, entry=entry)
|
||||||
|
|
||||||
|
def max_value(self, overrides: Overrides, entry: Entry) -> float:
|
||||||
|
"""
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
Max possible value
|
||||||
|
"""
|
||||||
|
return self._max_value(overrides=overrides, entry=entry)
|
||||||
|
|
||||||
|
|
||||||
|
class RandomizedRangeOverridesValidator(_RandomizedRangeValidator):
|
||||||
|
"""
|
||||||
|
Validator to specify a float range between [min, max) with
|
||||||
|
static variable support.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_float_validator = OverridesFloatFormatterValidator
|
||||||
|
|
||||||
|
def randomized_float(self, overrides: Overrides) -> float:
|
||||||
|
"""
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
A random float within the range
|
||||||
|
"""
|
||||||
|
return self._randomized_float(overrides=overrides)
|
||||||
|
|
||||||
|
def randomized_int(self, overrides: Overrides) -> int:
|
||||||
|
"""
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
A random float within the range, then cast to an integer (floored)
|
||||||
|
"""
|
||||||
|
return self._randomized_int(overrides=overrides)
|
||||||
|
|
||||||
|
def max_value(self, overrides: Overrides) -> float:
|
||||||
|
"""
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
Max possible value
|
||||||
|
"""
|
||||||
|
return self._max_value(overrides=overrides)
|
||||||
|
|
||||||
|
|
||||||
class ThrottleProtectionOptions(ToggleableOptionsDictValidator):
|
class ThrottleProtectionOptions(ToggleableOptionsDictValidator):
|
||||||
|
|
@ -80,6 +150,9 @@ class ThrottleProtectionOptions(ToggleableOptionsDictValidator):
|
||||||
range-based values, a random number will be chosen within the range to avoid sleeps looking
|
range-based values, a random number will be chosen within the range to avoid sleeps looking
|
||||||
scripted.
|
scripted.
|
||||||
|
|
||||||
|
Range min and max values support static override variables within their definitions.
|
||||||
|
``sleep_per_download_s`` supports both static and override variables.
|
||||||
|
|
||||||
:Usage:
|
:Usage:
|
||||||
|
|
||||||
.. code-block:: yaml
|
.. code-block:: yaml
|
||||||
|
|
@ -115,16 +188,16 @@ class ThrottleProtectionOptions(ToggleableOptionsDictValidator):
|
||||||
super().__init__(name, value)
|
super().__init__(name, value)
|
||||||
|
|
||||||
self._sleep_per_request_s = self._validate_key_if_present(
|
self._sleep_per_request_s = self._validate_key_if_present(
|
||||||
key="sleep_per_request_s", validator=RandomizedRangeValidator
|
key="sleep_per_request_s", validator=RandomizedRangeOverridesValidator
|
||||||
)
|
)
|
||||||
self._sleep_per_download_s = self._validate_key_if_present(
|
self._sleep_per_download_s = self._validate_key_if_present(
|
||||||
key="sleep_per_download_s", validator=RandomizedRangeValidator
|
key="sleep_per_download_s", validator=RandomizedRangeValidator
|
||||||
)
|
)
|
||||||
self._sleep_per_subscription_s = self._validate_key_if_present(
|
self._sleep_per_subscription_s = self._validate_key_if_present(
|
||||||
key="sleep_per_subscription_s", validator=RandomizedRangeValidator
|
key="sleep_per_subscription_s", validator=RandomizedRangeOverridesValidator
|
||||||
)
|
)
|
||||||
self._max_downloads_per_subscription = self._validate_key_if_present(
|
self._max_downloads_per_subscription = self._validate_key_if_present(
|
||||||
key="max_downloads_per_subscription", validator=RandomizedRangeValidator
|
key="max_downloads_per_subscription", validator=RandomizedRangeOverridesValidator
|
||||||
)
|
)
|
||||||
self._subscription_download_probability = self._validate_key_if_present(
|
self._subscription_download_probability = self._validate_key_if_present(
|
||||||
key="subscription_download_probability", validator=ProbabilityValidator
|
key="subscription_download_probability", validator=ProbabilityValidator
|
||||||
|
|
@ -205,12 +278,18 @@ class ThrottleProtectionPlugin(Plugin[ThrottleProtectionOptions]):
|
||||||
# If subscriptions have a max download limit, set it here for the first subscription
|
# If subscriptions have a max download limit, set it here for the first subscription
|
||||||
if self.plugin_options.max_downloads_per_subscription:
|
if self.plugin_options.max_downloads_per_subscription:
|
||||||
self._subscription_max_downloads = (
|
self._subscription_max_downloads = (
|
||||||
self.plugin_options.max_downloads_per_subscription.randomized_int()
|
self.plugin_options.max_downloads_per_subscription.randomized_int(
|
||||||
|
overrides=self.overrides
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def ytdl_options(self) -> Optional[Dict]:
|
def ytdl_options(self) -> Optional[Dict]:
|
||||||
if self.plugin_options.sleep_per_request_s is not None:
|
if self.plugin_options.sleep_per_request_s is not None:
|
||||||
return {"sleep_interval_requests": self.plugin_options.sleep_per_request_s.max_value()}
|
return {
|
||||||
|
"sleep_interval_requests": self.plugin_options.sleep_per_request_s.max_value(
|
||||||
|
overrides=self.overrides
|
||||||
|
)
|
||||||
|
}
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
def initialize_subscription(self) -> bool:
|
def initialize_subscription(self) -> bool:
|
||||||
|
|
@ -254,8 +333,12 @@ class ThrottleProtectionPlugin(Plugin[ThrottleProtectionOptions]):
|
||||||
self._subscription_download_counter += 1
|
self._subscription_download_counter += 1
|
||||||
|
|
||||||
if self.plugin_options.sleep_per_download_s:
|
if self.plugin_options.sleep_per_download_s:
|
||||||
sleep_time = self.plugin_options.sleep_per_download_s.randomized_float()
|
sleep_time = self.plugin_options.sleep_per_download_s.randomized_float(
|
||||||
logger.info("Sleeping between downloads for %0.2f seconds", sleep_time)
|
overrides=self.overrides, entry=entry
|
||||||
|
)
|
||||||
|
# pylint: disable=logging-fstring-interpolation)
|
||||||
|
# needed to test logs in unit test
|
||||||
|
logger.info(f"Sleeping between downloads for {sleep_time:.2f} seconds")
|
||||||
self.perform_sleep(sleep_time)
|
self.perform_sleep(sleep_time)
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
@ -267,10 +350,14 @@ class ThrottleProtectionPlugin(Plugin[ThrottleProtectionOptions]):
|
||||||
# If present, reset max downloads for the next subscription
|
# If present, reset max downloads for the next subscription
|
||||||
if self.plugin_options.max_downloads_per_subscription:
|
if self.plugin_options.max_downloads_per_subscription:
|
||||||
self._subscription_max_downloads = (
|
self._subscription_max_downloads = (
|
||||||
self.plugin_options.max_downloads_per_subscription.randomized_int
|
self.plugin_options.max_downloads_per_subscription.randomized_int(
|
||||||
|
overrides=self.overrides
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.plugin_options.sleep_per_subscription_s:
|
if self.plugin_options.sleep_per_subscription_s:
|
||||||
sleep_time = self.plugin_options.sleep_per_subscription_s.randomized_float()
|
sleep_time = self.plugin_options.sleep_per_subscription_s.randomized_float(
|
||||||
|
overrides=self.overrides
|
||||||
|
)
|
||||||
logger.info("Sleeping between subscriptions for %0.2f seconds", sleep_time)
|
logger.info("Sleeping between subscriptions for %0.2f seconds", sleep_time)
|
||||||
self.perform_sleep(sleep_time)
|
self.perform_sleep(sleep_time)
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,20 @@ class StringFormatterValidator(StringValidator):
|
||||||
return resolved
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
class FloatFormatterValidator(StringFormatterValidator):
|
||||||
|
_expected_value_type_name = "float"
|
||||||
|
|
||||||
|
def post_process(self, resolved: str) -> str:
|
||||||
|
try:
|
||||||
|
float(resolved)
|
||||||
|
except Exception as exc:
|
||||||
|
raise self._validation_exception(
|
||||||
|
f"Expected a float, but received '{resolved}'"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
class StandardizedDateValidator(StringFormatterValidator):
|
class StandardizedDateValidator(StringFormatterValidator):
|
||||||
_expected_value_type_name = "standardized_date"
|
_expected_value_type_name = "standardized_date"
|
||||||
|
|
||||||
|
|
@ -118,6 +132,12 @@ class OverridesIntegerFormatterValidator(OverridesStringFormatterValidator):
|
||||||
return resolved
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
class OverridesFloatFormatterValidator(FloatFormatterValidator, OverridesStringFormatterValidator):
|
||||||
|
"""
|
||||||
|
Float validator but static
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
class OverridesBooleanFormatterValidator(OverridesStringFormatterValidator):
|
class OverridesBooleanFormatterValidator(OverridesStringFormatterValidator):
|
||||||
_expected_value_type_name = "boolean"
|
_expected_value_type_name = "boolean"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ from ytdl_sub.plugins.throttle_protection import logger as throttle_protection_l
|
||||||
from ytdl_sub.script.functions.print_functions import logger as script_print_logger
|
from ytdl_sub.script.functions.print_functions import logger as script_print_logger
|
||||||
from ytdl_sub.script.utils.exceptions import UserThrownRuntimeError
|
from ytdl_sub.script.utils.exceptions import UserThrownRuntimeError
|
||||||
from ytdl_sub.subscriptions.subscription import Subscription
|
from ytdl_sub.subscriptions.subscription import Subscription
|
||||||
|
from ytdl_sub.utils.exceptions import StringFormattingVariableNotFoundException
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
@ -57,7 +58,7 @@ class TestThrottleProtectionPlugin:
|
||||||
),
|
),
|
||||||
assert_logs(
|
assert_logs(
|
||||||
logger=throttle_protection_logger,
|
logger=throttle_protection_logger,
|
||||||
expected_message="Sleeping between downloads for %0.2f seconds",
|
expected_message="Sleeping between downloads for 0.01 seconds",
|
||||||
log_level="info",
|
log_level="info",
|
||||||
expected_occurrences=4,
|
expected_occurrences=4,
|
||||||
),
|
),
|
||||||
|
|
@ -288,3 +289,58 @@ class TestResolutionAssert:
|
||||||
pytest.raises(UserThrownRuntimeError, match=re.escape(expected_message)),
|
pytest.raises(UserThrownRuntimeError, match=re.escape(expected_message)),
|
||||||
):
|
):
|
||||||
_ = subscription.download(dry_run=True)
|
_ = subscription.download(dry_run=True)
|
||||||
|
|
||||||
|
def test_sleep_per_download_supports_entry_variables(
|
||||||
|
self,
|
||||||
|
config,
|
||||||
|
subscription_name,
|
||||||
|
throttle_subscription_dict,
|
||||||
|
output_directory,
|
||||||
|
mock_download_collection_entries,
|
||||||
|
):
|
||||||
|
throttle_subscription_dict["throttle_protection"]["sleep_per_download_s"] = {
|
||||||
|
"min": "{%mul(3.14, duration)}",
|
||||||
|
"max": "{%mul(3.14, duration)}",
|
||||||
|
}
|
||||||
|
|
||||||
|
subscription = Subscription.from_dict(
|
||||||
|
config=config,
|
||||||
|
preset_name=subscription_name,
|
||||||
|
preset_dict=throttle_subscription_dict,
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock_download_collection_entries(
|
||||||
|
is_youtube_channel=False,
|
||||||
|
num_urls=1,
|
||||||
|
is_extracted_audio=False,
|
||||||
|
mock_entry_kwargs={"duration": 1},
|
||||||
|
),
|
||||||
|
assert_logs(
|
||||||
|
logger=throttle_protection_logger,
|
||||||
|
expected_message="Sleeping between downloads for 3.14 seconds",
|
||||||
|
log_level="info",
|
||||||
|
expected_occurrences=4,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
_ = subscription.download(dry_run=False)
|
||||||
|
|
||||||
|
def test_sleep_per_subscription_does_not_support_entry_variables(
|
||||||
|
self,
|
||||||
|
config,
|
||||||
|
subscription_name,
|
||||||
|
throttle_subscription_dict,
|
||||||
|
output_directory,
|
||||||
|
mock_download_collection_entries,
|
||||||
|
):
|
||||||
|
throttle_subscription_dict["throttle_protection"]["sleep_per_subscription_s"] = {
|
||||||
|
"min": "{%mul(3.14, duration)}",
|
||||||
|
"max": "{%mul(3.14, duration)}",
|
||||||
|
}
|
||||||
|
|
||||||
|
with pytest.raises(StringFormattingVariableNotFoundException):
|
||||||
|
_ = Subscription.from_dict(
|
||||||
|
config=config,
|
||||||
|
preset_name=subscription_name,
|
||||||
|
preset_dict=throttle_subscription_dict,
|
||||||
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue