[FEATURE] Option to add chapters to YouTube videos via timestamps file (#130)

This commit is contained in:
Jesse Bannon 2022-07-30 15:52:20 -07:00 committed by GitHub
parent 6056e662e6
commit 09bc12c815
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 206 additions and 80 deletions

View file

@ -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)]

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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": {

View file

@ -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
)