[FEATURE] Video tags plugin to embed metadata into video files (#173)
This commit is contained in:
parent
fe2f7186c5
commit
9a7a34f3d0
14 changed files with 184 additions and 9 deletions
|
|
@ -211,6 +211,13 @@ subtitles
|
|||
|
||||
-------------------------------------------------------------------------------
|
||||
|
||||
video_tags
|
||||
''''''''''
|
||||
.. autoclass:: ytdl_sub.plugins.video_tags.VideoTagsOptions()
|
||||
:members:
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
|
||||
.. _subscription_yaml:
|
||||
|
||||
subscription.yaml
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
62
src/ytdl_sub/plugins/video_tags.py
Normal file
62
src/ytdl_sub/plugins/video_tags.py
Normal 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:")
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue