diff --git a/src/ytdl_sub/downloaders/youtube/merge_playlist.py b/src/ytdl_sub/downloaders/youtube/merge_playlist.py index 4ef24b61..2c0da03f 100644 --- a/src/ytdl_sub/downloaders/youtube/merge_playlist.py +++ b/src/ytdl_sub/downloaders/youtube/merge_playlist.py @@ -122,7 +122,7 @@ class YoutubeMergePlaylistDownloader( file_duration_sec=merged_video.kwargs("duration"), ) - return chapters.to_file_metadata(title="Timestamps of playlist videos in the merged file:") + return chapters.to_file_metadata(title="Timestamps of playlist videos in the merged file") def _to_merged_video(self, entry_dict: Dict) -> YoutubeVideo: """ diff --git a/src/ytdl_sub/plugins/chapters.py b/src/ytdl_sub/plugins/chapters.py index dc4d4693..870b144c 100644 --- a/src/ytdl_sub/plugins/chapters.py +++ b/src/ytdl_sub/plugins/chapters.py @@ -334,7 +334,7 @@ class ChaptersPlugin(Plugin[ChaptersOptions]): chapters = Chapters.from_timestamps_file( chapters_file_path=self.plugin_options.embed_chapter_timestamps ) - return chapters.to_file_metadata(title="Chapters embedded from timestamp file:") + return chapters.to_file_metadata(title="Chapters embedded from timestamp file") if self.plugin_options.embed_chapters: metadata_dict = {} diff --git a/src/ytdl_sub/plugins/music_tags.py b/src/ytdl_sub/plugins/music_tags.py index c74c85a1..040e2ceb 100644 --- a/src/ytdl_sub/plugins/music_tags.py +++ b/src/ytdl_sub/plugins/music_tags.py @@ -84,4 +84,4 @@ class MusicTagsPlugin(Plugin[MusicTagsOptions]): audio_file.save() # report the tags written - return FileMetadata.from_dict(value_dict=tags_to_write, title="Music Tags:") + return FileMetadata.from_dict(value_dict=tags_to_write, title="Music Tags") diff --git a/src/ytdl_sub/plugins/nfo_tags.py b/src/ytdl_sub/plugins/nfo_tags.py index c7996f0e..14a3d08a 100644 --- a/src/ytdl_sub/plugins/nfo_tags.py +++ b/src/ytdl_sub/plugins/nfo_tags.py @@ -1,5 +1,6 @@ import os from abc import ABC +from collections import defaultdict from pathlib import Path from typing import Dict from typing import Generic @@ -97,19 +98,19 @@ class SharedNfoTagsPlugin( """ def _get_xml_element_dict(self, entry: Optional[Entry]) -> Dict[str, List[XmlElement]]: - nfo_tags: Dict[str, List[XmlElement]] = {} + nfo_tags: Dict[str, List[XmlElement]] = defaultdict(list) for key, string_tags in self.plugin_options.tags.string_tags.items(): - nfo_tags[key] = [ + nfo_tags[key].extend( XmlElement( text=self.overrides.apply_formatter(formatter=string_tag, entry=entry), attributes={}, ) for string_tag in string_tags - ] + ) for key, attribute_tags in self.plugin_options.tags.attribute_tags.items(): - nfo_tags[key] = [ + nfo_tags[key].extend( XmlElement( text=self.overrides.apply_formatter(formatter=attribute_tag.tag, entry=entry), attributes={ @@ -120,7 +121,7 @@ class SharedNfoTagsPlugin( }, ) for attribute_tag in attribute_tags - ] + ) return nfo_tags @@ -168,7 +169,7 @@ class SharedNfoTagsPlugin( for key, xml_elems in nfo_tags.items() } }, - title="NFO tags:", + title="NFO tags", ) self.save_file(file_name=nfo_file_name, file_metadata=nfo_metadata, entry=entry) diff --git a/src/ytdl_sub/plugins/split_by_chapters.py b/src/ytdl_sub/plugins/split_by_chapters.py index 78fd43cb..98ba5378 100644 --- a/src/ytdl_sub/plugins/split_by_chapters.py +++ b/src/ytdl_sub/plugins/split_by_chapters.py @@ -136,7 +136,7 @@ class SplitByChaptersPlugin(Plugin[SplitByChaptersOptions]): metadata = FileMetadata.from_dict( value_dict=metadata_value_dict, - title="From Chapter Split:", + title="From Chapter Split", sort_dict=False, ) diff --git a/src/ytdl_sub/plugins/video_tags.py b/src/ytdl_sub/plugins/video_tags.py index aa5989bb..61d89a78 100644 --- a/src/ytdl_sub/plugins/video_tags.py +++ b/src/ytdl_sub/plugins/video_tags.py @@ -59,4 +59,4 @@ class VideoTagsPlugin(Plugin[VideoTagsOptions]): ) # report the tags written - return FileMetadata.from_dict(value_dict=tags_to_write, title="Video Tags:") + return FileMetadata.from_dict(value_dict=tags_to_write, title="Video Tags") diff --git a/src/ytdl_sub/utils/file_handler.py b/src/ytdl_sub/utils/file_handler.py index c9531370..21388ad7 100644 --- a/src/ytdl_sub/utils/file_handler.py +++ b/src/ytdl_sub/utils/file_handler.py @@ -1,3 +1,4 @@ +import json import os import shutil from pathlib import Path @@ -7,6 +8,7 @@ from typing import List from typing import Optional from typing import Set from typing import Union +import yaml class FileMetadata: @@ -56,32 +58,13 @@ class FileMetadata: sort_dict Whether to sort dicts in the value_dict. Defaults to true. """ - lines: List[str] = [] - if title is not None: - lines.append(title) + if title: + value_dict = {title: value_dict} + if sort_dict: + value_dict = json.loads(json.dumps(value_dict, sort_keys=True)) - def _recursive_add_dict_lines(rdict: Dict, indent: int): - rdict_items = rdict.items() - if sort_dict: - rdict_items = sorted(rdict_items) - - for key, value in rdict_items: - _indent = " " * indent - if isinstance(value, Dict): - lines.append(f"{_indent}{key}:") - _recursive_add_dict_lines(rdict=value, indent=indent + 2) - else: - value = str(value) - # If there are newlines in the value, print them indented - if "\n" in value: - lines.append(f"{_indent}{key}:") - for value_line in value.split("\n"): - lines.append(f" {_indent}{value_line.strip()}") - else: - lines.append(f"{_indent}{key}: {value}") - - _recursive_add_dict_lines(rdict=value_dict, indent=2) - return cls(metadata=lines) + out = yaml.safe_dump(value_dict, allow_unicode=True, indent=2, default_style='', width=100) + return cls(metadata=out.rstrip().split('\n')) class FileHandlerTransactionLog: diff --git a/src/ytdl_sub/utils/xml.py b/src/ytdl_sub/utils/xml.py index f4f721d4..3e7dbd63 100644 --- a/src/ytdl_sub/utils/xml.py +++ b/src/ytdl_sub/utils/xml.py @@ -1,6 +1,6 @@ import xml.etree.ElementTree as et from dataclasses import dataclass -from typing import Any +from typing import Any, List from typing import Dict from typing import Union @@ -59,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, XmlElement], nfo_root: str) -> bytes: +def to_xml(nfo_dict: Dict[str, List[XmlElement]], nfo_root: str) -> bytes: """ Transforms a dict to XML @@ -75,10 +75,11 @@ def to_xml(nfo_dict: Dict[str, XmlElement], nfo_root: str) -> bytes: XML bytes """ xml_root = et.Element(nfo_root) - 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 + for key, xml_elems in sorted(nfo_dict.items()): + for xml_elem in xml_elems: + 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 index 850753b9..ef254a03 100644 --- a/src/ytdl_sub/validators/nfo_validators.py +++ b/src/ytdl_sub/validators/nfo_validators.py @@ -7,11 +7,13 @@ 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 DictFormatterValidator, \ + ListFormatterValidator, ListOverridesFormatterValidator 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 +from ytdl_sub.validators.validators import ListValidator TStringFormatterValidator = TypeVar("TStringFormatterValidator", bound=StringFormatterValidator) TDictFormatterValidator = TypeVar("TDictFormatterValidator", bound=DictFormatterValidator) @@ -55,34 +57,54 @@ class _NfoTagsWithAttributesValidator( class NfoTagsWithAttributesValidator( _NfoTagsWithAttributesValidator[StringFormatterValidator, DictFormatterValidator] ): + """TagsWithAttributes for the entry NFO validator""" + formatter_validator = StringFormatterValidator dict_formatter_validator = DictFormatterValidator +class NfoTagsWithAttributesListValidator(ListValidator[NfoTagsWithAttributesValidator]): + """TagsWithAttributes list for the entry NFO validator""" + + _inner_list_type = NfoTagsWithAttributesValidator + + class NfoOverrideTagsWithAttributesValidator( _NfoTagsWithAttributesValidator[ OverridesStringFormatterValidator, OverridesDictFormatterValidator ] ): + """TagsWithAttributes for the output directory NFO validator""" + formatter_validator = OverridesStringFormatterValidator dict_formatter_validator = OverridesDictFormatterValidator -TNfoTagsWithAttributesValidator = _NfoTagsWithAttributesValidator[ - TStringFormatterValidator, TDictFormatterValidator -] +class NfoOverrideTagsWithAttributesListValidator( + ListValidator[NfoOverrideTagsWithAttributesValidator] +): + """TagsWithAttributes list for the output directory NFO validator""" + _inner_list_type = NfoOverrideTagsWithAttributesValidator + + +# Generic TagsWithAttribute to use for SharedNfoTagsValidator +TNfoTagsWithAttributesValidator = _NfoTagsWithAttributesValidator[TStringFormatterValidator, TDictFormatterValidator] + +# List validators +TNfoTagsWithAttributesListValidator = ListValidator[TNfoTagsWithAttributesValidator] +TNfoTagsListValidator = ListValidator[TStringFormatterValidator] class SharedNfoTagsValidator( - DictValidator, Generic[TStringFormatterValidator, TDictFormatterValidator], ABC + DictValidator, ABC ): - - _tags_with_attributes_validator: Type[TNfoTagsWithAttributesValidator] + _tags_validator: Type[TNfoTagsListValidator] + _tags_with_attributes_validator: Type[TNfoTagsWithAttributesListValidator] def __init__(self, name, value): super().__init__(name, value) - self._string_tags: Dict[str, List[StringFormatterValidator]] = defaultdict(list) + self._string_tags: Dict[str, List[TStringFormatterValidator]] = defaultdict(list) self._attribute_tags: Dict[str, List[TNfoTagsWithAttributesValidator]] = defaultdict(list) for key, tag_value in self._dict.items(): @@ -93,15 +115,17 @@ class SharedNfoTagsValidator( # iterate each list, validate accordingly if it is a string tag or attribute tag for tag_value_i in tag_value: if isinstance(tag_value_i, str): - self._string_tags[key].append( + self._string_tags[key].extend( self._validate_key( key=key, - validator=self._tags_with_attributes_validator.formatter_validator, - ) + validator=self._tags_validator, + ).list ) elif isinstance(tag_value_i, dict): - self._attribute_tags[key].append( - self._validate_key(key=key, validator=self._tags_with_attributes_validator) + self._attribute_tags[key].extend( + self._validate_key( + key=key, validator=self._tags_with_attributes_validator + ).list ) else: raise self._validation_exception( @@ -109,7 +133,7 @@ class SharedNfoTagsValidator( ) @property - def string_tags(self) -> Dict[str, List[StringFormatterValidator]]: + def string_tags(self) -> Dict[str, List[TStringFormatterValidator]]: """ Returns ------- @@ -127,11 +151,11 @@ class SharedNfoTagsValidator( return self._attribute_tags -class NfoTagsValidator(SharedNfoTagsValidator[StringFormatterValidator, DictFormatterValidator]): - _tags_with_attributes_validator = NfoTagsWithAttributesValidator +class NfoTagsValidator(SharedNfoTagsValidator): + _tags_validator = ListFormatterValidator + _tags_with_attributes_validator = NfoTagsWithAttributesListValidator -class NfoOverrideTagsValidator( - SharedNfoTagsValidator[OverridesStringFormatterValidator, OverridesDictFormatterValidator] -): - _tags_with_attributes_validator = NfoOverrideTagsWithAttributesValidator +class NfoOverrideTagsValidator(SharedNfoTagsValidator): + _tags_validator = ListOverridesFormatterValidator + _tags_with_attributes_validator = NfoOverrideTagsWithAttributesListValidator diff --git a/src/ytdl_sub/validators/string_formatter_validators.py b/src/ytdl_sub/validators/string_formatter_validators.py index 65fc07ec..2893c054 100644 --- a/src/ytdl_sub/validators/string_formatter_validators.py +++ b/src/ytdl_sub/validators/string_formatter_validators.py @@ -209,6 +209,9 @@ class ListFormatterValidator(ListValidator[StringFormatterValidator]): _inner_list_type = StringFormatterValidator +class ListOverridesFormatterValidator(ListValidator[StringFormatterValidator]): + _inner_list_type = OverridesStringFormatterValidator + class DictFormatterValidator(LiteralDictValidator): """ A dict made up of @@ -241,4 +244,3 @@ class OverridesDictFormatterValidator(DictFormatterValidator): """ _key_validator = OverridesStringFormatterValidator - diff --git a/tests/e2e/plugins/test_nfo_tags.py b/tests/e2e/plugins/test_nfo_tags.py index cf3621e4..65040e62 100644 --- a/tests/e2e/plugins/test_nfo_tags.py +++ b/tests/e2e/plugins/test_nfo_tags.py @@ -33,7 +33,7 @@ def subscription_dict(output_directory): "attributes": {"🎸?": "value\nnewlines 🎸"}, "tag": "the \n tag 2 🎸🎸", }, - ] + ], }, }, "output_directory_nfo_tags": { @@ -55,7 +55,7 @@ def subscription_dict(output_directory): "attributes": {"🎸?": "value\nnewlines 🎸"}, "tag": "the \n tag 2 🎸🎸", }, - ] + ], }, }, } @@ -82,7 +82,7 @@ class TestNfoTagsPlugins: output_directory=output_directory, transaction_log=transaction_log, transaction_log_summary_file_name=f"plugins/nfo_tags/{transaction_log_file_name}", - regenerate_transaction_log=True + regenerate_transaction_log=True, ) def test_source_variable_in_output_directory_nfo_tags_errors( diff --git a/tests/e2e/resources/transaction_log_summaries/plugins/nfo_tags/test_nfo.txt b/tests/e2e/resources/transaction_log_summaries/plugins/nfo_tags/test_nfo.txt index a95d6bde..34717995 100644 --- a/tests/e2e/resources/transaction_log_summaries/plugins/nfo_tags/test_nfo.txt +++ b/tests/e2e/resources/transaction_log_summaries/plugins/nfo_tags/test_nfo.txt @@ -5,29 +5,79 @@ Rick Beato - Can you hear the difference? 🎸🔥 #shorts.3gp Rick Beato - Can you hear the difference? 🎸🔥 #shorts.info.json Rick Beato - Can you hear the difference? 🎸🔥 #shorts.nfo NFO tags: - musicvideo: - album: Music Videos - artist: Rick Beato - kodi_safe_title 🎸: kodi_safe_value 🎸 - kodi_safe_title_with_attrs: - attributes: - 🎸?: - value - newlines 🎸 - tag: - the - tag 🎸🎸 - title: Can you hear the difference? 🎸🔥 #shorts - year: 2022 + album: Music Videos + artist: Rick Beato + - value 1 🎸 + - value 2 🎸 + - value 1 🎸 + - value 2 🎸 + 🎸?: + value + newlines 🎸 + tag: + the + tag 1 🎸🎸 + 🎸?: + value + newlines 🎸 + tag: + the + tag 2 🎸🎸 + 🎸?: + value + newlines 🎸 + tag: + the + tag 1 🎸🎸 + 🎸?: + value + newlines 🎸 + tag: + the + tag 2 🎸🎸 + kodi_safe_title 🎸: kodi_safe_value 🎸 + 🎸?: + value + newlines 🎸 + tag: + the + tag 🎸🎸 + title: Can you hear the difference? 🎸🔥 #shorts + year: 2022 test.nfo NFO tags: - kodi_safe_root 🎸: - kodi_safe_title 🎸: kodi_safe_value 🎸 - kodi_safe_title_with_attrs: - attributes: - 🎸?: - value - newlines 🎸 - tag: - the - tag 🎸🎸 \ No newline at end of file + - value 1 🎸 + - value 2 🎸 + - value 1 🎸 + - value 2 🎸 + 🎸?: + value + newlines 🎸 + tag: + the + tag 1 🎸🎸 + 🎸?: + value + newlines 🎸 + tag: + the + tag 2 🎸🎸 + 🎸?: + value + newlines 🎸 + tag: + the + tag 1 🎸🎸 + 🎸?: + value + newlines 🎸 + tag: + the + tag 2 🎸🎸 + kodi_safe_title 🎸: kodi_safe_value 🎸 + 🎸?: + value + newlines 🎸 + tag: + the + tag 🎸🎸 \ No newline at end of file diff --git a/tests/e2e/resources/transaction_log_summaries/plugins/nfo_tags/test_nfo_kodi_safe.txt b/tests/e2e/resources/transaction_log_summaries/plugins/nfo_tags/test_nfo_kodi_safe.txt index 3076ae3f..3b83abf2 100644 --- a/tests/e2e/resources/transaction_log_summaries/plugins/nfo_tags/test_nfo_kodi_safe.txt +++ b/tests/e2e/resources/transaction_log_summaries/plugins/nfo_tags/test_nfo_kodi_safe.txt @@ -8,26 +8,74 @@ Rick Beato - Can you hear the difference? 🎸🔥 #shorts.nfo musicvideo: album: Music Videos artist: Rick Beato + kodi_safe_multi_title □: + - value 1 □ + - value 2 □ + - value 1 □ + - value 2 □ + kodi_safe_multi_title_with_attrs: + - attributes: + □?: 'value + + newlines □' + tag: "the \n tag 1 □□" + - attributes: + □?: 'value + + newlines □' + tag: "the \n tag 2 □□" + - attributes: + □?: 'value + + newlines □' + tag: "the \n tag 1 □□" + - attributes: + □?: 'value + + newlines □' + tag: "the \n tag 2 □□" kodi_safe_title □: kodi_safe_value □ kodi_safe_title_with_attrs: attributes: - □?: - value - newlines □ - tag: - the - tag □□ - title: Can you hear the difference? □□ #shorts - year: 2022 + □?: 'value + + newlines □' + tag: "the \n tag □□" + title: 'Can you hear the difference? □□ #shorts' + year: '2022' test.nfo NFO tags: kodi_safe_root □: + kodi_safe_multi_title □: + - value 1 □ + - value 2 □ + - value 1 □ + - value 2 □ + kodi_safe_multi_title_with_attrs: + - attributes: + □?: 'value + + newlines □' + tag: "the \n tag 1 □□" + - attributes: + □?: 'value + + newlines □' + tag: "the \n tag 2 □□" + - attributes: + □?: 'value + + newlines □' + tag: "the \n tag 1 □□" + - attributes: + □?: 'value + + newlines □' + tag: "the \n tag 2 □□" kodi_safe_title □: kodi_safe_value □ kodi_safe_title_with_attrs: attributes: - □?: - value - newlines □ - tag: - the - tag □□ \ No newline at end of file + □?: 'value + + newlines □' + tag: "the \n tag □□" \ No newline at end of file