[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

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.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,

View file

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

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.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"),

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

View file

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

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.mp4": "931a705864c57d21d6fedebed4af6bbc",
"JMC - Oblivion Mod Falcor p.1.mp4": "170bec01308f639da7459c51ec4a1d7e",
"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.mp4": "76b8a7dd428e67e5072d003983bb7e33",
"JMC - Oblivion Mod Falcor p.1.mp4": "567631875d95fee5899e2ff407b74fd8",
"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.mp4
Video Tags:
title: Oblivion Mod "Falcor" p.1
JMC - Oblivion Mod Falcor p.1.nfo
NFO tags:
musicvideo:

View file

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

View file

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