From 70c78dc109cffda6a9b449824c8275575bc983b9 Mon Sep 17 00:00:00 2001 From: Jesse Bannon Date: Sat, 19 Nov 2022 23:36:15 -0800 Subject: [PATCH] [FEATURE] Embed thumbnails into audio files as cover art (#345) * [FEATURE] Embed thumbnails into audio files as cover art * lint * fix ytdl options --- examples/music_audio_from_videos.yaml | 1 + src/ytdl_sub/plugins/music_tags.py | 33 ++++++++++- .../subscription_ytdl_options.py | 55 ++++++++----------- src/ytdl_sub/utils/thumbnail.py | 6 ++ tests/e2e/plugins/test_audio_extract.py | 2 +- .../plugins/test_audio_extract_single.json | 2 +- .../plugins/test_audio_extract_single.txt | 2 +- 7 files changed, 65 insertions(+), 36 deletions(-) diff --git a/examples/music_audio_from_videos.yaml b/examples/music_audio_from_videos.yaml index 02f6f615..801e018d 100644 --- a/examples/music_audio_from_videos.yaml +++ b/examples/music_audio_from_videos.yaml @@ -17,6 +17,7 @@ presets: quality: 128 music_tags: + embed_thumbnail: False # Set to True to embed album art tags: artist: "{custom_artist_name}" albumartist: "{custom_artist_name}" diff --git a/src/ytdl_sub/plugins/music_tags.py b/src/ytdl_sub/plugins/music_tags.py index 0f85c55b..b5387876 100644 --- a/src/ytdl_sub/plugins/music_tags.py +++ b/src/ytdl_sub/plugins/music_tags.py @@ -7,7 +7,9 @@ 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.thumbnail import convert_download_thumbnail from ytdl_sub.validators.string_formatter_validators import DictFormatterValidator +from ytdl_sub.validators.validators import BoolValidator class MusicTagsOptions(PluginOptions): @@ -31,9 +33,12 @@ class MusicTagsOptions(PluginOptions): artist: "{artist}" album: "{album}" genre: "ytdl downloaded music" + # Optional + embed_thumbnail: False """ _required_keys = {"tags"} + _optional_keys = {"embed_thumbnail"} @classmethod def partial_validate(cls, name: str, value: Any) -> None: @@ -48,6 +53,9 @@ class MusicTagsOptions(PluginOptions): super().__init__(name, value) self._tags = self._validate_key(key="tags", validator=DictFormatterValidator) + self._embed_thumbnail = self._validate_key_if_present( + key="embed_thumbnail", validator=BoolValidator, default=False + ).value @property def tags(self) -> DictFormatterValidator: @@ -56,6 +64,13 @@ class MusicTagsOptions(PluginOptions): """ return self._tags + @property + def embed_thumbnail(self) -> bool: + """ + Optional. Whether to embed the thumbnail into the audio file. + """ + return self._embed_thumbnail + class MusicTagsPlugin(Plugin[MusicTagsOptions]): plugin_options_type = MusicTagsOptions @@ -85,7 +100,23 @@ class MusicTagsPlugin(Plugin[MusicTagsOptions]): audio_file = mediafile.MediaFile(entry.get_download_file_path()) for tag_name, tag_value in tags_to_write.items(): setattr(audio_file, tag_name, tag_value) + + if self.plugin_options.embed_thumbnail: + # convert the entry thumbnail so it is embedded as jpg + convert_download_thumbnail(entry=entry) + + with open(entry.get_download_thumbnail_path(), "rb") as thumb: + mediafile_img = mediafile.Image( + data=thumb.read(), desc="cover", type=mediafile.ImageType.front + ) + + audio_file.images = [mediafile_img] + audio_file.save() # report the tags written - return FileMetadata.from_dict(value_dict=tags_to_write, title="Music Tags") + title = f"{'Embedded Thumbnail, ' if self.plugin_options.embed_thumbnail else ''}Music Tags" + return FileMetadata.from_dict( + value_dict=tags_to_write, + title=title, + ) diff --git a/src/ytdl_sub/subscriptions/subscription_ytdl_options.py b/src/ytdl_sub/subscriptions/subscription_ytdl_options.py index 045845b0..b5c52be4 100644 --- a/src/ytdl_sub/subscriptions/subscription_ytdl_options.py +++ b/src/ytdl_sub/subscriptions/subscription_ytdl_options.py @@ -53,17 +53,11 @@ class SubscriptionYTDLOptions: """ ytdl_options = { # Download all files in the format of {id}.{ext} - "outtmpl": str(Path(self._working_directory) / "%(id)s.%(ext)s") + "outtmpl": str(Path(self._working_directory) / "%(id)s.%(ext)s"), + # Always write thumbnails + "writethumbnail": True, } - if ( - self._downloader.supports_download_archive - and self._preset.output_options.maintain_download_archive - ): - ytdl_options["download_archive"] = str( - Path(self._working_directory) / self._enhanced_download_archive.archive_file_name - ) - return ytdl_options @property @@ -71,7 +65,6 @@ class SubscriptionYTDLOptions: return { "skip_download": True, "writethumbnail": False, - # TODO: find a way to not write subtitles; using `simulate: True` breaks tests } @property @@ -85,10 +78,14 @@ class SubscriptionYTDLOptions: @property def _output_options(self) -> Dict: ytdl_options = {} - output_options = self._preset.output_options - if output_options.thumbnail_name: - ytdl_options["writethumbnail"] = True + if ( + self._downloader.supports_download_archive + and self._preset.output_options.maintain_download_archive + ): + ytdl_options["download_archive"] = str( + Path(self._working_directory) / self._enhanced_download_archive.archive_file_name + ) return ytdl_options @@ -111,6 +108,7 @@ class SubscriptionYTDLOptions: """ return YTDLOptionsBuilder().add( self._global_options, + self._output_options, self._plugin_ytdl_options(DateRangePlugin), self._user_ytdl_options, # user ytdl options... self._info_json_only_options, # then info_json_only options @@ -123,25 +121,18 @@ class SubscriptionYTDLOptions: YTDLOptionsBuilder Builder with values set based on the subscription for actual downloading """ - ytdl_options_builder = YTDLOptionsBuilder().add(self._global_options) + ytdl_options_builder = YTDLOptionsBuilder().add( + self._global_options, + self._output_options, + self._plugin_ytdl_options(DateRangePlugin), + self._plugin_ytdl_options(FileConvertPlugin), + self._plugin_ytdl_options(SubtitlesPlugin), + self._plugin_ytdl_options(ChaptersPlugin), + self._plugin_ytdl_options(AudioExtractPlugin), + self._user_ytdl_options, # user ytdl options... + ) + # Add dry run options last if enabled if self._dry_run: - ytdl_options_builder.add( - self._plugin_ytdl_options(DateRangePlugin), - self._plugin_ytdl_options(FileConvertPlugin), - self._plugin_ytdl_options(SubtitlesPlugin), - self._plugin_ytdl_options(ChaptersPlugin), - self._user_ytdl_options, # user ytdl options... - self._dry_run_options, # then dry-run - ) - else: - ytdl_options_builder.add( - self._output_options, - self._plugin_ytdl_options(DateRangePlugin), - self._plugin_ytdl_options(FileConvertPlugin), - self._plugin_ytdl_options(SubtitlesPlugin), - self._plugin_ytdl_options(ChaptersPlugin), - self._plugin_ytdl_options(AudioExtractPlugin), - self._user_ytdl_options, # user ytdl options last - ) + ytdl_options_builder.add(self._dry_run_options) return ytdl_options_builder diff --git a/src/ytdl_sub/utils/thumbnail.py b/src/ytdl_sub/utils/thumbnail.py index 09cb3eaf..0507a52d 100644 --- a/src/ytdl_sub/utils/thumbnail.py +++ b/src/ytdl_sub/utils/thumbnail.py @@ -1,3 +1,4 @@ +import os import tempfile from typing import Optional from urllib.request import urlopen @@ -29,6 +30,11 @@ def convert_download_thumbnail(entry: Entry, error_if_not_found: bool = True) -> """ download_thumbnail_path = entry.get_ytdlp_download_thumbnail_path() download_thumbnail_path_as_jpg = entry.get_download_thumbnail_path() + + # If it was already converted, do not convert again + if os.path.isfile(download_thumbnail_path_as_jpg): + return + if not download_thumbnail_path: if error_if_not_found: raise ValueError("Thumbnail not found") diff --git a/tests/e2e/plugins/test_audio_extract.py b/tests/e2e/plugins/test_audio_extract.py index 4832bc0a..fef552b5 100644 --- a/tests/e2e/plugins/test_audio_extract.py +++ b/tests/e2e/plugins/test_audio_extract.py @@ -11,6 +11,7 @@ def single_song_preset_dict(output_directory): "preset": "song", "download": {"url": "https://www.youtube.com/watch?v=2lAe1cqCOXo"}, "output_options": {"output_directory": output_directory}, + "music_tags": {"embed_thumbnail": True}, # download the worst format so it is fast "ytdl_options": { "format": "worst[ext=mp4]", @@ -34,7 +35,6 @@ def multiple_songs_preset_dict(output_directory): } -# TODO: Test album from chapters class TestAudioExtract: @pytest.mark.parametrize("dry_run", [True, False]) def test_audio_extract_single_song( diff --git a/tests/resources/expected_downloads_summaries/plugins/test_audio_extract_single.json b/tests/resources/expected_downloads_summaries/plugins/test_audio_extract_single.json index 81329080..7ac29d35 100644 --- a/tests/resources/expected_downloads_summaries/plugins/test_audio_extract_single.json +++ b/tests/resources/expected_downloads_summaries/plugins/test_audio_extract_single.json @@ -1,3 +1,3 @@ { - "YouTube Rewind 2019: For the Record | #YouTubeRewind.mp3": "d3687bf6c13a2a5f3a8a05fbce9be28c" + "YouTube Rewind 2019: For the Record | #YouTubeRewind.mp3": "8e531bfb93b144fe652f44950e31b3f7" } \ No newline at end of file diff --git a/tests/resources/transaction_log_summaries/plugins/test_audio_extract_single.txt b/tests/resources/transaction_log_summaries/plugins/test_audio_extract_single.txt index cf9114ee..aa7ec2d8 100644 --- a/tests/resources/transaction_log_summaries/plugins/test_audio_extract_single.txt +++ b/tests/resources/transaction_log_summaries/plugins/test_audio_extract_single.txt @@ -2,7 +2,7 @@ Files created: ---------------------------------------- {output_directory} YouTube Rewind 2019: For the Record | #YouTubeRewind.mp3 - Music Tags: + Embedded Thumbnail, Music Tags: album: Singles albumartist: YouTube artist: YouTube