[FEATURE] Subtitles support via Subtitles plugin (#169)
This commit is contained in:
parent
bcf45f7f7e
commit
1f09398ad5
21 changed files with 662 additions and 81 deletions
|
|
@ -203,6 +203,14 @@ regex
|
||||||
|
|
||||||
-------------------------------------------------------------------------------
|
-------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
subtitles
|
||||||
|
'''''''''
|
||||||
|
.. autoclass:: ytdl_sub.plugins.subtitles.SubtitleOptions()
|
||||||
|
:members: subtitles_name, subtitles_type, embed_subtitles, languages, allow_auto_generated_subtitles
|
||||||
|
:member-order: bysource
|
||||||
|
|
||||||
|
-------------------------------------------------------------------------------
|
||||||
|
|
||||||
.. _subscription_yaml:
|
.. _subscription_yaml:
|
||||||
|
|
||||||
subscription.yaml
|
subscription.yaml
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import copy
|
import copy
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
|
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
|
||||||
|
|
@ -39,6 +40,28 @@ PRESET_KEYS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PresetPlugins:
|
||||||
|
def __init__(self):
|
||||||
|
self.plugin_types: List[Type[Plugin]] = []
|
||||||
|
self.plugin_options: List[PluginOptions] = []
|
||||||
|
|
||||||
|
def add(self, plugin_type: Type[Plugin], plugin_options: PluginOptions) -> "PresetPlugins":
|
||||||
|
"""
|
||||||
|
Add a pair of plugin type and options to the list
|
||||||
|
"""
|
||||||
|
self.plugin_types.append(plugin_type)
|
||||||
|
self.plugin_options.append(plugin_options)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def zipped(self) -> Iterable[Tuple[Type[Plugin], PluginOptions]]:
|
||||||
|
"""
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
Plugin and PluginOptions zipped
|
||||||
|
"""
|
||||||
|
return zip(self.plugin_types, self.plugin_options)
|
||||||
|
|
||||||
|
|
||||||
class DownloadStrategyValidator(StrictDictValidator):
|
class DownloadStrategyValidator(StrictDictValidator):
|
||||||
"""
|
"""
|
||||||
Ensures a download strategy exists for a source. Does not validate any more than that.
|
Ensures a download strategy exists for a source. Does not validate any more than that.
|
||||||
|
|
@ -136,8 +159,8 @@ class Preset(StrictDictValidator):
|
||||||
|
|
||||||
return downloader, download_options
|
return downloader, download_options
|
||||||
|
|
||||||
def __validate_and_get_plugins(self) -> List[Tuple[Type[Plugin], PluginOptions]]:
|
def __validate_and_get_plugins(self) -> PresetPlugins:
|
||||||
plugins: List[Tuple[Type[Plugin], PluginOptions]] = []
|
plugins = PresetPlugins()
|
||||||
|
|
||||||
for key in self._keys:
|
for key in self._keys:
|
||||||
if key not in PluginMapping.plugins():
|
if key not in PluginMapping.plugins():
|
||||||
|
|
@ -149,7 +172,7 @@ class Preset(StrictDictValidator):
|
||||||
source_variables=self._source_variables, override_variables=self.overrides.keys
|
source_variables=self._source_variables, override_variables=self.overrides.keys
|
||||||
)
|
)
|
||||||
|
|
||||||
plugins.append((plugin, plugin_options))
|
plugins.add(plugin_type=plugin, plugin_options=plugin_options)
|
||||||
|
|
||||||
return plugins
|
return plugins
|
||||||
|
|
||||||
|
|
@ -167,7 +190,7 @@ class Preset(StrictDictValidator):
|
||||||
variable_dict = dict(source_variables, **variable_dict)
|
variable_dict = dict(source_variables, **variable_dict)
|
||||||
|
|
||||||
# For all plugins, add in any extra added source variables
|
# For all plugins, add in any extra added source variables
|
||||||
for _, plugin_options in self.plugins:
|
for plugin_options in self.plugins.plugin_options:
|
||||||
added_plugin_variables = {
|
added_plugin_variables = {
|
||||||
source_var: "dummy_string" for source_var in plugin_options.added_source_variables()
|
source_var: "dummy_string" for source_var in plugin_options.added_source_variables()
|
||||||
}
|
}
|
||||||
|
|
@ -253,7 +276,7 @@ class Preset(StrictDictValidator):
|
||||||
)
|
)
|
||||||
|
|
||||||
self.overrides = self._validate_key(key="overrides", validator=Overrides, default={})
|
self.overrides = self._validate_key(key="overrides", validator=Overrides, default={})
|
||||||
self.plugins = self.__validate_and_get_plugins()
|
self.plugins: PresetPlugins = self.__validate_and_get_plugins()
|
||||||
|
|
||||||
# After all options are initialized, perform a recursive post-validate that requires
|
# After all options are initialized, perform a recursive post-validate that requires
|
||||||
# values from multiple validators
|
# values from multiple validators
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ from ytdl_sub.plugins.nfo_tags import NfoTagsPlugin
|
||||||
from ytdl_sub.plugins.output_directory_nfo_tags import OutputDirectoryNfoTagsPlugin
|
from ytdl_sub.plugins.output_directory_nfo_tags import OutputDirectoryNfoTagsPlugin
|
||||||
from ytdl_sub.plugins.plugin import Plugin
|
from ytdl_sub.plugins.plugin import Plugin
|
||||||
from ytdl_sub.plugins.regex import RegexPlugin
|
from ytdl_sub.plugins.regex import RegexPlugin
|
||||||
|
from ytdl_sub.plugins.subtitles import SubtitlesPlugin
|
||||||
|
|
||||||
|
|
||||||
class DownloadStrategyMapping:
|
class DownloadStrategyMapping:
|
||||||
|
|
@ -109,6 +110,7 @@ class PluginMapping:
|
||||||
"nfo_tags": NfoTagsPlugin,
|
"nfo_tags": NfoTagsPlugin,
|
||||||
"output_directory_nfo_tags": OutputDirectoryNfoTagsPlugin,
|
"output_directory_nfo_tags": OutputDirectoryNfoTagsPlugin,
|
||||||
"regex": RegexPlugin,
|
"regex": RegexPlugin,
|
||||||
|
"subtitles": SubtitlesPlugin,
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
from typing import Dict
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from yt_dlp.utils import DateRange
|
from yt_dlp.utils import DateRange
|
||||||
|
|
@ -74,15 +75,31 @@ class Overrides(DictFormatterValidator):
|
||||||
)
|
)
|
||||||
|
|
||||||
def apply_formatter(
|
def apply_formatter(
|
||||||
self, formatter: StringFormatterValidator, entry: Optional[Entry] = None
|
self,
|
||||||
|
formatter: StringFormatterValidator,
|
||||||
|
entry: Optional[Entry] = None,
|
||||||
|
function_overrides: Dict[str, str] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Returns the format_string after .format has been called on it using entry (if provided) and
|
Parameters
|
||||||
override values
|
----------
|
||||||
|
formatter
|
||||||
|
Formatter to apply
|
||||||
|
entry
|
||||||
|
Optional. Entry to add source variables to the formatter
|
||||||
|
function_overrides
|
||||||
|
Optional. Explicit values to override the overrides themselves and source variables
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
The format_string after .format has been called
|
||||||
"""
|
"""
|
||||||
variable_dict = self.dict_with_format_strings
|
variable_dict = self.dict_with_format_strings
|
||||||
if entry:
|
if entry:
|
||||||
variable_dict = dict(entry.to_dict(), **variable_dict)
|
variable_dict = dict(entry.to_dict(), **variable_dict)
|
||||||
|
if function_overrides:
|
||||||
|
variable_dict = dict(variable_dict, **function_overrides)
|
||||||
|
|
||||||
return formatter.apply_formatter(variable_dict)
|
return formatter.apply_formatter(variable_dict)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -110,6 +127,7 @@ class OutputOptions(StrictDictValidator):
|
||||||
_required_keys = {"output_directory", "file_name"}
|
_required_keys = {"output_directory", "file_name"}
|
||||||
_optional_keys = {
|
_optional_keys = {
|
||||||
"thumbnail_name",
|
"thumbnail_name",
|
||||||
|
"subtitles_name",
|
||||||
"maintain_download_archive",
|
"maintain_download_archive",
|
||||||
"keep_files_before",
|
"keep_files_before",
|
||||||
"keep_files_after",
|
"keep_files_after",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -56,15 +56,11 @@ class Downloader(DownloadArchiver, Generic[DownloaderOptionsT, DownloaderEntryT]
|
||||||
downloader_entry_type: Type[Entry] = Entry
|
downloader_entry_type: Type[Entry] = Entry
|
||||||
|
|
||||||
supports_download_archive: bool = True
|
supports_download_archive: bool = True
|
||||||
|
supports_subtitles: bool = True
|
||||||
|
|
||||||
_extract_entry_num_retries: int = 5
|
_extract_entry_num_retries: int = 5
|
||||||
_extract_entry_retry_wait_sec: int = 3
|
_extract_entry_retry_wait_sec: int = 3
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def ytdl_option_overrides(cls) -> Dict:
|
|
||||||
"""Global overrides that even overwrite user input"""
|
|
||||||
return {}
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def ytdl_option_defaults(cls) -> Dict:
|
def ytdl_option_defaults(cls) -> Dict:
|
||||||
"""
|
"""
|
||||||
|
|
@ -75,32 +71,11 @@ class Downloader(DownloadArchiver, Generic[DownloaderOptionsT, DownloaderEntryT]
|
||||||
"""
|
"""
|
||||||
return {"ignoreerrors": True}
|
return {"ignoreerrors": True}
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _configure_ytdl_options(
|
|
||||||
cls,
|
|
||||||
working_directory: str,
|
|
||||||
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)
|
|
||||||
|
|
||||||
# Overwrite defaults + input with global options
|
|
||||||
ytdl_options = dict(ytdl_options, **cls.ytdl_option_overrides())
|
|
||||||
|
|
||||||
# Overwrite the output location with the specified working directory
|
|
||||||
ytdl_options["outtmpl"] = str(Path(working_directory) / "%(id)s.%(ext)s")
|
|
||||||
|
|
||||||
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
|
||||||
|
|
@ -109,14 +84,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(
|
||||||
working_directory=self.working_directory,
|
self.ytdl_option_defaults(), before=True
|
||||||
)
|
)
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
|
|
@ -124,9 +99,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"):
|
||||||
|
|
@ -140,7 +113,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:
|
||||||
"""
|
"""
|
||||||
|
|
@ -186,7 +159,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)
|
||||||
|
|
@ -198,7 +170,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(
|
||||||
|
|
@ -312,19 +288,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
|
{
|
||||||
|
"skip_download": True,
|
||||||
ytdl_options_overrides = dict(ytdl_options_overrides, **extract_info_ytdl_options)
|
"writethumbnail": False,
|
||||||
|
"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:
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ class SoundcloudAlbumsAndSinglesDownloader(
|
||||||
SoundcloudDownloader[SoundcloudAlbumsAndSinglesDownloadOptions]
|
SoundcloudDownloader[SoundcloudAlbumsAndSinglesDownloadOptions]
|
||||||
):
|
):
|
||||||
downloader_options_type = SoundcloudAlbumsAndSinglesDownloadOptions
|
downloader_options_type = SoundcloudAlbumsAndSinglesDownloadOptions
|
||||||
|
supports_subtitles = False
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def ytdl_option_defaults(cls) -> Dict:
|
def ytdl_option_defaults(cls) -> Dict:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@ class YoutubeMergePlaylistDownloader(
|
||||||
downloader_options_type = YoutubeMergePlaylistDownloaderOptions
|
downloader_options_type = YoutubeMergePlaylistDownloaderOptions
|
||||||
downloader_entry_type = YoutubeVideo
|
downloader_entry_type = YoutubeVideo
|
||||||
supports_download_archive = False
|
supports_download_archive = False
|
||||||
|
supports_subtitles = False
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def ytdl_option_defaults(cls) -> Dict:
|
def ytdl_option_defaults(cls) -> Dict:
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,9 @@ class YoutubeSplitVideoDownloader(
|
||||||
downloader_options_type = YoutubeSplitVideoDownloaderOptions
|
downloader_options_type = YoutubeSplitVideoDownloaderOptions
|
||||||
downloader_entry_type = YoutubePlaylistVideo
|
downloader_entry_type = YoutubePlaylistVideo
|
||||||
|
|
||||||
|
supports_download_archive = False
|
||||||
|
supports_subtitles = False
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def ytdl_option_defaults(cls) -> Dict:
|
def ytdl_option_defaults(cls) -> Dict:
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
59
src/ytdl_sub/downloaders/ytdl_options_builder.py
Normal file
59
src/ytdl_sub/downloaders/ytdl_options_builder.py
Normal 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)
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
from abc import ABC
|
from abc import ABC
|
||||||
|
from typing import Dict
|
||||||
from typing import Generic
|
from typing import Generic
|
||||||
from typing import List
|
from typing import List
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
@ -73,6 +74,13 @@ class Plugin(DownloadArchiver, Generic[PluginOptionsT], ABC):
|
||||||
# TODO pass yaml snake case name in the class somewhere, and use it for the logger
|
# TODO pass yaml snake case name in the class somewhere, and use it for the logger
|
||||||
self._logger = Logger.get(self.__class__.__name__)
|
self._logger = Logger.get(self.__class__.__name__)
|
||||||
|
|
||||||
|
def ytdl_options(self) -> Optional[Dict]:
|
||||||
|
"""
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
ytdl options to enable/disable when downloading entries for this specific plugin
|
||||||
|
"""
|
||||||
|
|
||||||
# pylint: disable=no-self-use
|
# pylint: disable=no-self-use
|
||||||
def modify_entry(self, entry: Entry) -> Optional[Entry]:
|
def modify_entry(self, entry: Entry) -> Optional[Entry]:
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
209
src/ytdl_sub/plugins/subtitles.py
Normal file
209
src/ytdl_sub/plugins/subtitles.py
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict
|
||||||
|
from typing import List
|
||||||
|
from typing import Optional
|
||||||
|
from typing import Set
|
||||||
|
|
||||||
|
from ytdl_sub.downloaders.ytdl_options_builder import YTDLOptionsBuilder
|
||||||
|
from ytdl_sub.entries.entry import Entry
|
||||||
|
from ytdl_sub.plugins.plugin import Plugin
|
||||||
|
from ytdl_sub.plugins.plugin import PluginOptions
|
||||||
|
from ytdl_sub.utils.file_handler import FileMetadata
|
||||||
|
from ytdl_sub.validators.string_formatter_validators import StringFormatterValidator
|
||||||
|
from ytdl_sub.validators.string_select_validator import StringSelectValidator
|
||||||
|
from ytdl_sub.validators.validators import BoolValidator
|
||||||
|
from ytdl_sub.validators.validators import StringListValidator
|
||||||
|
|
||||||
|
SUBTITLE_EXTENSIONS: Set[str] = {"srt", "vtt", "ass", "lrc"}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_entry_subtitle_file(path: Path, entry: Entry) -> bool:
|
||||||
|
if path.is_file() and path.name.startswith(entry.uid):
|
||||||
|
for ext in SUBTITLE_EXTENSIONS:
|
||||||
|
if path.name.endswith(f".{ext}"):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class SubtitlesTypeValidator(StringSelectValidator):
|
||||||
|
_expected_value_type_name = "subtitles type"
|
||||||
|
_select_values = SUBTITLE_EXTENSIONS
|
||||||
|
|
||||||
|
|
||||||
|
class SubtitleOptions(PluginOptions):
|
||||||
|
"""
|
||||||
|
Defines how to download and store subtitles. Using this plugin creates two new variables:
|
||||||
|
``lang`` and ``subtitles_ext``. ``lang`` is dynamic since you can download multiple subtitles.
|
||||||
|
It will set the respective language to the correct subtitle file.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
|
||||||
|
.. code-block:: yaml
|
||||||
|
|
||||||
|
presets:
|
||||||
|
my_example_preset:
|
||||||
|
subtitle_options:
|
||||||
|
subtitles_name: "{title_sanitized}.{lang}.{subtitles_ext}"
|
||||||
|
subtitles_type: "srt"
|
||||||
|
embed_subtitles: False
|
||||||
|
languages: "en" # supports list of multiple languages
|
||||||
|
allow_auto_generated_subtitles: False
|
||||||
|
"""
|
||||||
|
|
||||||
|
_optional_keys = {
|
||||||
|
"subtitles_name",
|
||||||
|
"subtitles_type",
|
||||||
|
"embed_subtitles",
|
||||||
|
"languages",
|
||||||
|
"allow_auto_generated_subtitles",
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, name, value):
|
||||||
|
super().__init__(name, value)
|
||||||
|
self._subtitles_name = self._validate_key_if_present(
|
||||||
|
key="subtitles_name", validator=StringFormatterValidator
|
||||||
|
)
|
||||||
|
self._subtitles_type = self._validate_key_if_present(
|
||||||
|
key="subtitles_type", validator=SubtitlesTypeValidator, default="srt"
|
||||||
|
).value
|
||||||
|
self._embed_subtitles = self._validate_key_if_present(
|
||||||
|
key="embed_subtitles", validator=BoolValidator
|
||||||
|
).value
|
||||||
|
self._languages = self._validate_key_if_present(
|
||||||
|
key="languages", validator=StringListValidator, default=["en"]
|
||||||
|
).list
|
||||||
|
self._allow_auto_generated_subtitles = self._validate_key_if_present(
|
||||||
|
key="allow_auto_generated_subtitles", validator=BoolValidator, default=False
|
||||||
|
).value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def subtitles_name(self) -> Optional[StringFormatterValidator]:
|
||||||
|
"""
|
||||||
|
Optional. The file name for the media's subtitles if they are present. This can include
|
||||||
|
directories such as ``"Season {upload_year}/{title_sanitized}.{lang}.{subtitles_ext}"``, and
|
||||||
|
will be placed in the output directory. ``lang`` is dynamic since you can download multiple
|
||||||
|
subtitles. It will set the respective language to the correct subtitle file.
|
||||||
|
"""
|
||||||
|
return self._subtitles_name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def subtitles_type(self) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Optional. One of the subtitle file types "srt", "vtt", "ass", "lrc". Defaults to "srt"
|
||||||
|
"""
|
||||||
|
return self._subtitles_type
|
||||||
|
|
||||||
|
@property
|
||||||
|
def embed_subtitles(self) -> Optional[bool]:
|
||||||
|
"""
|
||||||
|
Optional. Whether to embed the subtitles into the video file. Defaults to False.
|
||||||
|
NOTE: webm files can only embed "vtt" subtitle types.
|
||||||
|
"""
|
||||||
|
return self._embed_subtitles
|
||||||
|
|
||||||
|
@property
|
||||||
|
def languages(self) -> Optional[List[str]]:
|
||||||
|
"""
|
||||||
|
Optional. Language code(s) to download for subtitles. Supports a single or list of multiple
|
||||||
|
language codes. Defaults to "en".
|
||||||
|
"""
|
||||||
|
return [lang.value for lang in self._languages]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def allow_auto_generated_subtitles(self) -> Optional[bool]:
|
||||||
|
"""
|
||||||
|
Optional. Whether to allow auto generated subtitles. Defaults to False.
|
||||||
|
"""
|
||||||
|
return self._allow_auto_generated_subtitles
|
||||||
|
|
||||||
|
def added_source_variables(self) -> List[str]:
|
||||||
|
"""
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
List of new source variables created by using the subtitles plugin
|
||||||
|
"""
|
||||||
|
return ["lang", "subtitles_ext"]
|
||||||
|
|
||||||
|
|
||||||
|
class SubtitlesPlugin(Plugin[SubtitleOptions]):
|
||||||
|
plugin_options_type = SubtitleOptions
|
||||||
|
|
||||||
|
def ytdl_options(self) -> Optional[Dict]:
|
||||||
|
ytdl_options_builder = YTDLOptionsBuilder()
|
||||||
|
|
||||||
|
write_subtitle_file: bool = self.plugin_options.subtitles_name is not None
|
||||||
|
if write_subtitle_file:
|
||||||
|
ytdl_options_builder.add(
|
||||||
|
{
|
||||||
|
"writesubtitles": True,
|
||||||
|
"postprocessors": {
|
||||||
|
"key": "FFmpegSubtitlesConvertor",
|
||||||
|
"format": self.plugin_options.subtitles_type,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.plugin_options.embed_subtitles:
|
||||||
|
ytdl_options_builder.add(
|
||||||
|
{
|
||||||
|
"postprocessors": [
|
||||||
|
# already_have_subtitle=True means keep the subtitle files
|
||||||
|
{"key": "FFmpegEmbedSubtitle", "already_have_subtitle": write_subtitle_file}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# If neither subtitles_name or embed_subtitles is set, do not set any other flags
|
||||||
|
if not ytdl_options_builder.to_dict():
|
||||||
|
return {}
|
||||||
|
|
||||||
|
return ytdl_options_builder.add(
|
||||||
|
{
|
||||||
|
"writeautomaticsub": self.plugin_options.allow_auto_generated_subtitles,
|
||||||
|
"subtitleslangs": self.plugin_options.languages,
|
||||||
|
}
|
||||||
|
).to_dict()
|
||||||
|
|
||||||
|
def modify_entry(self, entry: Entry) -> Optional[Entry]:
|
||||||
|
requested_subtitles = entry.kwargs("requested_subtitles")
|
||||||
|
languages = sorted(requested_subtitles.keys())
|
||||||
|
entry.add_variables(
|
||||||
|
variables_to_add={
|
||||||
|
"subtitles_ext": self.plugin_options.subtitles_type,
|
||||||
|
"lang": ",".join(languages),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return entry
|
||||||
|
|
||||||
|
def post_process_entry(self, entry: Entry) -> Optional[FileMetadata]:
|
||||||
|
"""
|
||||||
|
Creates an entry's NFO file using values defined in the metadata options
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
entry:
|
||||||
|
Entry to create subtitles for
|
||||||
|
"""
|
||||||
|
requested_subtitles = entry.kwargs("requested_subtitles")
|
||||||
|
file_metadata: Optional[FileMetadata] = None
|
||||||
|
langs = list(requested_subtitles.keys())
|
||||||
|
|
||||||
|
if self.plugin_options.embed_subtitles:
|
||||||
|
file_metadata = FileMetadata(f"Embedded subtitles with lang(s) {', '.join(langs)}")
|
||||||
|
if self.plugin_options.subtitles_name:
|
||||||
|
for lang in langs:
|
||||||
|
subtitle_file_name = f"{entry.uid}.{lang}.{self.plugin_options.subtitles_type}"
|
||||||
|
output_subtitle_file_name = self.overrides.apply_formatter(
|
||||||
|
formatter=self.plugin_options.subtitles_name,
|
||||||
|
entry=entry,
|
||||||
|
function_overrides={"lang": lang},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.save_file(
|
||||||
|
file_name=subtitle_file_name,
|
||||||
|
output_file_name=output_subtitle_file_name,
|
||||||
|
entry=entry,
|
||||||
|
)
|
||||||
|
|
||||||
|
return file_metadata
|
||||||
|
|
@ -1,17 +1,16 @@
|
||||||
import contextlib
|
import contextlib
|
||||||
import copy
|
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
from typing import List
|
from typing import List
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from typing import Tuple
|
|
||||||
from typing import Type
|
from typing import Type
|
||||||
|
|
||||||
from ytdl_sub.config.config_file import ConfigFile
|
from ytdl_sub.config.config_file import ConfigFile
|
||||||
from ytdl_sub.config.config_file import ConfigOptions
|
from ytdl_sub.config.config_file import ConfigOptions
|
||||||
from ytdl_sub.config.preset import Preset
|
from ytdl_sub.config.preset import Preset
|
||||||
|
from ytdl_sub.config.preset import PresetPlugins
|
||||||
from ytdl_sub.config.preset_options import OutputOptions
|
from ytdl_sub.config.preset_options import OutputOptions
|
||||||
from ytdl_sub.config.preset_options import Overrides
|
from ytdl_sub.config.preset_options import Overrides
|
||||||
from ytdl_sub.config.preset_options import YTDLOptions
|
from ytdl_sub.config.preset_options import YTDLOptions
|
||||||
|
|
@ -19,7 +18,7 @@ from ytdl_sub.downloaders.downloader import Downloader
|
||||||
from ytdl_sub.downloaders.downloader import DownloaderValidator
|
from ytdl_sub.downloaders.downloader import DownloaderValidator
|
||||||
from ytdl_sub.entries.entry import Entry
|
from ytdl_sub.entries.entry import Entry
|
||||||
from ytdl_sub.plugins.plugin import Plugin
|
from ytdl_sub.plugins.plugin import Plugin
|
||||||
from ytdl_sub.plugins.plugin import PluginOptions
|
from ytdl_sub.subscriptions.subscription_ytdl_options import SubscriptionYTDLOptions
|
||||||
from ytdl_sub.utils.file_handler import FileHandlerTransactionLog
|
from ytdl_sub.utils.file_handler import FileHandlerTransactionLog
|
||||||
from ytdl_sub.utils.file_handler import FileMetadata
|
from ytdl_sub.utils.file_handler import FileMetadata
|
||||||
from ytdl_sub.utils.thumbnail import convert_download_thumbnail
|
from ytdl_sub.utils.thumbnail import convert_download_thumbnail
|
||||||
|
|
@ -83,7 +82,7 @@ class Subscription:
|
||||||
return self.__preset_options.downloader_options
|
return self.__preset_options.downloader_options
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def plugins(self) -> List[Tuple[Type[Plugin], PluginOptions]]:
|
def plugins(self) -> PresetPlugins:
|
||||||
"""
|
"""
|
||||||
Returns
|
Returns
|
||||||
-------
|
-------
|
||||||
|
|
@ -178,6 +177,7 @@ class Subscription:
|
||||||
entry=entry,
|
entry=entry,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# TODO: see if entry even has a thumbnail
|
||||||
if self.output_options.thumbnail_name:
|
if self.output_options.thumbnail_name:
|
||||||
output_thumbnail_name = self.overrides.apply_formatter(
|
output_thumbnail_name = self.overrides.apply_formatter(
|
||||||
formatter=self.output_options.thumbnail_name, entry=entry
|
formatter=self.output_options.thumbnail_name, entry=entry
|
||||||
|
|
@ -240,7 +240,7 @@ class Subscription:
|
||||||
List of plugins defined in the subscription, initialized and ready to use.
|
List of plugins defined in the subscription, initialized and ready to use.
|
||||||
"""
|
"""
|
||||||
plugins: List[Plugin] = []
|
plugins: List[Plugin] = []
|
||||||
for plugin_type, plugin_options in self.plugins:
|
for plugin_type, plugin_options in self.plugins.zipped():
|
||||||
plugin = plugin_type(
|
plugin = plugin_type(
|
||||||
plugin_options=plugin_options,
|
plugin_options=plugin_options,
|
||||||
overrides=self.overrides,
|
overrides=self.overrides,
|
||||||
|
|
@ -262,25 +262,21 @@ class Subscription:
|
||||||
directory.
|
directory.
|
||||||
"""
|
"""
|
||||||
self._enhanced_download_archive.reinitialize(dry_run=dry_run)
|
self._enhanced_download_archive.reinitialize(dry_run=dry_run)
|
||||||
|
|
||||||
# TODO: Move this logic to separate function
|
|
||||||
# TODO: set id here as well
|
|
||||||
ytdl_options = copy.deepcopy(self.ytdl_options.dict)
|
|
||||||
ytdl_options["writethumbnail"] = True
|
|
||||||
if dry_run:
|
|
||||||
ytdl_options["skip_download"] = True
|
|
||||||
ytdl_options["writethumbnail"] = False
|
|
||||||
if self.downloader_class.supports_download_archive and self.maintain_download_archive:
|
|
||||||
ytdl_options["download_archive"] = str(
|
|
||||||
Path(self.working_directory) / self._enhanced_download_archive.archive_file_name
|
|
||||||
)
|
|
||||||
|
|
||||||
plugins = self._initialize_plugins()
|
plugins = self._initialize_plugins()
|
||||||
|
|
||||||
|
ytdl_options_builder = SubscriptionYTDLOptions(
|
||||||
|
preset=self.__preset_options,
|
||||||
|
plugins=plugins,
|
||||||
|
enhanced_download_archive=self._enhanced_download_archive,
|
||||||
|
working_directory=self.working_directory,
|
||||||
|
dry_run=dry_run,
|
||||||
|
).builder()
|
||||||
|
|
||||||
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():
|
||||||
|
|
|
||||||
147
src/ytdl_sub/subscriptions/subscription_ytdl_options.py
Normal file
147
src/ytdl_sub/subscriptions/subscription_ytdl_options.py
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict
|
||||||
|
from typing import List
|
||||||
|
from typing import Optional
|
||||||
|
from typing import Type
|
||||||
|
from typing import TypeVar
|
||||||
|
|
||||||
|
from ytdl_sub.config.preset import Preset
|
||||||
|
from ytdl_sub.downloaders.downloader import Downloader
|
||||||
|
from ytdl_sub.downloaders.ytdl_options_builder import YTDLOptionsBuilder
|
||||||
|
from ytdl_sub.plugins.plugin import Plugin
|
||||||
|
from ytdl_sub.plugins.subtitles import SubtitleOptions
|
||||||
|
from ytdl_sub.plugins.subtitles import SubtitlesPlugin
|
||||||
|
from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive
|
||||||
|
|
||||||
|
PluginT = TypeVar("PluginT", bound=Plugin)
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionYTDLOptions:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
preset: Preset,
|
||||||
|
plugins: List[Plugin],
|
||||||
|
enhanced_download_archive: EnhancedDownloadArchive,
|
||||||
|
working_directory: str,
|
||||||
|
dry_run: bool,
|
||||||
|
):
|
||||||
|
self._preset = preset
|
||||||
|
self._plugins = plugins
|
||||||
|
self._enhanced_download_archive = enhanced_download_archive
|
||||||
|
self._working_directory = working_directory
|
||||||
|
self._dry_run = dry_run
|
||||||
|
|
||||||
|
def _get_plugin(self, plugin_type: Type[PluginT]) -> Optional[PluginT]:
|
||||||
|
for plugin in self._plugins:
|
||||||
|
if isinstance(plugin, plugin_type):
|
||||||
|
return plugin
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _downloader(self) -> Type[Downloader]:
|
||||||
|
return self._preset.downloader
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _global_options(self) -> Dict:
|
||||||
|
"""
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
ytdl-options to apply to every run no matter what
|
||||||
|
"""
|
||||||
|
ytdl_options = {
|
||||||
|
# Download all files in the format of {id}.{ext}
|
||||||
|
"outtmpl": str(Path(self._working_directory) / "%(id)s.%(ext)s")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
self._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
|
||||||
|
def _dry_run_options(self) -> Dict:
|
||||||
|
return {
|
||||||
|
"skip_download": True,
|
||||||
|
"writethumbnail": False,
|
||||||
|
# TODO: find a way to not write subtitles; using `simulate: True` breaks tests
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _output_options(self) -> Dict:
|
||||||
|
ytdl_options = {}
|
||||||
|
output_options = self._preset.output_options
|
||||||
|
|
||||||
|
if output_options.thumbnail_name:
|
||||||
|
ytdl_options["writethumbnail"] = True
|
||||||
|
|
||||||
|
return ytdl_options
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _subtitle_options(self) -> Dict:
|
||||||
|
if not (subtitle_plugin := self._get_plugin(SubtitlesPlugin)):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
if not self._downloader.supports_subtitles:
|
||||||
|
# TODO: warn here
|
||||||
|
return {}
|
||||||
|
|
||||||
|
builder = YTDLOptionsBuilder().add({"writesubtitles": True})
|
||||||
|
subtitle_options: SubtitleOptions = subtitle_plugin.plugin_options
|
||||||
|
|
||||||
|
if subtitle_options.embed_subtitles:
|
||||||
|
builder.add(
|
||||||
|
{"postprocessors": [{"key": "FFmpegEmbedSubtitle", "already_have_subtitle": True}]}
|
||||||
|
)
|
||||||
|
|
||||||
|
if subtitle_options.subtitles_name:
|
||||||
|
builder.add(
|
||||||
|
{
|
||||||
|
"postprocessors": [
|
||||||
|
{
|
||||||
|
"key": "FFmpegSubtitlesConvertor",
|
||||||
|
"format": subtitle_options.subtitles_type,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# If neither subtitles_name or embed_subtitles is set, do not set any other flags
|
||||||
|
if not builder.to_dict():
|
||||||
|
return {}
|
||||||
|
|
||||||
|
builder.add(
|
||||||
|
{
|
||||||
|
"writeautomaticsub": subtitle_options.allow_auto_generated_subtitles,
|
||||||
|
"subtitleslangs": subtitle_options.languages,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return builder.to_dict()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _user_ytdl_options(self) -> Dict:
|
||||||
|
return self._preset.ytdl_options.dict
|
||||||
|
|
||||||
|
def builder(self) -> YTDLOptionsBuilder:
|
||||||
|
"""
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
YTDLOptionsBuilder
|
||||||
|
Builder with values set based on the subscription
|
||||||
|
"""
|
||||||
|
ytdl_options_builder = YTDLOptionsBuilder().add(self._global_options)
|
||||||
|
if self._dry_run:
|
||||||
|
ytdl_options_builder.add(
|
||||||
|
self._subtitle_options, self._user_ytdl_options, self._dry_run_options
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
ytdl_options_builder.add(
|
||||||
|
self._output_options, self._subtitle_options, self._user_ytdl_options
|
||||||
|
)
|
||||||
|
|
||||||
|
return ytdl_options_builder
|
||||||
|
|
@ -27,13 +27,6 @@ class StrictDictValidator(DictValidator):
|
||||||
if required_key not in self._dict:
|
if required_key not in self._dict:
|
||||||
raise self._validation_exception(f"missing the required field '{required_key}'")
|
raise self._validation_exception(f"missing the required field '{required_key}'")
|
||||||
|
|
||||||
# Ensure an empty dict was not passed as the value
|
|
||||||
if not self._dict:
|
|
||||||
raise self._validation_exception(
|
|
||||||
f"at least one of the following fields must be defined: "
|
|
||||||
f"{', '.join(self._optional_keys)}'"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Ensure all keys are either required or optional keys if no extra field are allowed
|
# Ensure all keys are either required or optional keys if no extra field are allowed
|
||||||
if not self._allow_extra_keys:
|
if not self._allow_extra_keys:
|
||||||
for object_key in self._keys:
|
for object_key in self._keys:
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,11 @@ class ListValidator(Validator, ABC, Generic[ValidatorT]):
|
||||||
return self._list
|
return self._list
|
||||||
|
|
||||||
|
|
||||||
|
class StringListValidator(ListValidator[StringValidator]):
|
||||||
|
_expected_value_type_name = "string list"
|
||||||
|
_inner_list_type = StringValidator
|
||||||
|
|
||||||
|
|
||||||
class DictValidator(Validator):
|
class DictValidator(Validator):
|
||||||
"""
|
"""
|
||||||
Validates dictionary-based fields. Errors to them as 'object's since this could be validating
|
Validates dictionary-based fields. Errors to them as 'object's since this could be validating
|
||||||
|
|
|
||||||
87
tests/e2e/plugins/test_subtitles.py
Normal file
87
tests/e2e/plugins/test_subtitles.py
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
import pytest
|
||||||
|
from e2e.expected_download import assert_expected_downloads
|
||||||
|
from e2e.expected_transaction_log import assert_transaction_log_matches
|
||||||
|
|
||||||
|
from ytdl_sub.subscriptions.subscription import Subscription
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def single_video_subs_embed_preset_dict(output_directory):
|
||||||
|
return {
|
||||||
|
"preset": "yt_music_video",
|
||||||
|
"youtube": {"video_url": "https://www.youtube.com/watch?v=2lAe1cqCOXo"},
|
||||||
|
# override the output directory with our fixture-generated dir
|
||||||
|
"output_options": {"output_directory": output_directory},
|
||||||
|
"subtitles": {
|
||||||
|
"embed_subtitles": True,
|
||||||
|
"languages": ["en", "de"],
|
||||||
|
"allow_auto_generated_subtitles": True,
|
||||||
|
},
|
||||||
|
# download the worst format so it is fast
|
||||||
|
"ytdl_options": {
|
||||||
|
"format": "worst[ext=mp4]",
|
||||||
|
},
|
||||||
|
"overrides": {"artist": "JMC"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_single_video_subs_embed_and_file_preset_dict(single_video_subs_embed_preset_dict):
|
||||||
|
single_video_subs_embed_preset_dict["subtitles"][
|
||||||
|
"subtitles_name"
|
||||||
|
] = "{music_video_name}.{lang}.{subtitles_ext}"
|
||||||
|
return single_video_subs_embed_preset_dict
|
||||||
|
|
||||||
|
|
||||||
|
class TestSubtitles:
|
||||||
|
@pytest.mark.parametrize("dry_run", [True, False])
|
||||||
|
def test_subtitles_embedded(
|
||||||
|
self,
|
||||||
|
music_video_config,
|
||||||
|
single_video_subs_embed_preset_dict,
|
||||||
|
output_directory,
|
||||||
|
dry_run,
|
||||||
|
):
|
||||||
|
subscription = Subscription.from_dict(
|
||||||
|
config=music_video_config,
|
||||||
|
preset_name="subtitles_embedded_test",
|
||||||
|
preset_dict=single_video_subs_embed_preset_dict,
|
||||||
|
)
|
||||||
|
|
||||||
|
transaction_log = subscription.download(dry_run=dry_run)
|
||||||
|
assert_transaction_log_matches(
|
||||||
|
output_directory=output_directory,
|
||||||
|
transaction_log=transaction_log,
|
||||||
|
transaction_log_summary_file_name="plugins/test_subtitles_embedded.txt",
|
||||||
|
)
|
||||||
|
assert_expected_downloads(
|
||||||
|
output_directory=output_directory,
|
||||||
|
dry_run=dry_run,
|
||||||
|
expected_download_summary_file_name="plugins/test_subtitles_embedded.json",
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("dry_run", [True, False])
|
||||||
|
def test_subtitles_embedded_and_file(
|
||||||
|
self,
|
||||||
|
music_video_config,
|
||||||
|
test_single_video_subs_embed_and_file_preset_dict,
|
||||||
|
output_directory,
|
||||||
|
dry_run,
|
||||||
|
):
|
||||||
|
subscription = Subscription.from_dict(
|
||||||
|
config=music_video_config,
|
||||||
|
preset_name="subtitles_embedded_and_file_test",
|
||||||
|
preset_dict=test_single_video_subs_embed_and_file_preset_dict,
|
||||||
|
)
|
||||||
|
|
||||||
|
transaction_log = subscription.download(dry_run=dry_run)
|
||||||
|
assert_transaction_log_matches(
|
||||||
|
output_directory=output_directory,
|
||||||
|
transaction_log=transaction_log,
|
||||||
|
transaction_log_summary_file_name="plugins/test_subtitles_embedded_and_file.txt",
|
||||||
|
)
|
||||||
|
assert_expected_downloads(
|
||||||
|
output_directory=output_directory,
|
||||||
|
dry_run=dry_run,
|
||||||
|
expected_download_summary_file_name="plugins/test_subtitles_embedded_and_file.json",
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"JMC - YouTube Rewind 2019: For the Record | #YouTubeRewind-thumb.jpg": "704246dd78074e8a0ec001dd8d03fd60",
|
||||||
|
"JMC - YouTube Rewind 2019: For the Record | #YouTubeRewind.mp4": "90ae944811ca3312fcb3175ea32a0aa5",
|
||||||
|
"JMC - YouTube Rewind 2019: For the Record | #YouTubeRewind.nfo": "ffa10f1cbc098ace7b1c7a8fbe3097a8"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"JMC - YouTube Rewind 2019: For the Record | #YouTubeRewind-thumb.jpg": "704246dd78074e8a0ec001dd8d03fd60",
|
||||||
|
"JMC - YouTube Rewind 2019: For the Record | #YouTubeRewind.de.srt": "b343c3bb9257b7ee7ba38f570a115b37",
|
||||||
|
"JMC - YouTube Rewind 2019: For the Record | #YouTubeRewind.en.srt": "fe8c6ee92cae6e059fd80fd61691adbe",
|
||||||
|
"JMC - YouTube Rewind 2019: For the Record | #YouTubeRewind.mp4": "90ae944811ca3312fcb3175ea32a0aa5",
|
||||||
|
"JMC - YouTube Rewind 2019: For the Record | #YouTubeRewind.nfo": "ffa10f1cbc098ace7b1c7a8fbe3097a8"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
Files created in '{output_directory}'
|
||||||
|
----------------------------------------
|
||||||
|
JMC - YouTube Rewind 2019: For the Record | #YouTubeRewind-thumb.jpg
|
||||||
|
JMC - YouTube Rewind 2019: For the Record | #YouTubeRewind.mp4
|
||||||
|
Embedded subtitles with lang(s) en, de
|
||||||
|
JMC - YouTube Rewind 2019: For the Record | #YouTubeRewind.nfo
|
||||||
|
NFO tags:
|
||||||
|
musicvideo:
|
||||||
|
album: Music Videos
|
||||||
|
artist: JMC
|
||||||
|
title: YouTube Rewind 2019: For the Record | #YouTubeRewind
|
||||||
|
year: 2019
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
Files created in '{output_directory}'
|
||||||
|
----------------------------------------
|
||||||
|
JMC - YouTube Rewind 2019: For the Record | #YouTubeRewind-thumb.jpg
|
||||||
|
JMC - YouTube Rewind 2019: For the Record | #YouTubeRewind.de.srt
|
||||||
|
JMC - YouTube Rewind 2019: For the Record | #YouTubeRewind.en.srt
|
||||||
|
JMC - YouTube Rewind 2019: For the Record | #YouTubeRewind.mp4
|
||||||
|
Embedded subtitles with lang(s) en, de
|
||||||
|
JMC - YouTube Rewind 2019: For the Record | #YouTubeRewind.nfo
|
||||||
|
NFO tags:
|
||||||
|
musicvideo:
|
||||||
|
album: Music Videos
|
||||||
|
artist: JMC
|
||||||
|
title: YouTube Rewind 2019: For the Record | #YouTubeRewind
|
||||||
|
year: 2019
|
||||||
Loading…
Reference in a new issue