[FEATURE] Option to add chapters to YouTube videos via timestamps file (#130)
This commit is contained in:
parent
6056e662e6
commit
09bc12c815
6 changed files with 206 additions and 80 deletions
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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": {
|
||||
|
|
|
|||
120
tests/e2e/youtube/test_video.py
Normal file
120
tests/e2e/youtube/test_video.py
Normal 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
|
||||
)
|
||||
Loading…
Reference in a new issue