diff --git a/src/ytdl_sub/config/config_file.py b/src/ytdl_sub/config/config_file.py index 77f68aa6..d8f48920 100644 --- a/src/ytdl_sub/config/config_file.py +++ b/src/ytdl_sub/config/config_file.py @@ -3,6 +3,9 @@ from typing import Any from typing import Dict from typing import Optional +import mergedeep + +from ytdl_sub.prebuilt_presets import PREBUILT_PRESETS from ytdl_sub.utils.yaml import load_yaml from ytdl_sub.validators.strict_dict_validator import StrictDictValidator from ytdl_sub.validators.validators import LiteralDictValidator @@ -77,9 +80,21 @@ class ConfigFile(StrictDictValidator): super().__init__(name, value) self.config_options = self._validate_key("configuration", ConfigOptions) + prebuilt_presets = PREBUILT_PRESETS + # Make sure presets is a dictionary. Will be validated in `PresetValidator` self.presets = self._validate_key("presets", LiteralDictValidator) + # Ensure custom presets do not collide with prebuilt presets + for preset_name in self.presets.keys: + if preset_name in prebuilt_presets: + raise self._validation_exception( + f"preset name '{preset_name}' conflicts with a prebuilt preset" + ) + + # Merge prebuilt presets into the config so custom presets can use them + mergedeep.merge(self.presets._value, prebuilt_presets) + def initialize(self): """ Configures things (umask, pgid) prior to any downloading diff --git a/src/ytdl_sub/prebuilt_presets/__init__.py b/src/ytdl_sub/prebuilt_presets/__init__.py index e69de29b..70440aec 100644 --- a/src/ytdl_sub/prebuilt_presets/__init__.py +++ b/src/ytdl_sub/prebuilt_presets/__init__.py @@ -0,0 +1,25 @@ +from pathlib import Path +from typing import Any, Dict + +import mergedeep + +from ytdl_sub.prebuilt_presets.tv_show.out import PrebuiltKodiTVShowPresets +from ytdl_sub.utils.yaml import load_yaml + +import pathlib + + +def _merge_presets() -> Dict[str, Any]: + merged_configs: Dict[str, Any] = {} + + # Get all presets from the loose YAML files + for file in pathlib.Path(__file__).parent.resolve().rglob("*"): + if file.is_file() and file.name.endswith('yaml'): + mergedeep.merge(merged_configs, load_yaml(file)) + + # Get all presets from published preset configs + mergedeep.merge(merged_configs, *PrebuiltKodiTVShowPresets.get_presets()) + + return merged_configs['presets'] + +PREBUILT_PRESETS: Dict[str, Any] = _merge_presets() diff --git a/src/ytdl_sub/prebuilt_presets/helpers/kodi-safe.yaml b/src/ytdl_sub/prebuilt_presets/helpers/kodi-safe.yaml index eb0801f7..bec36307 100644 --- a/src/ytdl_sub/prebuilt_presets/helpers/kodi-safe.yaml +++ b/src/ytdl_sub/prebuilt_presets/helpers/kodi-safe.yaml @@ -1,5 +1,7 @@ presets: - kodi-safe: + kodi-safe-nfo: nfo_tags: kodi_safe: True + output_directory_nfo_tags: + kodi_safe: True \ No newline at end of file diff --git a/src/ytdl_sub/prebuilt_presets/helpers/video_players.yaml b/src/ytdl_sub/prebuilt_presets/helpers/video_players.yaml deleted file mode 100644 index ab59d69b..00000000 --- a/src/ytdl_sub/prebuilt_presets/helpers/video_players.yaml +++ /dev/null @@ -1,14 +0,0 @@ -presets: - - plex: - overrides: - season_poster_prefix: "" - - jellyfin: - overrides: - poster_file_name: "poster" - season_poster_prefix: "-poster" - - kodi: - presets: - - "jellyfin" diff --git a/src/ytdl_sub/prebuilt_presets/music_videos/jellyfin-music-videos.yaml b/src/ytdl_sub/prebuilt_presets/music_videos/jellyfin-music-videos.yaml index a15000f0..2db3c8cc 100644 --- a/src/ytdl_sub/prebuilt_presets/music_videos/jellyfin-music-videos.yaml +++ b/src/ytdl_sub/prebuilt_presets/music_videos/jellyfin-music-videos.yaml @@ -1,6 +1,6 @@ presets: kodi-music-videos: - presets: + preset: - "jellyfin-music-videos" - "kodi-safe" diff --git a/src/ytdl_sub/prebuilt_presets/tv_show/__init__.py b/src/ytdl_sub/prebuilt_presets/tv_show/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/ytdl_sub/prebuilt_presets/tv_show/download_strategy.yaml b/src/ytdl_sub/prebuilt_presets/tv_show/download_strategy.yaml index 622409ad..27310fc5 100644 --- a/src/ytdl_sub/prebuilt_presets/tv_show/download_strategy.yaml +++ b/src/ytdl_sub/prebuilt_presets/tv_show/download_strategy.yaml @@ -1,5 +1,7 @@ presets: +#################################################################################################### + tv-show-url: generic: download_strategy: "source" @@ -22,17 +24,100 @@ presets: #################################################################################################### - tv-show-collection-youtube-channel-season: + tv-show-collection: generic: download_strategy: "collection" - urls: - - url: "{youtube_channel_url}" - playlist_thumbnails: - - name: "{season_number_padded}{season_poster_prefix}.jpg" - uid: "latest_entry" - - name: "{poster_file_name}" - uid: "avatar_uncropped" - - name: "{fanart_file_name}" - uid: "banner_uncropped" - overrides: \ No newline at end of file + overrides: + season_number: "{collection_season_number}" + season_number_padded: "{collection_season_number_padded}" + + tv-show-collection-season-1: + generic: + urls: + - url: "{collection_season_1_url}" + variables: + collection_season_number: "1" + collection_season_number_padded: "01" + playlist_thumbnails: + - name: "{season_poster_name}" + uid: "latest_entry" + + output_directory_nfo_tags: + tags: + namedseason: + - tag: "{collection_season_1_name}" + attributes: + number: "1" + + tv-show-collection-season-2: + generic: + urls: + - url: "{collection_season_2_url}" + variables: + collection_season_number: "2" + collection_season_number_padded: "02" + playlist_thumbnails: + - name: "{season_poster_name}" + uid: "latest_entry" + + output_directory_nfo_tags: + tags: + namedseason: + - tag: "{collection_season_2_name}" + attributes: + number: "2" + + tv-show-collection-season-3: + generic: + urls: + - url: "{collection_season_3_url}" + variables: + collection_season_number: "3" + collection_season_number_padded: "03" + playlist_thumbnails: + - name: "{season_poster_name}" + uid: "latest_entry" + + output_directory_nfo_tags: + tags: + namedseason: + - tag: "{collection_season_3_name}" + attributes: + number: "3" + + tv-show-collection-season-4: + generic: + urls: + - url: "{collection_season_4_url}" + variables: + collection_season_number: "4" + collection_season_number_padded: "04" + playlist_thumbnails: + - name: "{season_poster_name}" + uid: "latest_entry" + + output_directory_nfo_tags: + tags: + namedseason: + - tag: "{collection_season_4_name}" + attributes: + number: "4" + + tv-show-collection-season-5: + generic: + urls: + - url: "{collection_season_5_url}" + variables: + collection_season_number: "5" + collection_season_number_padded: "05" + playlist_thumbnails: + - name: "{season_poster_name}" + uid: "latest_entry" + + output_directory_nfo_tags: + tags: + namedseason: + - tag: "{collection_season_5_name}" + attributes: + number: "5" \ No newline at end of file diff --git a/src/ytdl_sub/prebuilt_presets/tv_show/episode.yaml b/src/ytdl_sub/prebuilt_presets/tv_show/episode.yaml index 2232a8bc..97df1632 100644 --- a/src/ytdl_sub/prebuilt_presets/tv_show/episode.yaml +++ b/src/ytdl_sub/prebuilt_presets/tv_show/episode.yaml @@ -22,36 +22,11 @@ presets: # episode_number_padded season_title: "{season_number_padded}" episode_title: "{upload_date_standardized} - {title}" - episode_file_name: "s{season_number_padded}.e{episode_number_padded} - {uid_sanitized}" - episode_file_path: "Season {season_number_padded}/{episode_file_name}" - -#################################################################################################### - - episode-nfo: - presets: - - "episode-base" - - nfo_tags: - nfo_name: "{episode_file_path}.nfo" - nfo_root: "episodedetails" - tags: - season: "{season_number}" - episode: "{episode_number}" - title: "{episode_title}" - plot: "{episode_plot}" - year: "{episode_year}" - aired: "{episode_air_date_standardized}" - - output_directory_nfo_tags: - nfo_name: "tvshow.nfo" - nfo_root: "tvshow" - tags: - title: "{tv_show_name}" - - overrides: episode_plot: "{webpage_url}" episode_year: "{upload_year}" episode_air_date_standardized: "{upload_date_standardized}" + episode_file_name: "s{season_number_padded}.e{episode_number_padded} - {uid_sanitized}" + episode_file_path: "Season {season_number_padded}/{episode_file_name}" #################################################################################################### @@ -60,12 +35,26 @@ presets: season_number: "{upload_year}" season_number_padded: "{upload_year}" - episode-by-day: + episode-by-month-day: + preset: + - "season-by-year" overrides: episode_number: "{upload_month}{upload_day_padded}" episode_number_padded: "{upload_month_padded}{upload_day_padded}" - episode-by-day-reversed: + episode-by-month-day-reversed: + preset: + - "season-by-year" overrides: episode_number: "{upload_day_of_year_reversed}" - episode_number_padded: "{upload_day_of_year_reversed_padded}" \ No newline at end of file + episode_number_padded: "{upload_day_of_year_reversed_padded}" + + episode-by-year-month-day: + overrides: + episode_number: "{upload_year_truncated}{upload_month_padded}{upload_day_padded}" + episode_number_padded: "{episode_number}" + + episode-by-year-month-day-reversed: + overrides: + episode_number: "{upload_year_truncated_reversed}{upload_month_reversed_padded}{upload_day_reversed_padded}" + episode_number_padded: "{episode_number}" \ No newline at end of file diff --git a/src/ytdl_sub/prebuilt_presets/tv_show/jellyfin-tv-show.yaml b/src/ytdl_sub/prebuilt_presets/tv_show/jellyfin-tv-show.yaml deleted file mode 100644 index 52c9358b..00000000 --- a/src/ytdl_sub/prebuilt_presets/tv_show/jellyfin-tv-show.yaml +++ /dev/null @@ -1,220 +0,0 @@ -presets: - - ############################################################################### - # Single video as an episode - jellyfin-episode: - output_options: - output_directory: "{tv_shows_directory}/{tv_show_name_sanitized}" - file_name: "{episode_file_path}.{ext}" - thumbnail_name: "{episode_file_path}-thumb.jpg" - info_json_name: "{episode_file_path}.{info_json_ext}" - maintain_download_archive: True - - nfo_tags: - nfo_name: "{episode_file_path}.nfo" - nfo_root: "episodedetails" - kodi_safe: True - tags: - season: "{season_number}" - episode: "{episode_number}" - title: "{episode_title}" - year: "{episode_year}" - plot: "{episode_plot}" - aired: "{episode_air_date_standardized}" - - chapters: - embed_chapters: True - - overrides: -# MUST DEFINE: -# tv_shows_directory -# tv_show_name -# season_number -# season_number_padded -# episode_number -# episode_number_padded - season_title: "{season_number_padded}" - episode_title: "{upload_date_standardized} - {title}" - episode_file_name: "s{season_number_padded}.e{episode_number_padded} - {uid_sanitized}" - episode_file_path: "Season {season_number_padded}/{episode_file_name}" - - - ############################################################################### - # Create a TV show from a youtube playlist or channel - yt_show: - preset: "yt_episode" - output_directory_nfo_tags: - nfo_name: "tvshow.nfo" - nfo_root: "tvshow" - kodi_safe: True - tags: - title: "{show_name}" - namedseason: - - tag: "{season_name}" - attributes: - number: "{season}" - genre: "{genre}" - - ############################################################################### - # Entire channel as a TV show - yt_channel: - preset: "yt_show" - youtube: - download_strategy: "channel" - channel_avatar_path: "poster.jpg" - channel_banner_path: "fanart.jpg" - - ############################################################################### - # Playlist as a TV show - yt_playlist: - preset: "yt_show" - youtube: - download_strategy: "playlist" - - - ############################################################################### - # Entire channel as a TV show - - ############################################################################### - # Only store recent videos in the last {time_to_live} timespan. - # Defaults to 2 months - only_recent: - ytdl_options: - break_on_reject: True - output_options: - keep_files_after: "today-{time_to_live}" - date_range: - after: "today-{time_to_live}" - overrides: - time_to_live: "2months" - - ############################################################################### - # For channels/playlists/etc with known sponsor content. - # Wait 2 days after upload for sponsorblock submissions, then cut it out. - sponsor_block: - date_range: - before: "today-2days" - - chapters: - sponsorblock_categories: - - "outro" - - "selfpromo" - - "preview" - - "interaction" - - "sponsor" - - "music_offtopic" - - "intro" - remove_sponsorblock_categories: "all" - force_key_frames: False - - ############################################################################### - # Genres - genre_music: - overrides: - genre: "Music" - genre_news: - overrides: - genre: "News" - genre_finance: - overrides: - genre: "Finance" - genre_podcast: - overrides: - genre: "Podcast" - genre_cooking: - overrides: - genre: "Cooking" - genre_gaming: - overrides: - genre: "Gaming" - - ############################################################################### - # Other - include_description: - nfo_tags: - tags: - plot: "{webpage_url}\n\n{description}" - - ############################################################################### - # Other - yt_collection: - preset: "yt_show" - generic: - download_strategy: "collection" - urls: - - url: "{channel_url}" - variables: - url_season_name: "{channel_season_name}" - url_season_index: "{channel_season_index}" - playlist_thumbnails: - - name: "season{channel_season_index}-poster.jpg" - uid: "latest_entry" - - name: "poster.jpg" - uid: "avatar_uncropped" - - name: "fanart.jpg" - uid: "banner_uncropped" - - output_directory_nfo_tags: - nfo_name: "tvshow.nfo" - nfo_root: "tvshow" - kodi_safe: True - tags: - namedseason: - - tag: "{channel_season_name}" - attributes: - number: "{channel_season_index}" - - overrides: - # Need to fill out channel_url - channel_season_name: "Videos" - channel_season_index: "1" - season: "{url_season_index}" - season_name: "{url_season_name}" - - yt_collection_p1: - preset: "yt_collection" - generic: - download_strategy: "collection" - urls: - - url: "{playlist1_url}" - variables: - url_season_name: "{playlist1_season_name}" - url_season_index: "{playlist1_season_index}" - playlist_thumbnails: - - name: "season{playlist1_season_index}-poster.jpg" - uid: "latest_entry" - - output_directory_nfo_tags: - tags: - namedseason: - - tag: "{playlist1_season_name}" - attributes: - number: "{playlist1_season_index}" - - overrides: - # Need channel_url, playlist1_url, playlist1_season_name - playlist1_season_index: "2" - - yt_collection_p2: - preset: "yt_collection_p1" - generic: - download_strategy: "collection" - urls: - - url: "{playlist2_url}" - variables: - url_season_name: "{playlist2_season_name}" - url_season_index: "{playlist2_season_index}" - playlist_thumbnails: - - name: "season{playlist2_season_index}-poster.jpg" - uid: "latest_entry" - - output_directory_nfo_tags: - tags: - namedseason: - - tag: "{playlist2_season_name}" - attributes: - number: "{playlist2_season_index}" - - overrides: - # Need channel_url, playlist1_url, playlist1_season_name, playlist2_url, playlist2_season_name - playlist2_season_index: "3" diff --git a/src/ytdl_sub/prebuilt_presets/tv_show/media_players.yaml b/src/ytdl_sub/prebuilt_presets/tv_show/media_players.yaml new file mode 100644 index 00000000..c4be88c9 --- /dev/null +++ b/src/ytdl_sub/prebuilt_presets/tv_show/media_players.yaml @@ -0,0 +1,31 @@ +presets: + + plex-tv-show: + overrides: + season_poster_name: "Season{season_number_padded}.jpg" + + jellyfin-tv-show: + overrides: + season_poster_name: "season{season_number_padded}-poster.jpg" + + nfo_tags: + nfo_name: "{episode_file_path}.nfo" + nfo_root: "episodedetails" + tags: + season: "{season_number}" + episode: "{episode_number}" + title: "{episode_title}" + plot: "{episode_plot}" + year: "{episode_year}" + aired: "{episode_air_date_standardized}" + + output_directory_nfo_tags: + nfo_name: "tvshow.nfo" + nfo_root: "tvshow" + tags: + title: "{tv_show_name}" + + kodi-tv-show: + preset: + - "jellyfin-tv-show" + - "kodi-safe-nfo" diff --git a/src/ytdl_sub/prebuilt_presets/tv_show/out.py b/src/ytdl_sub/prebuilt_presets/tv_show/out.py new file mode 100644 index 00000000..689e622d --- /dev/null +++ b/src/ytdl_sub/prebuilt_presets/tv_show/out.py @@ -0,0 +1,97 @@ +from typing import Any, Dict +from typing import List + +Preset = Dict[str, Any] + + +class PrebuiltPresets: + BASE_PRESET: str + + def _preset(self, name: str, presets: List[str]) -> Preset: + return { + "presets": { + name: { + "preset": [self.BASE_PRESET] + presets + } + } + } + + def _tv_show_url(self, name: str) -> Preset: + """ + Kodi TV Show with seasons as years, episodes ordered by upload date + """ + return self._preset(name=name, presets=["tv-show-url", "episode-by-month-day"]) + + def _tv_show_url_reversed(self, name: str) -> Preset: + return self._preset(name=name, presets=["tv-show-url", "episode-by-month-day-reversed"]) + + def _tv_show_youtube_channel(self, name: str) -> Preset: + return self._preset(name=name, presets=["tv-show-youtube-channel", "episode-by-month-day"]) + + def _tv_show_youtube_channel_reversed(self, name: str) -> Preset: + return self._preset(name=name, presets=["tv-show-youtube-channel", "episode-by-month-day"]) + + def _tv_show_collection(self, name: str) -> Preset: + return self._preset(name=name, presets=["tv-show-collection", "episode-by-year-month-day"]) + + def _tv_show_collection_reversed(self, name: str) -> Preset: + return self._preset( + name=name, presets=["tv-show-collection", "episode-by-year-month-day-reversed"] + ) + + @classmethod + def get_preset_names(cls) -> List[str]: + preset_names = [prop for prop in dir(cls) if isinstance(getattr(cls, prop), property)] + return preset_names + + @classmethod + def get_presets(cls) -> List[Preset]: + return [getattr(cls(), preset_name) for preset_name in cls.get_preset_names()] + + +class PrebuiltKodiTVShowPresets(PrebuiltPresets): + BASE_PRESET = "kodi-tv-show" + + @property + def kodi_tv_show_url(self) -> Preset: + """ + Kodi TV Show with seasons as years, episodes ordered by upload date + """ + return self._tv_show_url(name="kodi_tv_show_url") + + @property + def kodi_tv_show_url_reversed(self) -> Preset: + """ + Kodi TV Show with seasons as years, episodes ordered by upload date in descending order + (more recent uploads have smaller episode number) + """ + return self._tv_show_url_reversed(name="kodi_tv_show_url_reversed") + + @property + def kodi_tv_show_youtube_channel(self) -> Preset: + """ + Kodi TV Show from a YouTube channel with seasons as years, episodes ordered by upload date + """ + return self._tv_show_youtube_channel(name="kodi_tv_show_youtube_channel") + + @property + def kodi_tv_show_youtube_channel_reversed(self) -> Preset: + """ + Kodi TV Show from a YouTube channel with seasons as years, episodes ordered by upload date + in descending order (more recent uploads have smaller episode number) + """ + return self._tv_show_youtube_channel_reversed(name="kodi_tv_show_youtube_channel_reversed") + + @property + def kodi_tv_show_collection(self) -> Preset: + """ + Kodi TV Show from a collection of multiple URLs. TODO: finish docstring + """ + return self._tv_show_collection(name="kodi_tv_show_collection") + + @property + def kodi_tv_show_collection_reversed(self) -> Preset: + """ + Kodi TV Show from a collection of multiple URLs. TODO: finish docstring + """ + return self._tv_show_collection_reversed(name="kodi_tv_show_collection_reversed") diff --git a/src/ytdl_sub/utils/yaml.py b/src/ytdl_sub/utils/yaml.py index 0556133c..7410efbe 100644 --- a/src/ytdl_sub/utils/yaml.py +++ b/src/ytdl_sub/utils/yaml.py @@ -1,4 +1,5 @@ import os.path +from pathlib import Path from typing import Dict import yaml @@ -8,7 +9,7 @@ from ytdl_sub.utils.exceptions import FileNotFoundException from ytdl_sub.utils.exceptions import InvalidYamlException -def load_yaml(file_path: str) -> Dict: +def load_yaml(file_path: str | Path) -> Dict: """ Parameters ---------- diff --git a/tests/unit/prebuilt_presets/__init__.py b/tests/unit/prebuilt_presets/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/prebuilt_presets/test_prebuilt_presets.py b/tests/unit/prebuilt_presets/test_prebuilt_presets.py new file mode 100644 index 00000000..200a3809 --- /dev/null +++ b/tests/unit/prebuilt_presets/test_prebuilt_presets.py @@ -0,0 +1,31 @@ +from typing import List + +import pytest + +from ytdl_sub.config.config_file import ConfigFile +from ytdl_sub.config.preset import Preset +from ytdl_sub.prebuilt_presets import PrebuiltKodiTVShowPresets + +@pytest.fixture +def config() -> ConfigFile: + return ConfigFile( + name="config", value={"configuration": {"working_directory": "test"}, "presets": {}} + ) + +class TestPrebuiltTVShowPresets: + + + + @pytest.mark.parametrize("preset_name", PrebuiltKodiTVShowPresets.get_preset_names()) + def test_presets_compile(self, config, preset_name: str): + Preset.from_dict( + config=config, + preset_name=f"{preset_name}-test", + preset_dict={ + 'preset': preset_name, + 'overrides': { + 'tv_show_name': 'test tv show', + 'tv_show_directory': 'output_path' + } + } + ) \ No newline at end of file