subtitles now a plugin

This commit is contained in:
jbannon 2022-08-11 06:36:10 +00:00
parent baae5ff72d
commit ad17bb6ddb
8 changed files with 248 additions and 127 deletions

View file

@ -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
@ -14,7 +15,6 @@ from ytdl_sub.config.preset_class_mappings import DownloadStrategyMapping
from ytdl_sub.config.preset_class_mappings import PluginMapping from ytdl_sub.config.preset_class_mappings import PluginMapping
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 SubtitleOptions
from ytdl_sub.config.preset_options import YTDLOptions from ytdl_sub.config.preset_options import YTDLOptions
from ytdl_sub.downloaders.downloader import Downloader from ytdl_sub.downloaders.downloader import Downloader
from ytdl_sub.downloaders.downloader import DownloaderValidator from ytdl_sub.downloaders.downloader import DownloaderValidator
@ -33,7 +33,6 @@ from ytdl_sub.validators.validators import Validator
PRESET_KEYS = { PRESET_KEYS = {
"preset", "preset",
"output_options", "output_options",
"subtitle_options",
"ytdl_options", "ytdl_options",
"overrides", "overrides",
*DownloadStrategyMapping.sources(), *DownloadStrategyMapping.sources(),
@ -41,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.
@ -79,8 +100,6 @@ 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
@ -140,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():
@ -153,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
@ -171,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()
} }
@ -252,16 +271,12 @@ class Preset(StrictDictValidator):
validator=OutputOptions, validator=OutputOptions,
) )
self.subtitle_options = self._validate_key_if_present(
key="subtitle_options", validator=SubtitleOptions, default={}
)
self.ytdl_options = self._validate_key( self.ytdl_options = self._validate_key(
key="ytdl_options", validator=YTDLOptions, default={} key="ytdl_options", validator=YTDLOptions, default={}
) )
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

View file

@ -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

View file

@ -1,4 +1,3 @@
from typing import List
from typing import Optional from typing import Optional
from yt_dlp.utils import DateRange from yt_dlp.utils import DateRange
@ -10,10 +9,8 @@ from ytdl_sub.validators.string_datetime import StringDatetimeValidator
from ytdl_sub.validators.string_formatter_validators import DictFormatterValidator from ytdl_sub.validators.string_formatter_validators import DictFormatterValidator
from ytdl_sub.validators.string_formatter_validators import OverridesStringFormatterValidator from ytdl_sub.validators.string_formatter_validators import OverridesStringFormatterValidator
from ytdl_sub.validators.string_formatter_validators import StringFormatterValidator 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 BoolValidator
from ytdl_sub.validators.validators import LiteralDictValidator from ytdl_sub.validators.validators import LiteralDictValidator
from ytdl_sub.validators.validators import StringListValidator
class YTDLOptions(LiteralDictValidator): class YTDLOptions(LiteralDictValidator):
@ -225,93 +222,3 @@ class OutputOptions(StrictDictValidator):
end=self.keep_files_before.datetime_str if self.keep_files_before else None, end=self.keep_files_before.datetime_str if self.keep_files_before else None,
) )
return None return None
class SubtitlesTypeValidator(StringSelectValidator):
_expected_value_type_name = "subtitles type"
_select_values = {"srt", "vtt", "ass", "lrc"}
class SubtitleOptions(StrictDictValidator):
"""
Defines how to download and store subtitles.
Usage:
.. code-block:: yaml
presets:
my_example_preset:
subtitle_options:
# required
output_directory: "/path/to/videos_or_music"
file_name: "{title_sanitized}.{ext}"
# optional
thumbnail_name: "{title_sanitized}.{thumbnail_ext}"
maintain_download_archive: True
keep_files_before: now
keep_files_after: 19000101
"""
_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"
)
self._embed_subtitles = self._validate_key_if_present(
key="embed_subtitles", validator=BoolValidator
)
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}.{subtitle_ext}"``, and will be placed
in the output directory.
"""
return self._subtitles_name
@property
def subtitles_type(self) -> Optional[str]:
"""
Optional. The subtitles file format. Defaults to ``srt``
"""
return self._subtitles_type
@property
def embed_subtitles(self) -> Optional[bool]:
"""
Optional. Whether to embed the subtitles into the video file.
"""
return self._subtitles_type
@property
def languages(self) -> Optional[List[str]]:
"""
Optional. Language(s) to download for subtitles. 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

View file

@ -118,8 +118,7 @@ class EntryVariables(SourceVariables):
Returns Returns
------- -------
str str
The download entry's thumbnail extension. Will always return 'str'. Until there is a The download entry's subtitles extension.
need to support other subtitle types, we always use srt.
""" """
return "srt" return "srt"

View file

@ -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]:
""" """

View file

@ -0,0 +1,170 @@
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.validators.strict_dict_validator import StrictDictValidator
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"}
class SubtitlesTypeValidator(StringSelectValidator):
_expected_value_type_name = "subtitles type"
_select_values = SUBTITLE_EXTENSIONS
class SubtitleOptions(StrictDictValidator):
"""
Defines how to download and store subtitles.
Usage:
.. code-block:: yaml
presets:
my_example_preset:
subtitle_options:
# required
output_directory: "/path/to/videos_or_music"
file_name: "{title_sanitized}.{ext}"
# optional
thumbnail_name: "{title_sanitized}.{thumbnail_ext}"
maintain_download_archive: True
keep_files_before: now
keep_files_after: 19000101
"""
_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"
)
self._embed_subtitles = self._validate_key_if_present(
key="embed_subtitles", validator=BoolValidator
)
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}.{subtitle_ext}"``, and will be placed
in the output directory.
"""
return self._subtitles_name
@property
def subtitles_type(self) -> Optional[str]:
"""
Optional. The subtitles file format. Defaults to ``srt``
"""
return self._subtitles_type
@property
def embed_subtitles(self) -> Optional[bool]:
"""
Optional. Whether to embed the subtitles into the video file.
"""
return self._subtitles_type
@property
def languages(self) -> Optional[List[str]]:
"""
Optional. Language(s) to download for subtitles. 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
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 post_process_entry(self, entry: Entry) -> None:
"""
Creates an entry's NFO file using values defined in the metadata options
Parameters
----------
entry:
Entry to create an NFO file for
"""
# def get_ytdlp_download_subtitle_paths(self) -> List[str]:
# possible_subtitle_exts = SUBTITLE_EXTENSIONS
# subtitle_paths: List[str] = []
#
# for ext in possible_subtitle_exts:
# for path in Path(self.working_directory()).rglob("*"):
# if (
# path.is_file()
# and path.name.startswith(self.uid)
# and path.name.endswith(f".{ext}")
# ):
# subtitle_paths.append(str(path))
#
# return subtitle_paths

View file

@ -5,21 +5,20 @@ 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 SubtitleOptions
from ytdl_sub.config.preset_options import YTDLOptions from ytdl_sub.config.preset_options import YTDLOptions
from ytdl_sub.downloaders.downloader import Downloader 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.plugins.subtitles import SubtitleOptions
from ytdl_sub.subscriptions.subscription_ytdl_options import SubscriptionYTDLOptions 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
@ -84,7 +83,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
------- -------
@ -204,18 +203,17 @@ class Subscription:
entry=entry, entry=entry,
) )
# if self.downloader_class.supports_subtitles and self.subtitle_options.subtitles_name and ( if self.downloader_class.supports_subtitles and self.subtitle_options.subtitles_name:
# entry.kwargs_contains("subtitles") or entry.kwargs_contains("automatic_captions")
# ): output_subtitles_name = self.overrides.apply_formatter(
# output_subtitles_name = self.overrides.apply_formatter( formatter=self.output_options.subtitles_name, entry=entry
# formatter=self.output_options.subtitles_name, entry=entry )
# )
# self._enhanced_download_archive.save_file_to_output_directory(
# self._enhanced_download_archive.save_file_to_output_directory( file_name=entry.get_download_subtitles_name(),
# file_name=entry.get_download_subtitles_name(), output_file_name=output_subtitles_name,
# output_file_name=output_subtitles_name, entry=entry,
# entry=entry, )
# )
@contextlib.contextmanager @contextlib.contextmanager
def _prepare_working_directory(self): def _prepare_working_directory(self):
@ -264,7 +262,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,
@ -286,14 +284,16 @@ class Subscription:
directory. directory.
""" """
self._enhanced_download_archive.reinitialize(dry_run=dry_run) self._enhanced_download_archive.reinitialize(dry_run=dry_run)
plugins = self._initialize_plugins()
ytdl_options_builder = SubscriptionYTDLOptions( ytdl_options_builder = SubscriptionYTDLOptions(
preset=self.__preset_options, preset=self.__preset_options,
plugins=plugins,
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,
).builder() ).builder()
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,

View file

@ -1,26 +1,42 @@
from pathlib import Path from pathlib import Path
from typing import Dict from typing import Dict
from typing import List
from typing import Optional
from typing import Type from typing import Type
from typing import TypeVar
from ytdl_sub.config.preset import Preset from ytdl_sub.config.preset import Preset
from ytdl_sub.downloaders.downloader import Downloader from ytdl_sub.downloaders.downloader import Downloader
from ytdl_sub.downloaders.ytdl_options_builder import YTDLOptionsBuilder 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 from ytdl_sub.ytdl_additions.enhanced_download_archive import EnhancedDownloadArchive
PluginT = TypeVar("PluginT", bound=Plugin)
class SubscriptionYTDLOptions: class SubscriptionYTDLOptions:
def __init__( def __init__(
self, self,
preset: Preset, preset: Preset,
plugins: List[Plugin],
enhanced_download_archive: EnhancedDownloadArchive, enhanced_download_archive: EnhancedDownloadArchive,
working_directory: str, working_directory: str,
dry_run: bool, dry_run: bool,
): ):
self._preset = preset self._preset = preset
self._plugins = plugins
self._enhanced_download_archive = enhanced_download_archive self._enhanced_download_archive = enhanced_download_archive
self._working_directory = working_directory self._working_directory = working_directory
self._dry_run = dry_run 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 @property
def _downloader(self) -> Type[Downloader]: def _downloader(self) -> Type[Downloader]:
return self._preset.downloader return self._preset.downloader
@ -67,11 +83,15 @@ class SubscriptionYTDLOptions:
@property @property
def _subtitle_options(self) -> Dict: def _subtitle_options(self) -> Dict:
if not (subtitle_plugin := self._get_plugin(SubtitlesPlugin)):
return {}
if not self._downloader.supports_subtitles: if not self._downloader.supports_subtitles:
# TODO: warn here
return {} return {}
ytdl_options: Dict = {} ytdl_options: Dict = {}
subtitle_options = self._preset.subtitle_options subtitle_options: SubtitleOptions = subtitle_plugin.plugin_options
write_subtitle_file: bool = subtitle_options.subtitles_name is not None write_subtitle_file: bool = subtitle_options.subtitles_name is not None
if write_subtitle_file: if write_subtitle_file: