[FEATURE] Embed thumbnails into audio files as cover art (#345)

* [FEATURE] Embed thumbnails into audio files as cover art

* lint

* fix ytdl options
This commit is contained in:
Jesse Bannon 2022-11-19 23:36:15 -08:00 committed by GitHub
parent 20bec0c664
commit 70c78dc109
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 65 additions and 36 deletions

View file

@ -17,6 +17,7 @@ presets:
quality: 128 quality: 128
music_tags: music_tags:
embed_thumbnail: False # Set to True to embed album art
tags: tags:
artist: "{custom_artist_name}" artist: "{custom_artist_name}"
albumartist: "{custom_artist_name}" albumartist: "{custom_artist_name}"

View file

@ -7,7 +7,9 @@ from ytdl_sub.entries.entry import Entry
from ytdl_sub.plugins.plugin import Plugin from ytdl_sub.plugins.plugin import Plugin
from ytdl_sub.plugins.plugin import PluginOptions from ytdl_sub.plugins.plugin import PluginOptions
from ytdl_sub.utils.file_handler import FileMetadata 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.string_formatter_validators import DictFormatterValidator
from ytdl_sub.validators.validators import BoolValidator
class MusicTagsOptions(PluginOptions): class MusicTagsOptions(PluginOptions):
@ -31,9 +33,12 @@ class MusicTagsOptions(PluginOptions):
artist: "{artist}" artist: "{artist}"
album: "{album}" album: "{album}"
genre: "ytdl downloaded music" genre: "ytdl downloaded music"
# Optional
embed_thumbnail: False
""" """
_required_keys = {"tags"} _required_keys = {"tags"}
_optional_keys = {"embed_thumbnail"}
@classmethod @classmethod
def partial_validate(cls, name: str, value: Any) -> None: def partial_validate(cls, name: str, value: Any) -> None:
@ -48,6 +53,9 @@ class MusicTagsOptions(PluginOptions):
super().__init__(name, value) super().__init__(name, value)
self._tags = self._validate_key(key="tags", validator=DictFormatterValidator) 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 @property
def tags(self) -> DictFormatterValidator: def tags(self) -> DictFormatterValidator:
@ -56,6 +64,13 @@ class MusicTagsOptions(PluginOptions):
""" """
return self._tags 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]): class MusicTagsPlugin(Plugin[MusicTagsOptions]):
plugin_options_type = MusicTagsOptions plugin_options_type = MusicTagsOptions
@ -85,7 +100,23 @@ class MusicTagsPlugin(Plugin[MusicTagsOptions]):
audio_file = mediafile.MediaFile(entry.get_download_file_path()) audio_file = mediafile.MediaFile(entry.get_download_file_path())
for tag_name, tag_value in tags_to_write.items(): for tag_name, tag_value in tags_to_write.items():
setattr(audio_file, tag_name, tag_value) 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() audio_file.save()
# report the tags written # 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,
)

View file

@ -53,17 +53,11 @@ class SubscriptionYTDLOptions:
""" """
ytdl_options = { ytdl_options = {
# Download all files in the format of {id}.{ext} # 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 return ytdl_options
@property @property
@ -71,7 +65,6 @@ class SubscriptionYTDLOptions:
return { return {
"skip_download": True, "skip_download": True,
"writethumbnail": False, "writethumbnail": False,
# TODO: find a way to not write subtitles; using `simulate: True` breaks tests
} }
@property @property
@ -85,10 +78,14 @@ class SubscriptionYTDLOptions:
@property @property
def _output_options(self) -> Dict: def _output_options(self) -> Dict:
ytdl_options = {} ytdl_options = {}
output_options = self._preset.output_options
if output_options.thumbnail_name: if (
ytdl_options["writethumbnail"] = True 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 return ytdl_options
@ -111,6 +108,7 @@ class SubscriptionYTDLOptions:
""" """
return YTDLOptionsBuilder().add( return YTDLOptionsBuilder().add(
self._global_options, self._global_options,
self._output_options,
self._plugin_ytdl_options(DateRangePlugin), self._plugin_ytdl_options(DateRangePlugin),
self._user_ytdl_options, # user ytdl options... self._user_ytdl_options, # user ytdl options...
self._info_json_only_options, # then info_json_only options self._info_json_only_options, # then info_json_only options
@ -123,25 +121,18 @@ class SubscriptionYTDLOptions:
YTDLOptionsBuilder YTDLOptionsBuilder
Builder with values set based on the subscription for actual downloading 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: if self._dry_run:
ytdl_options_builder.add( ytdl_options_builder.add(self._dry_run_options)
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
)
return ytdl_options_builder return ytdl_options_builder

View file

@ -1,3 +1,4 @@
import os
import tempfile import tempfile
from typing import Optional from typing import Optional
from urllib.request import urlopen 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 = entry.get_ytdlp_download_thumbnail_path()
download_thumbnail_path_as_jpg = entry.get_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 not download_thumbnail_path:
if error_if_not_found: if error_if_not_found:
raise ValueError("Thumbnail not found") raise ValueError("Thumbnail not found")

View file

@ -11,6 +11,7 @@ def single_song_preset_dict(output_directory):
"preset": "song", "preset": "song",
"download": {"url": "https://www.youtube.com/watch?v=2lAe1cqCOXo"}, "download": {"url": "https://www.youtube.com/watch?v=2lAe1cqCOXo"},
"output_options": {"output_directory": output_directory}, "output_options": {"output_directory": output_directory},
"music_tags": {"embed_thumbnail": True},
# download the worst format so it is fast # download the worst format so it is fast
"ytdl_options": { "ytdl_options": {
"format": "worst[ext=mp4]", "format": "worst[ext=mp4]",
@ -34,7 +35,6 @@ def multiple_songs_preset_dict(output_directory):
} }
# TODO: Test album from chapters
class TestAudioExtract: class TestAudioExtract:
@pytest.mark.parametrize("dry_run", [True, False]) @pytest.mark.parametrize("dry_run", [True, False])
def test_audio_extract_single_song( def test_audio_extract_single_song(

View file

@ -1,3 +1,3 @@
{ {
"YouTube Rewind 2019 For the Record #YouTubeRewind.mp3": "d3687bf6c13a2a5f3a8a05fbce9be28c" "YouTube Rewind 2019 For the Record #YouTubeRewind.mp3": "8e531bfb93b144fe652f44950e31b3f7"
} }

View file

@ -2,7 +2,7 @@ Files created:
---------------------------------------- ----------------------------------------
{output_directory} {output_directory}
YouTube Rewind 2019 For the Record #YouTubeRewind.mp3 YouTube Rewind 2019 For the Record #YouTubeRewind.mp3
Music Tags: Embedded Thumbnail, Music Tags:
album: Singles album: Singles
albumartist: YouTube albumartist: YouTube
artist: YouTube artist: YouTube