[FEATURE] download_individually for Youtube channel (#101)

This commit is contained in:
Jesse Bannon 2022-07-17 18:17:00 -07:00 committed by GitHub
parent 2b0e7e0e4f
commit 8e35c1674b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 125 additions and 107 deletions

View file

@ -6,6 +6,7 @@ from contextlib import contextmanager
from pathlib import Path from pathlib import Path
from typing import Dict from typing import Dict
from typing import Generic from typing import Generic
from typing import Iterable
from typing import List from typing import List
from typing import Optional from typing import Optional
from typing import Tuple from typing import Tuple
@ -199,7 +200,9 @@ class Downloader(DownloadArchiver, Generic[DownloaderOptionsT, DownloaderEntryT]
return self._get_entry_dicts_from_info_json_files() return self._get_entry_dicts_from_info_json_files()
@abc.abstractmethod @abc.abstractmethod
def download(self) -> List[DownloaderEntryT] | List[Tuple[DownloaderEntryT, FileMetadata]]: def download(
self,
) -> Iterable[DownloaderEntryT] | Iterable[Tuple[DownloaderEntryT, FileMetadata]]:
"""The function to perform the download of all media entries""" """The function to perform the download of all media entries"""
def post_download(self, overrides: Overrides): def post_download(self, overrides: Overrides):

View file

@ -1,5 +1,6 @@
from pathlib import Path from pathlib import Path
from typing import Dict from typing import Dict
from typing import Generator
from typing import List from typing import List
from typing import Optional from typing import Optional
@ -14,6 +15,7 @@ from ytdl_sub.utils.thumbnail import convert_url_thumbnail
from ytdl_sub.validators.date_range_validator import DateRangeValidator from ytdl_sub.validators.date_range_validator import DateRangeValidator
from ytdl_sub.validators.string_formatter_validators import OverridesStringFormatterValidator from ytdl_sub.validators.string_formatter_validators import OverridesStringFormatterValidator
from ytdl_sub.validators.url_validator import YoutubeChannelUrlValidator from ytdl_sub.validators.url_validator import YoutubeChannelUrlValidator
from ytdl_sub.validators.validators import BoolValidator
from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive
logger = Logger.get() logger = Logger.get()
@ -36,12 +38,19 @@ class YoutubeChannelDownloaderOptions(YoutubeDownloaderOptions, DateRangeValidat
# optional # optional
channel_avatar_path: "poster.jpg" channel_avatar_path: "poster.jpg"
channel_banner_path: "fanart.jpg" channel_banner_path: "fanart.jpg"
download_individually: True
before: "now" before: "now"
after: "today-2weeks" after: "today-2weeks"
""" """
_required_keys = {"channel_url"} _required_keys = {"channel_url"}
_optional_keys = {"before", "after", "channel_avatar_path", "channel_banner_path"} _optional_keys = {
"before",
"after",
"channel_avatar_path",
"channel_banner_path",
"download_individually",
}
def __init__(self, name, value): def __init__(self, name, value):
YoutubeDownloaderOptions.__init__(self, name, value) YoutubeDownloaderOptions.__init__(self, name, value)
@ -55,6 +64,9 @@ class YoutubeChannelDownloaderOptions(YoutubeDownloaderOptions, DateRangeValidat
self._channel_banner_path = self._validate_key_if_present( self._channel_banner_path = self._validate_key_if_present(
"channel_banner_path", OverridesStringFormatterValidator "channel_banner_path", OverridesStringFormatterValidator
) )
self._download_individually = self._validate_key_if_present(
"download_individually", BoolValidator, default=True
)
@property @property
def channel_url(self) -> str: def channel_url(self) -> str:
@ -79,6 +91,15 @@ class YoutubeChannelDownloaderOptions(YoutubeDownloaderOptions, DateRangeValidat
""" """
return self._channel_banner_path return self._channel_banner_path
@property
def download_individually(self) -> Optional[bool]:
"""
Optional. Downloads files from the channel individually instead of in bulk. Setting to True
is safer when downloading large amounts of videos in case an error occurs. Downloading by
bulk (by setting to False) can increase speeds. Defaults to True.
"""
return self._download_individually.value
class YoutubeChannelDownloader(YoutubeDownloader[YoutubeChannelDownloaderOptions, YoutubeVideo]): class YoutubeChannelDownloader(YoutubeDownloader[YoutubeChannelDownloaderOptions, YoutubeVideo]):
downloader_options_type = YoutubeChannelDownloaderOptions downloader_options_type = YoutubeChannelDownloaderOptions
@ -120,13 +141,25 @@ class YoutubeChannelDownloader(YoutubeDownloader[YoutubeChannelDownloaderOptions
) )
self.channel: Optional[YoutubeChannel] = None self.channel: Optional[YoutubeChannel] = None
def download(self) -> List[YoutubeVideo]: def _get_channel_from_entry_dicts(self, entry_dicts: List[Dict]) -> YoutubeChannel:
channel_entry_dict = [
entry_dict for entry_dict in entry_dicts if entry_dict.get("extractor") == "youtube:tab"
][0]
return YoutubeChannel(
entry_dict=channel_entry_dict, working_directory=self.working_directory
)
def download(self) -> Generator[YoutubeVideo, None, None]:
""" """
Downloads all videos from a channel Downloads all videos from a channel
""" """
channel_videos: List[YoutubeVideo] = []
ytdl_options_overrides = {} ytdl_options_overrides = {}
# If downloading individually, dry-run the entire channel download first, this will get the
# videos that will be downloaded. Afterwards, download each video one-by-one
if self.download_options.download_individually:
ytdl_options_overrides["skip_download"] = True
# If a date range is specified when download a YT channel, add it into the ytdl options # If a date range is specified when download a YT channel, add it into the ytdl options
source_date_range = self.download_options.get_date_range() source_date_range = self.download_options.get_date_range()
if source_date_range: if source_date_range:
@ -135,18 +168,22 @@ class YoutubeChannelDownloader(YoutubeDownloader[YoutubeChannelDownloaderOptions
entry_dicts = self.extract_info_via_info_json( entry_dicts = self.extract_info_via_info_json(
ytdl_options_overrides=ytdl_options_overrides, url=self.download_options.channel_url ytdl_options_overrides=ytdl_options_overrides, url=self.download_options.channel_url
) )
self.channel = self._get_channel_from_entry_dicts(entry_dicts=entry_dicts)
# If downloading individually, remove the skip_download to actually download the video
if self.download_options.download_individually:
del ytdl_options_overrides["skip_download"]
for entry_dict in entry_dicts: for entry_dict in entry_dicts:
if entry_dict.get("extractor") == "youtube": if entry_dict.get("extractor") == "youtube":
channel_videos.append( # Only do the individual download if it is not dry-run and downloading individually
YoutubeVideo(entry_dict=entry_dict, working_directory=self.working_directory) if not self.is_dry_run and self.download_options.download_individually:
) ytdl_options_overrides["playlist_items"] = str(entry_dict.get("playlist_index"))
if entry_dict.get("extractor") == "youtube:tab": _ = self.extract_info(
self.channel = YoutubeChannel( ytdl_options_overrides=ytdl_options_overrides,
entry_dict=entry_dict, working_directory=self.working_directory url=self.download_options.channel_url,
) )
yield YoutubeVideo(entry_dict=entry_dict, working_directory=self.working_directory)
return channel_videos
def _download_thumbnail( def _download_thumbnail(
self, self,

View file

@ -292,6 +292,10 @@ class Subscription:
entry=entry, entry_metadata=entry_metadata entry=entry, entry_metadata=entry_metadata
) )
# Re-save the download archive after each entry is moved to the output directory
if self.maintain_download_archive:
self._enhanced_download_archive.save_download_mappings()
downloader.post_download(overrides=self.overrides) downloader.post_download(overrides=self.overrides)
for plugin in plugins: for plugin in plugins:
plugin.post_process_subscription() plugin.post_process_subscription()

View file

@ -1,4 +1,6 @@
import copy
from pathlib import Path from pathlib import Path
from typing import Dict
import mergedeep import mergedeep
import pytest import pytest
@ -8,28 +10,16 @@ from e2e.expected_download import ExpectedDownloads
from e2e.expected_transaction_log import assert_transaction_log_matches from e2e.expected_transaction_log import assert_transaction_log_matches
import ytdl_sub.downloaders.downloader import ytdl_sub.downloaders.downloader
from ytdl_sub.config.config_file import ConfigFile
from ytdl_sub.config.preset import Preset
from ytdl_sub.subscriptions.subscription import Subscription from ytdl_sub.subscriptions.subscription import Subscription
@pytest.fixture
def config_path():
return "examples/kodi_tv_shows_config.yaml"
@pytest.fixture @pytest.fixture
def subscription_name(): def subscription_name():
return "pz" return "pz"
@pytest.fixture @pytest.fixture
def config(config_path): def channel_preset_dict(output_directory):
return ConfigFile.from_file_path(config_path=config_path)
@pytest.fixture
def subscription_dict(output_directory):
return { return {
"preset": "yt_channel_as_tv", "preset": "yt_channel_as_tv",
"youtube": {"channel_url": "https://youtube.com/channel/UCcRSMoQqXc_JrBZRHDFGbqA"}, "youtube": {"channel_url": "https://youtube.com/channel/UCcRSMoQqXc_JrBZRHDFGbqA"},
@ -45,24 +35,20 @@ def subscription_dict(output_directory):
} }
@pytest.fixture
def channel_subscription_generator(channel_as_tv_show_config, subscription_name):
def _channel_subscription_generator(preset_dict: Dict):
return Subscription.from_dict(
config=channel_as_tv_show_config, preset_name=subscription_name, preset_dict=preset_dict
)
return _channel_subscription_generator
#################################################################################################### ####################################################################################################
# FULL CHANNEL FIXTURES # FULL CHANNEL FIXTURES
@pytest.fixture
def full_channel_subscription(config, subscription_name, subscription_dict):
full_channel_preset = Preset.from_dict(
config=config,
preset_name=subscription_name,
preset_dict=subscription_dict,
)
return Subscription.from_preset(
preset=full_channel_preset,
config=config,
)
@pytest.fixture @pytest.fixture
def expected_full_channel_download(): def expected_full_channel_download():
# turn off black formatter here for readability # turn off black formatter here for readability
@ -133,11 +119,12 @@ def expected_full_channel_download():
#################################################################################################### ####################################################################################################
# RECENT CHANNEL FIXTURES # RECENT CHANNEL FIXTURES
@pytest.fixture @pytest.fixture
def recent_channel_subscription_dict(subscription_dict): def recent_channel_preset_dict(channel_preset_dict):
# TODO: remove this hack by using a different channel # TODO: remove this hack by using a different channel
del subscription_dict["ytdl_options"]["break_on_reject"] channel_preset_dict = copy.deepcopy(channel_preset_dict)
del channel_preset_dict["ytdl_options"]["break_on_reject"]
return mergedeep.merge( return mergedeep.merge(
subscription_dict, channel_preset_dict,
{ {
"preset": "yt_channel_as_tv__recent", "preset": "yt_channel_as_tv__recent",
"youtube": {"after": "20150101"}, "youtube": {"after": "20150101"},
@ -145,20 +132,6 @@ def recent_channel_subscription_dict(subscription_dict):
) )
@pytest.fixture
def recent_channel_subscription(config, subscription_name, recent_channel_subscription_dict):
recent_channel_preset = Preset.from_dict(
config=config,
preset_name=subscription_name,
preset_dict=recent_channel_subscription_dict,
)
return Subscription.from_preset(
preset=recent_channel_preset,
config=config,
)
@pytest.fixture @pytest.fixture
def expected_recent_channel_download(): def expected_recent_channel_download():
# turn off black formatter here for readability # turn off black formatter here for readability
@ -188,33 +161,6 @@ def expected_recent_channel_download():
#################################################################################################### ####################################################################################################
# RECENT CHANNEL FIXTURES -- NO VIDS IN RANGE # RECENT CHANNEL FIXTURES -- NO VIDS IN RANGE
@pytest.fixture
def recent_channel_no_vids_in_range_subscription_dict(subscription_dict):
# TODO: remove this hack by using a different channel
del subscription_dict["ytdl_options"]["break_on_reject"]
return mergedeep.merge(
subscription_dict,
{
"preset": "yt_channel_as_tv__recent",
"youtube": {"after": "20880101"},
},
)
@pytest.fixture
def recent_channel_no_vids_in_range_subscription(
config, subscription_name, recent_channel_no_vids_in_range_subscription_dict
):
recent_channel_preset = Preset.from_dict(
config=config,
preset_name=subscription_name,
preset_dict=recent_channel_no_vids_in_range_subscription_dict,
)
return Subscription.from_preset(
preset=recent_channel_preset,
config=config,
)
@pytest.fixture @pytest.fixture
@ -238,9 +184,10 @@ def expected_recent_channel_no_vids_in_range_download():
#################################################################################################### ####################################################################################################
# ROLLING RECENT CHANNEL FIXTURES # ROLLING RECENT CHANNEL FIXTURES
@pytest.fixture @pytest.fixture
def rolling_recent_channel_subscription_dict(recent_channel_subscription_dict): def rolling_recent_channel_preset_dict(recent_channel_preset_dict):
recent_channel_preset_dict = copy.deepcopy(recent_channel_preset_dict)
return mergedeep.merge( return mergedeep.merge(
recent_channel_subscription_dict, recent_channel_preset_dict,
{ {
"preset": "yt_channel_as_tv__only_recent", "preset": "yt_channel_as_tv__only_recent",
"output_options": {"keep_files_after": "20181101"}, "output_options": {"keep_files_after": "20181101"},
@ -248,22 +195,6 @@ def rolling_recent_channel_subscription_dict(recent_channel_subscription_dict):
) )
@pytest.fixture
def rolling_recent_channel_subscription(
config, subscription_name, rolling_recent_channel_subscription_dict
):
rolling_recent_channel_preset = Preset.from_dict(
config=config,
preset_name=subscription_name,
preset_dict=rolling_recent_channel_subscription_dict,
)
return Subscription.from_preset(
preset=rolling_recent_channel_preset,
config=config,
)
@pytest.fixture @pytest.fixture
def expected_rolling_recent_channel_download(): def expected_rolling_recent_channel_download():
# turn off black formatter here for readability # turn off black formatter here for readability
@ -294,9 +225,19 @@ class TestChannelAsKodiTvShow:
""" """
@pytest.mark.parametrize("dry_run", [True, False]) @pytest.mark.parametrize("dry_run", [True, False])
@pytest.mark.parametrize("download_individually", [True, False])
def test_full_channel_download( def test_full_channel_download(
self, full_channel_subscription, expected_full_channel_download, output_directory, dry_run self,
channel_subscription_generator,
channel_preset_dict,
expected_full_channel_download,
output_directory,
dry_run,
download_individually,
): ):
channel_preset_dict["youtube"]["download_individually"] = download_individually
full_channel_subscription = channel_subscription_generator(preset_dict=channel_preset_dict)
transaction_log = full_channel_subscription.download(dry_run=dry_run) transaction_log = full_channel_subscription.download(dry_run=dry_run)
assert_transaction_log_matches( assert_transaction_log_matches(
output_directory=output_directory, output_directory=output_directory,
@ -307,13 +248,22 @@ class TestChannelAsKodiTvShow:
expected_full_channel_download.assert_files_exist(relative_directory=output_directory) expected_full_channel_download.assert_files_exist(relative_directory=output_directory)
@pytest.mark.parametrize("dry_run", [True, False]) @pytest.mark.parametrize("dry_run", [True, False])
@pytest.mark.parametrize("download_individually", [True, False])
def test_recent_channel_download( def test_recent_channel_download(
self, self,
recent_channel_subscription, channel_subscription_generator,
recent_channel_preset_dict,
expected_recent_channel_download, expected_recent_channel_download,
output_directory, output_directory,
dry_run, dry_run,
download_individually,
): ):
recent_channel_preset_dict["youtube"]["download_individually"] = download_individually
recent_channel_subscription = channel_subscription_generator(
preset_dict=recent_channel_preset_dict
)
transaction_log = recent_channel_subscription.download(dry_run=dry_run) transaction_log = recent_channel_subscription.download(dry_run=dry_run)
assert_transaction_log_matches( assert_transaction_log_matches(
output_directory=output_directory, output_directory=output_directory,
@ -341,13 +291,22 @@ class TestChannelAsKodiTvShow:
) )
@pytest.mark.parametrize("dry_run", [True, False]) @pytest.mark.parametrize("dry_run", [True, False])
@pytest.mark.parametrize("download_individually", [True, False])
def test_recent_channel_download__no_vids_in_range( def test_recent_channel_download__no_vids_in_range(
self, self,
recent_channel_no_vids_in_range_subscription, channel_subscription_generator,
recent_channel_preset_dict,
expected_recent_channel_no_vids_in_range_download, expected_recent_channel_no_vids_in_range_download,
output_directory, output_directory,
dry_run, dry_run,
download_individually,
): ):
recent_channel_preset_dict["youtube"]["download_individually"] = download_individually
recent_channel_preset_dict["youtube"]["after"] = "21000101"
recent_channel_no_vids_in_range_subscription = channel_subscription_generator(
preset_dict=recent_channel_preset_dict
)
# Run twice, ensure nothing changes between runs # Run twice, ensure nothing changes between runs
for _ in range(2): for _ in range(2):
transaction_log = recent_channel_no_vids_in_range_subscription.download(dry_run=dry_run) transaction_log = recent_channel_no_vids_in_range_subscription.download(dry_run=dry_run)
@ -362,15 +321,30 @@ class TestChannelAsKodiTvShow:
) )
@pytest.mark.parametrize("dry_run", [True, False]) @pytest.mark.parametrize("dry_run", [True, False])
@pytest.mark.parametrize("download_individually", [True, False])
def test_rolling_recent_channel_download( def test_rolling_recent_channel_download(
self, self,
recent_channel_subscription, channel_subscription_generator,
rolling_recent_channel_subscription, recent_channel_preset_dict,
rolling_recent_channel_preset_dict,
expected_recent_channel_download, expected_recent_channel_download,
expected_rolling_recent_channel_download, expected_rolling_recent_channel_download,
output_directory, output_directory,
dry_run, dry_run,
download_individually,
): ):
recent_channel_preset_dict["youtube"]["download_individually"] = download_individually
rolling_recent_channel_preset_dict["youtube"][
"download_individually"
] = download_individually
recent_channel_subscription = channel_subscription_generator(
preset_dict=recent_channel_preset_dict
)
rolling_recent_channel_subscription = channel_subscription_generator(
preset_dict=rolling_recent_channel_preset_dict
)
# First, download recent vids. Always download since we want to test dry-run # First, download recent vids. Always download since we want to test dry-run
# on the rolling recent portion. # on the rolling recent portion.
with assert_debug_log( with assert_debug_log(