diff --git a/src/ytdl_sub/downloaders/youtube/split_video.py b/src/ytdl_sub/downloaders/youtube/split_video.py index a7059c74..7a0f6512 100644 --- a/src/ytdl_sub/downloaders/youtube/split_video.py +++ b/src/ytdl_sub/downloaders/youtube/split_video.py @@ -31,19 +31,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 +145,7 @@ 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..27b91061 100644 --- a/src/ytdl_sub/downloaders/youtube/video.py +++ b/src/ytdl_sub/downloaders/youtube/video.py @@ -103,7 +103,7 @@ 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/split_by_chapters.py b/src/ytdl_sub/plugins/split_by_chapters.py index 6fdc8d3c..ee913833 100644 --- a/src/ytdl_sub/plugins/split_by_chapters.py +++ b/src/ytdl_sub/plugins/split_by_chapters.py @@ -1,10 +1,32 @@ -from typing import Optional +import copy +from pathlib import Path +from typing import Optional, List 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, Timestamp +from ytdl_sub.utils.ffmpeg import FFMPEG +from ytdl_sub.utils.file_handler import FileHandler +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 = "when no chapters option" @@ -46,8 +68,77 @@ class SplitByChaptersOptions(PluginOptions): class SplitByChaptersPlugin(Plugin[SplitByChaptersOptions]): plugin_options_type = SplitByChaptersOptions + def _create_split_entry( + self, source_entry: Entry, title: str, idx: int, chapters: Chapters + ) -> Entry: + """ + Runs ffmpeg to create the split video + """ + entry_dict = copy.deepcopy(source_entry) + entry_dict["title"] = title + entry_dict["playlist_index"] = idx + 1 + entry_dict["playlist_count"] = len(chapters.timestamps) + entry_dict["id"] = _split_video_uid(source_uid=entry_dict["id"], idx=idx) + + # Remove track and artist since its now split + if "track" in entry_dict: + del entry_dict["track"] + if "artist" in entry_dict: + del entry_dict["artist"] + + timestamp_begin = chapters.timestamps[idx].readable_str + timestamp_end = Timestamp(source_entry_dict["duration"]).readable_str + if idx + 1 < len(chapters.timestamps): + timestamp_end = chapters.timestamps[idx + 1].readable_str + + metadata = FileMetadata(metadata=f"{timestamp_begin} - {timestamp_end}") + return ( + YoutubePlaylistVideo(entry_dict=entry_dict, working_directory=self.working_directory), + metadata, + ) + def modify_entry(self, entry: Entry) -> Optional[Entry]: """ Tags the entry's audio file using values defined in the metadata options """ - return entry + split_entries: List[Entry] = [] + chapters = Chapters.from_embedded_chapters(file_path=entry.get_download_file_path()) + + # convert the entry thumbnail early so we do not have to guess the thumbnail extension + # when copying it + if not self.is_dry_run: + convert_download_thumbnail(entry=entry) + + 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 + 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_video_entry( + source_entry_dict=entry_dict, title=title, idx=idx, chapters=chapters + ) + ) + + return split_videos_and_metadata diff --git a/src/ytdl_sub/utils/chapters.py b/src/ytdl_sub/utils/chapters.py index aedc614a..53b49700 100644 --- a/src/ytdl_sub/utils/chapters.py +++ b/src/ytdl_sub/utils/chapters.py @@ -1,10 +1,14 @@ +import json import os import re +import subprocess +from io import BytesIO from typing import List from typing import Optional from typing import Tuple from ytdl_sub.utils.exceptions import ValidationException +from ytdl_sub.utils.ffmpeg import FFMPEG from ytdl_sub.utils.file_handler import FileMetadata @@ -165,7 +169,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 +211,20 @@ class Chapters: titles.append(title) return cls(timestamps=timestamps, titles=titles) + + @classmethod + def from_embedded_chapters(cls, file_path: str) -> "Chapters": + with BytesIO() as bytes_io: + subprocess.run([ + "-loglevel", "quiet", "-print_format", "json", "-show_chapters", "--", file_path + ], check=True, stdout=bytes_io) + + embedded_chapters = json.load(bytes_io) + + timestamps: List[Timestamp] = [] + titles: List[str] = [] + for chapter in embedded_chapters['chapters']: + timestamps.append(Timestamp.from_seconds(int(chapter['start_time']))) + titles.append(chapter['tags']['title']) + + return Chapters(timestamps=timestamps, titles=titles) diff --git a/tests/e2e/plugins/test_chapters.py b/tests/e2e/plugins/test_chapters.py index 5e122b03..ff444ab8 100644 --- a/tests/e2e/plugins/test_chapters.py +++ b/tests/e2e/plugins/test_chapters.py @@ -17,6 +17,10 @@ def single_video_sponsorblock_and_embedded_subs_preset_dict(output_directory): "languages": ["en", "de"], "allow_auto_generated_subtitles": True, }, + "audio_extract": { + "codec": "mp3", + "quality": 128, + }, "chapters": { "sponsorblock_categories": [ "outro", diff --git a/tests/e2e/resources/transaction_log_summaries/plugins/test_chapters_sb_and_embedded_subs.txt b/tests/e2e/resources/transaction_log_summaries/plugins/test_chapters_sb_and_embedded_subs.txt index 127e6543..ac4d145b 100644 --- a/tests/e2e/resources/transaction_log_summaries/plugins/test_chapters_sb_and_embedded_subs.txt +++ b/tests/e2e/resources/transaction_log_summaries/plugins/test_chapters_sb_and_embedded_subs.txt @@ -1,14 +1,9 @@ Files created in '{output_directory}' ---------------------------------------- JMC - This GPU SLIDES into this Case! - Silverstone SUGO 16 ITX Case-thumb.jpg -JMC - This GPU SLIDES into this Case! - Silverstone SUGO 16 ITX Case.mp4 +JMC - This GPU SLIDES into this Case! - Silverstone SUGO 16 ITX Case.mp3 Embedded Chapters Removed Chapter(s): Intro, Outro - Removed SponsorBlock Category Count(s): - Sponsor: 2 - Endcards/Credits: 1 - Intermission/Intro Animation: 1 - Unpaid/Self Promotion: 1 Embedded subtitles with lang(s) en, de JMC - This GPU SLIDES into this Case! - Silverstone SUGO 16 ITX Case.nfo NFO tags: