[FEATURE] Video tags plugin to embed metadata into video files (#173)

This commit is contained in:
Jesse Bannon 2022-08-13 09:53:55 -07:00 committed by GitHub
parent fe2f7186c5
commit 9a7a34f3d0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 184 additions and 9 deletions

View file

@ -211,6 +211,13 @@ subtitles
------------------------------------------------------------------------------- -------------------------------------------------------------------------------
video_tags
''''''''''
.. autoclass:: ytdl_sub.plugins.video_tags.VideoTagsOptions()
:members:
-------------------------------------------------------------------------------
.. _subscription_yaml: .. _subscription_yaml:
subscription.yaml subscription.yaml

View file

@ -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.plugin import Plugin
from ytdl_sub.plugins.regex import RegexPlugin from ytdl_sub.plugins.regex import RegexPlugin
from ytdl_sub.plugins.subtitles import SubtitlesPlugin from ytdl_sub.plugins.subtitles import SubtitlesPlugin
from ytdl_sub.plugins.video_tags import VideoTagsPlugin
class DownloadStrategyMapping: class DownloadStrategyMapping:
@ -107,6 +108,7 @@ class PluginMapping:
_MAPPING: Dict[str, Type[Plugin]] = { _MAPPING: Dict[str, Type[Plugin]] = {
"music_tags": MusicTagsPlugin, "music_tags": MusicTagsPlugin,
"video_tags": VideoTagsPlugin,
"nfo_tags": NfoTagsPlugin, "nfo_tags": NfoTagsPlugin,
"output_directory_nfo_tags": OutputDirectoryNfoTagsPlugin, "output_directory_nfo_tags": OutputDirectoryNfoTagsPlugin,
"regex": RegexPlugin, "regex": RegexPlugin,

View file

@ -8,7 +8,7 @@ from ytdl_sub.downloaders.youtube.playlist import YoutubePlaylistDownloaderOptio
from ytdl_sub.entries.youtube import YoutubeVideo from ytdl_sub.entries.youtube import YoutubeVideo
from ytdl_sub.utils.chapters import Chapters from ytdl_sub.utils.chapters import Chapters
from ytdl_sub.utils.chapters import Timestamp 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.utils.file_handler import FileMetadata
from ytdl_sub.validators.validators import BoolValidator from ytdl_sub.validators.validators import BoolValidator
@ -115,7 +115,7 @@ class YoutubeMergePlaylistDownloader(
chapters = Chapters(timestamps=timestamps, titles=titles) chapters = Chapters(timestamps=timestamps, titles=titles)
if not self.is_dry_run and add_chapters: if not self.is_dry_run and add_chapters:
add_ffmpeg_metadata( set_ffmpeg_metadata_chapters(
file_path=merged_video.get_download_file_path(), file_path=merged_video.get_download_file_path(),
chapters=chapters, chapters=chapters,
file_duration_sec=merged_video.kwargs("duration"), file_duration_sec=merged_video.kwargs("duration"),

View file

@ -7,7 +7,7 @@ from ytdl_sub.downloaders.youtube.abc import YoutubeDownloader
from ytdl_sub.downloaders.youtube.abc import YoutubeDownloaderOptions from ytdl_sub.downloaders.youtube.abc import YoutubeDownloaderOptions
from ytdl_sub.entries.youtube import YoutubeVideo from ytdl_sub.entries.youtube import YoutubeVideo
from ytdl_sub.utils.chapters import Chapters 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.utils.file_handler import FileMetadata
from ytdl_sub.validators.url_validator import YoutubeVideoUrlValidator from ytdl_sub.validators.url_validator import YoutubeVideoUrlValidator
from ytdl_sub.validators.validators import StringValidator 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 # Otherwise, add the chapters and return the video + chapter metadata
chapters = Chapters.from_file(chapters_file_path=self.download_options.chapter_timestamps) chapters = Chapters.from_file(chapters_file_path=self.download_options.chapter_timestamps)
if not self.is_dry_run: if not self.is_dry_run:
add_ffmpeg_metadata( set_ffmpeg_metadata_chapters(
file_path=video.get_download_file_path(), file_path=video.get_download_file_path(),
chapters=chapters, chapters=chapters,
file_duration_sec=video.kwargs("duration"), file_duration_sec=video.kwargs("duration"),

View file

@ -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:")

View file

@ -1,6 +1,7 @@
import shutil import shutil
import subprocess import subprocess
import tempfile import tempfile
from typing import Dict
from typing import List from typing import List
from typing import Optional from typing import Optional
@ -88,11 +89,12 @@ def _create_metadata_chapters(chapters: Chapters, file_duration_sec: int) -> Lis
return lines return lines
def add_ffmpeg_metadata( def set_ffmpeg_metadata_chapters(
file_path: str, chapters: Optional[Chapters], file_duration_sec: int file_path: str, chapters: Optional[Chapters], file_duration_sec: int
) -> None: ) -> 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 Parameters
---------- ----------
@ -120,7 +122,9 @@ def add_ffmpeg_metadata(
file_path, file_path,
"-i", "-i",
metadata_file.name, metadata_file.name,
"-map_metadata", "-map",
"0",
"-map_chapters",
"1", "1",
"-bitexact", # for reproducibility "-bitexact", # for reproducibility
"-codec", "-codec",
@ -130,3 +134,24 @@ def add_ffmpeg_metadata(
) )
shutil.move(src=output_file_path, dst=file_path) 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)

View file

@ -1,3 +1,4 @@
import mergedeep
import pytest import pytest
from e2e.expected_download import assert_expected_downloads from e2e.expected_download import assert_expected_downloads
from e2e.expected_transaction_log import assert_transaction_log_matches from e2e.expected_transaction_log import assert_transaction_log_matches
@ -85,3 +86,39 @@ class TestSubtitles:
dry_run=dry_run, dry_run=dry_run,
expected_download_summary_file_name="plugins/test_subtitles_embedded_and_file.json", 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",
)

View file

@ -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"
}

View file

@ -1,5 +1,5 @@
{ {
"JMC - Oblivion Mod Falcor p.1-thumb.jpg": "fb95b510681676e81c321171fc23143e", "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" "JMC - Oblivion Mod Falcor p.1.nfo": "89f509a8a3d9003e22a9091abeeae5dc"
} }

View file

@ -1,5 +1,5 @@
{ {
"JMC - Oblivion Mod Falcor p.1-thumb.jpg": "fb95b510681676e81c321171fc23143e", "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" "JMC - Oblivion Mod Falcor p.1.nfo": "89f509a8a3d9003e22a9091abeeae5dc"
} }

View file

@ -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

View file

@ -2,6 +2,8 @@ Files created in '{output_directory}'
---------------------------------------- ----------------------------------------
JMC - Oblivion Mod Falcor p.1-thumb.jpg JMC - Oblivion Mod Falcor p.1-thumb.jpg
JMC - Oblivion Mod Falcor p.1.mp4 JMC - Oblivion Mod Falcor p.1.mp4
Video Tags:
title: Oblivion Mod "Falcor" p.1
JMC - Oblivion Mod Falcor p.1.nfo JMC - Oblivion Mod Falcor p.1.nfo
NFO tags: NFO tags:
musicvideo: musicvideo:

View file

@ -9,6 +9,11 @@ JMC - Oblivion Mod Falcor p.1.mp4
0:30: Part 3 0:30: Part 3
0:40: Part 4 0:40: Part 4
1:01: Part 5 1:01: Part 5
Video Tags:
description:
🎸 / ' "
newline?
title: Oblivion Mod "Falcor" p.1
JMC - Oblivion Mod Falcor p.1.nfo JMC - Oblivion Mod Falcor p.1.nfo
NFO tags: NFO tags:
musicvideo: musicvideo:

View file

@ -18,6 +18,12 @@ def single_video_preset_dict(output_directory):
"ytdl_options": { "ytdl_options": {
"format": "worst[ext=mp4]", "format": "worst[ext=mp4]",
}, },
# also test video tags
"video_tags": {
"tags": {
"title": "{title}",
}
},
"overrides": {"artist": "JMC"}, "overrides": {"artist": "JMC"},
} }
@ -90,7 +96,10 @@ class TestYoutubeVideo:
output_directory, output_directory,
dry_run, 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["youtube"]["chapter_timestamps"] = timestamps_file_path
single_video_preset_dict["video_tags"]["tags"]["description"] = "🎸 / ' \" \n newline?"
single_video_subscription = Subscription.from_dict( single_video_subscription = Subscription.from_dict(
config=music_video_config, config=music_video_config,
preset_name="music_video_single_video_test", preset_name="music_video_single_video_test",