ytdl-sub/src/ytdl_sub/config/config_validator.py
Ross Patterson abd75f8005
[DOCS] logs: persistent logs config (#1304)
* docs(logs): Mimick argparse option default format

I find it more readable to use punctuation to separate technical information, such as
required vs optional or default values, from narrative description. Follow argparse's
lead, and put such information in parens following the narrative description.

* docs(usage): Consistent structure, clarifications

* docs(config): Clarify persist_logs behavior/opts

Clarify the `persist_logs:` options per [Discord discussion](https://discord.com/channels/994270357957648404/1409161361853780060/1409602529460879431).
2025-08-27 09:04:36 -07:00

264 lines
9.2 KiB
Python

import os
import posixpath
from typing import Any
from typing import Dict
from typing import Optional
from mergedeep import mergedeep
from yt_dlp.utils import datetime_from_str
from ytdl_sub.config.defaults import DEFAULT_FFMPEG_PATH
from ytdl_sub.config.defaults import DEFAULT_FFPROBE_PATH
from ytdl_sub.config.defaults import DEFAULT_LOCK_DIRECTORY
from ytdl_sub.config.defaults import MAX_FILE_NAME_BYTES
from ytdl_sub.prebuilt_presets import PREBUILT_PRESETS
from ytdl_sub.validators.file_path_validators import FFmpegFileValidator
from ytdl_sub.validators.file_path_validators import FFprobeFileValidator
from ytdl_sub.validators.strict_dict_validator import StrictDictValidator
from ytdl_sub.validators.validators import BoolValidator
from ytdl_sub.validators.validators import IntValidator
from ytdl_sub.validators.validators import LiteralDictValidator
from ytdl_sub.validators.validators import StringValidator
class ExperimentalValidator(StrictDictValidator):
_optional_keys = {"enable_update_with_info_json"}
_allow_extra_keys = True
def __init__(self, name: str, value: Any):
super().__init__(name, value)
self._enable_update_with_info_json = self._validate_key(
key="enable_update_with_info_json", validator=BoolValidator, default=False
)
@property
def enable_update_with_info_json(self) -> bool:
"""
Enables modifying subscription files using info.json files using the argument
``--update-with-info-json``. This feature is still being tested and has the ability to
destroy files. Ensure you have a full backup before usage. You have been warned!
"""
return self._enable_update_with_info_json.value
class PersistLogsValidator(StrictDictValidator):
_required_keys = {"logs_directory"}
_optional_keys = {"keep_logs_after", "keep_successful_logs"}
def __init__(self, name: str, value: Any):
super().__init__(name, value)
self._logs_directory = self._validate_key(key="logs_directory", validator=StringValidator)
self._keep_logs_after: Optional[str] = None
if keep_logs_validator := self._validate_key_if_present(
key="keep_logs_after", validator=StringValidator
):
try:
self._keep_logs_after = datetime_from_str(keep_logs_validator.value)
except Exception as exc:
raise self._validation_exception(f"Invalid datetime string: {str(exc)}")
self._keep_successful_logs = self._validate_key(
key="keep_successful_logs", validator=BoolValidator, default=True
)
@property
def logs_directory(self) -> str:
"""
Write log files to this directory with names like
``YYYY-mm-dd-HHMMSS.subscription_name.(success|error).log``. (required)
"""
return self._logs_directory.value
# pylint: disable=line-too-long
# @property
# def keep_logs_after(self) -> Optional[str]:
# """
# Optional. Keep logs after this date, in yt-dlp datetime format.
#
# .. code-block:: Markdown
#
# A string in the format YYYYMMDD or
# (now|today|yesterday|date)[+-][0-9](microsecond|second|minute|hour|day|week|month|year)(s)
#
# For example, ``today-1week`` means keep 1 week's worth of logs. By default, ytdl-sub will
# keep all log files.
# """
# return self._keep_logs_after
# pylint: enable=line-too-long
@property
def keep_successful_logs(self) -> bool:
"""
If the ``persist_logs:`` key is in the configuration, then ``ytdl-sub`` *always*
writes log files for the subscription both for successful downloads and when it
encounters an error while downloading. When this key is ``False``, only write
log files for errors. (default ``True``)
"""
return self._keep_successful_logs.value
class ConfigOptions(StrictDictValidator):
_optional_keys = {
"working_directory",
"umask",
"dl_aliases",
"persist_logs",
"lock_directory",
"ffmpeg_path",
"ffprobe_path",
"file_name_max_bytes",
"experimental",
}
def __init__(self, name: str, value: Any):
super().__init__(name, value)
self._working_directory = self._validate_key_if_present(
key="working_directory",
validator=StringValidator,
default=".ytdl-sub-working-directory",
)
self._umask = self._validate_key_if_present(
key="umask", validator=StringValidator, default="022"
)
self._dl_aliases = self._validate_key_if_present(
key="dl_aliases", validator=LiteralDictValidator
)
self._persist_logs = self._validate_key_if_present(
key="persist_logs", validator=PersistLogsValidator
)
self._lock_directory = self._validate_key(
key="lock_directory", validator=StringValidator, default=DEFAULT_LOCK_DIRECTORY
)
self._ffmpeg_path = self._validate_key(
key="ffmpeg_path", validator=FFmpegFileValidator, default=DEFAULT_FFMPEG_PATH
)
self._ffprobe_path = self._validate_key(
key="ffprobe_path", validator=FFprobeFileValidator, default=DEFAULT_FFPROBE_PATH
)
self._experimental = self._validate_key(
key="experimental", validator=ExperimentalValidator, default={}
)
self._file_name_max_bytes = self._validate_key(
key="file_name_max_bytes", validator=IntValidator, default=MAX_FILE_NAME_BYTES
)
@property
def working_directory(self) -> str:
"""
The directory to temporarily store downloaded files before moving them into their final
directory. (default ``./.ytdl-sub-working-directory``)
"""
# Expands tildas to actual paths, use native os sep
return os.path.expanduser(self._working_directory.value.replace(posixpath.sep, os.sep))
@property
def umask(self) -> Optional[str]:
"""
Umask in octal format to apply to every created file. (default ``022``)
"""
return self._umask.value
@property
def dl_aliases(self) -> Optional[Dict[str, str]]:
"""
.. _dl_aliases:
Alias definitions to shorten ``ytdl-sub dl`` arguments. For example,
.. code-block:: yaml
configuration:
dl_aliases:
mv: "--preset music_video"
u: "--download.url"
Simplifies
.. code-block:: bash
ytdl-sub dl --preset "Jellyfin Music Videos" --download.url "youtube.com/watch?v=a1b2c3"
to
.. code-block:: bash
ytdl-sub dl --mv --u "youtube.com/watch?v=a1b2c3"
"""
if self._dl_aliases:
return self._dl_aliases.dict
return {}
@property
def persist_logs(self) -> Optional[PersistLogsValidator]:
"""
Persist logs validator. readthedocs in the validator itself!
"""
return self._persist_logs
@property
def file_name_max_bytes(self) -> int:
"""
Max file name size in bytes. Most OS's typically default to 255 bytes.
"""
return self._file_name_max_bytes.value
@property
def experimental(self) -> ExperimentalValidator:
"""
Experimental validator. readthedocs in the validator itself!
"""
return self._experimental
@property
def lock_directory(self) -> str:
"""
The directory to temporarily store file locks, which prevents multiple instances
of ``ytdl-sub`` from running. Note that file locks do not work on
network-mounted directories. Ensure that this directory resides on the host
machine. (default ``/tmp``)
"""
return self._lock_directory.value
@property
def ffmpeg_path(self) -> str:
"""
Path to ffmpeg executable. (default ``/usr/bin/ffmpeg`` for Linux,
``./ffmpeg.exe`` in the same directory as ytdl-sub for Windows)
"""
return self._ffmpeg_path.value
@property
def ffprobe_path(self) -> str:
"""
Path to ffprobe executable. (default ``/usr/bin/ffprobe`` for Linux,
``./ffprobe.exe`` in the same directory as ytdl-sub for Windows)
"""
return self._ffprobe_path.value
class ConfigValidator(StrictDictValidator):
_optional_keys = {"configuration", "presets"}
def __init__(self, name: str, value: Any):
super().__init__(name, value)
self.config_options = self._validate_key_if_present(
"configuration", ConfigOptions, default={}
)
# Make sure presets is a dictionary. Will be validated in `PresetValidator`
self.presets = self._validate_key_if_present("presets", LiteralDictValidator, default={})
# Ensure custom presets do not collide with prebuilt presets
for preset_name in self.presets.keys:
if preset_name in PREBUILT_PRESETS:
raise self._validation_exception(
f"preset name '{preset_name}' conflicts with a prebuilt preset"
)
# Merge prebuilt presets into the config so custom presets can use them
mergedeep.merge(self.presets._value, PREBUILT_PRESETS)