diff --git a/config.yaml b/config.yaml index 7b058726..d7a35ae1 100644 --- a/config.yaml +++ b/config.yaml @@ -12,20 +12,19 @@ presets: output_options: output_directory: "/tmp/{sanitized_artist}" file_name: "[{album_year}] {sanitized_album}/{track_number_padded} - {sanitized_title}.{ext}" - convert_thumbnail: "jpeg" - thumbnail_name: "[{album_year}] {sanitized_album}/folder.jpeg" + thumbnail_name: "[{album_year}] {sanitized_album}/folder.jpg" maintain_download_archive: True - metadata_options: - id3: - id3_version: "2.4" - tags: - artist: "{artist}" - albumartist: "{artist}" - title: "{title}" - album: "{album}" - tracknumber: "{track_number}" - year: "{album_year}" - genre: "{genre}" + convert_thumbnail: + to: "jpg" + music_tags: + tags: + artist: "{artist}" + albumartist: "{artist}" + title: "{title}" + album: "{album}" + tracknumber: "{track_number}" + year: "{album_year}" + genre: "{genre}" music_videos: youtube: @@ -35,16 +34,17 @@ presets: output_options: file_name: "{sanitized_artist} - {sanitized_title}.{ext}" convert_thumbnail: "jpeg" - thumbnail_name: "{sanitized_artist} - {sanitized_title}.jpeg" - metadata_options: - nfo: - nfo_name: "{sanitized_artist} - {sanitized_title}.nfo" - nfo_root: "musicvideo" - tags: - artist: "{artist}" - title: "{title}" - album: "Music Videos" - year: "{upload_year}" + thumbnail_name: "{sanitized_artist} - {sanitized_title}.jpg" + convert_thumbnail: + to: "jpg" + nfo_tags: + nfo_name: "{sanitized_artist} - {sanitized_title}.nfo" + nfo_root: "musicvideo" + tags: + artist: "{artist}" + title: "{title}" + album: "Music Videos" + year: "{upload_year}" yt_channel: youtube: @@ -54,24 +54,25 @@ presets: output_options: file_name: "{output_file_name}.{ext}" convert_thumbnail: "jpeg" - thumbnail_name: "{output_file_name}.jpeg" + thumbnail_name: "{output_file_name}.jpg" maintain_download_archive: True maintain_stale_file_deletion: True - metadata_options: - nfo: - nfo_name: "{output_file_name}.nfo" - nfo_root: "episodedetails" - tags: - title: "{title}" - season: "{upload_year}" - episode: "{upload_month}{upload_day_padded}" - plot: "{description}" - year: "{upload_year}" - aired: "{upload_date_standardized}" - output_directory_nfo: - nfo_name: "tvshow.nfo" - nfo_root: "tvshow" - tags: - title: "{tv_show_name}" + convert_thumbnail: + to: "jpg" + nfo_tags: + nfo_name: "{output_file_name}.nfo" + nfo_root: "episodedetails" + tags: + title: "{title}" + season: "{upload_year}" + episode: "{upload_month}{upload_day_padded}" + plot: "{description}" + year: "{upload_year}" + aired: "{upload_date_standardized}" + output_directory_nfo_tags: + nfo_name: "tvshow.nfo" + nfo_root: "tvshow" + tags: + title: "{tv_show_name}" overrides: output_file_name: "Season {upload_year}/s{upload_year}.e{upload_month_padded}{upload_day_padded} - {sanitized_title}" \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py index 6b7e5d41..67f247bc 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -12,14 +12,15 @@ # import os import sys -sys.path.insert(0, os.path.abspath('../')) + +sys.path.insert(0, os.path.abspath("../")) # -- Project information ----------------------------------------------------- -project = 'ytdl-subscribe' -copyright = '2022, Jesse Bannon' -author = 'Jesse Bannon' +project = "ytdl-subscribe" +copyright = "2022, Jesse Bannon" +author = "Jesse Bannon" # -- General configuration --------------------------------------------------- @@ -28,17 +29,17 @@ author = 'Jesse Bannon' # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ - 'sphinx.ext.autodoc', - 'sphinx.ext.napoleon', + "sphinx.ext.autodoc", + "sphinx.ext.napoleon", ] # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. -exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] # -- Options for HTML output ------------------------------------------------- @@ -46,12 +47,12 @@ exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # -html_theme = 'sphinx_rtd_theme' +html_theme = "sphinx_rtd_theme" # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +html_static_path = ["_static"] # Do not show full module path in api docs add_module_names = False diff --git a/ytdl_subscribe/downloaders/soundcloud_downloader.py b/ytdl_subscribe/downloaders/soundcloud_downloader.py index 69f7bb96..8db2da1e 100644 --- a/ytdl_subscribe/downloaders/soundcloud_downloader.py +++ b/ytdl_subscribe/downloaders/soundcloud_downloader.py @@ -31,7 +31,9 @@ class SoundcloudDownloader(Downloader): artist_albums_url = f"{self.artist_url(artist_name)}/albums" info = self.extract_info(url=artist_albums_url) - albums = [SoundcloudAlbum(**e) for e in info["entries"]] + albums = [ + SoundcloudAlbum(working_direcotry=self.working_directory, **e) for e in info["entries"] + ] return [album for album in albums if album.track_count > 0] @@ -42,4 +44,7 @@ class SoundcloudDownloader(Downloader): artist_tracks_url = f"{self.artist_url(artist_name)}/tracks" info = self.extract_info(url=artist_tracks_url) - return [SoundcloudTrack(**e) for e in info["entries"]] + return [ + SoundcloudTrack(working_directory=self.working_directory, kwargs=e) + for e in info["entries"] + ] diff --git a/ytdl_subscribe/entries/entry.py b/ytdl_subscribe/entries/entry.py index 5f84bc8f..08974557 100644 --- a/ytdl_subscribe/entries/entry.py +++ b/ytdl_subscribe/entries/entry.py @@ -1,5 +1,6 @@ from abc import ABC from dataclasses import dataclass +from pathlib import Path from typing import Any from typing import Dict from typing import Optional @@ -23,10 +24,18 @@ class BaseEntry(ABC): Abstract entry object to represent anything download from ytdl (playlist metadata, media, etc). """ - def __init__(self, **kwargs): + def __init__(self, working_directory: Optional[str] = None, **kwargs): """ Initialize the entry using ytdl metadata + + Parameters + ---------- + working_directory + Optional. Directory that the entry is downloaded to + kwargs + Entry metadata """ + self._working_directory = working_directory self._kwargs = kwargs def kwargs_contains(self, key: str) -> bool: @@ -39,6 +48,14 @@ class BaseEntry(ABC): raise KeyError(f"Expected '{key}' in {self.__class__.__name__} but does not exist.") return self._kwargs[key] + @property + def working_directory(self) -> str: + if self._working_directory is None: + raise ValueError( + "Entry working directory is not set when trying to access its download file path" + ) + return self._working_directory + @property def uid(self) -> str: """Returns the entry's unique id""" @@ -133,6 +150,11 @@ class Entry(BaseEntry): """Returns the entry's file name when downloaded locally""" return f"{self.uid}.{self.ext}" + @property + def download_file_path(self) -> str: + """Returns the entry's file path to where it was downloaded""" + return str(Path(self.working_directory) / self.download_file_name) + @property def thumbnail_ext(self) -> str: """ @@ -145,6 +167,11 @@ class Entry(BaseEntry): """Returns the thumbnail's file name when downloaded locally""" return f"{self.uid}.{self.thumbnail_ext}" + @property + def download_thumbnail_path(self) -> str: + """Returns the entry's thumbnail's file path to where it was downloaded""" + return str(Path(self.working_directory) / self.download_thumbnail_name) + def to_dict(self) -> Dict: """Returns the entry's values as a dictionary""" return dict( diff --git a/ytdl_subscribe/entries/soundcloud.py b/ytdl_subscribe/entries/soundcloud.py index 889f3fb2..504c7b73 100644 --- a/ytdl_subscribe/entries/soundcloud.py +++ b/ytdl_subscribe/entries/soundcloud.py @@ -67,11 +67,18 @@ class SoundcloudAlbumTrack(SoundcloudTrack): Entry object to represent a Soundcloud track yt-dlp that belongs to an album. """ - def __init__(self, album: str, album_year: int, playlist_metadata: PlaylistMetadata, **kwargs): + def __init__( + self, + working_directory: str, + album: str, + album_year: int, + playlist_metadata: PlaylistMetadata, + **kwargs, + ): """ Initialize the album track using album metadata and ytdl metadata for the specific track. """ - super().__init__(**kwargs) + super().__init__(working_directory=working_directory, **kwargs) self._album = album self._album_year = album_year self._playlist_metadata = playlist_metadata @@ -104,6 +111,7 @@ class SoundcloudAlbumTrack(SoundcloudTrack): playlist_metadata: PlaylistMetadata, ) -> "SoundcloudAlbumTrack": return SoundcloudAlbumTrack( + working_directory=soundcloud_track.working_directory, album=album, album_year=album_year, playlist_metadata=playlist_metadata, @@ -122,7 +130,10 @@ class SoundcloudAlbum(Entry): Returns all tracks in the album represented by singles. Use this to fetch any data needed from the tracks before representing it as an album track. """ - return [SoundcloudTrack(**entry) for entry in self.kwargs("entries")] + return [ + SoundcloudTrack(working_directory=self._working_directory, **entry) + for entry in self.kwargs("entries") + ] def album_tracks(self, skip_premiere_tracks: bool = True) -> List[SoundcloudAlbumTrack]: """ diff --git a/ytdl_subscribe/plugins/__init__.py b/ytdl_subscribe/plugins/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ytdl_subscribe/plugins/convert_thumbnail.py b/ytdl_subscribe/plugins/convert_thumbnail.py new file mode 100644 index 00000000..b2538cff --- /dev/null +++ b/ytdl_subscribe/plugins/convert_thumbnail.py @@ -0,0 +1,41 @@ +import os + +from PIL.Image import Image + +from ytdl_subscribe.entries.entry import Entry +from ytdl_subscribe.plugins.plugin import Plugin +from ytdl_subscribe.plugins.plugin import PluginValidator +from ytdl_subscribe.validators.base.string_select_validator import StringSelectValidator + + +class ThumbnailTypes(StringSelectValidator): + """Valid image types that thumbnails can be converted to""" + + _select_values = {"jpg"} + + +class ConvertThumbnailValidator(PluginValidator): + _required_keys = {"to"} + + def __init__(self, name, value): + super().__init__(name, value) + self.to = self._validate_key(key="to", validator=ThumbnailTypes) + + +class ConvertThumbnail(Plugin[ConvertThumbnailValidator]): + def post_process_entry(self, entry: Entry): + """ + Convert the source thumbnail to the desired type. Leave the original file name and + extension intact to let output_options deal with renaming it. + """ + if not os.path.isfile(entry.download_thumbnail_path): + raise ValueError("Thumbnail not found") + + image = Image.open(entry.download_thumbnail_path).convert("RGB") + + # Pillow likes the formal 'jpeg' name and not 'jpg' + thumbnail_format = self.plugin_options.to.value + if thumbnail_format == "jpg": + thumbnail_format = "jpeg" + + image.save(fp=entry.download_thumbnail_path, format=thumbnail_format) diff --git a/ytdl_subscribe/plugins/music_tags.py b/ytdl_subscribe/plugins/music_tags.py new file mode 100644 index 00000000..972f2d8a --- /dev/null +++ b/ytdl_subscribe/plugins/music_tags.py @@ -0,0 +1,32 @@ +import music_tag + +from ytdl_subscribe.entries.entry import Entry +from ytdl_subscribe.plugins.plugin import Plugin +from ytdl_subscribe.plugins.plugin import PluginValidator +from ytdl_subscribe.validators.base.string_formatter_validators import DictFormatterValidator +from ytdl_subscribe.validators.base.validators import StringValidator + + +class MusicTagsValidator(PluginValidator): + _required_keys = {"tags"} + _optional_keys = {"multi_value_separator"} + + def __init__(self, name, value): + super().__init__(name, value) + + self.tags = self._validate_key(key="tags", validator=DictFormatterValidator) + + self.multi_value_separator = self._validate_key_if_present( + key="multi_value_separator", validator=StringValidator + ) + + +class MusicTags(Plugin[MusicTagsValidator]): + def post_process_entry(self, entry: Entry): + """ + Tags the entry's audio file using values defined in the metadata options + """ + audio_file = music_tag.load_file(entry.download_file_path) + for tag, tag_formatter in self.plugin_options.tags.dict.items(): + audio_file[tag] = self.overrides.apply_formatter(formatter=tag_formatter, entry=entry) + audio_file.save() diff --git a/ytdl_subscribe/plugins/nfo_tags.py b/ytdl_subscribe/plugins/nfo_tags.py new file mode 100644 index 00000000..2cb8c6e2 --- /dev/null +++ b/ytdl_subscribe/plugins/nfo_tags.py @@ -0,0 +1,58 @@ +from pathlib import Path + +import dicttoxml + +from ytdl_subscribe.entries.entry import Entry +from ytdl_subscribe.plugins.plugin import Plugin +from ytdl_subscribe.plugins.plugin import PluginValidator +from ytdl_subscribe.validators.base.string_formatter_validators import DictFormatterValidator +from ytdl_subscribe.validators.base.string_formatter_validators import StringFormatterValidator + + +class NfoTagsValidator(PluginValidator): + _required_keys = {"nfo_name", "nfo_root", "tags"} + + def __init__(self, name, value): + super().__init__(name, value) + + self.nfo_name = self._validate_key(key="nfo_name", validator=StringFormatterValidator) + self.nfo_root = self._validate_key(key="nfo_root", validator=StringFormatterValidator) + self.tags = self._validate_key(key="tags", validator=DictFormatterValidator) + + +class NfoTags(Plugin[NfoTagsValidator]): + def post_process_entry(self, entry: Entry): + """ + Creates an entry's NFO file using values defined in the metadata options + + Parameters + ---------- + entry: Entry to create an NFO file for + """ + nfo = {} + + for tag, tag_formatter in self.plugin_options.tags.dict.items(): + nfo[tag] = self.overrides.apply_formatter(formatter=tag_formatter, entry=entry) + + # Write the nfo tags to XML with the nfo_root + nfo_root = self.overrides.apply_formatter( + formatter=self.plugin_options.nfo_root, entry=entry + ) + xml = dicttoxml.dicttoxml( + obj=nfo, + root=True, # We assume all NFOs have a root. Maybe we should not? + custom_root=nfo_root, + attr_type=False, + ) + + nfo_file_name = self.overrides.apply_formatter( + formatter=self.plugin_options.nfo_name, entry=entry + ) + + # Save the nfo's XML to file + nfo_file_path = Path(self.output_directory) / nfo_file_name + with open(nfo_file_path, "wb") as nfo_file: + nfo_file.write(xml) + + # Archive the nfo's file name + self.archive_entry_file_name(entry=entry, relative_file_path=nfo_file_name) diff --git a/ytdl_subscribe/plugins/output_directory_nfo_tags.py b/ytdl_subscribe/plugins/output_directory_nfo_tags.py new file mode 100644 index 00000000..cc994cfb --- /dev/null +++ b/ytdl_subscribe/plugins/output_directory_nfo_tags.py @@ -0,0 +1,59 @@ +from pathlib import Path + +import dicttoxml + +from ytdl_subscribe.plugins.plugin import Plugin +from ytdl_subscribe.plugins.plugin import PluginValidator +from ytdl_subscribe.validators.base.string_formatter_validators import ( + OverridesDictFormatterValidator, +) +from ytdl_subscribe.validators.base.string_formatter_validators import ( + OverridesStringFormatterValidator, +) + + +class OutputDirectoryNfoTagsValidator(PluginValidator): + """ + Validates config values for the output directory nfo tags plugin. + """ + + _required_keys = {"nfo_name", "nfo_root", "tags"} + + def __init__(self, name, value): + super().__init__(name, value) + # Since this does not create NFOs for entries, we must use the overrides formatter classes + # to ensure we are only using values defined as string literals or in overrides + self.nfo_name = self._validate_key( + key="nfo_name", validator=OverridesStringFormatterValidator + ) + self.nfo_root = self._validate_key( + key="nfo_root", validator=OverridesStringFormatterValidator + ) + self.tags = self._validate_key(key="tags", validator=OverridesDictFormatterValidator) + + +class NfoTags(Plugin[OutputDirectoryNfoTagsValidator]): + def post_process_subscription(self): + """ + Creates an NFO file in the root of the output directory + """ + nfo = {} + + for tag, tag_formatter in self.plugin_options.tags.dict.items(): + nfo[tag] = self.overrides.apply_formatter(formatter=tag_formatter) + + # Write the nfo tags to XML with the nfo_root + nfo_root = self.overrides.apply_formatter(formatter=self.plugin_options.nfo_root) + xml = dicttoxml.dicttoxml( + obj=nfo, + root=True, # We assume all NFOs have a root. Maybe we should not? + custom_root=nfo_root, + attr_type=False, + ) + + nfo_file_name = self.overrides.apply_formatter(formatter=self.plugin_options.nfo_name) + + # Save the nfo's XML to file + nfo_file_path = Path(self.output_directory) / nfo_file_name + with open(nfo_file_path, "wb") as nfo_file: + nfo_file.write(xml) diff --git a/ytdl_subscribe/plugins/plugin.py b/ytdl_subscribe/plugins/plugin.py new file mode 100644 index 00000000..86f1b424 --- /dev/null +++ b/ytdl_subscribe/plugins/plugin.py @@ -0,0 +1,73 @@ +from abc import ABC +from typing import Generic +from typing import Optional +from typing import TypeVar +from typing import final + +from ytdl_subscribe.entries.entry import Entry +from ytdl_subscribe.validators.base.strict_dict_validator import StrictDictValidator +from ytdl_subscribe.validators.base.string_formatter_validators import StringFormatterValidator +from ytdl_subscribe.validators.config.overrides.overrides_validator import OverridesValidator +from ytdl_subscribe.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive + + +class PluginValidator(StrictDictValidator): + """ + Class that defines the parameters to a plugin + """ + + pass + + +PluginValidatorT = TypeVar("PluginValidatorT", bound=PluginValidator) + + +class Plugin(Generic[PluginValidatorT], ABC): + """ + Class to define the new plugin functionality + """ + + @final + def __init__( + self, + plugin_options: PluginValidatorT, + output_directory: str, + overrides: OverridesValidator, + enhanced_download_archive: EnhancedDownloadArchive, + ): + self.plugin_options = plugin_options + self.output_directory = output_directory + self.overrides = overrides + self.__enhanced_download_archive = enhanced_download_archive + + @final + def archive_entry_file_name(self, entry: Entry, relative_file_path: str) -> None: + """ + Adds an entry and a file name that belongs to it into the archive mapping. + + Parameters + ---------- + entry: + Optional. The entry the file belongs to + relative_file_path: + The name of the file path relative to the output directory + """ + self.__enhanced_download_archive.mapping.add_entry( + entry=entry, entry_file_path=relative_file_path + ) + + def post_process_entry(self, entry: Entry): + """ + For each file downloaded, apply post processing to it. + + Parameters + ---------- + entry: Entry to post process + """ + pass + + def post_process_subscription(self): + """ + After all downloaded files have been post-processed, apply a subscription-wide post process + """ + pass diff --git a/ytdl_subscribe/subscriptions/subscription.py b/ytdl_subscribe/subscriptions/subscription.py index e461b032..f0d4fce1 100644 --- a/ytdl_subscribe/subscriptions/subscription.py +++ b/ytdl_subscribe/subscriptions/subscription.py @@ -11,20 +11,11 @@ from typing import Optional from typing import Type from typing import TypeVar -import dicttoxml -import music_tag -from PIL import Image - from ytdl_subscribe.downloaders.downloader import Downloader from ytdl_subscribe.entries.entry import Entry -from ytdl_subscribe.validators.base.string_formatter_validators import StringFormatterValidator from ytdl_subscribe.validators.config.config_options.config_options_validator import ( ConfigOptionsValidator, ) -from ytdl_subscribe.validators.config.metadata_options.metadata_options_validator import ( - MetadataOptionsValidator, -) -from ytdl_subscribe.validators.config.metadata_options.nfo_validator import NFOValidator from ytdl_subscribe.validators.config.output_options.output_options_validator import ( OutputOptionsValidator, ) @@ -94,11 +85,6 @@ class Subscription(Generic[SourceT, EntryT], ABC): """Returns the output options defined for this subscription""" return self.__preset_options.output_options - @property - def metadata_options(self) -> MetadataOptionsValidator: - """Returns the metadata options defined for this subscription""" - return self.__preset_options.metadata_options - @property def overrides(self) -> OverridesValidator: """Returns the overrides defined for this subscription""" @@ -114,19 +100,7 @@ class Subscription(Generic[SourceT, EntryT], ABC): """ :return: The formatted output directory """ - return self._apply_formatter(formatter=self.output_options.output_directory) - - def _archive_entry_file_name(self, entry: Optional[Entry], relative_file_name: str) -> None: - """ - Adds an entry and a file name that belongs to it into the archive mapping. - - :param entry: Optional. The entry the file belongs to - :param relative_file_name: The name of the file - """ - if entry: - self._enhanced_download_archive.mapping.add_entry( - entry=entry, entry_file_path=relative_file_name - ) + return self.overrides.apply_formatter(formatter=self.output_options.output_directory) def get_downloader( self, downloader_type: Type[DownloaderT], source_ytdl_options: Optional[Dict] = None @@ -143,113 +117,30 @@ class Subscription(Generic[SourceT, EntryT], ABC): download_archive_file_name=self._enhanced_download_archive.archive_file_name, ) - def _apply_formatter( - self, formatter: StringFormatterValidator, entry: Optional[Entry] = None - ) -> str: - """ - Returns the format_string after .format has been called on it using entry (if provided) and - override values - """ - variable_dict = self.overrides.dict_with_format_strings - if entry: - variable_dict = dict(entry.to_dict(), **variable_dict) - return formatter.apply_formatter(variable_dict) + def _copy_file_to_output_directory(self, source_file_path: str, output_file_name: str): + destination_file_path = Path(self.output_directory) / Path(output_file_name) + os.makedirs(os.path.dirname(destination_file_path), exist_ok=True) + copyfile(source_file_path, destination_file_path) - def _post_process_tagging(self, entry: Entry): - """ - Tags the entry's audio file using values defined in the metadata options - """ - id3_options = self.metadata_options.id3 - entry_source_file_path = Path(self.working_directory) / entry.download_file_name - - audio_file = music_tag.load_file(entry_source_file_path) - for tag, tag_formatter in id3_options.tags.dict.items(): - audio_file[tag] = self._apply_formatter(formatter=tag_formatter, entry=entry) - audio_file.save() - - def _post_process_nfo(self, nfo_options: NFOValidator, entry: Optional[Entry] = None): - """ - Creates an entry's NFO file using values defined in the metadata options - - :param nfo_options: Options for the NFO - :param entry: Optional. Will pass entry values to nfo string formatters. If None, will only - use override variables that must resolve. - """ - nfo = {} - - for tag, tag_formatter in nfo_options.tags.dict.items(): - nfo[tag] = self._apply_formatter(formatter=tag_formatter, entry=entry) - - # Write the nfo tags to XML with the nfo_root - nfo_root = self._apply_formatter(formatter=nfo_options.nfo_root, entry=entry) - xml = dicttoxml.dicttoxml( - obj=nfo, - root=True, # We assume all NFOs have a root. Maybe we should not? - custom_root=nfo_root, - attr_type=False, - ) - - nfo_file_name = self._apply_formatter(formatter=nfo_options.nfo_name, entry=entry) - - # Save the nfo's XML to file - nfo_file_path = Path(self.output_directory) / Path(nfo_file_name) - with open(nfo_file_path, "wb") as nfo_file: - nfo_file.write(xml) - - # Archive the nfo's file name - self._archive_entry_file_name(entry=entry, relative_file_name=nfo_file_name) - - def _post_process_thumbnail(self, entry: Entry): - source_thumbnail_path = Path(self.working_directory) / entry.download_thumbnail_name - - # Bug that mismatches webp and jpg extensions. Try to hotfix here - if not os.path.isfile(source_thumbnail_path): - to_replace = f".{entry.thumbnail_ext}" - actual_thumbnail_ext = ".webp" - if entry.thumbnail_ext == "webp": - actual_thumbnail_ext = ".jpg" - - source_thumbnail_name = entry.download_thumbnail_name.replace( - to_replace, actual_thumbnail_ext - ) - source_thumbnail_path = Path(self.working_directory) / source_thumbnail_name - - if not os.path.isfile(source_thumbnail_path): - raise ValueError("Hotfix for getting thumbnail file extension failed") - - output_thumbnail_name = self._apply_formatter( - formatter=self.output_options.thumbnail_name, entry=entry - ) - output_thumbnail_path = Path(self.output_directory) / Path(output_thumbnail_name) - - os.makedirs(os.path.dirname(output_thumbnail_path), exist_ok=True) - - # If the thumbnail is to be converted, then save the converted thumbnail to the - # output filepath - if self.output_options.convert_thumbnail: - image = Image.open(source_thumbnail_path).convert("RGB") - image.save( - fp=output_thumbnail_path, - format=self.output_options.convert_thumbnail.value, - ) - # Otherwise, just copy the downloaded thumbnail - else: - copyfile(source_thumbnail_path, output_thumbnail_path) - - # Archive the thumbnail file name - self._archive_entry_file_name(entry=entry, relative_file_name=output_thumbnail_name) - - def _copy_entry_file_to_output_directory(self, entry: Entry): + def _copy_entry_files_to_output_directory(self, entry: Entry): # Move the file after all direct file modifications are complete - entry_source_file_path = Path(self.working_directory) / entry.download_file_name - - output_file_name = self._apply_formatter( + output_file_name = self.overrides.apply_formatter( formatter=self.output_options.file_name, entry=entry ) - entry_destination_file_path = Path(self.output_directory) / Path(output_file_name) + self._enhanced_download_archive.mapping.add_entry(entry, output_file_name) + self._copy_file_to_output_directory( + source_file_path=entry.download_file_path, output_file_name=output_file_name + ) - os.makedirs(os.path.dirname(entry_destination_file_path), exist_ok=True) - copyfile(entry_source_file_path, entry_destination_file_path) + if self.output_options.thumbnail_name: + output_thumbnail_name = self.overrides.apply_formatter( + formatter=self.output_options.thumbnail_name, entry=entry + ) + self._enhanced_download_archive.mapping.add_entry(entry, output_thumbnail_name) + self._copy_file_to_output_directory( + source_file_path=entry.download_thumbnail_path, + output_file_name=output_thumbnail_name, + ) self._enhanced_download_archive.mapping.add_entry(entry, output_file_name) @@ -305,16 +196,7 @@ class Subscription(Generic[SourceT, EntryT], ABC): :param entry: The entry to post-process """ - if self.metadata_options.id3: - self._post_process_tagging(entry) + # before move + self._copy_entry_files_to_output_directory(entry=entry) - self._copy_entry_file_to_output_directory(entry=entry) - - if self.output_options.thumbnail_name: - self._post_process_thumbnail(entry=entry) - - if self.metadata_options.nfo: - self._post_process_nfo(nfo_options=self.metadata_options.nfo, entry=entry) - - if self.metadata_options.output_directory_nfo: - self._post_process_nfo(nfo_options=self.metadata_options.output_directory_nfo) + # after move diff --git a/ytdl_subscribe/validators/config/metadata_options/id3_validator.py b/ytdl_subscribe/validators/config/metadata_options/id3_validator.py deleted file mode 100644 index 6480a602..00000000 --- a/ytdl_subscribe/validators/config/metadata_options/id3_validator.py +++ /dev/null @@ -1,25 +0,0 @@ -from ytdl_subscribe.validators.base.strict_dict_validator import StrictDictValidator -from ytdl_subscribe.validators.base.string_formatter_validators import DictFormatterValidator -from ytdl_subscribe.validators.base.string_select_validator import StringSelectValidator -from ytdl_subscribe.validators.base.validators import StringValidator - - -class Id3VersionValidator(StringSelectValidator): - _select_values = {"2.3", "2.4"} - - -class Id3Validator(StrictDictValidator): - _required_keys = {"id3_version", "tags"} - _optional_keys = {"multi_value_separator"} - - def __init__(self, name, value): - super().__init__(name, value) - - self.id3_version = self._validate_key( - key="id3_version", validator=Id3VersionValidator - ).value - self.tags = self._validate_key(key="tags", validator=DictFormatterValidator) - - self.multi_value_separator = self._validate_key_if_present( - key="multi_value_separator", validator=StringValidator - ) diff --git a/ytdl_subscribe/validators/config/metadata_options/metadata_options_validator.py b/ytdl_subscribe/validators/config/metadata_options/metadata_options_validator.py index fe1a0122..8b7e78ff 100644 --- a/ytdl_subscribe/validators/config/metadata_options/metadata_options_validator.py +++ b/ytdl_subscribe/validators/config/metadata_options/metadata_options_validator.py @@ -1,9 +1,7 @@ +from ytdl_subscribe.plugins.music_tags import MusicTagsValidator +from ytdl_subscribe.plugins.nfo_tags import NfoTagsValidator +from ytdl_subscribe.plugins.output_directory_nfo_tags import OutputDirectoryNfoTagsValidator from ytdl_subscribe.validators.base.strict_dict_validator import StrictDictValidator -from ytdl_subscribe.validators.config.metadata_options.id3_validator import Id3Validator -from ytdl_subscribe.validators.config.metadata_options.nfo_validator import NFOValidator -from ytdl_subscribe.validators.config.metadata_options.nfo_validator import ( - OutputDirectoryNFOValidator, -) class MetadataOptionsValidator(StrictDictValidator): @@ -12,9 +10,9 @@ class MetadataOptionsValidator(StrictDictValidator): def __init__(self, name, value): super().__init__(name, value) - self.id3 = self._validate_key_if_present(key="id3", validator=Id3Validator) - self.nfo = self._validate_key_if_present(key="nfo", validator=NFOValidator) + self.id3 = self._validate_key_if_present(key="id3", validator=MusicTagsValidator) + self.nfo = self._validate_key_if_present(key="nfo", validator=NfoTagsValidator) self.output_directory_nfo = self._validate_key_if_present( - key="output_directory_nfo", validator=OutputDirectoryNFOValidator + key="output_directory_nfo", validator=OutputDirectoryNfoTagsValidator ) diff --git a/ytdl_subscribe/validators/config/metadata_options/nfo_validator.py b/ytdl_subscribe/validators/config/metadata_options/nfo_validator.py deleted file mode 100644 index 9a0a912d..00000000 --- a/ytdl_subscribe/validators/config/metadata_options/nfo_validator.py +++ /dev/null @@ -1,29 +0,0 @@ -from ytdl_subscribe.validators.base.strict_dict_validator import StrictDictValidator -from ytdl_subscribe.validators.base.string_formatter_validators import DictFormatterValidator -from ytdl_subscribe.validators.base.string_formatter_validators import ( - OverridesStringFormatterValidator, -) -from ytdl_subscribe.validators.base.string_formatter_validators import StringFormatterValidator - - -class NFOValidator(StrictDictValidator): - _required_keys = {"nfo_name", "nfo_root", "tags"} - - def __init__(self, name, value): - super().__init__(name, value) - - self.nfo_name = self._validate_key(key="nfo_name", validator=StringFormatterValidator) - self.nfo_root = self._validate_key(key="nfo_root", validator=StringFormatterValidator) - self.tags = self._validate_key(key="tags", validator=DictFormatterValidator) - - -class OutputDirectoryNFOValidator(NFOValidator): - def __init__(self, name, value): - super().__init__(name, value) - self.nfo_name = self._validate_key( - key="nfo_name", validator=OverridesStringFormatterValidator - ) - self.nfo_root = self._validate_key( - key="nfo_root", validator=OverridesStringFormatterValidator - ) - self.tags = self._validate_key(key="tags", validator=DictFormatterValidator) diff --git a/ytdl_subscribe/validators/config/output_options/output_options_validator.py b/ytdl_subscribe/validators/config/output_options/output_options_validator.py index 6aec32f3..8e42337e 100644 --- a/ytdl_subscribe/validators/config/output_options/output_options_validator.py +++ b/ytdl_subscribe/validators/config/output_options/output_options_validator.py @@ -3,22 +3,14 @@ from ytdl_subscribe.validators.base.string_formatter_validators import ( OverridesStringFormatterValidator, ) from ytdl_subscribe.validators.base.string_formatter_validators import StringFormatterValidator -from ytdl_subscribe.validators.base.string_select_validator import StringSelectValidator from ytdl_subscribe.validators.base.validators import BoolValidator -class ConvertThumbnailValidator(StringSelectValidator): - """Valid image types that thumbnails can be converted to""" - - _select_values = {"jpeg"} - - class OutputOptionsValidator(StrictDictValidator): """Where to output the final files and thumbnails""" _required_keys = {"output_directory", "file_name"} _optional_keys = { - "convert_thumbnail", "thumbnail_name", "maintain_download_archive", "maintain_stale_file_deletion", @@ -37,9 +29,6 @@ class OutputOptionsValidator(StrictDictValidator): self.file_name: StringFormatterValidator = self._validate_key( key="file_name", validator=StringFormatterValidator ) - self.convert_thumbnail = self._validate_key_if_present( - key="convert_thumbnail", validator=ConvertThumbnailValidator - ) self.thumbnail_name = self._validate_key_if_present( key="thumbnail_name", validator=StringFormatterValidator ) diff --git a/ytdl_subscribe/validators/config/overrides/overrides_validator.py b/ytdl_subscribe/validators/config/overrides/overrides_validator.py index 30ee0157..04ee54f4 100644 --- a/ytdl_subscribe/validators/config/overrides/overrides_validator.py +++ b/ytdl_subscribe/validators/config/overrides/overrides_validator.py @@ -1,5 +1,8 @@ +from typing import Optional + from yt_dlp.utils import sanitize_filename +from ytdl_subscribe.entries.entry import Entry from ytdl_subscribe.validators.base.string_formatter_validators import DictFormatterValidator from ytdl_subscribe.validators.base.string_formatter_validators import StringFormatterValidator @@ -19,3 +22,15 @@ class OverridesValidator(DictFormatterValidator): name="__should_never_fail__", value=self._value[sanitized_key_name], ) + + def apply_formatter( + self, formatter: StringFormatterValidator, entry: Optional[Entry] = None + ) -> str: + """ + Returns the format_string after .format has been called on it using entry (if provided) and + override values + """ + variable_dict = self.dict_with_format_strings + if entry: + variable_dict = dict(entry.to_dict(), **variable_dict) + return formatter.apply_formatter(variable_dict)