From 790c65859afb6f8b19ab7a49665910bd72933cf9 Mon Sep 17 00:00:00 2001 From: Jesse Bannon Date: Thu, 18 Aug 2022 23:24:03 -0700 Subject: [PATCH] [FEATURE] Add `split_by_chapters` plugin (#184) --- docs/config.rst | 8 + examples/youtube_extract_and_tag_audio.yaml | 38 ++- src/ytdl_sub/config/preset.py | 18 +- src/ytdl_sub/config/preset_class_mappings.py | 2 + .../downloaders/youtube/split_video.py | 18 +- src/ytdl_sub/downloaders/youtube/video.py | 4 +- src/ytdl_sub/plugins/chapters.py | 13 +- src/ytdl_sub/plugins/plugin.py | 50 +++- src/ytdl_sub/plugins/regex.py | 4 + src/ytdl_sub/plugins/split_by_chapters.py | 217 ++++++++++++++++++ src/ytdl_sub/subscriptions/subscription.py | 110 +++++++-- src/ytdl_sub/utils/chapters.py | 75 +++++- src/ytdl_sub/utils/thumbnail.py | 8 +- tests/e2e/plugins/test_audio_extract.py | 1 - tests/e2e/plugins/test_split_by_chapters.py | 162 +++++++++++++ .../plugins/split_by_chapters_video.json | 14 ++ ...chapters_with_regex_no_chapters_video.json | 4 + ...ters_with_regex_no_chapters_video_drop.txt | 1 + ...ters_with_regex_no_chapters_video_pass.txt | 4 + .../split_by_chapters_with_regex_video.json | 14 ++ .../plugins/test_audio_extract_single.json | 2 +- .../split_by_chapters_video-dry-run.txt | 146 ++++++++++++ .../plugins/split_by_chapters_video.txt | 135 +++++++++++ ...ters_with_regex_no_chapters_video_drop.txt | 0 ...ters_with_regex_no_chapters_video_pass.txt | 12 + ...t_by_chapters_with_regex_video-dry-run.txt | 146 ++++++++++++ .../split_by_chapters_with_regex_video.txt | 135 +++++++++++ .../plugins/test_audio_extract_single.txt | 2 +- 28 files changed, 1281 insertions(+), 62 deletions(-) create mode 100644 src/ytdl_sub/plugins/split_by_chapters.py create mode 100644 tests/e2e/plugins/test_split_by_chapters.py create mode 100644 tests/e2e/resources/expected_downloads_summaries/plugins/split_by_chapters_video.json create mode 100644 tests/e2e/resources/expected_downloads_summaries/plugins/split_by_chapters_with_regex_no_chapters_video.json create mode 100644 tests/e2e/resources/expected_downloads_summaries/plugins/split_by_chapters_with_regex_no_chapters_video_drop.txt create mode 100644 tests/e2e/resources/expected_downloads_summaries/plugins/split_by_chapters_with_regex_no_chapters_video_pass.txt create mode 100644 tests/e2e/resources/expected_downloads_summaries/plugins/split_by_chapters_with_regex_video.json create mode 100644 tests/e2e/resources/transaction_log_summaries/plugins/split_by_chapters_video-dry-run.txt create mode 100644 tests/e2e/resources/transaction_log_summaries/plugins/split_by_chapters_video.txt create mode 100644 tests/e2e/resources/transaction_log_summaries/plugins/split_by_chapters_with_regex_no_chapters_video_drop.txt create mode 100644 tests/e2e/resources/transaction_log_summaries/plugins/split_by_chapters_with_regex_no_chapters_video_pass.txt create mode 100644 tests/e2e/resources/transaction_log_summaries/plugins/split_by_chapters_with_regex_video-dry-run.txt create mode 100644 tests/e2e/resources/transaction_log_summaries/plugins/split_by_chapters_with_regex_video.txt diff --git a/docs/config.rst b/docs/config.rst index b058dcf1..531652f4 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -219,6 +219,14 @@ regex ------------------------------------------------------------------------------- +split_by_chapters +''''''''''''''''' +.. autoclass:: ytdl_sub.plugins.split_by_chapters.SplitByChaptersOptions() + :members: when_no_chapters + :member-order: bysource + +------------------------------------------------------------------------------- + subtitles ''''''''' .. autoclass:: ytdl_sub.plugins.subtitles.SubtitleOptions() diff --git a/examples/youtube_extract_and_tag_audio.yaml b/examples/youtube_extract_and_tag_audio.yaml index e738ba59..223fcfb6 100644 --- a/examples/youtube_extract_and_tag_audio.yaml +++ b/examples/youtube_extract_and_tag_audio.yaml @@ -9,7 +9,7 @@ presets: output_options: output_directory: "{music_directory}" - file_name: "{title_sanitized}.{ext}" + file_name: "{custom_track_name_sanitized}.{ext}" audio_extract: codec: "mp3" @@ -17,22 +17,42 @@ presets: music_tags: tags: - artist: "{artist}" - albumartist: "{artist}" - title: "{title}" - album: "Singles" - track: "1" + artist: "{custom_artist_name}" + albumartist: "{custom_artist_name}" + title: "{custom_track_name}" + album: "{custom_album_name}" + track: "{custom_track_number}" year: "{upload_year}" genre: "Unset" overrides: music_directory: "/path/to/music" + custom_track_name: "{title}" + custom_album_name: "Singles" + custom_artist_name: "{artist}" + custom_track_number: "1" + # TODO: make a playlist of individual songs into an album. Need playlist_title yt_song_playlist: preset: yt_song youtube: download_strategy: "playlist" + overrides: + custom_track_number: "{playlist_index}" - music_tags: - tags: - track: "{playlist_index}" \ No newline at end of file + yt_album_as_chapters: + preset: "yt_song" + output_options: + file_name: "{custom_album_name_sanitized}/{chapter_index_padded} - {custom_track_name_sanitized}.{ext}" + thumbnail_name: "{custom_album_name_sanitized}/folder.{thumbnail_ext}" + + chapters: + embed_chapters: True + + split_by_chapters: + when_no_chapters: "pass" + + overrides: + custom_track_name: "{chapter_title}" + custom_album_name: "{title}" # Have the video name be the album + custom_track_number: "{chapter_index}" diff --git a/src/ytdl_sub/config/preset.py b/src/ytdl_sub/config/preset.py index 48e4f461..22fcfb07 100644 --- a/src/ytdl_sub/config/preset.py +++ b/src/ytdl_sub/config/preset.py @@ -160,7 +160,8 @@ class Preset(StrictDictValidator): return downloader, download_options def __validate_and_get_plugins(self) -> PresetPlugins: - plugins = PresetPlugins() + preset_plugins = PresetPlugins() + source_variables = copy.deepcopy(self._source_variables) for key in self._keys: if key not in PluginMapping.plugins(): @@ -168,13 +169,22 @@ class Preset(StrictDictValidator): plugin = PluginMapping.get(plugin=key) plugin_options = self._validate_key(key=key, validator=plugin.plugin_options_type) + + preset_plugins.add(plugin_type=plugin, plugin_options=plugin_options) + + for plugin, plugin_options in sorted( + preset_plugins.zipped(), + key=lambda _plugin_and_options: _plugin_and_options[0].priority.modify_entry, + ): + # Validate current plugin using source + added plugin variables plugin_options.validate_with_variables( - source_variables=self._source_variables, override_variables=self.overrides.keys + source_variables=source_variables, override_variables=self.overrides.keys ) - plugins.add(plugin_type=plugin, plugin_options=plugin_options) + # Extend existing source variables with ones created from this plugin + source_variables.extend(plugin_options.added_source_variables()) - return plugins + return preset_plugins def __validate_override_string_formatter_validator( self, diff --git a/src/ytdl_sub/config/preset_class_mappings.py b/src/ytdl_sub/config/preset_class_mappings.py index de94220c..9554340f 100644 --- a/src/ytdl_sub/config/preset_class_mappings.py +++ b/src/ytdl_sub/config/preset_class_mappings.py @@ -16,6 +16,7 @@ from ytdl_sub.plugins.nfo_tags import NfoTagsPlugin from ytdl_sub.plugins.output_directory_nfo_tags import OutputDirectoryNfoTagsPlugin from ytdl_sub.plugins.plugin import Plugin from ytdl_sub.plugins.regex import RegexPlugin +from ytdl_sub.plugins.split_by_chapters import SplitByChaptersPlugin from ytdl_sub.plugins.subtitles import SubtitlesPlugin from ytdl_sub.plugins.video_tags import VideoTagsPlugin @@ -117,6 +118,7 @@ class PluginMapping: "regex": RegexPlugin, "subtitles": SubtitlesPlugin, "chapters": ChaptersPlugin, + "split_by_chapters": SplitByChaptersPlugin, } @classmethod diff --git a/src/ytdl_sub/downloaders/youtube/split_video.py b/src/ytdl_sub/downloaders/youtube/split_video.py index a7059c74..215659ba 100644 --- a/src/ytdl_sub/downloaders/youtube/split_video.py +++ b/src/ytdl_sub/downloaders/youtube/split_video.py @@ -9,6 +9,7 @@ from ytdl_sub.downloaders.youtube.abc import YoutubeDownloader from ytdl_sub.downloaders.youtube.video import YoutubeVideoDownloaderOptions from ytdl_sub.entries.youtube import YoutubePlaylistVideo from ytdl_sub.entries.youtube import YoutubeVideo +from ytdl_sub.plugins.split_by_chapters import _split_video_ffmpeg_cmd from ytdl_sub.utils.chapters import Chapters from ytdl_sub.utils.chapters import Timestamp from ytdl_sub.utils.ffmpeg import FFMPEG @@ -31,19 +32,6 @@ def _split_video_uid(source_uid: str, idx: int) -> str: return f"{source_uid}___{idx}" -def _split_video_ffmpeg_cmd( - input_file: str, output_file: str, timestamps: List[Timestamp], idx: int -) -> List[str]: - timestamp_begin = timestamps[idx].standardized_str - timestamp_end = timestamps[idx + 1].standardized_str if idx + 1 < len(timestamps) else "" - - cmd = ["-i", input_file, "-ss", timestamp_begin] - if timestamp_end: - cmd += ["-to", timestamp_end] - cmd += ["-vcodec", "copy", "-acodec", "copy", output_file] - return cmd - - class YoutubeSplitVideoDownloaderOptions(YoutubeVideoDownloaderOptions): r""" Downloads a single youtube video, then splits in to separate videos using a file containing @@ -158,7 +146,9 @@ class YoutubeSplitVideoDownloader( """Download a single Youtube video, then split it into multiple videos""" split_videos_and_metadata: List[Tuple[YoutubePlaylistVideo, FileMetadata]] = [] - chapters = Chapters.from_file(chapters_file_path=self.download_options.split_timestamps) + chapters = Chapters.from_timestamps_file( + chapters_file_path=self.download_options.split_timestamps + ) entry_dict = self.extract_info(url=self.download_options.video_url) entry = YoutubeVideo(entry_dict=entry_dict, working_directory=self.working_directory) diff --git a/src/ytdl_sub/downloaders/youtube/video.py b/src/ytdl_sub/downloaders/youtube/video.py index 86b12d1d..e9a00826 100644 --- a/src/ytdl_sub/downloaders/youtube/video.py +++ b/src/ytdl_sub/downloaders/youtube/video.py @@ -103,7 +103,9 @@ class YoutubeVideoDownloader(YoutubeDownloader[YoutubeVideoDownloaderOptions, Yo return [video] # 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_timestamps_file( + chapters_file_path=self.download_options.chapter_timestamps + ) if not self.is_dry_run: set_ffmpeg_metadata_chapters( file_path=video.get_download_file_path(), diff --git a/src/ytdl_sub/plugins/chapters.py b/src/ytdl_sub/plugins/chapters.py index dead5838..0fc9164d 100644 --- a/src/ytdl_sub/plugins/chapters.py +++ b/src/ytdl_sub/plugins/chapters.py @@ -30,16 +30,20 @@ SPONSORBLOCK_CATEGORIES: Set[str] = SPONSORBLOCK_HIGHLIGHT_CATEGORIES | { def _chapters(entry: Entry) -> List[Dict]: if entry.kwargs_contains("chapters"): - return entry.kwargs("chapters") + return entry.kwargs("chapters") or [] return [] def _sponsorblock_chapters(entry: Entry) -> List[Dict]: if entry.kwargs_contains("sponsorblock_chapters"): - return entry.kwargs("sponsorblock_chapters") + return entry.kwargs("sponsorblock_chapters") or [] return [] +def _contains_any_chapters(entry: Entry) -> bool: + return len(_chapters(entry)) > 0 or len(_sponsorblock_chapters(entry)) > 0 + + class SponsorBlockCategoriesValidator(StringSelectValidator): _expected_value_type_name = "sponsorblock category" _select_values = {"all"} | SPONSORBLOCK_CATEGORIES @@ -274,11 +278,16 @@ class ChaptersPlugin(Plugin[ChaptersOptions]): removed_chapters = self._get_removed_chapters(entry) removed_sponsorblock = self._get_removed_sponsorblock_category_counts(entry) + # If no chapters are on the entry, do not report any embedded chapters + if not _contains_any_chapters(entry): + return None + if removed_chapters: metadata_dict["Removed Chapter(s)"] = ", ".join(removed_chapters) if removed_sponsorblock: metadata_dict["Removed SponsorBlock Category Count(s)"] = removed_sponsorblock + # TODO: check if file actually has embedded chapters return FileMetadata.from_dict( value_dict=metadata_dict, title="Embedded Chapters", sort_dict=False ) diff --git a/src/ytdl_sub/plugins/plugin.py b/src/ytdl_sub/plugins/plugin.py index 654957e0..d0bee3f4 100644 --- a/src/ytdl_sub/plugins/plugin.py +++ b/src/ytdl_sub/plugins/plugin.py @@ -3,6 +3,7 @@ from typing import Dict from typing import Generic from typing import List from typing import Optional +from typing import Tuple from typing import Type from typing import TypeVar from typing import final @@ -16,6 +17,28 @@ from ytdl_sub.ytdl_additions.enhanced_download_archive import DownloadArchiver from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive +class PluginPriority: + """ + Defines priority for plugins, 0 is highest priority + """ + + # If modify_entry priority is >= to this value, run after split + MODIFY_ENTRY_AFTER_SPLIT = 10 + + def __init__(self, modify_entry: int = 0, post_process: int = 0): + self.modify_entry = modify_entry + self.post_process = post_process + + @property + def modify_entry_after_split(self) -> bool: + """ + Returns + ------- + True if the plugin should modify an entry after a potential split. False otherwise. + """ + return self.modify_entry >= PluginPriority.MODIFY_ENTRY_AFTER_SPLIT + + class PluginOptions(StrictDictValidator): """ Class that defines the parameters to a plugin @@ -60,6 +83,10 @@ class Plugin(DownloadArchiver, Generic[PluginOptionsT], ABC): """ plugin_options_type: Type[PluginOptionsT] = NotImplemented + priority: PluginPriority = PluginPriority() + + # If the plugin creates multile entries from a single entry + is_split_plugin: bool = False @final def __init__( @@ -74,6 +101,7 @@ class Plugin(DownloadArchiver, Generic[PluginOptionsT], ABC): # TODO pass yaml snake case name in the class somewhere, and use it for the logger self._logger = Logger.get(self.__class__.__name__) + # pylint: disable=no-self-use,unused-argument def ytdl_options(self) -> Optional[Dict]: """ Returns @@ -81,7 +109,22 @@ class Plugin(DownloadArchiver, Generic[PluginOptionsT], ABC): ytdl options to enable/disable when downloading entries for this specific plugin """ - # pylint: disable=no-self-use + def split(self, entry: Entry) -> List[Tuple[Entry, FileMetadata]]: + """ + Very specialized function that takes an entry and creates multiple entries from it. + Should mark ``is_split_plugin`` on the plugin class. + + Parameters + ---------- + entry + Entry to create multiple entries from + + Returns + ------- + List of entries and metadata created from the source entry + """ + return [] + def modify_entry(self, entry: Entry) -> Optional[Entry]: """ For each entry downloaded, modify the entry in some way before sending it to @@ -98,8 +141,6 @@ class Plugin(DownloadArchiver, Generic[PluginOptionsT], ABC): """ return entry - # pylint: enable=no-self-use - def post_process_entry(self, entry: Entry) -> Optional[FileMetadata]: """ For each entry downloaded, apply post processing to it. @@ -113,6 +154,9 @@ class Plugin(DownloadArchiver, Generic[PluginOptionsT], ABC): ------- Optional file metadata for the entry media file. """ + return None + + # pylint: enable=no-self-use,unused-argument def post_process_subscription(self): """ diff --git a/src/ytdl_sub/plugins/regex.py b/src/ytdl_sub/plugins/regex.py index c60c4b51..34070fbd 100644 --- a/src/ytdl_sub/plugins/regex.py +++ b/src/ytdl_sub/plugins/regex.py @@ -7,6 +7,7 @@ from yt_dlp.utils import sanitize_filename from ytdl_sub.entries.entry import Entry from ytdl_sub.plugins.plugin import Plugin from ytdl_sub.plugins.plugin import PluginOptions +from ytdl_sub.plugins.plugin import PluginPriority from ytdl_sub.utils.exceptions import RegexNoMatchException from ytdl_sub.utils.logger import Logger from ytdl_sub.validators.regex_validator import RegexListValidator @@ -224,6 +225,9 @@ class RegexOptions(PluginOptions): class RegexPlugin(Plugin[RegexOptions]): plugin_options_type = RegexOptions + priority = PluginPriority( + modify_entry=PluginPriority.MODIFY_ENTRY_AFTER_SPLIT + 0, + ) def modify_entry(self, entry: Entry) -> Optional[Entry]: """ diff --git a/src/ytdl_sub/plugins/split_by_chapters.py b/src/ytdl_sub/plugins/split_by_chapters.py new file mode 100644 index 00000000..78fd43cb --- /dev/null +++ b/src/ytdl_sub/plugins/split_by_chapters.py @@ -0,0 +1,217 @@ +import copy +import os.path +from pathlib import Path +from typing import List +from typing import Optional +from typing import Tuple + +from yt_dlp.utils import sanitize_filename + +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.chapters import Chapters +from ytdl_sub.utils.chapters import Timestamp +from ytdl_sub.utils.exceptions import ValidationException +from ytdl_sub.utils.ffmpeg import FFMPEG +from ytdl_sub.utils.file_handler import FileHandler +from ytdl_sub.utils.file_handler import FileMetadata +from ytdl_sub.utils.thumbnail import convert_download_thumbnail +from ytdl_sub.validators.string_select_validator import StringSelectValidator + + +def _split_video_ffmpeg_cmd( + input_file: str, output_file: str, timestamps: List[Timestamp], idx: int +) -> List[str]: + timestamp_begin = timestamps[idx].standardized_str + timestamp_end = timestamps[idx + 1].standardized_str if idx + 1 < len(timestamps) else "" + + cmd = ["-i", input_file, "-ss", timestamp_begin] + if timestamp_end: + cmd += ["-to", timestamp_end] + cmd += ["-vcodec", "copy", "-acodec", "copy", output_file] + return cmd + + +def _split_video_uid(source_uid: str, idx: int) -> str: + return f"{source_uid}___{idx}" + + +class WhenNoChaptersValidator(StringSelectValidator): + _expected_value_type_name = "when no chapters option" + _select_values = {"pass", "drop", "error"} + + +class SplitByChaptersOptions(PluginOptions): + """ + Splits a file by chapters into multiple files. Each file becomes its own entry with the + new source variables ``chapter_title``, ``chapter_title_sanitized``, ``chapter_index``, + ``chapter_index_padded``, ``chapter_count``. + + If a file has no chapters, and ``when_no_chapters`` is set to "pass", then ``chapter_title`` is + set to the entry's title and ``chapter_index``, ``chapter_count`` are both set to 1. + + Note that when using this plugin and performing dry-run, it assumes embedded chapters are being + used with no modifications. + + Usage: + + .. code-block:: yaml + + presets: + my_example_preset: + split_by_chapters: + when_no_chapters: "pass" + """ + + _required_keys = {"when_no_chapters"} + + def __init__(self, name, value): + super().__init__(name, value) + self._when_no_chapters = self._validate_key( + key="when_no_chapters", validator=WhenNoChaptersValidator + ).value + + def added_source_variables(self) -> List[str]: + return [ + "chapter_title", + "chapter_title_sanitized", + "chapter_index", + "chapter_index_padded", + "chapter_count", + ] + + @property + def when_no_chapters(self) -> str: + """ + Behavior to perform when no chapters are present. Supports "pass" (continue processing), + "drop" (exclude it from output), and "error" (stop processing for everything). + """ + return self._when_no_chapters + + +class SplitByChaptersPlugin(Plugin[SplitByChaptersOptions]): + plugin_options_type = SplitByChaptersOptions + is_split_plugin = True + + def _create_split_entry( + self, source_entry: Entry, title: str, idx: int, chapters: Chapters + ) -> Tuple[Entry, FileMetadata]: + """ + Runs ffmpeg to create the split video + """ + entry = copy.deepcopy(source_entry) + + entry.add_variables( + { + "chapter_title": title, + "chapter_title_sanitized": sanitize_filename(title), + "chapter_index": idx + 1, + "chapter_index_padded": f"{(idx + 1):02d}", + "chapter_count": len(chapters.timestamps), + } + ) + + # pylint: disable=protected-access + entry._kwargs["id"] = _split_video_uid(source_uid=entry.uid, idx=idx) + if "chapters" in entry._kwargs: + del entry._kwargs["chapters"] + if "sponsorblock_chapters" in entry._kwargs: + del entry._kwargs["sponsorblock_chapters"] + # pylint: enable=protected-access + + timestamp_begin = chapters.timestamps[idx].readable_str + timestamp_end = Timestamp(entry.kwargs("duration")).readable_str + if idx + 1 < len(chapters.timestamps): + timestamp_end = chapters.timestamps[idx + 1].readable_str + + metadata_value_dict = {} + if self.is_dry_run: + metadata_value_dict[ + "Warning" + ] = "Dry-run assumes embedded chapters with no modifications" + + metadata_value_dict["Source Title"] = entry.title + metadata_value_dict["Segment"] = f"{timestamp_begin} - {timestamp_end}" + + metadata = FileMetadata.from_dict( + value_dict=metadata_value_dict, + title="From Chapter Split:", + sort_dict=False, + ) + + return entry, metadata + + def split(self, entry: Entry) -> Optional[List[Tuple[Entry, FileMetadata]]]: + """ + Tags the entry's audio file using values defined in the metadata options + """ + split_videos_and_metadata: List[Tuple[Entry, FileMetadata]] = [] + + if self.is_dry_run: + chapters = Chapters.from_entry_chapters(entry=entry) + else: + chapters = Chapters.from_embedded_chapters(file_path=entry.get_download_file_path()) + + # If no chapters, do not split anything + if not chapters.contains_any_chapters(): + if self.plugin_options.when_no_chapters == "pass": + entry.add_variables( + { + "chapter_title": entry.title, + "chapter_index": 1, + "chapter_index_padded": "01", + "chapter_count": 1, + } + ) + return [(entry, FileMetadata())] + if self.plugin_options.when_no_chapters == "drop": + return [] + + raise ValidationException( + f"Tried to split '{entry.title}' by chapters but it has no chapters" + ) + + # convert the entry thumbnail early so we do not have to guess the thumbnail extension + # when copying it. Do not error if it's not found, in case thumbnail_name is not set + if not self.is_dry_run: + convert_download_thumbnail(entry=entry, error_if_not_found=False) + + for idx, title in enumerate(chapters.titles): + new_uid = _split_video_uid(source_uid=entry.uid, idx=idx) + + if not self.is_dry_run: + # Get the input/output file paths + input_file = entry.get_download_file_path() + output_file = str(Path(self.working_directory) / f"{new_uid}.{entry.ext}") + + # Run ffmpeg to create the split the video + FFMPEG.run( + _split_video_ffmpeg_cmd( + input_file=input_file, + output_file=output_file, + timestamps=chapters.timestamps, + idx=idx, + ) + ) + + # Copy the original vid thumbnail to the working directory with the new uid. This so + # downstream logic thinks this split video has its own thumbnail + if os.path.isfile(entry.get_download_thumbnail_path()): + FileHandler.copy( + src_file_path=entry.get_download_thumbnail_path(), + dst_file_path=Path(self.working_directory) + / f"{new_uid}.{entry.thumbnail_ext}", + ) + + # Format the split video as a YoutubePlaylistVideo + split_videos_and_metadata.append( + self._create_split_entry( + source_entry=entry, + title=title, + idx=idx, + chapters=chapters, + ) + ) + + return split_videos_and_metadata diff --git a/src/ytdl_sub/subscriptions/subscription.py b/src/ytdl_sub/subscriptions/subscription.py index d234bfa5..5683cab1 100644 --- a/src/ytdl_sub/subscriptions/subscription.py +++ b/src/ytdl_sub/subscriptions/subscription.py @@ -19,12 +19,23 @@ from ytdl_sub.downloaders.downloader import DownloaderValidator from ytdl_sub.entries.entry import Entry from ytdl_sub.plugins.plugin import Plugin from ytdl_sub.subscriptions.subscription_ytdl_options import SubscriptionYTDLOptions +from ytdl_sub.utils.exceptions import ValidationException from ytdl_sub.utils.file_handler import FileHandlerTransactionLog from ytdl_sub.utils.file_handler import FileMetadata from ytdl_sub.utils.thumbnail import convert_download_thumbnail from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive +def _get_split_plugin(plugins: List[Plugin]) -> Optional[Plugin]: + split_plugins = [plugin for plugin in plugins if plugin.is_split_plugin] + + if len(split_plugins) == 1: + return split_plugins[0] + if len(split_plugins) > 1: + raise ValidationException("Can not use more than one split plugins at a time") + return None + + class Subscription: """ Subscription classes are the 'controllers' that perform... @@ -251,6 +262,74 @@ class Subscription: return plugins + def _post_process_entry( + self, plugins: List[Plugin], dry_run: bool, entry: Entry, entry_metadata: FileMetadata + ): + # Post-process the entry with all plugins + for plugin in sorted(plugins, key=lambda _plugin: _plugin.priority.post_process): + optional_plugin_entry_metadata = plugin.post_process_entry(entry) + if optional_plugin_entry_metadata: + entry_metadata.extend(optional_plugin_entry_metadata) + + # Then, move it to the output directory + self._move_entry_files_to_output_directory( + dry_run=dry_run, entry=entry, entry_metadata=entry_metadata + ) + + # Re-save the download archive after each entry is moved to the output directory + if self.maintain_download_archive: + self._enhanced_download_archive.save_download_mappings() + + def _process_entry( + self, plugins: List[Plugin], dry_run: bool, entry: Entry, entry_metadata: FileMetadata + ) -> None: + # First, modify the entry with all plugins + for plugin in sorted(plugins, key=lambda _plugin: _plugin.priority.modify_entry): + # Return if it is None, it is indicated to not process any further + if (entry := plugin.modify_entry(entry)) is None: + return + + self._post_process_entry( + plugins=plugins, dry_run=dry_run, entry=entry, entry_metadata=entry_metadata + ) + + def _process_split_entry( + self, split_plugin: Plugin, plugins: List[Plugin], dry_run: bool, entry: Entry + ) -> None: + plugins_pre_split = sorted( + [plugin for plugin in plugins if not plugin.priority.modify_entry_after_split], + key=lambda _plugin: _plugin.priority.modify_entry, + ) + + plugins_post_split = sorted( + [plugin for plugin in plugins if plugin.priority.modify_entry_after_split], + key=lambda _plugin: _plugin.priority.modify_entry, + ) + + # First, modify the entry with pre_split plugins + for plugin in plugins_pre_split: + # Return if it is None, it is indicated to not process any further + if (entry := plugin.modify_entry(entry)) is None: + return + + # Then, perform the split + for split_entry, split_entry_metadata in split_plugin.split(entry=entry): + + for plugin in plugins_post_split: + # Return if it is None, it is indicated to not process any further. + # Break out of the plugin loop + if (split_entry := plugin.modify_entry(split_entry)) is None: + break + + # If split_entry is None from modify_entry, do not post process + if split_entry: + self._process_entry( + plugins=plugins, + dry_run=dry_run, + entry=split_entry, + entry_metadata=split_entry_metadata, + ) + def download(self, dry_run: bool = False) -> FileHandlerTransactionLog: """ Performs the subscription download @@ -284,29 +363,14 @@ class Subscription: if isinstance(entry, tuple): entry, entry_metadata = entry - # First, modify the entry with all plugins - for plugin in plugins: - # Break out of this plugin loop if entry is None, it is indicated to not DL it - if (entry := plugin.modify_entry(entry)) is None: - break - - # If entry is None from the broken out loop, continue over the other entries - if entry is None: - continue - - # Then, post-process the entry with all plugins - for plugin in plugins: - optional_plugin_entry_metadata = plugin.post_process_entry(entry) - if optional_plugin_entry_metadata: - entry_metadata.extend(optional_plugin_entry_metadata) - - self._move_entry_files_to_output_directory( - dry_run=dry_run, entry=entry, entry_metadata=entry_metadata - ) - - # Re-save the download archive after each entry is moved to the output directory - if self.maintain_download_archive: - self._enhanced_download_archive.save_download_mappings() + if split_plugin := _get_split_plugin(plugins): + self._process_split_entry( + split_plugin=split_plugin, plugins=plugins, dry_run=dry_run, entry=entry + ) + else: + self._process_entry( + plugins=plugins, dry_run=dry_run, entry=entry, entry_metadata=entry_metadata + ) downloader.post_download(overrides=self.overrides) for plugin in plugins: diff --git a/src/ytdl_sub/utils/chapters.py b/src/ytdl_sub/utils/chapters.py index aedc614a..2c016402 100644 --- a/src/ytdl_sub/utils/chapters.py +++ b/src/ytdl_sub/utils/chapters.py @@ -1,9 +1,12 @@ +import json import os import re +import subprocess from typing import List from typing import Optional from typing import Tuple +from ytdl_sub.entries.entry import Entry from ytdl_sub.utils.exceptions import ValidationException from ytdl_sub.utils.file_handler import FileMetadata @@ -139,6 +142,14 @@ class Chapters: if timestamps[idx].timestamp_sec >= timestamps[idx + 1].timestamp_sec: raise ValueError("Timestamps must be in ascending order") + def contains_any_chapters(self) -> bool: + """ + Returns + ------- + True if there are chapters. False otherwise. + """ + return len(self.timestamps) > 0 + def contains_zero_timestamp(self) -> bool: """ Returns @@ -165,7 +176,7 @@ class Chapters: ) @classmethod - def from_file(cls, chapters_file_path: str) -> "Chapters": + def from_timestamps_file(cls, chapters_file_path: str) -> "Chapters": """ Parameters ---------- @@ -207,3 +218,65 @@ class Chapters: titles.append(title) return cls(timestamps=timestamps, titles=titles) + + @classmethod + def from_embedded_chapters(cls, file_path: str) -> "Chapters": + """ + Parameters + ---------- + file_path + File to read ffmpeg chapter metadata from + + Returns + ------- + Chapters object + """ + proc = subprocess.run( + [ + "ffprobe", + "-loglevel", + "quiet", + "-print_format", + "json", + "-show_chapters", + "--", + file_path, + ], + check=True, + stdout=subprocess.PIPE, + encoding="utf-8", + ) + + embedded_chapters = json.loads(proc.stdout) + timestamps: List[Timestamp] = [] + titles: List[str] = [] + for chapter in embedded_chapters["chapters"]: + timestamps.append(Timestamp.from_seconds(int(float(chapter["start_time"])))) + titles.append(chapter["tags"]["title"]) + + return Chapters(timestamps=timestamps, titles=titles) + + @classmethod + def from_entry_chapters(cls, entry: Entry) -> "Chapters": + """ + Parameters + ---------- + entry + Entry with yt-dlp chapter metadata + + Returns + ------- + Chapters object + """ + timestamps: List[Timestamp] = [] + titles: List[str] = [] + + chapters = {} + if entry.kwargs_contains("chapters"): + chapters = entry.kwargs("chapters") or [] + + for chapter in chapters: + timestamps.append(Timestamp.from_seconds(int(float(chapter["start_time"])))) + titles.append(chapter["title"]) + + return Chapters(timestamps=timestamps, titles=titles) diff --git a/src/ytdl_sub/utils/thumbnail.py b/src/ytdl_sub/utils/thumbnail.py index df56f3f5..4f866a61 100644 --- a/src/ytdl_sub/utils/thumbnail.py +++ b/src/ytdl_sub/utils/thumbnail.py @@ -5,7 +5,7 @@ from ytdl_sub.entries.entry import Entry from ytdl_sub.utils.ffmpeg import FFMPEG -def convert_download_thumbnail(entry: Entry): +def convert_download_thumbnail(entry: Entry, error_if_not_found: bool = True) -> None: """ Converts an entry's downloaded thumbnail into jpg format @@ -13,6 +13,8 @@ def convert_download_thumbnail(entry: Entry): ---------- entry Entry with the thumbnail + error_if_not_found + If the thumbnail is not found, error if True. Raises ------ @@ -22,7 +24,9 @@ def convert_download_thumbnail(entry: Entry): download_thumbnail_path = entry.get_ytdlp_download_thumbnail_path() download_thumbnail_path_as_jpg = entry.get_download_thumbnail_path() if not download_thumbnail_path: - raise ValueError("Thumbnail not found") + if error_if_not_found: + raise ValueError("Thumbnail not found") + return if not download_thumbnail_path == download_thumbnail_path_as_jpg: FFMPEG.run(["-bitexact", "-i", download_thumbnail_path, download_thumbnail_path_as_jpg]) diff --git a/tests/e2e/plugins/test_audio_extract.py b/tests/e2e/plugins/test_audio_extract.py index c3492cd3..3abc3542 100644 --- a/tests/e2e/plugins/test_audio_extract.py +++ b/tests/e2e/plugins/test_audio_extract.py @@ -1,4 +1,3 @@ -import mergedeep import pytest from e2e.expected_download import assert_expected_downloads from e2e.expected_transaction_log import assert_transaction_log_matches diff --git a/tests/e2e/plugins/test_split_by_chapters.py b/tests/e2e/plugins/test_split_by_chapters.py new file mode 100644 index 00000000..0e865a57 --- /dev/null +++ b/tests/e2e/plugins/test_split_by_chapters.py @@ -0,0 +1,162 @@ +import re + +import mergedeep +import pytest +from e2e.expected_download import assert_expected_downloads +from e2e.expected_transaction_log import assert_transaction_log_matches + +from ytdl_sub.subscriptions.subscription import Subscription +from ytdl_sub.utils.exceptions import ValidationException + + +@pytest.fixture +def yt_album_as_chapters_preset_dict(output_directory): + return { + "preset": "yt_album_as_chapters", + "youtube": {"video_url": "https://www.youtube.com/watch?v=zeR2_YjlXWA"}, + # override the output directory with our fixture-generated dir + "output_options": {"output_directory": output_directory}, + # download the worst format so it is fast + "ytdl_options": { + "format": "worst[ext=mp4]", + "postprocessor_args": {"ffmpeg": ["-bitexact"]}, # Must add this for reproducibility + }, + } + + +@pytest.fixture +def yt_album_as_chapters_with_regex_preset_dict(yt_album_as_chapters_preset_dict): + mergedeep.merge( + yt_album_as_chapters_preset_dict, + { + "regex": { + "from": { + "chapter_title": { + "match": r"\d+\. (.+)", + "capture_group_names": "captured_chapter_title", + "capture_group_defaults": "{chapter_title}", + }, + "title": { + "match": [ + "(.+) - (.+) [-[\\(\\{].+", + "(.+) - (.+)", + ], + "capture_group_names": [ + "captured_artist", + "captured_album", + ], + "capture_group_defaults": [ + "{artist}", + "{title}", + ], + }, + } + }, + "overrides": { + "custom_track_name": "{captured_chapter_title}", + "custom_album_name": "{captured_album}", + "custom_artist_name": "{captured_artist}", + }, + }, + ) + return yt_album_as_chapters_preset_dict + + +class TestSplitByChapters: + @pytest.mark.parametrize("dry_run", [True, False]) + def test_video_with_chapters( + self, + youtube_audio_config, + yt_album_as_chapters_preset_dict, + output_directory, + dry_run, + ): + subscription = Subscription.from_dict( + config=youtube_audio_config, + preset_name="split_by_chapters_video", + preset_dict=yt_album_as_chapters_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=f"plugins/split_by_chapters_video{'-dry-run' if dry_run else ''}.txt", + ) + assert_expected_downloads( + output_directory=output_directory, + dry_run=dry_run, + expected_download_summary_file_name="plugins/split_by_chapters_video.json", + ) + + @pytest.mark.parametrize("dry_run", [True, False]) + def test_video_with_chapters_and_regex( + self, + youtube_audio_config, + yt_album_as_chapters_with_regex_preset_dict, + output_directory, + dry_run, + ): + subscription = Subscription.from_dict( + config=youtube_audio_config, + preset_name="split_by_chapters_with_regex_video", + preset_dict=yt_album_as_chapters_with_regex_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=f"plugins/split_by_chapters_with_regex_video{'-dry-run' if dry_run else ''}.txt", + ) + assert_expected_downloads( + output_directory=output_directory, + dry_run=dry_run, + expected_download_summary_file_name="plugins/split_by_chapters_with_regex_video.json", + ) + + @pytest.mark.parametrize("dry_run", [True, False]) + @pytest.mark.parametrize("when_no_chapters", ["pass", "drop", "error"]) + def test_video_with_no_chapters_and_regex( + self, + youtube_audio_config, + yt_album_as_chapters_with_regex_preset_dict, + output_directory, + dry_run, + when_no_chapters, + ): + mergedeep.merge( + yt_album_as_chapters_with_regex_preset_dict, + { + "youtube": {"video_url": "https://youtube.com/watch?v=HKTNxEqsN3Q"}, + "split_by_chapters": {"when_no_chapters": when_no_chapters}, + }, + ) + + subscription = Subscription.from_dict( + config=youtube_audio_config, + preset_name="split_by_chapters_with_regex_video", + preset_dict=yt_album_as_chapters_with_regex_preset_dict, + ) + + if when_no_chapters == "error": + with pytest.raises( + ValidationException, + match=re.escape( + "Tried to split 'Oblivion Mod \"Falcor\" p.1' by chapters but it has no chapters" + ), + ): + _ = subscription.download(dry_run=dry_run) + return + + 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=f"plugins/split_by_chapters_with_regex_no_chapters_video_{when_no_chapters}.txt", + ) + assert_expected_downloads( + output_directory=output_directory, + dry_run=dry_run, + expected_download_summary_file_name=f"plugins/split_by_chapters_with_regex_no_chapters_video_{when_no_chapters}.txt", + ) diff --git a/tests/e2e/resources/expected_downloads_summaries/plugins/split_by_chapters_video.json b/tests/e2e/resources/expected_downloads_summaries/plugins/split_by_chapters_video.json new file mode 100644 index 00000000..3c6f04bd --- /dev/null +++ b/tests/e2e/resources/expected_downloads_summaries/plugins/split_by_chapters_video.json @@ -0,0 +1,14 @@ +{ + "Alfa Mist - Nocturne [Full Album]/01 - 01. Intro (Feat. Racheal Ofori & Barney Artist).mp3": "9313382b938547fa9cc02c708068ae42", + "Alfa Mist - Nocturne [Full Album]/02 - 02. Answers (Feat. Rick David & Kaya Thomas - Dyke).mp3": "c38c7186eb52e89246ed8c79be8485e7", + "Alfa Mist - Nocturne [Full Album]/03 - 03. Blaze (Feat. Kaya Thomas - Dyke).mp3": "3d873907e61d4af8e20539961913d486", + "Alfa Mist - Nocturne [Full Album]/04 - 04. What If (Interlude).mp3": "846898842d60b7d1d3b455dfa44821bd", + "Alfa Mist - Nocturne [Full Album]/05 - 05. No Peace (Feat. Tom Misch).mp3": "23e40cccc86e106ea576dc78dc8e6376", + "Alfa Mist - Nocturne [Full Album]/06 - 06. Closer (Feat. Lester Duval).mp3": "cf0fe6eea473f64bbeedc3ee82d91e17", + "Alfa Mist - Nocturne [Full Album]/07 - 07. Delusions: Rumination (Interlude) (Feat. Racheal Ofori).mp3": "d9eae099a8b25f603c0491c9184092da", + "Alfa Mist - Nocturne [Full Album]/08 - 08. Dreams (Feat. Carmody).mp3": "e07de2c95fb2561540048948f1478c25", + "Alfa Mist - Nocturne [Full Album]/09 - 09. Dreaming (Interlude) (Feat. Racheal Ofori).mp3": "218386492a03e912a29dfa1a56fd983c", + "Alfa Mist - Nocturne [Full Album]/10 - 10. Hopeful (Feat. Jordan Rakei).mp3": "0fb815e3648ad55c62328a3621aadd88", + "Alfa Mist - Nocturne [Full Album]/11 - 11. Sunrise (Pillows) (Feat. Emmavie).mp3": "860154a2aa2b0b0b31daca0a481ceb87", + "Alfa Mist - Nocturne [Full Album]/folder.jpg": "13be3c1a9ba600f7cab82a042cffbf72" +} \ No newline at end of file diff --git a/tests/e2e/resources/expected_downloads_summaries/plugins/split_by_chapters_with_regex_no_chapters_video.json b/tests/e2e/resources/expected_downloads_summaries/plugins/split_by_chapters_with_regex_no_chapters_video.json new file mode 100644 index 00000000..1df20954 --- /dev/null +++ b/tests/e2e/resources/expected_downloads_summaries/plugins/split_by_chapters_with_regex_no_chapters_video.json @@ -0,0 +1,4 @@ +{ + "Oblivion Mod \"Falcor\" p.1/01 - Oblivion Mod \"Falcor\" p.1.mp3": "703ffb93964ac025ee66221b98ee4d49", + "Oblivion Mod \"Falcor\" p.1/folder.jpg": "fb95b510681676e81c321171fc23143e" +} \ No newline at end of file diff --git a/tests/e2e/resources/expected_downloads_summaries/plugins/split_by_chapters_with_regex_no_chapters_video_drop.txt b/tests/e2e/resources/expected_downloads_summaries/plugins/split_by_chapters_with_regex_no_chapters_video_drop.txt new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/tests/e2e/resources/expected_downloads_summaries/plugins/split_by_chapters_with_regex_no_chapters_video_drop.txt @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/tests/e2e/resources/expected_downloads_summaries/plugins/split_by_chapters_with_regex_no_chapters_video_pass.txt b/tests/e2e/resources/expected_downloads_summaries/plugins/split_by_chapters_with_regex_no_chapters_video_pass.txt new file mode 100644 index 00000000..1df20954 --- /dev/null +++ b/tests/e2e/resources/expected_downloads_summaries/plugins/split_by_chapters_with_regex_no_chapters_video_pass.txt @@ -0,0 +1,4 @@ +{ + "Oblivion Mod \"Falcor\" p.1/01 - Oblivion Mod \"Falcor\" p.1.mp3": "703ffb93964ac025ee66221b98ee4d49", + "Oblivion Mod \"Falcor\" p.1/folder.jpg": "fb95b510681676e81c321171fc23143e" +} \ No newline at end of file diff --git a/tests/e2e/resources/expected_downloads_summaries/plugins/split_by_chapters_with_regex_video.json b/tests/e2e/resources/expected_downloads_summaries/plugins/split_by_chapters_with_regex_video.json new file mode 100644 index 00000000..575fed16 --- /dev/null +++ b/tests/e2e/resources/expected_downloads_summaries/plugins/split_by_chapters_with_regex_video.json @@ -0,0 +1,14 @@ +{ + "Nocturne/01 - Intro (Feat. Racheal Ofori & Barney Artist).mp3": "75e53ffa551ab9aebe5d25a7d7bc6757", + "Nocturne/02 - Answers (Feat. Rick David & Kaya Thomas - Dyke).mp3": "76f5e0a03e0984ed959809721ad2ae4a", + "Nocturne/03 - Blaze (Feat. Kaya Thomas - Dyke).mp3": "bd6495ad5b46ee7e46215e5687ac7e7a", + "Nocturne/04 - What If (Interlude).mp3": "928a7668b9e0d653cba66cb4f9e3474a", + "Nocturne/05 - No Peace (Feat. Tom Misch).mp3": "563d0416c1c9b1eeedb3bd6fdc9f648a", + "Nocturne/06 - Closer (Feat. Lester Duval).mp3": "eb36390ef7064ee0145b4b42e6745a1e", + "Nocturne/07 - Delusions: Rumination (Interlude) (Feat. Racheal Ofori).mp3": "9cba05a39738aadf2cd36d7941484a02", + "Nocturne/08 - Dreams (Feat. Carmody).mp3": "6833e149f8ec0c98dbd0c3b3caf9362c", + "Nocturne/09 - Dreaming (Interlude) (Feat. Racheal Ofori).mp3": "2bce7f06baadb6c9cc9742e2a0288c6b", + "Nocturne/10 - Hopeful (Feat. Jordan Rakei).mp3": "2e359012ffa86e231ec6694508da1455", + "Nocturne/11 - Sunrise (Pillows) (Feat. Emmavie).mp3": "dded786c10aade8bb0821507225b9bd2", + "Nocturne/folder.jpg": "13be3c1a9ba600f7cab82a042cffbf72" +} \ No newline at end of file diff --git a/tests/e2e/resources/expected_downloads_summaries/plugins/test_audio_extract_single.json b/tests/e2e/resources/expected_downloads_summaries/plugins/test_audio_extract_single.json index 4ac1b8be..f6a639ae 100644 --- a/tests/e2e/resources/expected_downloads_summaries/plugins/test_audio_extract_single.json +++ b/tests/e2e/resources/expected_downloads_summaries/plugins/test_audio_extract_single.json @@ -1,3 +1,3 @@ { - "YouTube Rewind 2019: For the Record | #YouTubeRewind.mp3": "3a156b122bd79c956cce5079d3530cc3" + "YouTube Rewind 2019: For the Record | #YouTubeRewind.mp3": "3a156b122bd79c956cce5079d3530cc3" } \ No newline at end of file diff --git a/tests/e2e/resources/transaction_log_summaries/plugins/split_by_chapters_video-dry-run.txt b/tests/e2e/resources/transaction_log_summaries/plugins/split_by_chapters_video-dry-run.txt new file mode 100644 index 00000000..e8ad7dbb --- /dev/null +++ b/tests/e2e/resources/transaction_log_summaries/plugins/split_by_chapters_video-dry-run.txt @@ -0,0 +1,146 @@ +Files created in '{output_directory}' +---------------------------------------- +Alfa Mist - Nocturne [Full Album]/01 - 01. Intro (Feat. Racheal Ofori & Barney Artist).mp3 + From Chapter Split: + Warning: Dry-run assumes embedded chapters with no modifications + Source Title: Alfa Mist - Nocturne [Full Album] + Segment: 0:00 - 2:06 + Music Tags: + album: Alfa Mist - Nocturne [Full Album] + albumartist: Proved Records + artist: Proved Records + genre: Unset + title: 01. Intro (Feat. Racheal Ofori & Barney Artist) + track: 1 + year: 2017 +Alfa Mist - Nocturne [Full Album]/02 - 02. Answers (Feat. Rick David & Kaya Thomas - Dyke).mp3 + From Chapter Split: + Warning: Dry-run assumes embedded chapters with no modifications + Source Title: Alfa Mist - Nocturne [Full Album] + Segment: 2:06 - 5:40 + Music Tags: + album: Alfa Mist - Nocturne [Full Album] + albumartist: Proved Records + artist: Proved Records + genre: Unset + title: 02. Answers (Feat. Rick David & Kaya Thomas - Dyke) + track: 2 + year: 2017 +Alfa Mist - Nocturne [Full Album]/03 - 03. Blaze (Feat. Kaya Thomas - Dyke).mp3 + From Chapter Split: + Warning: Dry-run assumes embedded chapters with no modifications + Source Title: Alfa Mist - Nocturne [Full Album] + Segment: 5:40 - 8:11 + Music Tags: + album: Alfa Mist - Nocturne [Full Album] + albumartist: Proved Records + artist: Proved Records + genre: Unset + title: 03. Blaze (Feat. Kaya Thomas - Dyke) + track: 3 + year: 2017 +Alfa Mist - Nocturne [Full Album]/04 - 04. What If (Interlude).mp3 + From Chapter Split: + Warning: Dry-run assumes embedded chapters with no modifications + Source Title: Alfa Mist - Nocturne [Full Album] + Segment: 8:11 - 9:35 + Music Tags: + album: Alfa Mist - Nocturne [Full Album] + albumartist: Proved Records + artist: Proved Records + genre: Unset + title: 04. What If (Interlude) + track: 4 + year: 2017 +Alfa Mist - Nocturne [Full Album]/05 - 05. No Peace (Feat. Tom Misch).mp3 + From Chapter Split: + Warning: Dry-run assumes embedded chapters with no modifications + Source Title: Alfa Mist - Nocturne [Full Album] + Segment: 9:35 - 13:19 + Music Tags: + album: Alfa Mist - Nocturne [Full Album] + albumartist: Proved Records + artist: Proved Records + genre: Unset + title: 05. No Peace (Feat. Tom Misch) + track: 5 + year: 2017 +Alfa Mist - Nocturne [Full Album]/06 - 06. Closer (Feat. Lester Duval).mp3 + From Chapter Split: + Warning: Dry-run assumes embedded chapters with no modifications + Source Title: Alfa Mist - Nocturne [Full Album] + Segment: 13:19 - 17:57 + Music Tags: + album: Alfa Mist - Nocturne [Full Album] + albumartist: Proved Records + artist: Proved Records + genre: Unset + title: 06. Closer (Feat. Lester Duval) + track: 6 + year: 2017 +Alfa Mist - Nocturne [Full Album]/07 - 07. Delusions: Rumination (Interlude) (Feat. Racheal Ofori).mp3 + From Chapter Split: + Warning: Dry-run assumes embedded chapters with no modifications + Source Title: Alfa Mist - Nocturne [Full Album] + Segment: 17:57 - 20:05 + Music Tags: + album: Alfa Mist - Nocturne [Full Album] + albumartist: Proved Records + artist: Proved Records + genre: Unset + title: 07. Delusions: Rumination (Interlude) (Feat. Racheal Ofori) + track: 7 + year: 2017 +Alfa Mist - Nocturne [Full Album]/08 - 08. Dreams (Feat. Carmody).mp3 + From Chapter Split: + Warning: Dry-run assumes embedded chapters with no modifications + Source Title: Alfa Mist - Nocturne [Full Album] + Segment: 20:05 - 23:58 + Music Tags: + album: Alfa Mist - Nocturne [Full Album] + albumartist: Proved Records + artist: Proved Records + genre: Unset + title: 08. Dreams (Feat. Carmody) + track: 8 + year: 2017 +Alfa Mist - Nocturne [Full Album]/09 - 09. Dreaming (Interlude) (Feat. Racheal Ofori).mp3 + From Chapter Split: + Warning: Dry-run assumes embedded chapters with no modifications + Source Title: Alfa Mist - Nocturne [Full Album] + Segment: 23:58 - 24:45 + Music Tags: + album: Alfa Mist - Nocturne [Full Album] + albumartist: Proved Records + artist: Proved Records + genre: Unset + title: 09. Dreaming (Interlude) (Feat. Racheal Ofori) + track: 9 + year: 2017 +Alfa Mist - Nocturne [Full Album]/10 - 10. Hopeful (Feat. Jordan Rakei).mp3 + From Chapter Split: + Warning: Dry-run assumes embedded chapters with no modifications + Source Title: Alfa Mist - Nocturne [Full Album] + Segment: 24:45 - 28:53 + Music Tags: + album: Alfa Mist - Nocturne [Full Album] + albumartist: Proved Records + artist: Proved Records + genre: Unset + title: 10. Hopeful (Feat. Jordan Rakei) + track: 10 + year: 2017 +Alfa Mist - Nocturne [Full Album]/11 - 11. Sunrise (Pillows) (Feat. Emmavie).mp3 + From Chapter Split: + Warning: Dry-run assumes embedded chapters with no modifications + Source Title: Alfa Mist - Nocturne [Full Album] + Segment: 28:53 - 31:43 + Music Tags: + album: Alfa Mist - Nocturne [Full Album] + albumartist: Proved Records + artist: Proved Records + genre: Unset + title: 11. Sunrise (Pillows) (Feat. Emmavie) + track: 11 + year: 2017 +Alfa Mist - Nocturne [Full Album]/folder.jpg \ No newline at end of file diff --git a/tests/e2e/resources/transaction_log_summaries/plugins/split_by_chapters_video.txt b/tests/e2e/resources/transaction_log_summaries/plugins/split_by_chapters_video.txt new file mode 100644 index 00000000..80c73b91 --- /dev/null +++ b/tests/e2e/resources/transaction_log_summaries/plugins/split_by_chapters_video.txt @@ -0,0 +1,135 @@ +Files created in '{output_directory}' +---------------------------------------- +Alfa Mist - Nocturne [Full Album]/01 - 01. Intro (Feat. Racheal Ofori & Barney Artist).mp3 + From Chapter Split: + Source Title: Alfa Mist - Nocturne [Full Album] + Segment: 0:00 - 2:06 + Music Tags: + album: Alfa Mist - Nocturne [Full Album] + albumartist: Proved Records + artist: Proved Records + genre: Unset + title: 01. Intro (Feat. Racheal Ofori & Barney Artist) + track: 1 + year: 2017 +Alfa Mist - Nocturne [Full Album]/02 - 02. Answers (Feat. Rick David & Kaya Thomas - Dyke).mp3 + From Chapter Split: + Source Title: Alfa Mist - Nocturne [Full Album] + Segment: 2:06 - 5:40 + Music Tags: + album: Alfa Mist - Nocturne [Full Album] + albumartist: Proved Records + artist: Proved Records + genre: Unset + title: 02. Answers (Feat. Rick David & Kaya Thomas - Dyke) + track: 2 + year: 2017 +Alfa Mist - Nocturne [Full Album]/03 - 03. Blaze (Feat. Kaya Thomas - Dyke).mp3 + From Chapter Split: + Source Title: Alfa Mist - Nocturne [Full Album] + Segment: 5:40 - 8:11 + Music Tags: + album: Alfa Mist - Nocturne [Full Album] + albumartist: Proved Records + artist: Proved Records + genre: Unset + title: 03. Blaze (Feat. Kaya Thomas - Dyke) + track: 3 + year: 2017 +Alfa Mist - Nocturne [Full Album]/04 - 04. What If (Interlude).mp3 + From Chapter Split: + Source Title: Alfa Mist - Nocturne [Full Album] + Segment: 8:11 - 9:35 + Music Tags: + album: Alfa Mist - Nocturne [Full Album] + albumartist: Proved Records + artist: Proved Records + genre: Unset + title: 04. What If (Interlude) + track: 4 + year: 2017 +Alfa Mist - Nocturne [Full Album]/05 - 05. No Peace (Feat. Tom Misch).mp3 + From Chapter Split: + Source Title: Alfa Mist - Nocturne [Full Album] + Segment: 9:35 - 13:19 + Music Tags: + album: Alfa Mist - Nocturne [Full Album] + albumartist: Proved Records + artist: Proved Records + genre: Unset + title: 05. No Peace (Feat. Tom Misch) + track: 5 + year: 2017 +Alfa Mist - Nocturne [Full Album]/06 - 06. Closer (Feat. Lester Duval).mp3 + From Chapter Split: + Source Title: Alfa Mist - Nocturne [Full Album] + Segment: 13:19 - 17:57 + Music Tags: + album: Alfa Mist - Nocturne [Full Album] + albumartist: Proved Records + artist: Proved Records + genre: Unset + title: 06. Closer (Feat. Lester Duval) + track: 6 + year: 2017 +Alfa Mist - Nocturne [Full Album]/07 - 07. Delusions: Rumination (Interlude) (Feat. Racheal Ofori).mp3 + From Chapter Split: + Source Title: Alfa Mist - Nocturne [Full Album] + Segment: 17:57 - 20:05 + Music Tags: + album: Alfa Mist - Nocturne [Full Album] + albumartist: Proved Records + artist: Proved Records + genre: Unset + title: 07. Delusions: Rumination (Interlude) (Feat. Racheal Ofori) + track: 7 + year: 2017 +Alfa Mist - Nocturne [Full Album]/08 - 08. Dreams (Feat. Carmody).mp3 + From Chapter Split: + Source Title: Alfa Mist - Nocturne [Full Album] + Segment: 20:05 - 23:58 + Music Tags: + album: Alfa Mist - Nocturne [Full Album] + albumartist: Proved Records + artist: Proved Records + genre: Unset + title: 08. Dreams (Feat. Carmody) + track: 8 + year: 2017 +Alfa Mist - Nocturne [Full Album]/09 - 09. Dreaming (Interlude) (Feat. Racheal Ofori).mp3 + From Chapter Split: + Source Title: Alfa Mist - Nocturne [Full Album] + Segment: 23:58 - 24:45 + Music Tags: + album: Alfa Mist - Nocturne [Full Album] + albumartist: Proved Records + artist: Proved Records + genre: Unset + title: 09. Dreaming (Interlude) (Feat. Racheal Ofori) + track: 9 + year: 2017 +Alfa Mist - Nocturne [Full Album]/10 - 10. Hopeful (Feat. Jordan Rakei).mp3 + From Chapter Split: + Source Title: Alfa Mist - Nocturne [Full Album] + Segment: 24:45 - 28:53 + Music Tags: + album: Alfa Mist - Nocturne [Full Album] + albumartist: Proved Records + artist: Proved Records + genre: Unset + title: 10. Hopeful (Feat. Jordan Rakei) + track: 10 + year: 2017 +Alfa Mist - Nocturne [Full Album]/11 - 11. Sunrise (Pillows) (Feat. Emmavie).mp3 + From Chapter Split: + Source Title: Alfa Mist - Nocturne [Full Album] + Segment: 28:53 - 31:43 + Music Tags: + album: Alfa Mist - Nocturne [Full Album] + albumartist: Proved Records + artist: Proved Records + genre: Unset + title: 11. Sunrise (Pillows) (Feat. Emmavie) + track: 11 + year: 2017 +Alfa Mist - Nocturne [Full Album]/folder.jpg \ No newline at end of file diff --git a/tests/e2e/resources/transaction_log_summaries/plugins/split_by_chapters_with_regex_no_chapters_video_drop.txt b/tests/e2e/resources/transaction_log_summaries/plugins/split_by_chapters_with_regex_no_chapters_video_drop.txt new file mode 100644 index 00000000..e69de29b diff --git a/tests/e2e/resources/transaction_log_summaries/plugins/split_by_chapters_with_regex_no_chapters_video_pass.txt b/tests/e2e/resources/transaction_log_summaries/plugins/split_by_chapters_with_regex_no_chapters_video_pass.txt new file mode 100644 index 00000000..3c36afab --- /dev/null +++ b/tests/e2e/resources/transaction_log_summaries/plugins/split_by_chapters_with_regex_no_chapters_video_pass.txt @@ -0,0 +1,12 @@ +Files created in '{output_directory}' +---------------------------------------- +Oblivion Mod "Falcor" p.1/01 - Oblivion Mod "Falcor" p.1.mp3 + Music Tags: + album: Oblivion Mod "Falcor" p.1 + albumartist: Project Zombie + artist: Project Zombie + genre: Unset + title: Oblivion Mod "Falcor" p.1 + track: 1 + year: 2010 +Oblivion Mod "Falcor" p.1/folder.jpg \ No newline at end of file diff --git a/tests/e2e/resources/transaction_log_summaries/plugins/split_by_chapters_with_regex_video-dry-run.txt b/tests/e2e/resources/transaction_log_summaries/plugins/split_by_chapters_with_regex_video-dry-run.txt new file mode 100644 index 00000000..d26a5384 --- /dev/null +++ b/tests/e2e/resources/transaction_log_summaries/plugins/split_by_chapters_with_regex_video-dry-run.txt @@ -0,0 +1,146 @@ +Files created in '{output_directory}' +---------------------------------------- +Nocturne/01 - Intro (Feat. Racheal Ofori & Barney Artist).mp3 + From Chapter Split: + Warning: Dry-run assumes embedded chapters with no modifications + Source Title: Alfa Mist - Nocturne [Full Album] + Segment: 0:00 - 2:06 + Music Tags: + album: Nocturne + albumartist: Alfa Mist + artist: Alfa Mist + genre: Unset + title: Intro (Feat. Racheal Ofori & Barney Artist) + track: 1 + year: 2017 +Nocturne/02 - Answers (Feat. Rick David & Kaya Thomas - Dyke).mp3 + From Chapter Split: + Warning: Dry-run assumes embedded chapters with no modifications + Source Title: Alfa Mist - Nocturne [Full Album] + Segment: 2:06 - 5:40 + Music Tags: + album: Nocturne + albumartist: Alfa Mist + artist: Alfa Mist + genre: Unset + title: Answers (Feat. Rick David & Kaya Thomas - Dyke) + track: 2 + year: 2017 +Nocturne/03 - Blaze (Feat. Kaya Thomas - Dyke).mp3 + From Chapter Split: + Warning: Dry-run assumes embedded chapters with no modifications + Source Title: Alfa Mist - Nocturne [Full Album] + Segment: 5:40 - 8:11 + Music Tags: + album: Nocturne + albumartist: Alfa Mist + artist: Alfa Mist + genre: Unset + title: Blaze (Feat. Kaya Thomas - Dyke) + track: 3 + year: 2017 +Nocturne/04 - What If (Interlude).mp3 + From Chapter Split: + Warning: Dry-run assumes embedded chapters with no modifications + Source Title: Alfa Mist - Nocturne [Full Album] + Segment: 8:11 - 9:35 + Music Tags: + album: Nocturne + albumartist: Alfa Mist + artist: Alfa Mist + genre: Unset + title: What If (Interlude) + track: 4 + year: 2017 +Nocturne/05 - No Peace (Feat. Tom Misch).mp3 + From Chapter Split: + Warning: Dry-run assumes embedded chapters with no modifications + Source Title: Alfa Mist - Nocturne [Full Album] + Segment: 9:35 - 13:19 + Music Tags: + album: Nocturne + albumartist: Alfa Mist + artist: Alfa Mist + genre: Unset + title: No Peace (Feat. Tom Misch) + track: 5 + year: 2017 +Nocturne/06 - Closer (Feat. Lester Duval).mp3 + From Chapter Split: + Warning: Dry-run assumes embedded chapters with no modifications + Source Title: Alfa Mist - Nocturne [Full Album] + Segment: 13:19 - 17:57 + Music Tags: + album: Nocturne + albumartist: Alfa Mist + artist: Alfa Mist + genre: Unset + title: Closer (Feat. Lester Duval) + track: 6 + year: 2017 +Nocturne/07 - Delusions: Rumination (Interlude) (Feat. Racheal Ofori).mp3 + From Chapter Split: + Warning: Dry-run assumes embedded chapters with no modifications + Source Title: Alfa Mist - Nocturne [Full Album] + Segment: 17:57 - 20:05 + Music Tags: + album: Nocturne + albumartist: Alfa Mist + artist: Alfa Mist + genre: Unset + title: Delusions: Rumination (Interlude) (Feat. Racheal Ofori) + track: 7 + year: 2017 +Nocturne/08 - Dreams (Feat. Carmody).mp3 + From Chapter Split: + Warning: Dry-run assumes embedded chapters with no modifications + Source Title: Alfa Mist - Nocturne [Full Album] + Segment: 20:05 - 23:58 + Music Tags: + album: Nocturne + albumartist: Alfa Mist + artist: Alfa Mist + genre: Unset + title: Dreams (Feat. Carmody) + track: 8 + year: 2017 +Nocturne/09 - Dreaming (Interlude) (Feat. Racheal Ofori).mp3 + From Chapter Split: + Warning: Dry-run assumes embedded chapters with no modifications + Source Title: Alfa Mist - Nocturne [Full Album] + Segment: 23:58 - 24:45 + Music Tags: + album: Nocturne + albumartist: Alfa Mist + artist: Alfa Mist + genre: Unset + title: Dreaming (Interlude) (Feat. Racheal Ofori) + track: 9 + year: 2017 +Nocturne/10 - Hopeful (Feat. Jordan Rakei).mp3 + From Chapter Split: + Warning: Dry-run assumes embedded chapters with no modifications + Source Title: Alfa Mist - Nocturne [Full Album] + Segment: 24:45 - 28:53 + Music Tags: + album: Nocturne + albumartist: Alfa Mist + artist: Alfa Mist + genre: Unset + title: Hopeful (Feat. Jordan Rakei) + track: 10 + year: 2017 +Nocturne/11 - Sunrise (Pillows) (Feat. Emmavie).mp3 + From Chapter Split: + Warning: Dry-run assumes embedded chapters with no modifications + Source Title: Alfa Mist - Nocturne [Full Album] + Segment: 28:53 - 31:43 + Music Tags: + album: Nocturne + albumartist: Alfa Mist + artist: Alfa Mist + genre: Unset + title: Sunrise (Pillows) (Feat. Emmavie) + track: 11 + year: 2017 +Nocturne/folder.jpg \ No newline at end of file diff --git a/tests/e2e/resources/transaction_log_summaries/plugins/split_by_chapters_with_regex_video.txt b/tests/e2e/resources/transaction_log_summaries/plugins/split_by_chapters_with_regex_video.txt new file mode 100644 index 00000000..460cabb3 --- /dev/null +++ b/tests/e2e/resources/transaction_log_summaries/plugins/split_by_chapters_with_regex_video.txt @@ -0,0 +1,135 @@ +Files created in '{output_directory}' +---------------------------------------- +Nocturne/01 - Intro (Feat. Racheal Ofori & Barney Artist).mp3 + From Chapter Split: + Source Title: Alfa Mist - Nocturne [Full Album] + Segment: 0:00 - 2:06 + Music Tags: + album: Nocturne + albumartist: Alfa Mist + artist: Alfa Mist + genre: Unset + title: Intro (Feat. Racheal Ofori & Barney Artist) + track: 1 + year: 2017 +Nocturne/02 - Answers (Feat. Rick David & Kaya Thomas - Dyke).mp3 + From Chapter Split: + Source Title: Alfa Mist - Nocturne [Full Album] + Segment: 2:06 - 5:40 + Music Tags: + album: Nocturne + albumartist: Alfa Mist + artist: Alfa Mist + genre: Unset + title: Answers (Feat. Rick David & Kaya Thomas - Dyke) + track: 2 + year: 2017 +Nocturne/03 - Blaze (Feat. Kaya Thomas - Dyke).mp3 + From Chapter Split: + Source Title: Alfa Mist - Nocturne [Full Album] + Segment: 5:40 - 8:11 + Music Tags: + album: Nocturne + albumartist: Alfa Mist + artist: Alfa Mist + genre: Unset + title: Blaze (Feat. Kaya Thomas - Dyke) + track: 3 + year: 2017 +Nocturne/04 - What If (Interlude).mp3 + From Chapter Split: + Source Title: Alfa Mist - Nocturne [Full Album] + Segment: 8:11 - 9:35 + Music Tags: + album: Nocturne + albumartist: Alfa Mist + artist: Alfa Mist + genre: Unset + title: What If (Interlude) + track: 4 + year: 2017 +Nocturne/05 - No Peace (Feat. Tom Misch).mp3 + From Chapter Split: + Source Title: Alfa Mist - Nocturne [Full Album] + Segment: 9:35 - 13:19 + Music Tags: + album: Nocturne + albumartist: Alfa Mist + artist: Alfa Mist + genre: Unset + title: No Peace (Feat. Tom Misch) + track: 5 + year: 2017 +Nocturne/06 - Closer (Feat. Lester Duval).mp3 + From Chapter Split: + Source Title: Alfa Mist - Nocturne [Full Album] + Segment: 13:19 - 17:57 + Music Tags: + album: Nocturne + albumartist: Alfa Mist + artist: Alfa Mist + genre: Unset + title: Closer (Feat. Lester Duval) + track: 6 + year: 2017 +Nocturne/07 - Delusions: Rumination (Interlude) (Feat. Racheal Ofori).mp3 + From Chapter Split: + Source Title: Alfa Mist - Nocturne [Full Album] + Segment: 17:57 - 20:05 + Music Tags: + album: Nocturne + albumartist: Alfa Mist + artist: Alfa Mist + genre: Unset + title: Delusions: Rumination (Interlude) (Feat. Racheal Ofori) + track: 7 + year: 2017 +Nocturne/08 - Dreams (Feat. Carmody).mp3 + From Chapter Split: + Source Title: Alfa Mist - Nocturne [Full Album] + Segment: 20:05 - 23:58 + Music Tags: + album: Nocturne + albumartist: Alfa Mist + artist: Alfa Mist + genre: Unset + title: Dreams (Feat. Carmody) + track: 8 + year: 2017 +Nocturne/09 - Dreaming (Interlude) (Feat. Racheal Ofori).mp3 + From Chapter Split: + Source Title: Alfa Mist - Nocturne [Full Album] + Segment: 23:58 - 24:45 + Music Tags: + album: Nocturne + albumartist: Alfa Mist + artist: Alfa Mist + genre: Unset + title: Dreaming (Interlude) (Feat. Racheal Ofori) + track: 9 + year: 2017 +Nocturne/10 - Hopeful (Feat. Jordan Rakei).mp3 + From Chapter Split: + Source Title: Alfa Mist - Nocturne [Full Album] + Segment: 24:45 - 28:53 + Music Tags: + album: Nocturne + albumartist: Alfa Mist + artist: Alfa Mist + genre: Unset + title: Hopeful (Feat. Jordan Rakei) + track: 10 + year: 2017 +Nocturne/11 - Sunrise (Pillows) (Feat. Emmavie).mp3 + From Chapter Split: + Source Title: Alfa Mist - Nocturne [Full Album] + Segment: 28:53 - 31:43 + Music Tags: + album: Nocturne + albumartist: Alfa Mist + artist: Alfa Mist + genre: Unset + title: Sunrise (Pillows) (Feat. Emmavie) + track: 11 + year: 2017 +Nocturne/folder.jpg \ No newline at end of file diff --git a/tests/e2e/resources/transaction_log_summaries/plugins/test_audio_extract_single.txt b/tests/e2e/resources/transaction_log_summaries/plugins/test_audio_extract_single.txt index 2ee2d4a4..3046395d 100644 --- a/tests/e2e/resources/transaction_log_summaries/plugins/test_audio_extract_single.txt +++ b/tests/e2e/resources/transaction_log_summaries/plugins/test_audio_extract_single.txt @@ -1,6 +1,6 @@ Files created in '{output_directory}' ---------------------------------------- -YouTube Rewind 2019: For the Record | #YouTubeRewind.mp3 +YouTube Rewind 2019: For the Record | #YouTubeRewind.mp3 Music Tags: album: Singles albumartist: YouTube