diff --git a/src/ytdl_sub/config/plugin/plugin.py b/src/ytdl_sub/config/plugin/plugin.py index 24d616f6..33e98a1b 100644 --- a/src/ytdl_sub/config/plugin/plugin.py +++ b/src/ytdl_sub/config/plugin/plugin.py @@ -1,5 +1,6 @@ from abc import ABC from abc import abstractmethod +from functools import cached_property from typing import Dict from typing import Generic from typing import List @@ -8,9 +9,11 @@ from typing import Tuple from typing import Type from ytdl_sub.config.overrides import Overrides +from ytdl_sub.config.validators.options import ToggleableOptionsDictValidator from ytdl_sub.config.validators.options import TOptionsValidator from ytdl_sub.entries.entry import Entry from ytdl_sub.utils.file_handler import FileMetadata +from ytdl_sub.utils.script import ScriptUtils from ytdl_sub.ytdl_additions.enhanced_download_archive import DownloadArchiver from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive @@ -40,6 +43,17 @@ class Plugin(BasePlugin[TOptionsValidator], Generic[TOptionsValidator], ABC): Class to define the new plugin functionality """ + @cached_property + def is_enabled(self) -> bool: + """ + Returns True if enabled, False if disabled. + """ + if isinstance(self.plugin_options, ToggleableOptionsDictValidator): + return ScriptUtils.bool_formatter_output( + self.overrides.apply_formatter(self.plugin_options.enable) + ) + return True + def ytdl_options_match_filters(self) -> Tuple[List[str], List[str]]: """ Returns diff --git a/src/ytdl_sub/config/validators/options.py b/src/ytdl_sub/config/validators/options.py index c35defa2..9c9a0acc 100644 --- a/src/ytdl_sub/config/validators/options.py +++ b/src/ytdl_sub/config/validators/options.py @@ -6,6 +6,7 @@ from typing import TypeVar from ytdl_sub.config.plugin.plugin_operation import PluginOperation from ytdl_sub.utils.exceptions import ValidationException from ytdl_sub.validators.strict_dict_validator import StrictDictValidator +from ytdl_sub.validators.string_formatter_validators import OverridesBooleanFormatterValidator from ytdl_sub.validators.validators import Validator # pylint: disable=no-self-use @@ -57,3 +58,25 @@ TOptionsValidator = TypeVar("TOptionsValidator", bound=OptionsValidator) class OptionsDictValidator(StrictDictValidator, OptionsValidator, ABC): pass + + +class ToggleableOptionsDictValidator(OptionsDictValidator): + _optional_keys = {"enable"} + + def __init__(self, name, value): + assert "enable" in self._optional_keys, "" + super().__init__(name, value) + + self._enable = self._validate_key( + key="enable", validator=OverridesBooleanFormatterValidator, default="False" + ) + + @property + def enable(self) -> OverridesBooleanFormatterValidator: + """ + :expected type: Optional[Boolean] + :description: + Whether to enable or disable this plugin when it is defined in a config. Defaults + to enabled (True). + """ + return self._enable diff --git a/src/ytdl_sub/plugins/filter_exclude.py b/src/ytdl_sub/plugins/filter_exclude.py index 72ad6c88..08f13939 100644 --- a/src/ytdl_sub/plugins/filter_exclude.py +++ b/src/ytdl_sub/plugins/filter_exclude.py @@ -1,4 +1,3 @@ -import json from typing import Dict from typing import Optional @@ -8,6 +7,7 @@ from ytdl_sub.config.validators.options import OptionsValidator from ytdl_sub.entries.entry import Entry from ytdl_sub.utils.exceptions import StringFormattingException from ytdl_sub.utils.logger import Logger +from ytdl_sub.utils.script import ScriptUtils from ytdl_sub.validators.string_formatter_validators import ListFormatterValidator from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive @@ -53,7 +53,9 @@ class FilterExcludePlugin(Plugin[FilterExcludeOptions]): return entry for formatter in self.plugin_options.list: - out = json.loads(self.overrides.apply_formatter(formatter=formatter, entry=entry)) + out = ScriptUtils.bool_formatter_output( + self.overrides.apply_formatter(formatter=formatter, entry=entry) + ) if bool(out): logger.info( "Filtering '%s' from the filter %s evaluating to True", diff --git a/src/ytdl_sub/plugins/filter_include.py b/src/ytdl_sub/plugins/filter_include.py index 5a54e8ec..5f7fcc4a 100644 --- a/src/ytdl_sub/plugins/filter_include.py +++ b/src/ytdl_sub/plugins/filter_include.py @@ -1,4 +1,3 @@ -import json from typing import Dict from typing import Optional @@ -8,6 +7,7 @@ from ytdl_sub.config.validators.options import OptionsValidator from ytdl_sub.entries.entry import Entry from ytdl_sub.utils.exceptions import StringFormattingException from ytdl_sub.utils.logger import Logger +from ytdl_sub.utils.script import ScriptUtils from ytdl_sub.validators.string_formatter_validators import ListFormatterValidator from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive @@ -61,7 +61,9 @@ class FilterIncludePlugin(Plugin[FilterIncludeOptions]): return entry for formatter in self.plugin_options.list: - out = json.loads(self.overrides.apply_formatter(formatter=formatter, entry=entry)) + out = ScriptUtils.bool_formatter_output( + self.overrides.apply_formatter(formatter=formatter, entry=entry) + ) if not bool(out): logger.info( "Filtering '%s' from the filter %s evaluating to False", diff --git a/src/ytdl_sub/plugins/throttle_protection.py b/src/ytdl_sub/plugins/throttle_protection.py index 5295c4f2..d7a8aeb5 100644 --- a/src/ytdl_sub/plugins/throttle_protection.py +++ b/src/ytdl_sub/plugins/throttle_protection.py @@ -6,7 +6,7 @@ from typing import Tuple from ytdl_sub.config.overrides import Overrides from ytdl_sub.config.plugin.plugin import Plugin -from ytdl_sub.config.validators.options import OptionsDictValidator +from ytdl_sub.config.validators.options import ToggleableOptionsDictValidator from ytdl_sub.entries.entry import Entry from ytdl_sub.utils.file_handler import FileMetadata from ytdl_sub.utils.logger import Logger @@ -59,7 +59,7 @@ class RandomizedRangeValidator(StrictDictValidator): return int(self.randomized_float()) -class ThrottleProtectionOptions(OptionsDictValidator): +class ThrottleProtectionOptions(ToggleableOptionsDictValidator): """ 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 @@ -84,7 +84,7 @@ class ThrottleProtectionOptions(OptionsDictValidator): subscription_download_probability: 1.0 """ - _optional_keys = { + _optional_keys = ToggleableOptionsDictValidator._optional_keys | { "sleep_per_download_s", "sleep_per_subscription_s", "max_downloads_per_subscription", diff --git a/src/ytdl_sub/subscriptions/subscription_download.py b/src/ytdl_sub/subscriptions/subscription_download.py index 56e93954..06ce1bd2 100644 --- a/src/ytdl_sub/subscriptions/subscription_download.py +++ b/src/ytdl_sub/subscriptions/subscription_download.py @@ -190,7 +190,7 @@ class SubscriptionDownload(BaseSubscription, ABC): ------- List of plugins defined in the subscription, initialized and ready to use. """ - return [ + plugins = [ plugin_type( options=plugin_options, overrides=self.overrides, @@ -198,6 +198,7 @@ class SubscriptionDownload(BaseSubscription, ABC): ) for plugin_type, plugin_options in self.plugins.zipped() ] + return [plugin for plugin in plugins if plugin.is_enabled] @classmethod def _cleanup_entry_files(cls, entry: Entry): diff --git a/src/ytdl_sub/utils/script.py b/src/ytdl_sub/utils/script.py index d5d36b87..5653bc74 100644 --- a/src/ytdl_sub/utils/script.py +++ b/src/ytdl_sub/utils/script.py @@ -38,3 +38,15 @@ class ScriptUtils: out = f"{{%from_json('''{dumped_json}''')}}" return out + + @classmethod + def bool_formatter_output(cls, output: str) -> bool: + """ + Translate formatter output to a boolean + """ + if not output or output.lower() == "false": + return False + try: + return bool(json.loads(output)) + except Exception: # pylint: disable=broad-except + return True diff --git a/src/ytdl_sub/validators/string_formatter_validators.py b/src/ytdl_sub/validators/string_formatter_validators.py index def6e6d8..c561e47e 100644 --- a/src/ytdl_sub/validators/string_formatter_validators.py +++ b/src/ytdl_sub/validators/string_formatter_validators.py @@ -92,7 +92,7 @@ class OverridesStringFormatterValidator(StringFormatterValidator): # pylint: enable=line-too-long -class OverridesIntegerFormatterValidator(StringFormatterValidator): +class OverridesIntegerFormatterValidator(OverridesStringFormatterValidator): _expected_value_type_name = "integer" def post_process(self, resolved: str) -> str: @@ -106,6 +106,10 @@ class OverridesIntegerFormatterValidator(StringFormatterValidator): return resolved +class OverridesBooleanFormatterValidator(OverridesStringFormatterValidator): + _expected_value_type_name = "boolean" + + class ListFormatterValidator(ListValidator[StringFormatterValidator]): _inner_list_type = StringFormatterValidator diff --git a/tests/conftest.py b/tests/conftest.py index b7ec75ff..522ddb3b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -113,7 +113,7 @@ def assert_logs( for call_args in patched_debug.call_args_list: occurrences += int(expected_message in call_args.args[0]) - if expected_occurrences: + if expected_occurrences is not None: assert ( occurrences == expected_occurrences ), f"{expected_message} was expected {expected_occurrences} times, got {occurrences}" diff --git a/tests/unit/plugins/test_throttle_protection.py b/tests/unit/plugins/test_throttle_protection.py index 0d72e9fd..9dcdb89b 100644 --- a/tests/unit/plugins/test_throttle_protection.py +++ b/tests/unit/plugins/test_throttle_protection.py @@ -1,3 +1,4 @@ +import pytest from conftest import assert_logs from ytdl_sub.plugins.throttle_protection import logger as throttle_protection_logger @@ -57,3 +58,59 @@ class TestThrottleProtectionPlugin: expected_occurrences=1, ): _ = subscription.download(dry_run=False) + + @pytest.mark.parametrize( + "disable_value", + [ + "", + False, + "{tp_bool_string}", + "{tp_empty_string}", + ], + ) + def test_disabled( + self, + config, + subscription_name, + output_directory, + mock_download_collection_entries, + disable_value, + ): + preset_dict = { + "preset": [ + "Kodi Music Videos", + ], + "overrides": { + "url": "https://your.name.here", + "music_video_directory": output_directory, + "tp_bool_string": "{ %bool(False) }", + "tp_empty_string": "", + }, + "throttle_protection": { + "enable": disable_value, + "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=0, + ): + _ = subscription.download(dry_run=False) diff --git a/tests/unit/utils/test_script_utils.py b/tests/unit/utils/test_script_utils.py index 8b2b0e90..d3ff8921 100644 --- a/tests/unit/utils/test_script_utils.py +++ b/tests/unit/utils/test_script_utils.py @@ -1,5 +1,6 @@ import copy +import pytest from unit.script.conftest import single_variable_output from ytdl_sub.utils.script import ScriptUtils @@ -31,3 +32,22 @@ class TestScriptUtils: output = single_variable_output(ScriptUtils.to_script(json_dict)) assert output == expected_output + + @pytest.mark.parametrize( + "input_str, expected_output", + [ + ("", False), + ("true", True), + ("false", False), + ("[ ]", False), + ("{ }", False), + ("True", True), + ("False", False), + ("lol not False", True), + ("0", False), + ("-1", True), + ("1", True), + ], + ) + def test_bool_formatter_output(self, input_str: str, expected_output: bool): + assert ScriptUtils.bool_formatter_output(input_str) == expected_output