[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:
parent
6615e1d1ac
commit
3e41e0f59e
5 changed files with 169 additions and 97 deletions
|
|
@ -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`.
|
||||||
|
|
||||||
-------------------------------------------------------------------------------
|
-------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
========
|
========
|
||||||
|
|
|
||||||
|
|
@ -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"}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
):
|
):
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue