diff --git a/docs/config.rst b/docs/config.rst index 55a2fec3..dd06cbdd 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -211,6 +211,13 @@ subtitles ------------------------------------------------------------------------------- +video_tags +'''''''''' +.. autoclass:: ytdl_sub.plugins.video_tags.VideoTagsOptions() + :members: + +------------------------------------------------------------------------------- + .. _subscription_yaml: subscription.yaml diff --git a/src/ytdl_sub/config/preset_class_mappings.py b/src/ytdl_sub/config/preset_class_mappings.py index 59520211..3221160a 100644 --- a/src/ytdl_sub/config/preset_class_mappings.py +++ b/src/ytdl_sub/config/preset_class_mappings.py @@ -15,6 +15,7 @@ from ytdl_sub.plugins.output_directory_nfo_tags import OutputDirectoryNfoTagsPlu from ytdl_sub.plugins.plugin import Plugin from ytdl_sub.plugins.regex import RegexPlugin from ytdl_sub.plugins.subtitles import SubtitlesPlugin +from ytdl_sub.plugins.video_tags import VideoTagsPlugin class DownloadStrategyMapping: @@ -107,6 +108,7 @@ class PluginMapping: _MAPPING: Dict[str, Type[Plugin]] = { "music_tags": MusicTagsPlugin, + "video_tags": VideoTagsPlugin, "nfo_tags": NfoTagsPlugin, "output_directory_nfo_tags": OutputDirectoryNfoTagsPlugin, "regex": RegexPlugin, diff --git a/src/ytdl_sub/downloaders/youtube/merge_playlist.py b/src/ytdl_sub/downloaders/youtube/merge_playlist.py index e71adf04..7f94397d 100644 --- a/src/ytdl_sub/downloaders/youtube/merge_playlist.py +++ b/src/ytdl_sub/downloaders/youtube/merge_playlist.py @@ -8,7 +8,7 @@ from ytdl_sub.downloaders.youtube.playlist import YoutubePlaylistDownloaderOptio from ytdl_sub.entries.youtube import YoutubeVideo from ytdl_sub.utils.chapters import Chapters from ytdl_sub.utils.chapters import Timestamp -from ytdl_sub.utils.ffmpeg import add_ffmpeg_metadata +from ytdl_sub.utils.ffmpeg import set_ffmpeg_metadata_chapters from ytdl_sub.utils.file_handler import FileMetadata from ytdl_sub.validators.validators import BoolValidator @@ -115,7 +115,7 @@ class YoutubeMergePlaylistDownloader( chapters = Chapters(timestamps=timestamps, titles=titles) if not self.is_dry_run and add_chapters: - add_ffmpeg_metadata( + set_ffmpeg_metadata_chapters( file_path=merged_video.get_download_file_path(), chapters=chapters, file_duration_sec=merged_video.kwargs("duration"), diff --git a/src/ytdl_sub/downloaders/youtube/video.py b/src/ytdl_sub/downloaders/youtube/video.py index b63db71d..86b12d1d 100644 --- a/src/ytdl_sub/downloaders/youtube/video.py +++ b/src/ytdl_sub/downloaders/youtube/video.py @@ -7,7 +7,7 @@ from ytdl_sub.downloaders.youtube.abc import YoutubeDownloader from ytdl_sub.downloaders.youtube.abc import YoutubeDownloaderOptions from ytdl_sub.entries.youtube import YoutubeVideo from ytdl_sub.utils.chapters import Chapters -from ytdl_sub.utils.ffmpeg import add_ffmpeg_metadata +from ytdl_sub.utils.ffmpeg import set_ffmpeg_metadata_chapters from ytdl_sub.utils.file_handler import FileMetadata from ytdl_sub.validators.url_validator import YoutubeVideoUrlValidator from ytdl_sub.validators.validators import StringValidator @@ -105,7 +105,7 @@ class YoutubeVideoDownloader(YoutubeDownloader[YoutubeVideoDownloaderOptions, Yo # Otherwise, add the chapters and return the video + chapter metadata chapters = Chapters.from_file(chapters_file_path=self.download_options.chapter_timestamps) if not self.is_dry_run: - add_ffmpeg_metadata( + set_ffmpeg_metadata_chapters( file_path=video.get_download_file_path(), chapters=chapters, file_duration_sec=video.kwargs("duration"), diff --git a/src/ytdl_sub/plugins/video_tags.py b/src/ytdl_sub/plugins/video_tags.py new file mode 100644 index 00000000..aa5989bb --- /dev/null +++ b/src/ytdl_sub/plugins/video_tags.py @@ -0,0 +1,62 @@ +from typing import Dict + +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.ffmpeg import add_ffmpeg_metadata_key_values +from ytdl_sub.utils.file_handler import FileMetadata +from ytdl_sub.validators.string_formatter_validators import DictFormatterValidator + + +class VideoTagsOptions(PluginOptions): + """ + Adds tags to every downloaded video file using ffmpeg ``-metadata key=value`` args. + + Usage: + + .. code-block:: yaml + + presets: + my_example_preset: + video_tags: + tags: + title: "{title}" + date: "{upload_date}" + description: "{description}" + """ + + _required_keys = {"tags"} + + def __init__(self, name, value): + super().__init__(name, value) + self._tags = self._validate_key(key="tags", validator=DictFormatterValidator) + + @property + def tags(self) -> DictFormatterValidator: + """ + Key/values of tag names/values. Supports source and override variables. + """ + return self._tags + + +class VideoTagsPlugin(Plugin[VideoTagsOptions]): + plugin_options_type = VideoTagsOptions + + def post_process_entry(self, entry: Entry) -> FileMetadata: + """ + Tags the entry's audio file using values defined in the metadata options + """ + tags_to_write: Dict[str, str] = {} + for tag_name, tag_formatter in self.plugin_options.tags.dict.items(): + tag_value = self.overrides.apply_formatter(formatter=tag_formatter, entry=entry) + tags_to_write[tag_name] = tag_value + + # write the actual tags if its not a dry run + if not self.is_dry_run: + add_ffmpeg_metadata_key_values( + file_path=entry.get_download_file_path(), + key_values=tags_to_write, + ) + + # report the tags written + return FileMetadata.from_dict(value_dict=tags_to_write, title="Video Tags:") diff --git a/src/ytdl_sub/utils/ffmpeg.py b/src/ytdl_sub/utils/ffmpeg.py index e4213a14..affed520 100644 --- a/src/ytdl_sub/utils/ffmpeg.py +++ b/src/ytdl_sub/utils/ffmpeg.py @@ -1,6 +1,7 @@ import shutil import subprocess import tempfile +from typing import Dict from typing import List from typing import Optional @@ -88,11 +89,12 @@ def _create_metadata_chapters(chapters: Chapters, file_duration_sec: int) -> Lis return lines -def add_ffmpeg_metadata( +def set_ffmpeg_metadata_chapters( file_path: str, chapters: Optional[Chapters], file_duration_sec: int ) -> None: """ - Adds ffmetadata to a file. TODO: support more than just chapters + Sets ffmetadata chapters to a file. Note that this will (I think) wipe all prior + metadata. Parameters ---------- @@ -120,7 +122,9 @@ def add_ffmpeg_metadata( file_path, "-i", metadata_file.name, - "-map_metadata", + "-map", + "0", + "-map_chapters", "1", "-bitexact", # for reproducibility "-codec", @@ -130,3 +134,24 @@ def add_ffmpeg_metadata( ) shutil.move(src=output_file_path, dst=file_path) + + +def add_ffmpeg_metadata_key_values(file_path: str, key_values: Dict[str, str]) -> None: + """ + Parameters + ---------- + file_path + File to add metadata key/values to + key_values + The key/values to add + """ + file_path_ext = file_path.split(".")[-1] + output_file_path = f"{file_path}.out.{file_path_ext}" + + ffmpeg_args = ["-i", file_path, "-map", "0"] + for key, value in key_values.items(): + ffmpeg_args.extend(["-metadata", f"{key}={value}"]) + ffmpeg_args.extend(["-codec", "copy", output_file_path]) + + FFMPEG.run(ffmpeg_args) + shutil.move(src=output_file_path, dst=file_path) diff --git a/tests/e2e/plugins/test_subtitles.py b/tests/e2e/plugins/test_subtitles.py index bea0cbe5..041855c6 100644 --- a/tests/e2e/plugins/test_subtitles.py +++ b/tests/e2e/plugins/test_subtitles.py @@ -1,3 +1,4 @@ +import mergedeep import pytest from e2e.expected_download import assert_expected_downloads from e2e.expected_transaction_log import assert_transaction_log_matches @@ -85,3 +86,39 @@ class TestSubtitles: dry_run=dry_run, expected_download_summary_file_name="plugins/test_subtitles_embedded_and_file.json", ) + + @pytest.mark.parametrize("dry_run", [True, False]) + def test_subtitles_chapters_tags_embedded( + self, + music_video_config, + timestamps_file_path, + single_video_subs_embed_preset_dict, + output_directory, + dry_run, + ): + # Test chapters and video tags in addition to subtitles + mergedeep.merge( + single_video_subs_embed_preset_dict, + { + "youtube": {"chapter_timestamps": timestamps_file_path}, + "video_tags": {"tags": {"title": "{title}"}}, + }, + ) + + subscription = Subscription.from_dict( + config=music_video_config, + preset_name="subtitles_embedded_test", + preset_dict=single_video_subs_embed_preset_dict, + ) + + transaction_log = subscription.download(dry_run=dry_run) + assert_transaction_log_matches( + output_directory=output_directory, + transaction_log=transaction_log, + transaction_log_summary_file_name="plugins/test_subtitles_tags_chapters.txt", + ) + assert_expected_downloads( + output_directory=output_directory, + dry_run=dry_run, + expected_download_summary_file_name="plugins/test_subtitles_tags_chapters.json", + ) diff --git a/tests/e2e/resources/expected_downloads_summaries/plugins/test_subtitles_tags_chapters.json b/tests/e2e/resources/expected_downloads_summaries/plugins/test_subtitles_tags_chapters.json new file mode 100644 index 00000000..f695f22d --- /dev/null +++ b/tests/e2e/resources/expected_downloads_summaries/plugins/test_subtitles_tags_chapters.json @@ -0,0 +1,5 @@ +{ + "JMC - YouTube Rewind 2019: For the Record | #YouTubeRewind-thumb.jpg": "704246dd78074e8a0ec001dd8d03fd60", + "JMC - YouTube Rewind 2019: For the Record | #YouTubeRewind.mp4": "e2e60d3e3ff7739d071aa953642980af", + "JMC - YouTube Rewind 2019: For the Record | #YouTubeRewind.nfo": "ffa10f1cbc098ace7b1c7a8fbe3097a8" +} \ No newline at end of file diff --git a/tests/e2e/resources/expected_downloads_summaries/youtube/test_video.json b/tests/e2e/resources/expected_downloads_summaries/youtube/test_video.json index 8fe87f3f..7aecd554 100644 --- a/tests/e2e/resources/expected_downloads_summaries/youtube/test_video.json +++ b/tests/e2e/resources/expected_downloads_summaries/youtube/test_video.json @@ -1,5 +1,5 @@ { "JMC - Oblivion Mod "Falcor" p.1-thumb.jpg": "fb95b510681676e81c321171fc23143e", - "JMC - Oblivion Mod "Falcor" p.1.mp4": "931a705864c57d21d6fedebed4af6bbc", + "JMC - Oblivion Mod "Falcor" p.1.mp4": "170bec01308f639da7459c51ec4a1d7e", "JMC - Oblivion Mod "Falcor" p.1.nfo": "89f509a8a3d9003e22a9091abeeae5dc" } \ No newline at end of file diff --git a/tests/e2e/resources/expected_downloads_summaries/youtube/test_video_with_chapter_timestamps.json b/tests/e2e/resources/expected_downloads_summaries/youtube/test_video_with_chapter_timestamps.json index 609ffa44..99ee3e1c 100644 --- a/tests/e2e/resources/expected_downloads_summaries/youtube/test_video_with_chapter_timestamps.json +++ b/tests/e2e/resources/expected_downloads_summaries/youtube/test_video_with_chapter_timestamps.json @@ -1,5 +1,5 @@ { "JMC - Oblivion Mod "Falcor" p.1-thumb.jpg": "fb95b510681676e81c321171fc23143e", - "JMC - Oblivion Mod "Falcor" p.1.mp4": "76b8a7dd428e67e5072d003983bb7e33", + "JMC - Oblivion Mod "Falcor" p.1.mp4": "567631875d95fee5899e2ff407b74fd8", "JMC - Oblivion Mod "Falcor" p.1.nfo": "89f509a8a3d9003e22a9091abeeae5dc" } \ No newline at end of file diff --git a/tests/e2e/resources/transaction_log_summaries/plugins/test_subtitles_tags_chapters.txt b/tests/e2e/resources/transaction_log_summaries/plugins/test_subtitles_tags_chapters.txt new file mode 100644 index 00000000..250be877 --- /dev/null +++ b/tests/e2e/resources/transaction_log_summaries/plugins/test_subtitles_tags_chapters.txt @@ -0,0 +1,21 @@ +Files created in '{output_directory}' +---------------------------------------- +JMC - YouTube Rewind 2019: For the Record | #YouTubeRewind-thumb.jpg +JMC - YouTube Rewind 2019: For the Record | #YouTubeRewind.mp4 + Chapters embedded into the video: + 0:00: Intro + 0:10: Part 1 + 0:20: Part 2 + 0:30: Part 3 + 0:40: Part 4 + 1:01: Part 5 + Embedded subtitles with lang(s) en, de + Video Tags: + title: YouTube Rewind 2019: For the Record | #YouTubeRewind +JMC - YouTube Rewind 2019: For the Record | #YouTubeRewind.nfo + NFO tags: + musicvideo: + album: Music Videos + artist: JMC + title: YouTube Rewind 2019: For the Record | #YouTubeRewind + year: 2019 \ No newline at end of file diff --git a/tests/e2e/resources/transaction_log_summaries/youtube/test_video.txt b/tests/e2e/resources/transaction_log_summaries/youtube/test_video.txt index bbeae952..b3e7b13b 100644 --- a/tests/e2e/resources/transaction_log_summaries/youtube/test_video.txt +++ b/tests/e2e/resources/transaction_log_summaries/youtube/test_video.txt @@ -2,6 +2,8 @@ Files created in '{output_directory}' ---------------------------------------- JMC - Oblivion Mod "Falcor" p.1-thumb.jpg JMC - Oblivion Mod "Falcor" p.1.mp4 + Video Tags: + title: Oblivion Mod "Falcor" p.1 JMC - Oblivion Mod "Falcor" p.1.nfo NFO tags: musicvideo: diff --git a/tests/e2e/resources/transaction_log_summaries/youtube/test_video_with_chapter_timestamps.txt b/tests/e2e/resources/transaction_log_summaries/youtube/test_video_with_chapter_timestamps.txt index d329bbb9..ce6e41e2 100644 --- a/tests/e2e/resources/transaction_log_summaries/youtube/test_video_with_chapter_timestamps.txt +++ b/tests/e2e/resources/transaction_log_summaries/youtube/test_video_with_chapter_timestamps.txt @@ -9,6 +9,11 @@ JMC - Oblivion Mod "Falcor" p.1.mp4 0:30: Part 3 0:40: Part 4 1:01: Part 5 + Video Tags: + description: + 🎸 / ' " + newline? + title: Oblivion Mod "Falcor" p.1 JMC - Oblivion Mod "Falcor" p.1.nfo NFO tags: musicvideo: diff --git a/tests/e2e/youtube/test_video.py b/tests/e2e/youtube/test_video.py index 7c044c67..77dba7ef 100644 --- a/tests/e2e/youtube/test_video.py +++ b/tests/e2e/youtube/test_video.py @@ -18,6 +18,12 @@ def single_video_preset_dict(output_directory): "ytdl_options": { "format": "worst[ext=mp4]", }, + # also test video tags + "video_tags": { + "tags": { + "title": "{title}", + } + }, "overrides": {"artist": "JMC"}, } @@ -90,7 +96,10 @@ class TestYoutubeVideo: output_directory, dry_run, ): + # Test chapters and video tags, throw in a video tag with special chars while we are at it single_video_preset_dict["youtube"]["chapter_timestamps"] = timestamps_file_path + single_video_preset_dict["video_tags"]["tags"]["description"] = "🎸 / ' \" \n newline?" + single_video_subscription = Subscription.from_dict( config=music_video_config, preset_name="music_video_single_video_test",