ytdl-sub/src/ytdl_sub/plugins/subtitles.py
Jesse Bannon 863eae68a2
[FEATURE] New parameter for required subtitle languages, and fixes (#1357)
Closes https://github.com/jmbannon/ytdl-sub/issues/1269

Adds the new paramter `languages_required`, which forces an entry to have this set of subtitle files, otherwise it will error.
In addition, better file checking has been added to ensure they exist before attempting to move.
2025-10-11 17:22:13 -07:00

266 lines
9.9 KiB
Python

import os
from pathlib import Path
from typing import Dict
from typing import List
from typing import Optional
from typing import Set
from typing import Tuple
from ytdl_sub.config.plugin.plugin import Plugin
from ytdl_sub.config.plugin.plugin_operation import PluginOperation
from ytdl_sub.config.validators.options import ToggleableOptionsDictValidator
from ytdl_sub.downloaders.ytdl_options_builder import YTDLOptionsBuilder
from ytdl_sub.entries.entry import Entry
from ytdl_sub.entries.script.variable_definitions import VARIABLES
from ytdl_sub.entries.script.variable_definitions import VariableDefinitions
from ytdl_sub.script.utils.exceptions import UserThrownRuntimeError
from ytdl_sub.utils.file_handler import FileHandler
from ytdl_sub.utils.file_handler import FileMetadata
from ytdl_sub.utils.logger import Logger
from ytdl_sub.utils.subtitles import SUBTITLE_EXTENSIONS
from ytdl_sub.validators.file_path_validators import StringFormatterFileNameValidator
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
v: VariableDefinitions = VARIABLES
logger = Logger.get(name="subtitles")
class SubtitlesTypeValidator(StringSelectValidator):
_expected_value_type_name = "subtitles type"
_select_values = SUBTITLE_EXTENSIONS
class SubtitleOptions(ToggleableOptionsDictValidator):
"""
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
subtitles:
subtitles_name: "{title_sanitized}.{lang}.{subtitles_ext}"
subtitles_type: "srt"
embed_subtitles: False
languages:
- "en" # supports multiple languages
- "de"
allow_auto_generated_subtitles: False
"""
_optional_keys = {
"enable",
"subtitles_name",
"subtitles_type",
"embed_subtitles",
"languages",
"languages_required",
"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=StringFormatterFileNameValidator
)
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,
default=False,
).value
self._languages = self._validate_key_if_present(
key="languages", validator=StringListValidator, default=["en"]
).list
self._languages_required = self._validate_key_if_present(
key="languages_required", validator=StringListValidator, default=[]
).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]:
"""
:expected type: Optional[EntryFormatter]
:description:
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]:
"""
:expected type: Optional[String]
:description:
Defaults to "srt". One of the subtitle file types "srt", "vtt", "ass", "lrc".
"""
return self._subtitles_type
@property
def embed_subtitles(self) -> Optional[bool]:
"""
:expected type: Optional[Boolean]
:description:
Defaults to False. Whether to embed the subtitles into the video file. Note that
webm files can only embed "vtt" subtitle types.
"""
return self._embed_subtitles
@property
def languages(self) -> Optional[List[str]]:
"""
:expected type: Optional[List[String]]
:description:
Language code(s) to download for subtitles. Supports a single or list of multiple
language codes. Defaults to only "en".
"""
return [lang.value for lang in self._languages]
@property
def languages_required(self) -> Optional[List[str]]:
"""
:expected type: Optional[List[String]]
:description:
Language code(s) that are required to be present for downloads to continue. If missing,
ytdl-sub will throw an error. NOTE: currently this only checks file-based subtitles.
"""
return [lang.value for lang in self._languages_required]
@property
def allow_auto_generated_subtitles(self) -> Optional[bool]:
"""
:expected type: Optional[Boolean]
:description:
Defaults to False. Whether to allow auto generated subtitles.
"""
return self._allow_auto_generated_subtitles
def added_variables(
self,
unresolved_variables: Set[str],
) -> Dict[PluginOperation, Set[str]]:
"""
Returns
-------
List of new source variables created by using the subtitles plugin
"""
return {PluginOperation.MODIFY_ENTRY_METADATA: {"lang", "subtitles_ext"}}
class SubtitlesPlugin(Plugin[SubtitleOptions]):
plugin_options_type = SubtitleOptions
def ytdl_options(self) -> Optional[Dict]:
builder = YTDLOptionsBuilder().add(
{
"writesubtitles": True,
"sleep_interval_subtitles": 5.367, # for safe measure
}
)
if self.plugin_options.embed_subtitles:
builder.add(
{"postprocessors": [{"key": "FFmpegEmbedSubtitle", "already_have_subtitle": True}]}
)
if self.plugin_options.subtitles_name:
builder.add(
{
"postprocessors": [
{
"key": "FFmpegSubtitlesConvertor",
"format": self.plugin_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": self.plugin_options.allow_auto_generated_subtitles,
"subtitleslangs": self.plugin_options.languages,
}
)
return builder.to_dict()
def modify_entry_metadata(self, entry: Entry) -> Optional[Entry]:
entry.add({"subtitles_ext": self.plugin_options.subtitles_type, "lang": ""})
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.get(v.requested_subtitles, expected_type=dict)
if not requested_subtitles:
logger.debug("subtitles not found for %s", entry.title)
return None
file_metadata: Optional[FileMetadata] = None
langs = list(requested_subtitles.keys())
# HACK to maintain order of languages for fixtures
if len(langs) == len(self.plugin_options.languages):
langs = self.plugin_options.languages
if self.plugin_options.embed_subtitles:
file_metadata = FileMetadata(f"Embedded subtitles with lang(s) {', '.join(langs)}")
if self.plugin_options.subtitles_name:
download_subtitle_lang_file_names: List[Tuple[str, str]] = []
for lang in langs:
download_subtitle_file_name = entry.base_filename(
ext=f"{lang}.{self.plugin_options.subtitles_type}"
)
if os.path.isfile(Path(self.working_directory) / download_subtitle_file_name):
download_subtitle_lang_file_names.append((lang, download_subtitle_file_name))
elif lang in self.plugin_options.languages_required:
raise UserThrownRuntimeError(
f"Required the subtitle lang {lang}, but the file could not be found for "
f"the entry {entry.title}."
)
for lang, file_name in download_subtitle_lang_file_names:
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=file_name,
output_file_name=output_subtitle_file_name,
entry=entry,
)
# Delete any possible original subtitle files before conversion
# Can happen for both file and embedded subs
for lang in langs:
for possible_ext in SUBTITLE_EXTENSIONS:
possible_subs_filename = entry.base_filename(ext=f"{lang}.{possible_ext}")
possible_subs_file = Path(self.working_directory) / possible_subs_filename
FileHandler.delete(possible_subs_file)
return file_metadata