diff --git a/docs/source/config_reference/plugins.rst b/docs/source/config_reference/plugins.rst index 7329b171..95c636e8 100644 --- a/docs/source/config_reference/plugins.rst +++ b/docs/source/config_reference/plugins.rst @@ -830,6 +830,19 @@ used with no modifications. ---------------------------------------------------------------------------------------------------- +square_thumbnail +---------------- +Whether to make thumbnails square. Supports both file and embedded-based thumbnails. Ideal +for representing audio albums. + +:Usage: + +.. code-block:: yaml + + square_thumbnail: True + +---------------------------------------------------------------------------------------------------- + static_nfo_tags --------------- Adds an NFO file for every entry, but does not link it to an entry in the download archive. diff --git a/src/ytdl_sub/config/plugin/plugin_mapping.py b/src/ytdl_sub/config/plugin/plugin_mapping.py index b52bb776..ac1b851c 100644 --- a/src/ytdl_sub/config/plugin/plugin_mapping.py +++ b/src/ytdl_sub/config/plugin/plugin_mapping.py @@ -24,6 +24,7 @@ from ytdl_sub.plugins.music_tags import MusicTagsPlugin from ytdl_sub.plugins.nfo_tags import NfoTagsPlugin from ytdl_sub.plugins.output_directory_nfo_tags import OutputDirectoryNfoTagsPlugin from ytdl_sub.plugins.split_by_chapters import SplitByChaptersPlugin +from ytdl_sub.plugins.square_thumbnail import SquareThumbnailPlugin from ytdl_sub.plugins.static_nfo_tags import StaticNfoTagsPlugin from ytdl_sub.plugins.subtitles import SubtitlesPlugin from ytdl_sub.plugins.throttle_protection import ThrottleProtectionPlugin @@ -40,6 +41,7 @@ class PluginMapping: "audio_extract": AudioExtractPlugin, "date_range": DateRangePlugin, "embed_thumbnail": EmbedThumbnailPlugin, + "square_thumbnail": SquareThumbnailPlugin, "file_convert": FileConvertPlugin, "format": FormatPlugin, "match_filters": MatchFiltersPlugin, @@ -86,6 +88,7 @@ class PluginMapping: VideoTagsPlugin, NfoTagsPlugin, StaticNfoTagsPlugin, + SquareThumbnailPlugin, EmbedThumbnailPlugin, ] diff --git a/src/ytdl_sub/plugins/embed_thumbnail.py b/src/ytdl_sub/plugins/embed_thumbnail.py index 9a7c8bab..e7d19b8b 100644 --- a/src/ytdl_sub/plugins/embed_thumbnail.py +++ b/src/ytdl_sub/plugins/embed_thumbnail.py @@ -11,12 +11,12 @@ from ytdl_sub.utils.file_handler import FileHandler from ytdl_sub.utils.file_handler import FileMetadata from ytdl_sub.utils.logger import Logger from ytdl_sub.validators.audo_codec_validator import AUDIO_CODEC_EXTS -from ytdl_sub.validators.validators import BoolValidator +from ytdl_sub.validators.string_formatter_validators import OverridesBooleanFormatterValidator logger = Logger.get("embed-thumbnail") -class EmbedThumbnailOptions(BoolValidator, OptionsValidator): +class EmbedThumbnailOptions(OverridesBooleanFormatterValidator, OptionsValidator): """ Whether to embed thumbnails to the audio/video file or not. @@ -33,7 +33,7 @@ class EmbedThumbnailPlugin(Plugin[EmbedThumbnailOptions]): @property def _embed_thumbnail(self) -> bool: - return self.plugin_options.value + return self.overrides.evaluate_boolean(self.plugin_options) @classmethod def _embed_video_thumbnail(cls, entry: Entry) -> None: diff --git a/src/ytdl_sub/plugins/square_thumbnail.py b/src/ytdl_sub/plugins/square_thumbnail.py new file mode 100644 index 00000000..030f5a09 --- /dev/null +++ b/src/ytdl_sub/plugins/square_thumbnail.py @@ -0,0 +1,77 @@ +from typing import List +from typing import Optional + +from ytdl_sub.config.plugin.plugin import Plugin +from ytdl_sub.config.validators.options import OptionsValidator +from ytdl_sub.entries.entry import Entry +from ytdl_sub.utils.ffmpeg import FFMPEG +from ytdl_sub.utils.file_handler import FileHandler +from ytdl_sub.utils.file_handler import FileMetadata +from ytdl_sub.utils.logger import Logger +from ytdl_sub.validators.string_formatter_validators import OverridesBooleanFormatterValidator + +logger = Logger.get("square-thumbnail") + + +class SquareThumbnailOptions(OverridesBooleanFormatterValidator, OptionsValidator): + """ + Whether to make thumbnails square. Supports both file and embedded-based thumbnails. Ideal + for representing audio albums. + + :Usage: + + .. code-block:: yaml + + square_thumbnail: True + """ + + +class SquareThumbnailPlugin(Plugin[SquareThumbnailOptions]): + plugin_options_type = SquareThumbnailOptions + + @property + def _square_thumbnail(self) -> bool: + return self.overrides.evaluate_boolean(self.plugin_options) + + @classmethod + def _convert_to_square_thumbnail(cls, entry: Entry) -> None: + thumbnail_path = entry.get_download_thumbnail_path() + tmp_file_path = FFMPEG.tmp_file_path(thumbnail_path) + try: + ffmpeg_args: List[str] = [ + "-i", + thumbnail_path, + "-c:v", + "mjpeg", + "-qmin", + "1", + "-qscale:v", + "1", + "-vf", + "crop=min(iw\\,ih):min(iw\\,ih)", + "-bitexact", # for reproducibility + tmp_file_path, + ] + FFMPEG.run(ffmpeg_args) + FileHandler.move(tmp_file_path, thumbnail_path) + finally: + FileHandler.delete(tmp_file_path) + + def post_process_entry(self, entry: Entry) -> Optional[FileMetadata]: + """ + Maybe make the thumbnail square + """ + if not self._square_thumbnail: + return None + + if not self.is_dry_run: + if not entry.is_thumbnail_downloaded(): + logger.warning( + "Cannot make a square thumbnail for '%s' because it is not available", + entry.title, + ) + return None + + self._convert_to_square_thumbnail(entry) + + return FileMetadata("Square thumbnail") diff --git a/tests/integration/plugins/test_thumbnail_plugins.py b/tests/integration/plugins/test_thumbnail_plugins.py new file mode 100644 index 00000000..bd222ff2 --- /dev/null +++ b/tests/integration/plugins/test_thumbnail_plugins.py @@ -0,0 +1,68 @@ +from typing import Dict + +import pytest +from expected_download import assert_expected_downloads +from expected_transaction_log import assert_transaction_log_matches + +from ytdl_sub.subscriptions.subscription import Subscription + + +@pytest.fixture +def square_thumbnail_subscription_dict(output_directory) -> Dict: + return { + "preset": "Jellyfin Music Videos", + "output_options": {"output_directory": output_directory}, + "square_thumbnail": "{perform_square_thumbnail}", + "embed_thumbnail": "{perform_embed_thumbnail}", + "overrides": { + "music_video_artist": "JMC", + "url": "https://your.name.here", + "perform_square_thumbnail": True, + "perform_embed_thumbnail": False, + }, + } + + +class TestThumbnailPlugins: + @pytest.mark.parametrize("dry_run", [True, False]) + @pytest.mark.parametrize("embed_thumbnail", [True, False]) + def test_thumbnail( + self, + config, + subscription_name, + square_thumbnail_subscription_dict, + output_directory, + mock_download_collection_entries, + dry_run, + embed_thumbnail, + ): + square_thumbnail_subscription_dict["overrides"]["perform_embed_thumbnail"] = embed_thumbnail + + subscription = Subscription.from_dict( + config=config, + preset_name=subscription_name, + preset_dict=square_thumbnail_subscription_dict, + ) + + with mock_download_collection_entries( + is_youtube_channel=False, + num_urls=1, + is_extracted_audio=False, + is_dry_run=dry_run, + ): + transaction_log = subscription.download(dry_run=dry_run) + + expected_filename = "square_thumbnail" + if embed_thumbnail: + expected_filename = "embedded_square_thumbnail" + + assert_transaction_log_matches( + output_directory=output_directory, + transaction_log=transaction_log, + transaction_log_summary_file_name=f"plugins/thumbnail/{expected_filename}.txt", + ) + assert_expected_downloads( + output_directory=output_directory, + dry_run=dry_run, + expected_download_summary_file_name=f"plugins/thumbnail/{expected_filename}.json", + ) diff --git a/tests/resources/expected_downloads_summaries/plugins/thumbnail/embedded_square_thumbnail.json b/tests/resources/expected_downloads_summaries/plugins/thumbnail/embedded_square_thumbnail.json new file mode 100644 index 00000000..8643977f --- /dev/null +++ b/tests/resources/expected_downloads_summaries/plugins/thumbnail/embedded_square_thumbnail.json @@ -0,0 +1,19 @@ +{ + ".ytdl-sub-subscription_test-download-archive.json": "76e202bd03ceef93daaffffee2cfa193", + "JMC/Mock Entry 20-1.info.json": "INFO_JSON", + "JMC/Mock Entry 20-1.jpg": "e691c0591065c89e21fd552672a206af", + "JMC/Mock Entry 20-1.mp4": "071b3d0747705613c0ec0cba71a045fa", + "JMC/Mock Entry 20-1.nfo": "fefcf0b3e4f4ff80ad636584d50dadec", + "JMC/Mock Entry 20-2.info.json": "INFO_JSON", + "JMC/Mock Entry 20-2.jpg": "e691c0591065c89e21fd552672a206af", + "JMC/Mock Entry 20-2.mp4": "fce852b2c7567b917a7d02b1216215a0", + "JMC/Mock Entry 20-2.nfo": "025c0b631da5ff5470382b38fce78d2d", + "JMC/Mock Entry 20-3.info.json": "INFO_JSON", + "JMC/Mock Entry 20-3.jpg": "e691c0591065c89e21fd552672a206af", + "JMC/Mock Entry 20-3.mp4": "b2edb14a985b95fb4c530f7e868ed66c", + "JMC/Mock Entry 20-3.nfo": "618b0ff948d9de2e10cf1da8c0dd6615", + "JMC/Mock Entry 21-1.info.json": "INFO_JSON", + "JMC/Mock Entry 21-1.jpg": "e691c0591065c89e21fd552672a206af", + "JMC/Mock Entry 21-1.mp4": "3b50ad36e92501fef9d18dcd7532a921", + "JMC/Mock Entry 21-1.nfo": "e5c715749efc1603a6e2f59244d87aba" +} \ No newline at end of file diff --git a/tests/resources/expected_downloads_summaries/plugins/thumbnail/square_thumbnail.json b/tests/resources/expected_downloads_summaries/plugins/thumbnail/square_thumbnail.json new file mode 100644 index 00000000..52179d42 --- /dev/null +++ b/tests/resources/expected_downloads_summaries/plugins/thumbnail/square_thumbnail.json @@ -0,0 +1,19 @@ +{ + ".ytdl-sub-subscription_test-download-archive.json": "76e202bd03ceef93daaffffee2cfa193", + "JMC/Mock Entry 20-1.info.json": "INFO_JSON", + "JMC/Mock Entry 20-1.jpg": "e691c0591065c89e21fd552672a206af", + "JMC/Mock Entry 20-1.mp4": "d8dc9918d1646c92b4a4bd246291d849", + "JMC/Mock Entry 20-1.nfo": "fefcf0b3e4f4ff80ad636584d50dadec", + "JMC/Mock Entry 20-2.info.json": "INFO_JSON", + "JMC/Mock Entry 20-2.jpg": "e691c0591065c89e21fd552672a206af", + "JMC/Mock Entry 20-2.mp4": "6f925179a5b6bf7ffcaf2f70ad585296", + "JMC/Mock Entry 20-2.nfo": "025c0b631da5ff5470382b38fce78d2d", + "JMC/Mock Entry 20-3.info.json": "INFO_JSON", + "JMC/Mock Entry 20-3.jpg": "e691c0591065c89e21fd552672a206af", + "JMC/Mock Entry 20-3.mp4": "b48f1803f14be665677e9ac9da787593", + "JMC/Mock Entry 20-3.nfo": "618b0ff948d9de2e10cf1da8c0dd6615", + "JMC/Mock Entry 21-1.info.json": "INFO_JSON", + "JMC/Mock Entry 21-1.jpg": "e691c0591065c89e21fd552672a206af", + "JMC/Mock Entry 21-1.mp4": "5fb865f27ee1cac381597d1e182dfa48", + "JMC/Mock Entry 21-1.nfo": "e5c715749efc1603a6e2f59244d87aba" +} \ No newline at end of file diff --git a/tests/resources/transaction_log_summaries/plugins/thumbnail/embedded_square_thumbnail.txt b/tests/resources/transaction_log_summaries/plugins/thumbnail/embedded_square_thumbnail.txt new file mode 100644 index 00000000..8859266c --- /dev/null +++ b/tests/resources/transaction_log_summaries/plugins/thumbnail/embedded_square_thumbnail.txt @@ -0,0 +1,85 @@ +Files created: +---------------------------------------- +{output_directory} + .ytdl-sub-subscription_test-download-archive.json +{output_directory}/JMC + Mock Entry 20-1.info.json + Mock Entry 20-1.jpg + Mock Entry 20-1.mp4 + Video Tags: + album: Music Videos + artist: JMC + genre: ytdl-sub + premiered: 2020-08-08 + title: Mock Entry 20-1 + year: 2020 + Embedded thumbnail + Square thumbnail + Mock Entry 20-1.nfo + NFO tags: + musicvideo: + album: Music Videos + artist: JMC + genre: ytdl-sub + premiered: 2020-08-08 + title: Mock Entry 20-1 + Mock Entry 20-2.info.json + Mock Entry 20-2.jpg + Mock Entry 20-2.mp4 + Video Tags: + album: Music Videos + artist: JMC + genre: ytdl-sub + premiered: 2020-08-08 + title: Mock Entry 20-2 + year: 2020 + Embedded thumbnail + Square thumbnail + Mock Entry 20-2.nfo + NFO tags: + musicvideo: + album: Music Videos + artist: JMC + genre: ytdl-sub + premiered: 2020-08-08 + title: Mock Entry 20-2 + Mock Entry 20-3.info.json + Mock Entry 20-3.jpg + Mock Entry 20-3.mp4 + Video Tags: + album: Music Videos + artist: JMC + genre: ytdl-sub + premiered: 2020-08-07 + title: Mock Entry 20-3 + year: 2020 + Embedded thumbnail + Square thumbnail + Mock Entry 20-3.nfo + NFO tags: + musicvideo: + album: Music Videos + artist: JMC + genre: ytdl-sub + premiered: 2020-08-07 + title: Mock Entry 20-3 + Mock Entry 21-1.info.json + Mock Entry 21-1.jpg + Mock Entry 21-1.mp4 + Video Tags: + album: Music Videos + artist: JMC + genre: ytdl-sub + premiered: 2021-08-08 + title: Mock Entry 21-1 + year: 2021 + Embedded thumbnail + Square thumbnail + Mock Entry 21-1.nfo + NFO tags: + musicvideo: + album: Music Videos + artist: JMC + genre: ytdl-sub + premiered: 2021-08-08 + title: Mock Entry 21-1 \ No newline at end of file diff --git a/tests/resources/transaction_log_summaries/plugins/thumbnail/square_thumbnail.txt b/tests/resources/transaction_log_summaries/plugins/thumbnail/square_thumbnail.txt new file mode 100644 index 00000000..538acd06 --- /dev/null +++ b/tests/resources/transaction_log_summaries/plugins/thumbnail/square_thumbnail.txt @@ -0,0 +1,81 @@ +Files created: +---------------------------------------- +{output_directory} + .ytdl-sub-subscription_test-download-archive.json +{output_directory}/JMC + Mock Entry 20-1.info.json + Mock Entry 20-1.jpg + Mock Entry 20-1.mp4 + Video Tags: + album: Music Videos + artist: JMC + genre: ytdl-sub + premiered: 2020-08-08 + title: Mock Entry 20-1 + year: 2020 + Square thumbnail + Mock Entry 20-1.nfo + NFO tags: + musicvideo: + album: Music Videos + artist: JMC + genre: ytdl-sub + premiered: 2020-08-08 + title: Mock Entry 20-1 + Mock Entry 20-2.info.json + Mock Entry 20-2.jpg + Mock Entry 20-2.mp4 + Video Tags: + album: Music Videos + artist: JMC + genre: ytdl-sub + premiered: 2020-08-08 + title: Mock Entry 20-2 + year: 2020 + Square thumbnail + Mock Entry 20-2.nfo + NFO tags: + musicvideo: + album: Music Videos + artist: JMC + genre: ytdl-sub + premiered: 2020-08-08 + title: Mock Entry 20-2 + Mock Entry 20-3.info.json + Mock Entry 20-3.jpg + Mock Entry 20-3.mp4 + Video Tags: + album: Music Videos + artist: JMC + genre: ytdl-sub + premiered: 2020-08-07 + title: Mock Entry 20-3 + year: 2020 + Square thumbnail + Mock Entry 20-3.nfo + NFO tags: + musicvideo: + album: Music Videos + artist: JMC + genre: ytdl-sub + premiered: 2020-08-07 + title: Mock Entry 20-3 + Mock Entry 21-1.info.json + Mock Entry 21-1.jpg + Mock Entry 21-1.mp4 + Video Tags: + album: Music Videos + artist: JMC + genre: ytdl-sub + premiered: 2021-08-08 + title: Mock Entry 21-1 + year: 2021 + Square thumbnail + Mock Entry 21-1.nfo + NFO tags: + musicvideo: + album: Music Videos + artist: JMC + genre: ytdl-sub + premiered: 2021-08-08 + title: Mock Entry 21-1 \ No newline at end of file diff --git a/tests/unit/config/test_config_file.py b/tests/unit/config/test_config_file.py index 3bd98d6e..1bed7504 100644 --- a/tests/unit/config/test_config_file.py +++ b/tests/unit/config/test_config_file.py @@ -55,6 +55,7 @@ class TestConfigFilePartiallyValidatesPresets: def test_success__empty_plugins(self, plugin: str): excluded_plugins = [ "embed_thumbnail", # value is bool, not dict + "square_thumbnail", "format", # value is string, not dict "filter_include", # is list "filter_exclude", # is list diff --git a/tools/docgen/plugins.py b/tools/docgen/plugins.py index 8080c297..e42718ad 100644 --- a/tools/docgen/plugins.py +++ b/tools/docgen/plugins.py @@ -25,6 +25,7 @@ def should_filter_all_properties(plugin_name: str) -> bool: "filter_include", "filter_exclude", "embed_thumbnail", + "square_thumbnail", "video_tags", "download", )