[FEATURE] Ability to add subscription value via config (#744)

`Subscription Value` is the mechanism used to achieve one-liner subscriptions. It works by having the subscription `"Subscription Name": "value"`, and assigning `"value"` to an override variable via setting the subscription value to that override variable's name.

This feature allows you to set subscription value within the config. In the near future, ytdl-sub will strive to make subscription files simpler by hiding most of the "under-the-hood" logic within the config file.
This commit is contained in:
Jesse Bannon 2023-09-27 21:37:36 -07:00 committed by GitHub
parent 6615e1d1ac
commit 3e41e0f59e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 169 additions and 97 deletions

View file

@ -2,7 +2,7 @@ Config
====== ======
ytdl-sub is configured using a ``config.yaml`` file. ytdl-sub is configured using a ``config.yaml`` file.
.. _config_yaml: .. _config:
config.yaml config.yaml
----------- -----------
@ -318,8 +318,10 @@ by using the file-wide ``__preset__``:
This ``subscription.yaml`` is equivalent to the one above it because all This ``subscription.yaml`` is equivalent to the one above it because all
subscriptions automatically set ``__preset__`` as a `parent preset`_. subscriptions automatically set ``__preset__`` as a `parent preset`_.
File Subscription Value .. _subscription value:
^^^^^^^^^^^^^^^^^^^^^^^
Subscription Value
^^^^^^^^^^^^^^^^^^^
With a clever config and use of ``__preset__``, your subscriptions can typically boil With a clever config and use of ``__preset__``, your subscriptions can typically boil
down to a name and url. You can set ``__value__`` to the name of an override variable, down to a name and url. You can set ``__value__`` to the name of an override variable,
and use the override variable ``subscription_name`` to achieve one-liner subscriptions. and use the override variable ``subscription_name`` to achieve one-liner subscriptions.
@ -329,20 +331,19 @@ Using the example above, we can do:
:caption: subscription.yaml :caption: subscription.yaml
__preset__: __preset__:
preset: "playlist_preset_ex" preset:
- "tv_show"
overrides: overrides:
playlist_name: "{subscription_name}" tv_show_name: "{subscription_name}"
__value__: "url" __value__: "url"
# single-line subscription # single-line subscription, sets "Brandon Acker" and the subscription value
"diy-playlist": "https://youtube.com/playlist?list=UCsvn_Po0SmunchJYtttWpOxMg" # to the override variables tv_show_name and url
"Brandon Acker": "https://www.youtube.com/@brandonacker"
``"diy-playlist"`` gets assigned to the ``playlist_name`` override variable by setting
it with ``subscription_name`` , and the url gets assigned to ``url`` by setting ``__value__``
to write values to it.
Traditional subscriptions that can override presets will still work when using ``__value__``. Traditional subscriptions that can override presets will still work when using ``__value__``.
``__value__`` can also be set within a :ref:`config`.
------------------------------------------------------------------------------- -------------------------------------------------------------------------------

View file

@ -13,7 +13,7 @@ ytdl-sub configs, check out the
For navigating config docs, use the left-side bar on the For navigating config docs, use the left-side bar on the
:ref:`config_yaml` page to find every available ytdl-sub field. :ref:`config` page to find every available ytdl-sub field.
Contents Contents
======== ========

View file

@ -106,6 +106,7 @@ class ConfigOptions(StrictDictValidator):
"ffprobe_path", "ffprobe_path",
"file_name_max_bytes", "file_name_max_bytes",
"experimental", "experimental",
"subscription_value",
} }
def __init__(self, name: str, value: Any): def __init__(self, name: str, value: Any):
@ -138,6 +139,9 @@ class ConfigOptions(StrictDictValidator):
self._file_name_max_bytes = self._validate_key( self._file_name_max_bytes = self._validate_key(
key="file_name_max_bytes", validator=IntValidator, default=MAX_FILE_NAME_BYTES key="file_name_max_bytes", validator=IntValidator, default=MAX_FILE_NAME_BYTES
) )
self._subscription_value = self._validate_key_if_present(
key="subscription_value", validator=StringValidator
)
@property @property
def working_directory(self) -> str: def working_directory(self) -> str:
@ -230,6 +234,14 @@ class ConfigOptions(StrictDictValidator):
""" """
return self._ffprobe_path.value return self._ffprobe_path.value
@property
def subscription_value(self) -> Optional[str]:
"""
Optional. Sets the :ref:`subscription value` for subscription
files that use this config.
"""
return self._subscription_value.value if self._subscription_value else None
class ConfigValidator(StrictDictValidator): class ConfigValidator(StrictDictValidator):
_required_keys = {"configuration", "presets"} _required_keys = {"configuration", "presets"}

View file

@ -1,6 +1,7 @@
import copy import copy
from typing import Dict from typing import Dict
from typing import List from typing import List
from typing import Optional
from ytdl_sub.config.config_file import ConfigFile from ytdl_sub.config.config_file import ConfigFile
from ytdl_sub.config.preset import Preset from ytdl_sub.config.preset import Preset
@ -63,6 +64,20 @@ class Subscription(SubscriptionDownload):
config=config, config=config,
) )
@classmethod
def _maybe_get_subscription_value(
cls, config: ConfigFile, subscription_dict: Dict
) -> Optional[str]:
if FILE_SUBSCRIPTION_VALUE_KEY in subscription_dict:
if not isinstance(subscription_dict[FILE_SUBSCRIPTION_VALUE_KEY], str):
raise ValidationException(
f"Using {FILE_SUBSCRIPTION_VALUE_KEY} in a subscription"
f"must be a string that corresponds to an override variable"
)
return subscription_dict[FILE_SUBSCRIPTION_VALUE_KEY]
return config.config_options.subscription_value # can be None
@classmethod @classmethod
def from_file_path(cls, config: ConfigFile, subscription_path: str) -> List["Subscription"]: def from_file_path(cls, config: ConfigFile, subscription_path: str) -> List["Subscription"]:
""" """
@ -90,7 +105,9 @@ class Subscription(SubscriptionDownload):
subscription_dict = load_yaml(file_path=subscription_path) subscription_dict = load_yaml(file_path=subscription_path)
has_file_preset = FILE_PRESET_APPLY_KEY in subscription_dict has_file_preset = FILE_PRESET_APPLY_KEY in subscription_dict
has_file_subscription_value = FILE_SUBSCRIPTION_VALUE_KEY in subscription_dict file_subscription_value: Optional[str] = cls._maybe_get_subscription_value(
config=config, subscription_dict=subscription_dict
)
# If a file preset is present... # If a file preset is present...
if has_file_preset: if has_file_preset:
@ -104,13 +121,6 @@ class Subscription(SubscriptionDownload):
config = copy.deepcopy(config) config = copy.deepcopy(config)
config.presets.dict[FILE_PRESET_APPLY_KEY] = file_preset.dict config.presets.dict[FILE_PRESET_APPLY_KEY] = file_preset.dict
if has_file_subscription_value:
if not isinstance(subscription_dict[FILE_SUBSCRIPTION_VALUE_KEY], str):
raise ValidationException(
f"Using {FILE_SUBSCRIPTION_VALUE_KEY} in a subscription"
f"must be a string that corresponds to an override variable"
)
for subscription_key, subscription_object in subscription_dict.items(): for subscription_key, subscription_object in subscription_dict.items():
# Skip file preset or value # Skip file preset or value
@ -119,18 +129,14 @@ class Subscription(SubscriptionDownload):
# If the subscription obj is just a string, set it to the override variable # If the subscription obj is just a string, set it to the override variable
# defined in FILE_SUBSCRIPTION_VALUE_KEY # defined in FILE_SUBSCRIPTION_VALUE_KEY
if isinstance(subscription_object, str) and has_file_subscription_value: if isinstance(subscription_object, str) and file_subscription_value:
subscription_object = { subscription_object = {"overrides": {file_subscription_value: subscription_object}}
"overrides": {
subscription_dict[FILE_SUBSCRIPTION_VALUE_KEY]: subscription_object
}
}
elif isinstance(subscription_object, dict): elif isinstance(subscription_object, dict):
pass pass
elif isinstance(subscription_object, str) and not has_file_subscription_value: elif isinstance(subscription_object, str) and not file_subscription_value:
raise ValidationException( raise ValidationException(
f"Subscription {subscription_key} is a string, but " f"Subscription {subscription_key} is a string, but the subscription value "
f"{FILE_SUBSCRIPTION_VALUE_KEY} is not set to an override variable" f"is not set to an override variable"
) )
else: else:
raise ValidationException( raise ValidationException(

View file

@ -1,8 +1,10 @@
import re import re
from contextlib import contextmanager
from typing import Dict from typing import Dict
from unittest.mock import patch from unittest.mock import patch
import pytest import pytest
from mergedeep import mergedeep
from ytdl_sub.config.config_file import ConfigFile from ytdl_sub.config.config_file import ConfigFile
from ytdl_sub.plugins.nfo_tags import NfoTagsOptions from ytdl_sub.plugins.nfo_tags import NfoTagsOptions
@ -12,73 +14,70 @@ from ytdl_sub.utils.exceptions import ValidationException
@pytest.fixture @pytest.fixture
def preset_file(youtube_video: Dict, output_options: Dict): def config_file_with_subscription_value(config_file: ConfigFile):
config_dict = config_file.as_dict()
mergedeep.merge(
config_dict, {"configuration": {"subscription_value": "test_config_subscription_value"}}
)
return ConfigFile.from_dict(config_dict)
@contextmanager
def mock_load_yaml(preset_dict: Dict) -> None:
with patch("ytdl_sub.subscriptions.subscription.load_yaml") as mock_load_yaml: with patch("ytdl_sub.subscriptions.subscription.load_yaml") as mock_load_yaml:
mock_load_yaml.return_value = { mock_load_yaml.return_value = preset_dict
"__preset__": {
"download": youtube_video,
"output_options": output_options,
"nfo_tags": {
"tags": {"key-3": "file_preset"},
},
"overrides": {"test_override": "original"},
},
"__value__": "test_override",
"test_preset": {
"preset": "parent_preset_3",
"nfo_tags": {
"tags": {"key-4": "test_preset"},
},
},
}
yield yield
@pytest.fixture @pytest.fixture
def preset_file_with_value(youtube_video: Dict, output_options: Dict): def preset_with_file_preset(youtube_video: Dict, output_options: Dict):
with patch("ytdl_sub.subscriptions.subscription.load_yaml") as mock_load_yaml: return {
mock_load_yaml.return_value = { "__preset__": {
"__preset__": { "download": youtube_video,
"preset": "parent_preset_3", "output_options": output_options,
"download": youtube_video, "nfo_tags": {
"output_options": output_options, "nfo_name": "eh",
"nfo_tags": { "nfo_root": "eh",
"tags": {"key-3": "file_preset"}, "tags": {"key-3": "file_preset"},
},
"overrides": {"test_override": "original"},
}, },
"__value__": "test_override", "overrides": {
"test_file_subscription_value": "original",
"test_config_subscription_value": "original",
},
},
"test_preset": {
"preset": "parent_preset_3",
"nfo_tags": {
"tags": {"key-4": "test_preset"},
},
},
}
@pytest.fixture
def preset_with_subscription_value(preset_with_file_preset: Dict):
return dict(
preset_with_file_preset,
**{
"test_value": "is_overwritten", "test_value": "is_overwritten",
} },
yield )
@pytest.fixture @pytest.fixture
def preset_file_invalid_value(): def preset_with_subscription_file_value(preset_with_subscription_value: Dict):
with patch("ytdl_sub.subscriptions.subscription.load_yaml") as mock_load_yaml: return dict(
mock_load_yaml.return_value = { preset_with_subscription_value,
"__value__": {"should be": "string"}, **{
} "__value__": "test_file_subscription_value",
yield "test_value": "is_overwritten",
},
)
@pytest.fixture def test_subscription_file_preset_applies(config_file: ConfigFile, preset_with_file_preset: Dict):
def preset_file_subscription_using_value_when_not_defined(): with mock_load_yaml(preset_dict=preset_with_file_preset):
with patch("ytdl_sub.subscriptions.subscription.load_yaml") as mock_load_yaml: subs = Subscription.from_file_path(config=config_file, subscription_path="mocked")
mock_load_yaml.return_value = {"sub_name": "single value, __value__ not defined"}
yield
@pytest.fixture
def preset_file_subscription_with_invalid_form():
with patch("ytdl_sub.subscriptions.subscription.load_yaml") as mock_load_yaml:
mock_load_yaml.return_value = {"sub_name": 4332}
yield
@pytest.mark.usefixtures(preset_file.__name__)
def test_subscription_file_preset_applies(config_file: ConfigFile):
subs = Subscription.from_file_path(config=config_file, subscription_path="mocked")
assert len(subs) == 1 assert len(subs) == 1
# Test __preset__ worked correctly # Test __preset__ worked correctly
@ -96,19 +95,73 @@ def test_subscription_file_preset_applies(config_file: ConfigFile):
} }
@pytest.mark.usefixtures(preset_file_with_value.__name__) def test_subscription_file_value_applies(
def test_subscription_file_value_applies(config_file: ConfigFile): config_file: ConfigFile, preset_with_subscription_file_value: Dict
subs = Subscription.from_file_path(config=config_file, subscription_path="mocked") ):
assert len(subs) == 1 with mock_load_yaml(preset_dict=preset_with_subscription_file_value):
subs = Subscription.from_file_path(config=config_file, subscription_path="mocked")
assert len(subs) == 2
# Test __value__ worked correctly # Test __value__ worked correctly
value_sub = subs[0] value_sub = subs[1]
assert value_sub.overrides.dict_with_format_strings.get("test_override") == "is_overwritten" assert value_sub.name == "test_value"
assert (
value_sub.overrides.dict_with_format_strings.get("test_file_subscription_value")
== "is_overwritten"
)
assert (
value_sub.overrides.dict_with_format_strings.get("test_config_subscription_value")
== "original"
)
def test_subscription_file_value_applies_sub_file_takes_precedence(
config_file_with_subscription_value: ConfigFile,
preset_with_subscription_file_value: Dict,
):
with mock_load_yaml(preset_dict=preset_with_subscription_file_value):
subs = Subscription.from_file_path(
config=config_file_with_subscription_value, subscription_path="mocked"
)
assert len(subs) == 2
# Test __value__ worked correctly
value_sub = subs[1]
assert value_sub.name == "test_value"
assert (
value_sub.overrides.dict_with_format_strings.get("test_file_subscription_value")
== "is_overwritten"
)
assert (
value_sub.overrides.dict_with_format_strings.get("test_config_subscription_value")
== "original"
)
def test_subscription_file_value_applies_from_config(
config_file_with_subscription_value: ConfigFile, preset_with_subscription_value: Dict
):
with mock_load_yaml(preset_dict=preset_with_subscription_value):
subs = Subscription.from_file_path(
config=config_file_with_subscription_value, subscription_path="mocked"
)
assert len(subs) == 2
# Test __value__ worked correctly from the config
value_sub = subs[1]
assert value_sub.name == "test_value"
assert (
value_sub.overrides.dict_with_format_strings.get("test_file_subscription_value")
== "original"
)
assert (
value_sub.overrides.dict_with_format_strings.get("test_config_subscription_value")
== "is_overwritten"
)
@pytest.mark.usefixtures(preset_file_invalid_value.__name__)
def test_subscription_file_bad_value(config_file: ConfigFile): def test_subscription_file_bad_value(config_file: ConfigFile):
with pytest.raises( with mock_load_yaml(preset_dict={"__value__": {"should be": "string"}}), pytest.raises(
ValidationException, ValidationException,
match=re.escape( match=re.escape(
f"Using {FILE_SUBSCRIPTION_VALUE_KEY} in a subscription" f"Using {FILE_SUBSCRIPTION_VALUE_KEY} in a subscription"
@ -118,21 +171,21 @@ def test_subscription_file_bad_value(config_file: ConfigFile):
_ = Subscription.from_file_path(config=config_file, subscription_path="mocked") _ = Subscription.from_file_path(config=config_file, subscription_path="mocked")
@pytest.mark.usefixtures(preset_file_subscription_using_value_when_not_defined.__name__)
def test_subscription_file_using_value_when_not_defined(config_file: ConfigFile): def test_subscription_file_using_value_when_not_defined(config_file: ConfigFile):
with pytest.raises( with mock_load_yaml(
preset_dict={"sub_name": "single value, __value__ not defined"}
), pytest.raises(
ValidationException, ValidationException,
match=re.escape( match=re.escape(
f"Subscription sub_name is a string, but " f"Subscription sub_name is a string, but "
f"{FILE_SUBSCRIPTION_VALUE_KEY} is not set to an override variable" f"the subscription value is not set to an override variable"
), ),
): ):
_ = Subscription.from_file_path(config=config_file, subscription_path="mocked") _ = Subscription.from_file_path(config=config_file, subscription_path="mocked")
@pytest.mark.usefixtures(preset_file_subscription_with_invalid_form.__name__)
def test_subscription_file_invalid_form(config_file: ConfigFile): def test_subscription_file_invalid_form(config_file: ConfigFile):
with pytest.raises( with mock_load_yaml(preset_dict={"sub_name": 4332}), pytest.raises(
ValidationException, ValidationException,
match=re.escape(f"Subscription sub_name should be in the form of a preset"), match=re.escape(f"Subscription sub_name should be in the form of a preset"),
): ):