diff --git a/src/ytdl_sub/downloaders/youtube/video.py b/src/ytdl_sub/downloaders/youtube/video.py index ac7cab61..b63db71d 100644 --- a/src/ytdl_sub/downloaders/youtube/video.py +++ b/src/ytdl_sub/downloaders/youtube/video.py @@ -1,10 +1,16 @@ from typing import Dict from typing import List +from typing import Optional +from typing import Tuple 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.file_handler import FileMetadata from ytdl_sub.validators.url_validator import YoutubeVideoUrlValidator +from ytdl_sub.validators.validators import StringValidator class YoutubeVideoDownloaderOptions(YoutubeDownloaderOptions): @@ -22,20 +28,25 @@ class YoutubeVideoDownloaderOptions(YoutubeDownloaderOptions): # required download_strategy: "video" video_url: "youtube.com/watch?v=VMAPTo7RVDo" + # optional + chapter_timestamps: path/to/timestamps.txt CLI usage: .. code-block:: bash ytdl-sub dl --preset "example_preset" --youtube.video_url "youtube.com/watch?v=VMAPTo7RVDo" - """ _required_keys = {"video_url"} + _optional_keys = {"chapter_timestamps"} def __init__(self, name, value): super().__init__(name, value) self._video_url = self._validate_key("video_url", YoutubeVideoUrlValidator).video_url + self._chapter_timestamps = self._validate_key_if_present( + "chapter_timestamps", StringValidator + ) @property def video_url(self) -> str: @@ -44,6 +55,24 @@ class YoutubeVideoDownloaderOptions(YoutubeDownloaderOptions): """ return self._video_url + @property + def chapter_timestamps(self) -> Optional[str]: + """ + Optional. The path to the file containing the timestamps to embed into the video as + chapters. Should be formatted as: + + .. code-block:: markdown + + 0:00 Intro + 0:24 Blackwater Park + 10:23 Bleak + 16:39 Jokes + 1:02:23 Ending + """ + if self._chapter_timestamps: + return self._chapter_timestamps.value + return None + class YoutubeVideoDownloader(YoutubeDownloader[YoutubeVideoDownloaderOptions, YoutubeVideo]): downloader_options_type = YoutubeVideoDownloaderOptions @@ -64,7 +93,24 @@ class YoutubeVideoDownloader(YoutubeDownloader[YoutubeVideoDownloaderOptions, Yo **{"break_on_existing": True}, ) - def download(self) -> List[YoutubeVideo]: + def download(self) -> List[YoutubeVideo] | List[Tuple[YoutubeVideo, FileMetadata]]: """Download a single Youtube video""" entry_dict = self.extract_info(url=self.download_options.video_url) - return [YoutubeVideo(entry_dict=entry_dict, working_directory=self.working_directory)] + video = YoutubeVideo(entry_dict=entry_dict, working_directory=self.working_directory) + + # If no chapters, just return the video + if not self.download_options.chapter_timestamps: + return [video] + + # 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( + file_path=video.get_download_file_path(), + chapters=chapters, + file_duration_sec=video.kwargs("duration"), + ) + + file_metadata = chapters.to_file_metadata(title="Chapters embedded into the video:") + + return [(video, file_metadata)] diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 951602c7..29842c40 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -24,3 +24,20 @@ def channel_as_tv_show_config(): @pytest.fixture def soundcloud_discography_config(): return ConfigFile.from_file_path(config_path="examples/soundcloud_discography_config.yaml") + + +@pytest.fixture +def timestamps_file_path(): + timestamps = [ + "0:00 Intro\n", + "00:10 Part 1\n", + "0:20 Part 2\n", + "00:30 Part 3\n", + "0:00:40 Part 4\n", + "00:01:01 Part 5\n", + ] + + with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", suffix=".txt") as tmp: + tmp.writelines(timestamps) + tmp.seek(0) + yield tmp.name diff --git a/tests/e2e/resources/transaction_log_summaries/youtube/test_video_with_chapter_timestamps.txt b/tests/e2e/resources/transaction_log_summaries/youtube/test_video_with_chapter_timestamps.txt new file mode 100644 index 00000000..71f09d6b --- /dev/null +++ b/tests/e2e/resources/transaction_log_summaries/youtube/test_video_with_chapter_timestamps.txt @@ -0,0 +1,18 @@ +Files created in '{output_directory}' +---------------------------------------- +JMC - Oblivion Mod 'Falcor' p.1-thumb.jpg +JMC - Oblivion Mod 'Falcor' p.1.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 +JMC - Oblivion Mod 'Falcor' p.1.nfo + NFO tags: + musicvideo: + album: Music Videos + artist: JMC + title: Oblivion Mod "Falcor" p.1 + year: 2010 \ No newline at end of file diff --git a/tests/e2e/youtube/test_playlist_as_kodi_music_videos.py b/tests/e2e/youtube/test_playlist.py similarity index 66% rename from tests/e2e/youtube/test_playlist_as_kodi_music_videos.py rename to tests/e2e/youtube/test_playlist.py index ae47b24c..16254ff5 100644 --- a/tests/e2e/youtube/test_playlist_as_kodi_music_videos.py +++ b/tests/e2e/youtube/test_playlist.py @@ -1,6 +1,5 @@ from pathlib import Path -import mergedeep import pytest from conftest import assert_debug_log from e2e.expected_download import ExpectedDownloadFile @@ -26,10 +25,6 @@ def playlist_preset_dict(output_directory): } -#################################################################################################### -# PLAYLIST FIXTURES - - @pytest.fixture def expected_playlist_download(): # turn off black formatter here for readability @@ -56,35 +51,6 @@ def expected_playlist_download(): # fmt: on -#################################################################################################### -# SINGLE VIDEO FIXTURES -@pytest.fixture -def single_video_preset_dict(playlist_preset_dict): - del playlist_preset_dict["youtube"] - - return mergedeep.merge( - playlist_preset_dict, - { - "preset": "yt_music_video", - "youtube": {"video_url": "https://youtube.com/watch?v=HKTNxEqsN3Q"}, - }, - ) - - -@pytest.fixture -def expected_single_video_download(): - # turn off black formatter here for readability - # fmt: off - return ExpectedDownloads( - expected_downloads=[ - ExpectedDownloadFile(path=Path("JMC - Oblivion Mod 'Falcor' p.1-thumb.jpg"), md5="fb95b510681676e81c321171fc23143e"), - ExpectedDownloadFile(path=Path("JMC - Oblivion Mod 'Falcor' p.1.mp4"), md5="931a705864c57d21d6fedebed4af6bbc"), - ExpectedDownloadFile(path=Path("JMC - Oblivion Mod 'Falcor' p.1.nfo"), md5="89f509a8a3d9003e22a9091abeeae5dc"), - ] - ) - # fmt: on - - class TestPlaylistAsKodiMusicVideo: """ Downloads my old minecraft youtube channel, pretends they are music videos. Ensure the above @@ -123,27 +89,3 @@ class TestPlaylistAsKodiMusicVideo: ): playlist_subscription.download() expected_playlist_download.assert_files_exist(relative_directory=output_directory) - - @pytest.mark.parametrize("dry_run", [True, False]) - def test_single_video_download( - self, - music_video_config, - single_video_preset_dict, - expected_single_video_download, - output_directory, - dry_run, - ): - single_video_subscription = Subscription.from_dict( - config=music_video_config, - preset_name="music_video_single_video_test", - preset_dict=single_video_preset_dict, - ) - - transaction_log = single_video_subscription.download() - assert_transaction_log_matches( - output_directory=output_directory, - transaction_log=transaction_log, - transaction_log_summary_file_name="youtube/test_video.txt", - ) - if not dry_run: - expected_single_video_download.assert_files_exist(relative_directory=output_directory) diff --git a/tests/e2e/youtube/test_split_video.py b/tests/e2e/youtube/test_split_video.py index 0773ce3a..86255cb1 100644 --- a/tests/e2e/youtube/test_split_video.py +++ b/tests/e2e/youtube/test_split_video.py @@ -11,30 +11,13 @@ from ytdl_sub.subscriptions.subscription import Subscription @pytest.fixture -def split_timestamps_file_path(): - timestamps = [ - "0:00 Intro\n", - "00:10 Part 1\n", - "0:20 Part 2\n", - "00:30 Part 3\n", - "0:00:40 Part 4\n", - "00:01:01 Part 5\n", - ] - - with NamedTemporaryFile(mode="w", encoding="utf-8", suffix=".txt") as tmp: - tmp.writelines(timestamps) - tmp.seek(0) - yield tmp.name - - -@pytest.fixture -def subscription_dict(output_directory, split_timestamps_file_path): +def subscription_dict(output_directory, timestamps_file_path): return { "preset": "yt_music_video", "youtube": { "download_strategy": "split_video", "video_url": "https://youtube.com/watch?v=HKTNxEqsN3Q", - "split_timestamps": split_timestamps_file_path, + "split_timestamps": timestamps_file_path, }, # override the output directory with our fixture-generated dir "output_options": { diff --git a/tests/e2e/youtube/test_video.py b/tests/e2e/youtube/test_video.py new file mode 100644 index 00000000..f35bfba1 --- /dev/null +++ b/tests/e2e/youtube/test_video.py @@ -0,0 +1,120 @@ +from pathlib import Path + +import pytest +from e2e.expected_download import ExpectedDownloadFile +from e2e.expected_download import ExpectedDownloads +from e2e.expected_transaction_log import assert_transaction_log_matches + +from ytdl_sub.subscriptions.subscription import Subscription + + +@pytest.fixture +def playlist_preset_dict(output_directory): + return { + "preset": "yt_music_video_playlist", + "youtube": {"playlist_url": "https://youtube.com/playlist?list=PL5BC0FC26BECA5A35"}, + # 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]", + }, + "overrides": {"artist": "JMC"}, + } + + +@pytest.fixture +def single_video_preset_dict(output_directory): + return { + "preset": "yt_music_video", + "youtube": {"video_url": "https://youtube.com/watch?v=HKTNxEqsN3Q"}, + # 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]", + }, + "overrides": {"artist": "JMC"}, + } + + +@pytest.fixture +def expected_single_video_download(): + # turn off black formatter here for readability + # fmt: off + return ExpectedDownloads( + expected_downloads=[ + ExpectedDownloadFile(path=Path("JMC - Oblivion Mod 'Falcor' p.1-thumb.jpg"), md5="fb95b510681676e81c321171fc23143e"), + ExpectedDownloadFile(path=Path("JMC - Oblivion Mod 'Falcor' p.1.mp4"), md5="931a705864c57d21d6fedebed4af6bbc"), + ExpectedDownloadFile(path=Path("JMC - Oblivion Mod 'Falcor' p.1.nfo"), md5="89f509a8a3d9003e22a9091abeeae5dc"), + ] + ) + # fmt: on + + +@pytest.fixture +def expected_single_video_with_chapter_timestamps_download(): + # turn off black formatter here for readability + # fmt: off + return ExpectedDownloads( + expected_downloads=[ + ExpectedDownloadFile(path=Path("JMC - Oblivion Mod 'Falcor' p.1-thumb.jpg"), md5="fb95b510681676e81c321171fc23143e"), + ExpectedDownloadFile(path=Path("JMC - Oblivion Mod 'Falcor' p.1.mp4"), md5="76b8a7dd428e67e5072d003983bb7e33"), + ExpectedDownloadFile(path=Path("JMC - Oblivion Mod 'Falcor' p.1.nfo"), md5="89f509a8a3d9003e22a9091abeeae5dc"), + ] + ) + # fmt: on + + +class TestYoutubeVideo: + @pytest.mark.parametrize("dry_run", [True, False]) + def test_single_video_download( + self, + music_video_config, + single_video_preset_dict, + expected_single_video_download, + output_directory, + dry_run, + ): + single_video_subscription = Subscription.from_dict( + config=music_video_config, + preset_name="music_video_single_video_test", + preset_dict=single_video_preset_dict, + ) + + transaction_log = single_video_subscription.download() + assert_transaction_log_matches( + output_directory=output_directory, + transaction_log=transaction_log, + transaction_log_summary_file_name="youtube/test_video.txt", + ) + if not dry_run: + expected_single_video_download.assert_files_exist(relative_directory=output_directory) + + @pytest.mark.parametrize("dry_run", [True, False]) + def test_single_video_with_timestamp_chapters_download( + self, + timestamps_file_path, + music_video_config, + single_video_preset_dict, + expected_single_video_with_chapter_timestamps_download, + output_directory, + dry_run, + ): + single_video_preset_dict["youtube"]["chapter_timestamps"] = timestamps_file_path + single_video_subscription = Subscription.from_dict( + config=music_video_config, + preset_name="music_video_single_video_test", + preset_dict=single_video_preset_dict, + ) + + transaction_log = single_video_subscription.download() + assert_transaction_log_matches( + output_directory=output_directory, + transaction_log=transaction_log, + transaction_log_summary_file_name="youtube/test_video_with_chapter_timestamps.txt", + ) + if not dry_run: + expected_single_video_with_chapter_timestamps_download.assert_files_exist( + relative_directory=output_directory + )