diff --git a/src/ytdl_sub/plugins/nfo_tags.py b/src/ytdl_sub/plugins/nfo_tags.py index 39e65f02..05b1e7f3 100644 --- a/src/ytdl_sub/plugins/nfo_tags.py +++ b/src/ytdl_sub/plugins/nfo_tags.py @@ -1,97 +1,26 @@ import os from abc import ABC from pathlib import Path +from typing import Dict from typing import Generic -from typing import List from typing import Optional from typing import Type -from typing import TypeVar from ytdl_sub.entries.entry import Entry from ytdl_sub.plugins.plugin import Plugin from ytdl_sub.plugins.plugin import PluginOptions from ytdl_sub.utils.file_handler import FileMetadata +from ytdl_sub.utils.xml import XmlElement from ytdl_sub.utils.xml import to_max_3_byte_utf8_dict from ytdl_sub.utils.xml import to_max_3_byte_utf8_string from ytdl_sub.utils.xml import to_xml -from ytdl_sub.validators.strict_dict_validator import StrictDictValidator +from ytdl_sub.validators.nfo_validators import NfoTagsValidator +from ytdl_sub.validators.nfo_validators import SharedNfoTagsValidator +from ytdl_sub.validators.nfo_validators import TDictFormatterValidator +from ytdl_sub.validators.nfo_validators import TStringFormatterValidator from ytdl_sub.validators.string_formatter_validators import DictFormatterValidator from ytdl_sub.validators.string_formatter_validators import StringFormatterValidator from ytdl_sub.validators.validators import BoolValidator -from ytdl_sub.validators.validators import DictValidator - -TStringFormatterValidator = TypeVar("TStringFormatterValidator", bound=StringFormatterValidator) -TDictFormatterValidator = TypeVar("TDictFormatterValidator", bound=DictFormatterValidator) - - -class NfoTagsWithAttributesValidator( - StrictDictValidator, Generic[TStringFormatterValidator, TDictFormatterValidator], ABC -): - - _required_keys = {"attributes", "tag"} - - formatter_validator: Type[TStringFormatterValidator] - dict_formatter_validator: Type[TDictFormatterValidator] - - def __init__(self, name, value): - super().__init__(name, value) - self._attributes = self._validate_key( - key="attributes", validator=self.dict_formatter_validator - ) - self._tag = self._validate_key(key="tag", validator=self.formatter_validator) - - @property - def attributes(self) -> TDictFormatterValidator: - """ - Returns - ------- - The attributes for this NFO tag - """ - return self._attributes - - @property - def tag(self) -> TStringFormatterValidator: - """ - Returns - ------- - The value for this NFO tag - """ - return self._tag - - -_TagsWithAttributesValidator = NfoTagsWithAttributesValidator[ - TStringFormatterValidator, TDictFormatterValidator -] - - -class NfoTagsValidator( - DictValidator, Generic[TStringFormatterValidator, TDictFormatterValidator], ABC -): - - _tags_with_attributes_validator: Type[_TagsWithAttributesValidator] - - def __init__(self, name, value): - super().__init__(name, value) - - self.tags: List[TStringFormatterValidator] = [] - self.tags_with_attributes: List[_TagsWithAttributesValidator] = [] - - for key in self._keys: - if isinstance(value, str): - validated = self._validate_key( - key=key, validator=self._tags_with_attributes_validator.formatter_validator - ) - self.tags.append(validated) - elif isinstance(value, dict): - validated = self._validate_key( - key=key, validator=self._tags_with_attributes_validator - ) - self.tags_with_attributes.append(validated) - else: - raise self._validation_exception("must either be a string or attributes object") - - -_TagsValidator = NfoTagsValidator[TStringFormatterValidator, TDictFormatterValidator] class SharedNfoTagsOptions( @@ -102,7 +31,9 @@ class SharedNfoTagsOptions( """ _formatter_validator: Type[TStringFormatterValidator] - _tags_validator: Type[_TagsValidator] + _tags_validator: Type[ + SharedNfoTagsValidator[TStringFormatterValidator, TDictFormatterValidator] + ] _required_keys = {"nfo_name", "nfo_root", "tags"} _optional_keys = {"kodi_safe"} @@ -117,20 +48,78 @@ class SharedNfoTagsOptions( key="kodi_safe", validator=BoolValidator, default=False ).value + @property + def nfo_name(self) -> StringFormatterValidator: + """ + The NFO file name. + """ + return self._nfo_name -TSharedNfoTagsOptions = TypeVar("TSharedNfoTagsOptions", bound=SharedNfoTagsOptions) + @property + def nfo_root(self) -> StringFormatterValidator: + """ + The root tag of the NFO's XML. In the usage above, it would look like + + .. code-block:: xml + + + + + """ + return self._nfo_root + + @property + def tags(self) -> SharedNfoTagsValidator[TStringFormatterValidator, TDictFormatterValidator]: + """ + Tags within the nfo_root tag. In the usage above, it would look like + + .. code-block:: xml + + + + Awesome Youtube Video + 2022 + 502 + + """ + return self._tags + + @property + def kodi_safe(self) -> Optional[bool]: + """ + Optional. Kodi does not support > 3-byte unicode characters, which include emojis and some + foreign language characters. Setting this to True will replace those characters with '□'. + Defaults to False. + """ + return self._kodi_safe -class SharedNfoTagsPlugin(Plugin[TSharedNfoTagsOptions], Generic[TSharedNfoTagsOptions], ABC): +class SharedNfoTagsPlugin( + Plugin[SharedNfoTagsOptions[TStringFormatterValidator, TDictFormatterValidator]], + Generic[TStringFormatterValidator, TDictFormatterValidator], + ABC, +): """ Shared code between NFO tags and Ouptut Directory NFO Tags """ def _create_nfo(self, entry: Optional[Entry] = None) -> None: - nfo = {} + nfo_tags: Dict[str, XmlElement] = {} - for tag, tag_formatter in sorted(self.plugin_options.tags.dict.items()): - nfo[tag] = self.overrides.apply_formatter(formatter=tag_formatter, entry=entry) + for key, string_tag in self.plugin_options.tags.string_tags.items(): + nfo_tags[key] = XmlElement( + text=self.overrides.apply_formatter(formatter=string_tag, entry=entry), + attributes={}, + ) + + for key, attribute_tag in self.plugin_options.tags.attribute_tags.items(): + nfo_tags[key] = XmlElement( + text=self.overrides.apply_formatter(formatter=attribute_tag.tag, entry=entry), + attributes={ + attr_name: self.overrides.apply_formatter(formatter=attr_formatter, entry=entry) + for attr_name, attr_formatter in attribute_tag.attributes.dict.items() + }, + ) # Write the nfo tags to XML with the nfo_root nfo_root = self.overrides.apply_formatter( @@ -138,10 +127,14 @@ class SharedNfoTagsPlugin(Plugin[TSharedNfoTagsOptions], Generic[TSharedNfoTagsO ) if self.plugin_options.kodi_safe: - nfo = to_max_3_byte_utf8_dict(nfo) nfo_root = to_max_3_byte_utf8_string(nfo_root) + for key, xml_elem in nfo_tags.items(): + nfo_tags[key] = XmlElement( + text=to_max_3_byte_utf8_string(xml_elem.text), + attributes=to_max_3_byte_utf8_dict(xml_elem.attributes), + ) - xml = to_xml(nfo_dict=nfo, nfo_root=nfo_root) + xml = to_xml(nfo_dict=nfo_tags, nfo_root=nfo_root) nfo_file_name = self.overrides.apply_formatter( formatter=self.plugin_options.nfo_name, entry=entry @@ -154,7 +147,12 @@ class SharedNfoTagsPlugin(Plugin[TSharedNfoTagsOptions], Generic[TSharedNfoTagsO nfo_file.write(xml) # Save the nfo file and log its metadata - nfo_metadata = FileMetadata.from_dict(value_dict={nfo_root: nfo}, title="NFO tags:") + nfo_metadata = FileMetadata.from_dict( + value_dict={ + nfo_root: {key: xml_elem.to_dict_value() for key, xml_elem in nfo_tags.items()} + }, + title="NFO tags:", + ) self.save_file(file_name=nfo_file_name, file_metadata=nfo_metadata, entry=entry) @@ -183,54 +181,10 @@ class NfoTagsOptions(SharedNfoTagsOptions[StringFormatterValidator, DictFormatte _formatter_validator = StringFormatterValidator _dict_formatter_validator = DictFormatterValidator - - @property - def nfo_name(self) -> StringFormatterValidator: - """ - The NFO file name. - """ - return self._nfo_name - - @property - def nfo_root(self) -> StringFormatterValidator: - """ - The root tag of the NFO's XML. In the usage above, it would look like - - .. code-block:: xml - - - - - """ - return self._nfo_root - - @property - def tags(self) -> NfoTagsValidator[StringFormatterValidator, DictFormatterValidator]: - """ - Tags within the nfo_root tag. In the usage above, it would look like - - .. code-block:: xml - - - - Awesome Youtube Video - 2022 - 502 - - """ - return self._tags - - @property - def kodi_safe(self) -> Optional[bool]: - """ - Optional. Kodi does not support > 3-byte unicode characters, which include emojis and some - foreign language characters. Setting this to True will replace those characters with '□'. - Defaults to False. - """ - return self._kodi_safe + _tags_validator = NfoTagsValidator -class NfoTagsPlugin(SharedNfoTagsPlugin[NfoTagsOptions]): +class NfoTagsPlugin(SharedNfoTagsPlugin[StringFormatterValidator, DictFormatterValidator]): plugin_options_type = NfoTagsOptions def post_process_entry(self, entry: Entry) -> None: diff --git a/src/ytdl_sub/plugins/output_directory_nfo_tags.py b/src/ytdl_sub/plugins/output_directory_nfo_tags.py index fa611b61..31c07da4 100644 --- a/src/ytdl_sub/plugins/output_directory_nfo_tags.py +++ b/src/ytdl_sub/plugins/output_directory_nfo_tags.py @@ -3,6 +3,7 @@ from typing import Optional from ytdl_sub.plugins.nfo_tags import NfoTagsValidator from ytdl_sub.plugins.nfo_tags import SharedNfoTagsOptions from ytdl_sub.plugins.nfo_tags import SharedNfoTagsPlugin +from ytdl_sub.validators.nfo_validators import NfoOverrideTagsValidator from ytdl_sub.validators.string_formatter_validators import OverridesDictFormatterValidator from ytdl_sub.validators.string_formatter_validators import OverridesStringFormatterValidator @@ -32,6 +33,7 @@ class OutputDirectoryNfoTagsOptions( _formatter_validator = OverridesStringFormatterValidator _dict_formatter_validator = OverridesDictFormatterValidator + _tags_validator = NfoOverrideTagsValidator @property def nfo_name(self) -> OverridesStringFormatterValidator: @@ -56,7 +58,7 @@ class OutputDirectoryNfoTagsOptions( @property def tags( self, - ) -> NfoTagsValidator[OverridesStringFormatterValidator, OverridesDictFormatterValidator]: + ) -> NfoTagsValidator: """ Tags within the nfo_root tag. In the usage above, it would look like @@ -79,7 +81,9 @@ class OutputDirectoryNfoTagsOptions( return self._kodi_safe -class OutputDirectoryNfoTagsPlugin(SharedNfoTagsPlugin[OutputDirectoryNfoTagsOptions]): +class OutputDirectoryNfoTagsPlugin( + SharedNfoTagsPlugin[OverridesStringFormatterValidator, OverridesDictFormatterValidator] +): plugin_options_type = OutputDirectoryNfoTagsOptions def post_process_subscription(self): diff --git a/src/ytdl_sub/utils/xml.py b/src/ytdl_sub/utils/xml.py index bf452fd5..f4f721d4 100644 --- a/src/ytdl_sub/utils/xml.py +++ b/src/ytdl_sub/utils/xml.py @@ -1,5 +1,27 @@ import xml.etree.ElementTree as et +from dataclasses import dataclass +from typing import Any from typing import Dict +from typing import Union + + +@dataclass +class XmlElement: + text: str + attributes: Dict[str, str] + + def to_dict_value(self) -> Union[str, Dict[str, Any]]: + """ + Returns + ------- + Only the tag if no attributes, otherwise a dict containing both attributes and the tag + """ + if not self.attributes: + return self.text + return { + "attributes": self.attributes, + "tag": self.text, + } def _to_max_3_byte_utf8_char(char: str) -> str: @@ -37,7 +59,7 @@ def to_max_3_byte_utf8_dict(string_dict: Dict[str, str]) -> Dict[str, str]: } -def to_xml(nfo_dict: Dict[str, str], nfo_root: str) -> bytes: +def to_xml(nfo_dict: Dict[str, XmlElement], nfo_root: str) -> bytes: """ Transforms a dict to XML @@ -53,9 +75,10 @@ def to_xml(nfo_dict: Dict[str, str], nfo_root: str) -> bytes: XML bytes """ xml_root = et.Element(nfo_root) - for key, value in nfo_dict.items(): - sub_element = et.SubElement(xml_root, key) - sub_element.text = value + for key, xml_elem in sorted(nfo_dict.items()): + sorted_attr = dict(sorted(xml_elem.attributes.items())) + sub_element = et.SubElement(xml_root, key, sorted_attr) + sub_element.text = xml_elem.text et.indent(tree=xml_root, space=" ", level=0) return et.tostring(element=xml_root, encoding="utf-8", xml_declaration=True) diff --git a/src/ytdl_sub/validators/nfo_validators.py b/src/ytdl_sub/validators/nfo_validators.py new file mode 100644 index 00000000..36cb360e --- /dev/null +++ b/src/ytdl_sub/validators/nfo_validators.py @@ -0,0 +1,124 @@ +from abc import ABC +from typing import Dict +from typing import Generic +from typing import Type +from typing import TypeVar + +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 OverridesDictFormatterValidator +from ytdl_sub.validators.string_formatter_validators import OverridesStringFormatterValidator +from ytdl_sub.validators.string_formatter_validators import StringFormatterValidator +from ytdl_sub.validators.validators import DictValidator + +TStringFormatterValidator = TypeVar("TStringFormatterValidator", bound=StringFormatterValidator) +TDictFormatterValidator = TypeVar("TDictFormatterValidator", bound=DictFormatterValidator) + + +class _NfoTagsWithAttributesValidator( + StrictDictValidator, Generic[TStringFormatterValidator, TDictFormatterValidator], ABC +): + + _required_keys = {"attributes", "tag"} + + formatter_validator: Type[TStringFormatterValidator] + dict_formatter_validator: Type[TDictFormatterValidator] + + def __init__(self, name, value): + super().__init__(name, value) + self._attributes = self._validate_key( + key="attributes", validator=self.dict_formatter_validator + ) + self._tag = self._validate_key(key="tag", validator=self.formatter_validator) + + @property + def attributes(self) -> TDictFormatterValidator: + """ + Returns + ------- + The attributes for this NFO tag + """ + return self._attributes + + @property + def tag(self) -> TStringFormatterValidator: + """ + Returns + ------- + The value for this NFO tag + """ + return self._tag + + +class NfoTagsWithAttributesValidator( + _NfoTagsWithAttributesValidator[StringFormatterValidator, DictFormatterValidator] +): + formatter_validator = StringFormatterValidator + dict_formatter_validator = DictFormatterValidator + + +class NfoOverrideTagsWithAttributesValidator( + _NfoTagsWithAttributesValidator[ + OverridesStringFormatterValidator, OverridesDictFormatterValidator + ] +): + formatter_validator = OverridesStringFormatterValidator + dict_formatter_validator = OverridesDictFormatterValidator + + +TNfoTagsWithAttributesValidator = _NfoTagsWithAttributesValidator[ + TStringFormatterValidator, TDictFormatterValidator +] + + +class SharedNfoTagsValidator( + DictValidator, Generic[TStringFormatterValidator, TDictFormatterValidator], ABC +): + + _tags_with_attributes_validator: Type[TNfoTagsWithAttributesValidator] + + def __init__(self, name, value): + super().__init__(name, value) + + self._string_tags: Dict[str, StringFormatterValidator] = {} + self._attribute_tags: Dict[str, TNfoTagsWithAttributesValidator] = {} + + for key, tag_value in self._dict.items(): + if isinstance(tag_value, str): + self._string_tags[key] = self._validate_key( + key=key, validator=self._tags_with_attributes_validator.formatter_validator + ) + elif isinstance(tag_value, dict): + self._attribute_tags[key] = self._validate_key( + key=key, validator=self._tags_with_attributes_validator + ) + else: + raise self._validation_exception("must either be a string or attributes object") + + @property + def string_tags(self) -> Dict[str, StringFormatterValidator]: + """ + Returns + ------- + Tags with no attributes + """ + return self._string_tags + + @property + def attribute_tags(self) -> Dict[str, TNfoTagsWithAttributesValidator]: + """ + Returns + ------- + Tags with attributes + """ + return self._attribute_tags + + +class NfoTagsValidator(SharedNfoTagsValidator[StringFormatterValidator, DictFormatterValidator]): + _tags_with_attributes_validator = NfoTagsWithAttributesValidator + + +class NfoOverrideTagsValidator( + SharedNfoTagsValidator[OverridesStringFormatterValidator, OverridesDictFormatterValidator] +): + _tags_with_attributes_validator = NfoOverrideTagsWithAttributesValidator