[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 video_tags
'''''''''' ''''''''''
.. autoclass:: ytdl_sub.plugins.video_tags.VideoTagsOptions() .. autoclass:: ytdl_sub.plugins.video_tags.VideoTagsOptions()

View file

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

View file

@ -1,6 +1,7 @@
import hashlib import hashlib
import re import re
import shlex import shlex
from typing import Any
from typing import Dict from typing import Dict
from typing import List from typing import List
from typing import Tuple from typing import Tuple
@ -116,7 +117,7 @@ class DownloadArgsParser:
return largest_consecutive + 1 return largest_consecutive + 1
@classmethod @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_name: Argument name in the form of 'key1.key2.key3'
:param arg_value: Argument value :param arg_value: Argument value
@ -134,11 +135,15 @@ class DownloadArgsParser:
next_dict[arg_name_split[-1]] = arg_value next_dict[arg_name_split[-1]] = arg_value
# TODO: handle ints/floats if isinstance(arg_value, str):
if arg_value == "True": if arg_value == "True":
next_dict[arg_name_split[-1]] = True next_dict[arg_name_split[-1]] = True
elif arg_value == "False": elif arg_value == "False":
next_dict[arg_name_split[-1]] = 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 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.regex import RegexPlugin
from ytdl_sub.plugins.split_by_chapters import SplitByChaptersPlugin from ytdl_sub.plugins.split_by_chapters import SplitByChaptersPlugin
from ytdl_sub.plugins.subtitles import SubtitlesPlugin from ytdl_sub.plugins.subtitles import SubtitlesPlugin
from ytdl_sub.plugins.throttle_protection import ThrottleProtectionPlugin
from ytdl_sub.plugins.video_tags import VideoTagsPlugin from ytdl_sub.plugins.video_tags import VideoTagsPlugin
@ -41,6 +42,7 @@ class PluginMapping:
"subtitles": SubtitlesPlugin, "subtitles": SubtitlesPlugin,
"chapters": ChaptersPlugin, "chapters": ChaptersPlugin,
"split_by_chapters": SplitByChaptersPlugin, "split_by_chapters": SplitByChaptersPlugin,
"throttle_protection": ThrottleProtectionPlugin,
} }
@classmethod @classmethod

View file

@ -12,7 +12,7 @@ from mergedeep import mergedeep
from ytdl_sub.config.config_validator import ConfigValidator from ytdl_sub.config.config_validator import ConfigValidator
from ytdl_sub.config.plugin import Plugin 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 OptionsValidator
from ytdl_sub.config.preset_options import OutputOptions from ytdl_sub.config.preset_options import OutputOptions
from ytdl_sub.config.preset_options import Overrides 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" _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]): class ListValidator(Validator, ABC, Generic[ValidatorT]):
""" """
Validates a list of objects to validate Validates a list of objects to validate

View file

@ -9,6 +9,7 @@ from typing import Any
from typing import Callable from typing import Callable
from typing import Dict from typing import Dict
from typing import List from typing import List
from typing import Optional
from unittest.mock import patch from unittest.mock import patch
import pytest import pytest
@ -78,11 +79,17 @@ def reformat_directory() -> Path:
@contextlib.contextmanager @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. Patches any function, but calls the original function.
Intended to see if the particular function is called. Intended to see if the particular function is called.
""" """
occurrences = 0
debug_logger = Logger.get() debug_logger = Logger.get()
def _wrapped_debug(*args, **kwargs): def _wrapped_debug(*args, **kwargs):
@ -92,10 +99,14 @@ def assert_logs(logger: logging.Logger, expected_message: str, log_level: str =
yield yield
for call_args in patched_debug.call_args_list: for call_args in patched_debug.call_args_list:
if expected_message in call_args.args[0]: occurrences += int(expected_message in call_args.args[0])
return
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: 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.cli.entrypoint import main
from ytdl_sub.subscriptions.subscription import Subscription from ytdl_sub.subscriptions.subscription import Subscription
from ytdl_sub.utils.file_handler import FileHandler from ytdl_sub.utils.file_handler import FileHandler
from ytdl_sub.utils.system import IS_WINDOWS
@pytest.fixture @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", "download": "https://youtube.com/watch?v=HKTNxEqsN3Q",
# override the output directory with our fixture-generated dir # override the output directory with our fixture-generated dir
"output_options": { "output_options": {
"output_directory": output_directory,
"maintain_download_archive": False, "maintain_download_archive": False,
}, },
"embed_thumbnail": True, # embed thumb into the video "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}", "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", "download": "https://youtube.com/watch?v=HKTNxEqsN3Q",
# override the output directory with our fixture-generated dir # override the output directory with our fixture-generated dir
"output_options": { "output_options": {
"output_directory": output_directory,
"maintain_download_archive": False, "maintain_download_archive": False,
}, },
"embed_thumbnail": True, # embed thumb into the video "embed_thumbnail": True, # embed thumb into the video
@ -52,7 +53,12 @@ def single_video_preset_dict(output_directory):
"video_tags": { "video_tags": {
"title": "{title}", "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", 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( def test_single_video_download(
self, self,
default_config, default_config,

View file

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

View file

@ -97,6 +97,26 @@ class TestDownloadArgsParser:
"dl --parameter.not.using.list[0] 'v0'", "dl --parameter.not.using.list[0] 'v0'",
{"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): 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 import pytest
from ytdl_sub.config.config_file import ConfigFile 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 import PRESET_KEYS
from ytdl_sub.config.preset_class_mappings import PluginMapping
from ytdl_sub.utils.exceptions import ValidationException 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)