[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:
parent
20bec0c664
commit
70c78dc109
7 changed files with 65 additions and 36 deletions
|
|
@ -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}"
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
{
|
{
|
||||||
"YouTube Rewind 2019: For the Record | #YouTubeRewind.mp3": "d3687bf6c13a2a5f3a8a05fbce9be28c"
|
"YouTube Rewind 2019: For the Record | #YouTubeRewind.mp3": "8e531bfb93b144fe652f44950e31b3f7"
|
||||||
}
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue