[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 Dict
|
||||||
from typing import List
|
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 YoutubeDownloader
|
||||||
from ytdl_sub.downloaders.youtube.abc import YoutubeDownloaderOptions
|
from ytdl_sub.downloaders.youtube.abc import YoutubeDownloaderOptions
|
||||||
from ytdl_sub.entries.youtube import YoutubeVideo
|
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.url_validator import YoutubeVideoUrlValidator
|
||||||
|
from ytdl_sub.validators.validators import StringValidator
|
||||||
|
|
||||||
|
|
||||||
class YoutubeVideoDownloaderOptions(YoutubeDownloaderOptions):
|
class YoutubeVideoDownloaderOptions(YoutubeDownloaderOptions):
|
||||||
|
|
@ -22,20 +28,25 @@ class YoutubeVideoDownloaderOptions(YoutubeDownloaderOptions):
|
||||||
# required
|
# required
|
||||||
download_strategy: "video"
|
download_strategy: "video"
|
||||||
video_url: "youtube.com/watch?v=VMAPTo7RVDo"
|
video_url: "youtube.com/watch?v=VMAPTo7RVDo"
|
||||||
|
# optional
|
||||||
|
chapter_timestamps: path/to/timestamps.txt
|
||||||
|
|
||||||
CLI usage:
|
CLI usage:
|
||||||
|
|
||||||
.. code-block:: bash
|
.. code-block:: bash
|
||||||
|
|
||||||
ytdl-sub dl --preset "example_preset" --youtube.video_url "youtube.com/watch?v=VMAPTo7RVDo"
|
ytdl-sub dl --preset "example_preset" --youtube.video_url "youtube.com/watch?v=VMAPTo7RVDo"
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_required_keys = {"video_url"}
|
_required_keys = {"video_url"}
|
||||||
|
_optional_keys = {"chapter_timestamps"}
|
||||||
|
|
||||||
def __init__(self, name, value):
|
def __init__(self, name, value):
|
||||||
super().__init__(name, value)
|
super().__init__(name, value)
|
||||||
self._video_url = self._validate_key("video_url", YoutubeVideoUrlValidator).video_url
|
self._video_url = self._validate_key("video_url", YoutubeVideoUrlValidator).video_url
|
||||||
|
self._chapter_timestamps = self._validate_key_if_present(
|
||||||
|
"chapter_timestamps", StringValidator
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def video_url(self) -> str:
|
def video_url(self) -> str:
|
||||||
|
|
@ -44,6 +55,24 @@ class YoutubeVideoDownloaderOptions(YoutubeDownloaderOptions):
|
||||||
"""
|
"""
|
||||||
return self._video_url
|
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]):
|
class YoutubeVideoDownloader(YoutubeDownloader[YoutubeVideoDownloaderOptions, YoutubeVideo]):
|
||||||
downloader_options_type = YoutubeVideoDownloaderOptions
|
downloader_options_type = YoutubeVideoDownloaderOptions
|
||||||
|
|
@ -64,7 +93,24 @@ class YoutubeVideoDownloader(YoutubeDownloader[YoutubeVideoDownloaderOptions, Yo
|
||||||
**{"break_on_existing": True},
|
**{"break_on_existing": True},
|
||||||
)
|
)
|
||||||
|
|
||||||
def download(self) -> List[YoutubeVideo]:
|
def download(self) -> List[YoutubeVideo] | List[Tuple[YoutubeVideo, FileMetadata]]:
|
||||||
"""Download a single Youtube video"""
|
"""Download a single Youtube video"""
|
||||||
entry_dict = self.extract_info(url=self.download_options.video_url)
|
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
|
@pytest.fixture
|
||||||
def soundcloud_discography_config():
|
def soundcloud_discography_config():
|
||||||
return ConfigFile.from_file_path(config_path="examples/soundcloud_discography_config.yaml")
|
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
|
from pathlib import Path
|
||||||
|
|
||||||
import mergedeep
|
|
||||||
import pytest
|
import pytest
|
||||||
from conftest import assert_debug_log
|
from conftest import assert_debug_log
|
||||||
from e2e.expected_download import ExpectedDownloadFile
|
from e2e.expected_download import ExpectedDownloadFile
|
||||||
|
|
@ -26,10 +25,6 @@ def playlist_preset_dict(output_directory):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
####################################################################################################
|
|
||||||
# PLAYLIST FIXTURES
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def expected_playlist_download():
|
def expected_playlist_download():
|
||||||
# turn off black formatter here for readability
|
# turn off black formatter here for readability
|
||||||
|
|
@ -56,35 +51,6 @@ def expected_playlist_download():
|
||||||
# fmt: on
|
# 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:
|
class TestPlaylistAsKodiMusicVideo:
|
||||||
"""
|
"""
|
||||||
Downloads my old minecraft youtube channel, pretends they are music videos. Ensure the above
|
Downloads my old minecraft youtube channel, pretends they are music videos. Ensure the above
|
||||||
|
|
@ -123,27 +89,3 @@ class TestPlaylistAsKodiMusicVideo:
|
||||||
):
|
):
|
||||||
playlist_subscription.download()
|
playlist_subscription.download()
|
||||||
expected_playlist_download.assert_files_exist(relative_directory=output_directory)
|
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
|
@pytest.fixture
|
||||||
def split_timestamps_file_path():
|
def subscription_dict(output_directory, 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):
|
|
||||||
return {
|
return {
|
||||||
"preset": "yt_music_video",
|
"preset": "yt_music_video",
|
||||||
"youtube": {
|
"youtube": {
|
||||||
"download_strategy": "split_video",
|
"download_strategy": "split_video",
|
||||||
"video_url": "https://youtube.com/watch?v=HKTNxEqsN3Q",
|
"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
|
# override the output directory with our fixture-generated dir
|
||||||
"output_options": {
|
"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