ytdl-sub/src/ytdl_sub/downloaders/url/validators.py
Jesse Bannon e92b1cd12a
[BACKEND][HUGE] Function Support in variable syntax (#838)
A complete gutting of the internals of ytdl-sub to support functions in our variable syntax, in addition to being able to access a yt-dlp entry's .info.json fields using functions. Functionally, ytdl-sub should still look and behave the same from a user-perspective.

With so many lines of code changed (+8927, -2708), no doubt there will be new issues. Please make a GH issue or reach out on Discord if your config/subscriptions break in any way/shape/form.

Details on how to use function support will come soon in the form of proper documentation in our readthedocs.
2023-12-18 16:08:15 -08:00

270 lines
9.8 KiB
Python

import copy
from typing import Any
from typing import Dict
from typing import Optional
from typing import Set
from ytdl_sub.config.plugin.plugin_operation import PluginOperation
from ytdl_sub.config.validators.options import OptionsValidator
from ytdl_sub.script.parser import parse
from ytdl_sub.validators.strict_dict_validator import StrictDictValidator
from ytdl_sub.validators.string_formatter_validators import DictFormatterValidator
from ytdl_sub.validators.string_formatter_validators import OverridesStringFormatterValidator
from ytdl_sub.validators.string_formatter_validators import StringFormatterValidator
from ytdl_sub.validators.validators import BoolValidator
from ytdl_sub.validators.validators import ListValidator
class UrlThumbnailValidator(StrictDictValidator):
_required_keys = {"name", "uid"}
def __init__(self, name, value):
super().__init__(name, value)
self._name = self._validate_key(key="name", validator=StringFormatterValidator)
self._uid = self._validate_key(key="uid", validator=OverridesStringFormatterValidator)
@property
def name(self) -> StringFormatterValidator:
"""
File name for the thumbnail
"""
return self._name
@property
def uid(self) -> OverridesStringFormatterValidator:
"""
yt-dlp's unique ID of the thumbnail
"""
return self._uid
class UrlThumbnailListValidator(ListValidator[UrlThumbnailValidator]):
_inner_list_type = UrlThumbnailValidator
class UrlValidator(StrictDictValidator):
_required_keys = {"url"}
_optional_keys = {"variables", "source_thumbnails", "playlist_thumbnails", "download_reverse"}
@classmethod
def partial_validate(cls, name: str, value: Any) -> None:
"""
Partially validate a YouTube collection url
"""
if isinstance(value, dict):
value["url"] = value.get("url", "placeholder")
_ = cls(name, value)
def __init__(self, name, value):
super().__init__(name, value)
# TODO: url validate using yt-dlp IE
self._url = self._validate_key(key="url", validator=OverridesStringFormatterValidator)
self._variables = self._validate_key_if_present(
key="variables", validator=DictFormatterValidator, default={}
)
self._source_thumbnails = self._validate_key_if_present(
key="source_thumbnails", validator=UrlThumbnailListValidator, default=[]
)
self._playlist_thumbnails = self._validate_key_if_present(
key="playlist_thumbnails", validator=UrlThumbnailListValidator, default=[]
)
self._download_reverse = self._validate_key(
key="download_reverse", validator=BoolValidator, default=True
)
@property
def url(self) -> OverridesStringFormatterValidator:
"""
Required. URL to download from.
"""
return self._url
@property
def variables(self) -> DictFormatterValidator:
"""
Source variables to add to each entry. The top-most collection must define all possible
variables. Collections below can redefine all of them or a subset of the top-most variables.
"""
return self._variables
@property
def playlist_thumbnails(self) -> Optional[UrlThumbnailListValidator]:
"""
Thumbnails to download from the playlist, if any exist. Playlist is the ``yt-dlp`` naming
convention for a set that contains multiple entries. For example, the URL
``https://www.youtube.com/c/RickastleyCoUkOfficial`` would have ``playlist`` refer to the
channel.
Usage:
.. code-block:: yaml
playlist_thumbnails:
- name: "poster.jpg"
uid: "avatar_uncropped"
- name: "fanart.jpg"
uid: "banner_uncropped"
``name`` is the file name relative to the output directory to store the thumbnail.
``uid`` is the yt-dlp thumbnail ID. Can specify ``latest_entry`` to use the latest entry's
thumbnail.
"""
return self._playlist_thumbnails
@property
def source_thumbnails(self) -> Optional[UrlThumbnailListValidator]:
"""
Thumbnails to download from the source, if any exist. Source in this context refers to the
set of sets. For example, the URL
``https://www.youtube.com/c/RickastleyCoUkOfficial/playlists``
would have ``playlist`` refer to each individual playlist, whereas ``source`` refers
to the channel.
Usage:
.. code-block:: yaml
source_thumbnails:
- name: "poster.jpg"
uid: "avatar_uncropped"
- name: "fanart.jpg"
uid: "banner_uncropped"
``name`` is the file name relative to the output directory to store the thumbnail.
``uid`` is the yt-dlp thumbnail ID. Can specify ``latest_entry`` to use the latest entry's
thumbnail.
"""
return self._source_thumbnails
@property
def download_reverse(self) -> bool:
"""
Optional. Whether to download entries in the reverse order of the metadata downloaded.
Defaults to True.
"""
return self._download_reverse.value
class UrlStringOrDictValidator(UrlValidator):
"""
URL validator that supports a single string like:
download:
- "https://"
or
download:
- url: "https://"
"""
_expected_value_type = (dict, str)
def __init__(self, name, value):
super().__init__(name, {"url": value} if isinstance(value, str) else value)
class UrlListValidator(ListValidator[UrlStringOrDictValidator]):
_inner_list_type = UrlStringOrDictValidator
_expected_value_type_name = "collection url list"
def __init__(self, name, value):
super().__init__(name, value)
added_variables: Dict[str, str] = self.list[0].variables.dict_with_format_strings
for idx, url_validator in enumerate(self.list[1:]):
collection_variables = url_validator.variables.dict_with_format_strings
# see if this collection contains new added vars (it should not)
for var in collection_variables.keys():
if var not in added_variables:
raise self._validation_exception(
f"Collection url {idx} contains the variable '{var}' that the first "
f"collection url does not. The first collection url must define all added "
f"variables."
)
# see if this collection is missing any added vars (if so, inherit from the top)
for var in added_variables.keys():
if var not in collection_variables.keys():
collection_variables[var] = added_variables[var]
class MultiUrlValidator(OptionsValidator):
"""
Downloads from multiple URLs. If an entry is returned from more than one URL, it will
resolve to the bottom-most URL settings.
"""
@classmethod
def partial_validate(cls, name: str, value: Any) -> None:
"""
Partially validate a collection
"""
if isinstance(value, dict):
value["url"] = value.get("url", "sadfasdf")
_ = cls(name, value)
def __init__(self, name, value):
super().__init__(name, value)
# Copy since we're popping things
value_copy = copy.deepcopy(value)
if isinstance(value, dict):
# Pop old required field in case it's still there
value_copy.pop("download_strategy", None)
# Deal with old multi-url download strategy
if isinstance(value, dict) and "urls" in value_copy:
self._urls = UrlListValidator(name=name, value=value_copy["urls"])
else:
self._urls = UrlListValidator(name=name, value=value_copy)
@property
def urls(self) -> UrlListValidator:
"""
Required. A list of :ref:`url` with the addition of the ``variables`` attribute.
Multiple URLs should be listed in the order of priority, with the lowest priority being the
top-most, and highest priority being the bottom-most. If a download exists in more than
one URL, it will resolve to the bottom-most one (the highest priority) and
inherit those variables.
"""
return self._urls
@property
def variables(self) -> DictFormatterValidator:
"""
Optional. Source variables to add to each entry downloaded from its respective :ref:`url`.
The top-most :ref:`url` must define all possible variables. Other :ref:`url` entries can
redefine all of them or a subset of the top-most variables.
"""
# keep for readthedocs documentation
return self._urls.list[0].variables
def added_variables(
self,
resolved_variables: Set[str],
unresolved_variables: Set[str],
plugin_op: PluginOperation,
) -> Dict[PluginOperation, Set[str]]:
"""
Returns
-------
List of variables added. The first collection url always contains all the variables.
"""
if plugin_op != PluginOperation.ANY:
for url in self._urls.list:
for variable_name, definition in url.variables.dict_with_format_strings.items():
used_variables = set(var.name for var in parse(definition).variables)
if unresolved := used_variables & unresolved_variables:
raise self._validation_exception(
f"variable {variable_name} cannot use the variables "
f"{', '.join(sorted(list(unresolved)))} because it depends on other"
" variables that are computed later in execution"
)
return {PluginOperation.DOWNLOADER: set(self._urls.list[0].variables.keys)}