ytdl options builder

This commit is contained in:
jbannon 2022-08-10 23:39:27 +00:00
parent cb439a4d4d
commit 3b3c23963e
7 changed files with 124 additions and 63 deletions

View file

@ -79,6 +79,8 @@ class DownloadStrategyValidator(StrictDictValidator):
raise self._validation_exception(error_message=value_exc) raise self._validation_exception(error_message=value_exc)
# Preset is the meat of ytdl-sub, the linter is wrong here
# pylint: disable=too-many-instance-attributes
class Preset(StrictDictValidator): class Preset(StrictDictValidator):
# Have all present keys optional since parent presets could not have all the # Have all present keys optional since parent presets could not have all the
# required keys. They will get validated in the init after the mergedeep of dicts # required keys. They will get validated in the init after the mergedeep of dicts

View file

@ -1,6 +1,5 @@
import abc import abc
import contextlib import contextlib
import copy
import json import json
import os import os
import time import time
@ -22,6 +21,7 @@ from yt_dlp.utils import ExistingVideoReached
from yt_dlp.utils import RejectedVideoReached from yt_dlp.utils import RejectedVideoReached
from ytdl_sub.config.preset_options import Overrides from ytdl_sub.config.preset_options import Overrides
from ytdl_sub.downloaders.ytdl_options_builder import YTDLOptionsBuilder
from ytdl_sub.entries.base_entry import BaseEntry from ytdl_sub.entries.base_entry import BaseEntry
from ytdl_sub.entries.entry import Entry from ytdl_sub.entries.entry import Entry
from ytdl_sub.thread.log_entries_downloaded_listener import LogEntriesDownloadedListener from ytdl_sub.thread.log_entries_downloaded_listener import LogEntriesDownloadedListener
@ -70,25 +70,11 @@ class Downloader(DownloadArchiver, Generic[DownloaderOptionsT, DownloaderEntryT]
""" """
return {"ignoreerrors": True} return {"ignoreerrors": True}
@classmethod
def _configure_ytdl_options(
cls,
ytdl_options: Optional[Dict],
) -> Dict:
"""Configure the ytdl options for the downloader"""
if ytdl_options is None:
ytdl_options = {}
# Overwrite defaults with input
ytdl_options = dict(cls.ytdl_option_defaults(), **ytdl_options)
return ytdl_options
def __init__( def __init__(
self, self,
download_options: DownloaderOptionsT, download_options: DownloaderOptionsT,
enhanced_download_archive: EnhancedDownloadArchive, enhanced_download_archive: EnhancedDownloadArchive,
ytdl_options: Optional[Dict] = None, ytdl_options_builder: YTDLOptionsBuilder,
): ):
""" """
Parameters Parameters
@ -97,13 +83,14 @@ class Downloader(DownloadArchiver, Generic[DownloaderOptionsT, DownloaderEntryT]
Options validator for this downloader Options validator for this downloader
enhanced_download_archive enhanced_download_archive
Download archive Download archive
ytdl_options ytdl_options_builder
YTDL options validator YTDL options builder
""" """
DownloadArchiver.__init__(self=self, enhanced_download_archive=enhanced_download_archive) DownloadArchiver.__init__(self=self, enhanced_download_archive=enhanced_download_archive)
self.download_options = download_options self.download_options = download_options
self.ytdl_options = self._configure_ytdl_options(
ytdl_options=ytdl_options, self._ytdl_options_builder = ytdl_options_builder.clone().add(
self.ytdl_option_defaults(), before=True
) )
@contextmanager @contextmanager
@ -111,9 +98,7 @@ class Downloader(DownloadArchiver, Generic[DownloaderOptionsT, DownloaderEntryT]
""" """
Context manager to interact with yt_dlp. Context manager to interact with yt_dlp.
""" """
ytdl_options = self.ytdl_options ytdl_options = self._ytdl_options_builder.clone().add(ytdl_options_overrides).to_dict()
if ytdl_options_overrides is not None:
ytdl_options = dict(ytdl_options, **ytdl_options_overrides)
download_logger.debug("ytdl_options: %s", str(ytdl_options)) download_logger.debug("ytdl_options: %s", str(ytdl_options))
with Logger.handle_external_logs(name="yt-dlp"): with Logger.handle_external_logs(name="yt-dlp"):
@ -127,7 +112,7 @@ class Downloader(DownloadArchiver, Generic[DownloaderOptionsT, DownloaderEntryT]
------- -------
True if dry-run is enabled. False otherwise. True if dry-run is enabled. False otherwise.
""" """
return self.ytdl_options.get("skip_download", False) return self._ytdl_options_builder.to_dict().get("skip_download", False)
def extract_info(self, ytdl_options_overrides: Optional[Dict] = None, **kwargs) -> Dict: def extract_info(self, ytdl_options_overrides: Optional[Dict] = None, **kwargs) -> Dict:
""" """
@ -173,7 +158,6 @@ class Downloader(DownloadArchiver, Generic[DownloaderOptionsT, DownloaderEntryT]
""" """
num_tries = 0 num_tries = 0
entry_files_exist = False entry_files_exist = False
ytdl_options_overrides = copy.deepcopy(ytdl_options_overrides)
while not entry_files_exist and num_tries < self._extract_entry_num_retries: while not entry_files_exist and num_tries < self._extract_entry_num_retries:
entry_dict = self.extract_info(ytdl_options_overrides=ytdl_options_overrides, **kwargs) entry_dict = self.extract_info(ytdl_options_overrides=ytdl_options_overrides, **kwargs)
@ -185,7 +169,11 @@ class Downloader(DownloadArchiver, Generic[DownloaderOptionsT, DownloaderEntryT]
# Remove the download archive so it can retry without thinking its already downloaded, # Remove the download archive so it can retry without thinking its already downloaded,
# even though it is not # even though it is not
ytdl_options_overrides["download_archive"] = None ytdl_options_overrides = (
YTDLOptionsBuilder()
.add(ytdl_options_overrides, {"download_archive": None})
.to_dict()
)
if num_tries < self._extract_entry_retry_wait_sec: if num_tries < self._extract_entry_retry_wait_sec:
download_logger.debug( download_logger.debug(
@ -299,20 +287,25 @@ class Downloader(DownloadArchiver, Generic[DownloaderOptionsT, DownloaderEntryT]
**kwargs **kwargs
arguments passed directory to YoutubeDL extract_info arguments passed directory to YoutubeDL extract_info
""" """
ytdl_options_builder = self._ytdl_options_builder.clone()
if ytdl_options_overrides is None: if ytdl_options_overrides is None:
ytdl_options_overrides = {} ytdl_options_overrides = {}
extract_info_ytdl_options = {"writeinfojson": True} ytdl_options_builder.add({"writeinfojson": True}, ytdl_options_overrides)
if only_info_json: if only_info_json:
extract_info_ytdl_options["skip_download"] = True ytdl_options_builder.add(
extract_info_ytdl_options["writethumbnail"] = False {
extract_info_ytdl_options["writesubtitles"] = False "skip_download": True,
"writethumbnail": False,
ytdl_options_overrides = dict(ytdl_options_overrides, **extract_info_ytdl_options) "writesubtitles": False,
}
)
try: try:
with self._listen_and_log_downloaded_info_json(log_prefix=log_prefix_on_info_json_dl): with self._listen_and_log_downloaded_info_json(log_prefix=log_prefix_on_info_json_dl):
_ = self.extract_info(ytdl_options_overrides=ytdl_options_overrides, **kwargs) _ = self.extract_info(
ytdl_options_overrides=ytdl_options_builder.to_dict(), **kwargs
)
except RejectedVideoReached: except RejectedVideoReached:
download_logger.debug("RejectedVideoReached, stopping additional downloads") download_logger.debug("RejectedVideoReached, stopping additional downloads")
except ExistingVideoReached: except ExistingVideoReached:

View file

@ -9,6 +9,7 @@ from ytdl_sub.downloaders.downloader import DownloaderOptionsT
from ytdl_sub.downloaders.downloader import download_logger from ytdl_sub.downloaders.downloader import download_logger
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.downloaders.ytdl_options_builder import YTDLOptionsBuilder
from ytdl_sub.entries.youtube import YoutubeChannel from ytdl_sub.entries.youtube import YoutubeChannel
from ytdl_sub.entries.youtube import YoutubeVideo from ytdl_sub.entries.youtube import YoutubeVideo
from ytdl_sub.utils.thumbnail import convert_url_thumbnail from ytdl_sub.utils.thumbnail import convert_url_thumbnail
@ -115,12 +116,12 @@ class YoutubeChannelDownloader(YoutubeDownloader[YoutubeChannelDownloaderOptions
self, self,
download_options: DownloaderOptionsT, download_options: DownloaderOptionsT,
enhanced_download_archive: EnhancedDownloadArchive, enhanced_download_archive: EnhancedDownloadArchive,
ytdl_options: Optional[Dict] = None, ytdl_options_builder: YTDLOptionsBuilder,
): ):
super().__init__( super().__init__(
download_options=download_options, download_options=download_options,
enhanced_download_archive=enhanced_download_archive, enhanced_download_archive=enhanced_download_archive,
ytdl_options=ytdl_options, ytdl_options_builder=ytdl_options_builder,
) )
self.channel: Optional[YoutubeChannel] = None self.channel: Optional[YoutubeChannel] = None

View file

@ -0,0 +1,59 @@
import copy
from typing import Dict
from typing import Optional
import mergedeep
class YTDLOptionsBuilder:
"""
A class to track any modifications made to ytdl options
"""
def __init__(self):
self._ytdl_options: Dict = {}
def add(
self,
*ytdl_option_dicts: Optional[Dict],
before: bool = False,
strategy: mergedeep.Strategy = mergedeep.Strategy.TYPESAFE_ADDITIVE
) -> "YTDLOptionsBuilder":
"""
Parameters
----------
*ytdl_option_dicts
One or many ytdl_option dicts. Can also contain None's for convenience
before
Optional. Whether to add these dicts before or after the original
strategy
Optional. mergedeep strategy. Defaults to TYPESAFE_ADDITIVE
Returns
-------
instance with the added ytdl_option dict(s)
"""
non_empty = [ytdl_options for ytdl_options in ytdl_option_dicts if ytdl_options is not None]
if before:
non_empty.append(self.to_dict())
self._ytdl_options = {}
mergedeep.merge(self._ytdl_options, *non_empty, strategy=strategy)
return self
def clone(self) -> "YTDLOptionsBuilder":
"""
Returns
-------
Deep-copied instance
"""
return copy.deepcopy(self)
def to_dict(self) -> Dict:
"""
Returns
-------
Deep-copied dict of the current builder state
"""
return copy.deepcopy(self._ytdl_options)

View file

@ -287,19 +287,19 @@ class Subscription:
directory. directory.
""" """
self._enhanced_download_archive.reinitialize(dry_run=dry_run) self._enhanced_download_archive.reinitialize(dry_run=dry_run)
ytdl_options = SubscriptionYTDLOptions( ytdl_options_builder = SubscriptionYTDLOptions(
preset=self.__preset_options, preset=self.__preset_options,
enhanced_download_archive=self._enhanced_download_archive, enhanced_download_archive=self._enhanced_download_archive,
working_directory=self.working_directory, working_directory=self.working_directory,
dry_run=dry_run, dry_run=dry_run,
).to_dict() ).builder()
plugins = self._initialize_plugins() plugins = self._initialize_plugins()
with self._subscription_download_context_managers(): with self._subscription_download_context_managers():
downloader = self.downloader_class( downloader = self.downloader_class(
download_options=self.downloader_options, download_options=self.downloader_options,
enhanced_download_archive=self._enhanced_download_archive, enhanced_download_archive=self._enhanced_download_archive,
ytdl_options=ytdl_options, ytdl_options_builder=ytdl_options_builder,
) )
for entry in downloader.download(): for entry in downloader.download():

View file

@ -1,9 +1,8 @@
from pathlib import Path from pathlib import Path
from typing import Dict from typing import Dict
import mergedeep
from ytdl_sub.config.preset import Preset from ytdl_sub.config.preset import Preset
from ytdl_sub.downloaders.ytdl_options_builder import YTDLOptionsBuilder
from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive
@ -22,11 +21,26 @@ class SubscriptionYTDLOptions:
@property @property
def _global_options(self) -> Dict: def _global_options(self) -> Dict:
return { """
Returns
-------
ytdl-options to apply to every run no matter what
"""
ytdl_options = {
# Download all files in the format of {id}.{ext} # Download all files in the format of {id}.{ext}
"outtmpl": str(Path(self._working_directory) / "%(id)s.%(ext)s") "outtmpl": str(Path(self._working_directory) / "%(id)s.%(ext)s")
} }
if (
self._preset.downloader.supports_download_archive
and self._preset.output_options.maintain_download_archive
):
ytdl_options["download_archive"] = str(
Path(self._working_directory) / self._enhanced_download_archive.archive_file_name
)
return ytdl_options
@property @property
def _dry_run_options(self) -> Dict: def _dry_run_options(self) -> Dict:
return { return {
@ -42,13 +56,6 @@ class SubscriptionYTDLOptions:
if output_options.thumbnail_name: if output_options.thumbnail_name:
ytdl_options["writethumbnail"] = True ytdl_options["writethumbnail"] = True
if (
self._preset.downloader.supports_download_archive
and output_options.maintain_download_archive
):
ytdl_options["download_archive"] = str(
Path(self._working_directory) / self._enhanced_download_archive.archive_file_name
)
return ytdl_options return ytdl_options
@ -83,22 +90,19 @@ class SubscriptionYTDLOptions:
def _user_ytdl_options(self) -> Dict: def _user_ytdl_options(self) -> Dict:
return self._preset.ytdl_options.dict return self._preset.ytdl_options.dict
def to_dict(self) -> Dict: def builder(self) -> YTDLOptionsBuilder:
ytdl_options = self._global_options """
Returns
-------
YTDLOptionsBuilder
Builder with values set based on the subscription
"""
ytdl_options_builder = YTDLOptionsBuilder().add(self._global_options)
if self._dry_run: if self._dry_run:
mergedeep.merge( ytdl_options_builder.add(self._dry_run_options, self._user_ytdl_options)
ytdl_options,
self._dry_run_options,
self._user_ytdl_options,
strategy=mergedeep.Strategy.TYPESAFE_ADDITIVE,
)
else: else:
mergedeep.merge( ytdl_options_builder.add(
ytdl_options, self._output_options, self._subtitle_options, self._user_ytdl_options
self._output_options,
self._subtitle_options,
self._user_ytdl_options,
strategy=mergedeep.Strategy.TYPESAFE_ADDITIVE,
) )
return ytdl_options return ytdl_options_builder

View file

@ -54,10 +54,12 @@ def ext():
def thumbnail_ext(): def thumbnail_ext():
return "jpg" return "jpg"
@pytest.fixture @pytest.fixture
def subtitles_ext(): def subtitles_ext():
return "srt" return "srt"
@pytest.fixture @pytest.fixture
def download_thumbnail_name(uid, thumbnail_ext): def download_thumbnail_name(uid, thumbnail_ext):
return f"{uid}.{thumbnail_ext}" return f"{uid}.{thumbnail_ext}"