[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:
|
||||||
|
|
||||||
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.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,
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
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 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)
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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-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"
|
||||||
}
|
}
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
@ -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-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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue