[FEATURE] Add throttle_protection plugin (#799)

Provides options to make ytdl-sub look more 'human-like' to protect from throttling. For range-based values, a random number will be chosen within the range to avoid sleeps looking scripted.

Usage:
```
    throttle_protection:
      sleep_per_download_s:
        min: 2.2
        max: 10.8
      sleep_per_subscription_s:
        min: 9.0
        max: 14.1
      max_downloads_per_subscription:
        min: 10
        max: 36
      subscription_download_probability: 1.0
```
This commit is contained in:
Jesse Bannon 2023-11-05 08:23:12 -08:00 committed by GitHub
parent 19eee28f4a
commit f1a2187a17
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 446 additions and 22 deletions

View file

@ -252,6 +252,14 @@ subtitles
-------------------------------------------------------------------------------
throttle_protection
'''''''''''''''''''
.. autoclass:: ytdl_sub.plugins.throttle_protection.ThrottleProtectionOptions()
:members:
:member-order: bysource
-------------------------------------------------------------------------------
video_tags
''''''''''
.. autoclass:: ytdl_sub.plugins.video_tags.VideoTagsOptions()

View file

@ -15,6 +15,7 @@ disable = [
"R0913", # Too many arguments
"R0901", # too-many-ancestors
"R0902", # too-many-instance-attributes
"R1711", # useless-return
"W0511", # TODO
]

View file

@ -1,6 +1,7 @@
import hashlib
import re
import shlex
from typing import Any
from typing import Dict
from typing import List
from typing import Tuple
@ -116,7 +117,7 @@ class DownloadArgsParser:
return largest_consecutive + 1
@classmethod
def _argument_name_and_value_to_dict(cls, arg_name: str, arg_value: str) -> Dict:
def _argument_name_and_value_to_dict(cls, arg_name: str, arg_value: Any) -> Dict:
"""
:param arg_name: Argument name in the form of 'key1.key2.key3'
:param arg_value: Argument value
@ -134,11 +135,15 @@ class DownloadArgsParser:
next_dict[arg_name_split[-1]] = arg_value
# TODO: handle ints/floats
if arg_value == "True":
next_dict[arg_name_split[-1]] = True
elif arg_value == "False":
next_dict[arg_name_split[-1]] = False
if isinstance(arg_value, str):
if arg_value == "True":
next_dict[arg_name_split[-1]] = True
elif arg_value == "False":
next_dict[arg_name_split[-1]] = False
elif arg_value.isdigit():
next_dict[arg_name_split[-1]] = int(arg_value)
elif arg_value.replace(".", "", 1).isdigit():
next_dict[arg_name_split[-1]] = float(arg_value)
return argument_dict

View file

@ -17,6 +17,7 @@ from ytdl_sub.plugins.output_directory_nfo_tags import OutputDirectoryNfoTagsPlu
from ytdl_sub.plugins.regex import RegexPlugin
from ytdl_sub.plugins.split_by_chapters import SplitByChaptersPlugin
from ytdl_sub.plugins.subtitles import SubtitlesPlugin
from ytdl_sub.plugins.throttle_protection import ThrottleProtectionPlugin
from ytdl_sub.plugins.video_tags import VideoTagsPlugin
@ -41,6 +42,7 @@ class PluginMapping:
"subtitles": SubtitlesPlugin,
"chapters": ChaptersPlugin,
"split_by_chapters": SplitByChaptersPlugin,
"throttle_protection": ThrottleProtectionPlugin,
}
@classmethod

View file

@ -12,7 +12,7 @@ from mergedeep import mergedeep
from ytdl_sub.config.config_validator import ConfigValidator
from ytdl_sub.config.plugin import Plugin
from ytdl_sub.config.preset_class_mappings import PluginMapping
from ytdl_sub.config.plugin_mapping import PluginMapping
from ytdl_sub.config.preset_options import OptionsValidator
from ytdl_sub.config.preset_options import OutputOptions
from ytdl_sub.config.preset_options import Overrides

View file

@ -0,0 +1,233 @@
import random
import time
from typing import List
from typing import Optional
from typing import Tuple
from ytdl_sub.config.plugin import Plugin
from ytdl_sub.config.preset_options import OptionsDictValidator
from ytdl_sub.config.preset_options import Overrides
from ytdl_sub.entries.entry import Entry
from ytdl_sub.utils.file_handler import FileMetadata
from ytdl_sub.utils.logger import Logger
from ytdl_sub.validators.strict_dict_validator import StrictDictValidator
from ytdl_sub.validators.validators import FloatValidator
from ytdl_sub.validators.validators import ProbabilityValidator
from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive
logger = Logger.get("throttle-protection")
class RandomizedRangeValidator(StrictDictValidator):
"""
Validator to specify a float range between [min, max)
"""
_required_keys = {"max"}
_optional_keys = {"min"}
def __init__(self, name, value):
super().__init__(name, value)
self._max = self._validate_key(key="max", validator=FloatValidator).value
self._min = self._validate_key_if_present(
key="min", validator=FloatValidator, default=0.0
).value
if self._min < 0:
raise self._validation_exception("min must be greater than zero")
if self._max < self._min:
raise self._validation_exception(
f"max ({self._max}) must be greater than or equal to min ({self._min})"
)
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
-------
A random float within the range, then cast to an integer (floored)
"""
return int(self.randomized_float())
class ThrottleProtectionOptions(OptionsDictValidator):
"""
Provides options to make ytdl-sub look more 'human-like' to protect from throttling. For
range-based values, a random number will be chosen within the range to avoid sleeps looking
scripted.
Usage:
.. code-block:: yaml
presets:
my_example_preset:
throttle_protection:
sleep_per_download_s:
min: 2.2
max: 10.8
sleep_per_subscription_s:
min: 9.0
max: 14.1
max_downloads_per_subscription:
min: 10
max: 36
subscription_download_probability: 1.0
"""
_optional_keys = {
"sleep_per_download_s",
"sleep_per_subscription_s",
"max_downloads_per_subscription",
"subscription_download_probability",
}
def __init__(self, name, value):
super().__init__(name, value)
self._sleep_per_download_s = self._validate_key_if_present(
key="sleep_per_download_s", validator=RandomizedRangeValidator
)
self._sleep_per_subscription_s = self._validate_key_if_present(
key="sleep_per_subscription_s", validator=RandomizedRangeValidator
)
self._max_downloads_per_subscription = self._validate_key_if_present(
key="max_downloads_per_subscription", validator=RandomizedRangeValidator
)
self._subscription_download_probability = self._validate_key_if_present(
key="subscription_download_probability", validator=ProbabilityValidator
)
@property
def sleep_per_download_s(self) -> Optional[RandomizedRangeValidator]:
"""
Number in seconds to sleep between each download. Does not include time it takes for
ytdl-sub to perform post-processing.
"""
return self._sleep_per_download_s
@property
def sleep_per_subscription_s(self) -> Optional[RandomizedRangeValidator]:
"""
Number in seconds to sleep between each subscription.
"""
return self._sleep_per_subscription_s
@property
def max_downloads_per_subscription(self) -> Optional[RandomizedRangeValidator]:
"""
Number of downloads to perform per subscription.
"""
return self._max_downloads_per_subscription
@property
def subscription_download_probability(self) -> Optional[ProbabilityValidator]:
"""
Probability to perform any downloads, recomputed for each subscription. This is only
recommended to set if you run ytdl-sub in a cron-job, that way you are statistically
guaranteed over time to eventually download the subscription.
"""
return self._subscription_download_probability
class ThrottleProtectionPlugin(Plugin[ThrottleProtectionOptions]):
plugin_options_type = ThrottleProtectionOptions
def __init__(
self,
options: ThrottleProtectionOptions,
overrides: Overrides,
enhanced_download_archive: EnhancedDownloadArchive,
):
super().__init__(options, overrides, enhanced_download_archive)
self._subscription_download_counter: int = 0
self._subscription_max_downloads: Optional[int] = None
# If subscriptions have a max download limit, set it here for the first subscription
if self.plugin_options.max_downloads_per_subscription:
self._subscription_max_downloads = (
self.plugin_options.max_downloads_per_subscription.randomized_int()
)
def ytdl_options_match_filters(self) -> Tuple[List[str], List[str]]:
"""
Returns
-------
If subscription_download_probability, match-filters that will perform no downloads
if it's rolled to not download.
"""
perform_download: Tuple[List[str], List[str]] = [], []
do_not_perform_download: Tuple[List[str], List[str]] = [], [
"title = __YTDL_SUB_THROTTLE_PROTECTION_ON_SUBSCRIPTION_DOWNLOAD__"
]
if self.plugin_options.subscription_download_probability:
proba = self.plugin_options.subscription_download_probability.value
# assume proba is set to 1.0, random.random() will always be < 1, can never reach this
if random.random() > proba:
logger.info(
"Subscription download probability of %f missed, skipping this subscription",
proba,
)
return do_not_perform_download
return perform_download
def modify_entry_metadata(self, entry: Entry) -> Optional[Entry]:
if (
self._subscription_max_downloads is not None
and self._subscription_download_counter >= self._subscription_max_downloads
):
if self._subscription_download_counter == self._subscription_max_downloads:
logger.info(
"Reached subscription max downloads of %d for throttle protection",
self._subscription_max_downloads,
)
self._subscription_download_counter += 1 # increment to only print once
return None
return entry
def post_process_entry(self, entry: Entry) -> Optional[FileMetadata]:
if (
self._subscription_max_downloads is not None
and self._subscription_download_counter == 0
):
logger.debug(
"Setting subscription max downloads to %d", self._subscription_max_downloads
)
# Increment the counter
self._subscription_download_counter += 1
if self.plugin_options.sleep_per_download_s:
sleep_time = self.plugin_options.sleep_per_download_s.randomized_float()
logger.debug("Sleeping between downloads for %0.2f seconds", sleep_time)
time.sleep(sleep_time)
return None
def post_process_subscription(self):
# Reset counter to 0 for the next subscription
self._subscription_download_counter = 0
# If present, reset max downloads for the next subscription
if self.plugin_options.max_downloads_per_subscription:
self._subscription_max_downloads = (
self.plugin_options.max_downloads_per_subscription.randomized_int
)
if self.plugin_options.sleep_per_subscription_s:
sleep_time = self.plugin_options.sleep_per_subscription_s.randomized_float()
logger.debug("Sleeping between subscriptions for %0.2f seconds", sleep_time)
time.sleep(sleep_time)

View file

@ -149,6 +149,15 @@ class IntValidator(ValueValidator[int]):
_expected_value_type_name = "int"
class ProbabilityValidator(FloatValidator):
_expected_value_type_name = "probability"
def __init__(self, name, value):
super().__init__(name, value)
if self.value < 0 or self.value > 1:
raise self._validation_exception("Probabilities must be between 0 and 1.0")
class ListValidator(Validator, ABC, Generic[ValidatorT]):
"""
Validates a list of objects to validate

View file

@ -9,6 +9,7 @@ from typing import Any
from typing import Callable
from typing import Dict
from typing import List
from typing import Optional
from unittest.mock import patch
import pytest
@ -78,11 +79,17 @@ def reformat_directory() -> Path:
@contextlib.contextmanager
def assert_logs(logger: logging.Logger, expected_message: str, log_level: str = "debug"):
def assert_logs(
logger: logging.Logger,
expected_message: str,
log_level: str = "debug",
expected_occurrences: Optional[int] = None,
):
"""
Patches any function, but calls the original function.
Intended to see if the particular function is called.
"""
occurrences = 0
debug_logger = Logger.get()
def _wrapped_debug(*args, **kwargs):
@ -92,10 +99,14 @@ def assert_logs(logger: logging.Logger, expected_message: str, log_level: str =
yield
for call_args in patched_debug.call_args_list:
if expected_message in call_args.args[0]:
return
occurrences += int(expected_message in call_args.args[0])
assert False, f"{expected_message} was not found in a logger.debug call"
if expected_occurrences:
assert (
occurrences == expected_occurrences
), f"{expected_message} was expected {expected_occurrences} times, got {occurrences}"
else:
assert occurrences > 0, f"{expected_message} was not found in a logger.debug call"
def preset_dict_to_dl_args(preset_dict: Dict) -> str:

View file

@ -9,7 +9,6 @@ import pytest
from ytdl_sub.cli.entrypoint import main
from ytdl_sub.subscriptions.subscription import Subscription
from ytdl_sub.utils.file_handler import FileHandler
from ytdl_sub.utils.system import IS_WINDOWS
@pytest.fixture

View file

@ -0,0 +1,72 @@
import pytest
from conftest import assert_logs
from ytdl_sub.plugins.throttle_protection import logger as throttle_protection_logger
from ytdl_sub.subscriptions.subscription import Subscription
@pytest.fixture
def preset_dict_max_downloads_0(output_directory):
return {
"preset": "Jellyfin Music Videos",
"download": "https://youtube.com/watch?v=HKTNxEqsN3Q",
"format": "worst[ext=mp4]",
"overrides": {
"music_video_artist": "JMC",
"music_video_directory": output_directory,
},
"throttle_protection": {"max_downloads_per_subscription": {"max": 0}},
}
@pytest.fixture
def preset_dict_subscription_download_proba_0(output_directory):
return {
"preset": "Jellyfin Music Videos",
"download": "https://youtube.com/watch?v=HKTNxEqsN3Q",
"format": "worst[ext=mp4]",
"overrides": {
"music_video_artist": "JMC",
"music_video_directory": output_directory,
},
"throttle_protection": {"subscription_download_probability": 0.0},
}
class TestThrottleProtection:
def test_max_downloads(
self,
default_config,
preset_dict_max_downloads_0,
output_directory,
):
single_video_subscription = Subscription.from_dict(
config=default_config,
preset_name="music_video_single_video_test",
preset_dict=preset_dict_max_downloads_0,
)
with assert_logs(
logger=throttle_protection_logger,
expected_message="Reached subscription max downloads of %d",
log_level="info",
expected_occurrences=1,
):
transaction_log = single_video_subscription.download(dry_run=True)
assert transaction_log.is_empty
def test_subscription_probability(
self,
default_config,
preset_dict_subscription_download_proba_0,
output_directory,
):
single_video_subscription = Subscription.from_dict(
config=default_config,
preset_name="music_video_single_video_test",
preset_dict=preset_dict_subscription_download_proba_0,
)
transaction_log = single_video_subscription.download(dry_run=True)
assert transaction_log.is_empty

View file

@ -21,7 +21,6 @@ def single_video_preset_dict_old_video_tags_format(output_directory):
"download": "https://youtube.com/watch?v=HKTNxEqsN3Q",
# override the output directory with our fixture-generated dir
"output_options": {
"output_directory": output_directory,
"maintain_download_archive": False,
},
"embed_thumbnail": True, # embed thumb into the video
@ -32,7 +31,10 @@ def single_video_preset_dict_old_video_tags_format(output_directory):
"title": "{title}",
}
},
"overrides": {"music_video_artist": "JMC"},
"overrides": {
"music_video_artist": "JMC",
"music_video_directory": output_directory,
},
}
@ -43,7 +45,6 @@ def single_video_preset_dict(output_directory):
"download": "https://youtube.com/watch?v=HKTNxEqsN3Q",
# override the output directory with our fixture-generated dir
"output_options": {
"output_directory": output_directory,
"maintain_download_archive": False,
},
"embed_thumbnail": True, # embed thumb into the video
@ -52,7 +53,12 @@ def single_video_preset_dict(output_directory):
"video_tags": {
"title": "{title}",
},
"overrides": {"music_video_artist": "JMC"},
# And test subscription download proba = 1.0
"throttle_protection": {"subscription_download_probability": 1.0},
"overrides": {
"music_video_artist": "JMC",
"music_video_directory": output_directory,
},
}
@ -113,7 +119,7 @@ class TestYoutubeVideo:
transaction_log_summary_file_name="youtube/test_video.txt",
)
@pytest.mark.parametrize("dry_run", [True])
@pytest.mark.parametrize("dry_run", [True, False])
def test_single_video_download(
self,
default_config,

View file

@ -1,6 +1,5 @@
{
"JMC/JMC - Oblivion Mod Falcor p.1-thumb.jpg": "fb95b510681676e81c321171fc23143e",
"JMC/JMC - Oblivion Mod Falcor p.1.info.json": "08b0f7d93488d625bd7311ab925cec77",
"JMC/JMC - Oblivion Mod Falcor p.1.mp4": "797b44f3207be01651780d6d86cb70bb",
"JMC/JMC - Oblivion Mod Falcor p.1.nfo": "24cc4e17d2bebc89b2759ce5471d403e"
"JMC/Oblivion Mod Falcor p.1.jpg": "fb95b510681676e81c321171fc23143e",
"JMC/Oblivion Mod Falcor p.1.mp4": "0448c9fd3eeaba4eca7f650fb93fe21b",
"JMC/Oblivion Mod Falcor p.1.nfo": "58c2be339869b5d071c1758d55c72ddb"
}

View file

@ -97,6 +97,26 @@ class TestDownloadArgsParser:
"dl --parameter.not.using.list[0] 'v0'",
{"parameter": {"not": {"using": {"list[0]": "v0"}}}},
),
(
None,
"dl --a.float.parameter 1.3",
{"a": {"float": {"parameter": 1.3}}},
),
(
None,
"dl --a.int.parameter 6",
{"a": {"int": {"parameter": 6}}},
),
(
None,
"dl --a.true.parameter True",
{"a": {"true": {"parameter": True}}},
),
(
None,
"dl --a.false.parameter False",
{"a": {"false": {"parameter": False}}},
),
],
)
def test_successful_args(self, config_options_generator, aliases, cmd, expected_sub_dict):

View file

@ -5,8 +5,8 @@ from typing import Optional
import pytest
from ytdl_sub.config.config_file import ConfigFile
from ytdl_sub.config.plugin_mapping import PluginMapping
from ytdl_sub.config.preset import PRESET_KEYS
from ytdl_sub.config.preset_class_mappings import PluginMapping
from ytdl_sub.utils.exceptions import ValidationException

View file

@ -0,0 +1,59 @@
from conftest import assert_logs
from ytdl_sub.plugins.throttle_protection import logger as throttle_protection_logger
from ytdl_sub.subscriptions.subscription import Subscription
class TestThrottleProtectionPlugin:
def test_sleeps_log(
self,
config,
subscription_name,
output_directory,
mock_download_collection_entries,
):
preset_dict = {
"preset": [
"Kodi Music Videos",
],
"overrides": {
"url": "https://your.name.here",
"music_video_directory": output_directory,
},
"throttle_protection": {
"sleep_per_download_s": {
"min": 0.01,
"max": 0.01,
},
"sleep_per_subscription_s": {
"min": 0.02,
"max": 0.02,
},
},
}
subscription = Subscription.from_dict(
config=config,
preset_name=subscription_name,
preset_dict=preset_dict,
)
with mock_download_collection_entries(
is_youtube_channel=False, num_urls=1, is_extracted_audio=False
), assert_logs(
logger=throttle_protection_logger,
expected_message="Sleeping between downloads for %0.2f seconds",
log_level="debug",
expected_occurrences=4,
):
_ = subscription.download(dry_run=False)
with mock_download_collection_entries(
is_youtube_channel=False, num_urls=1, is_extracted_audio=False
), assert_logs(
logger=throttle_protection_logger,
expected_message="Sleeping between subscriptions for %0.2f seconds",
log_level="debug",
expected_occurrences=1,
):
_ = subscription.download(dry_run=False)