ytptube/app/library/config.py

396 lines
12 KiB
Python

import logging
import multiprocessing
import os
import re
import sys
import time
from logging.handlers import RotatingFileHandler
from multiprocessing.managers import SyncManager
from pathlib import Path
import coloredlogs
from dotenv import load_dotenv
from yt_dlp.version import __version__ as YTDLP_VERSION
from .Utils import IGNORED_KEYS, load_file, merge_dict
from .version import APP_VERSION
class Config:
config_path: str = "."
"""The path to the configuration directory."""
download_path: str = "."
"""The path to the download directory."""
temp_path: str = "/tmp"
"""The path to the temporary directory."""
temp_keep: bool = False
"""Keep temporary files after the download is complete."""
url_socketio: str = "/socket.io"
"""The URL to use for the socket.io server."""
output_template: str = "%(title)s.%(ext)s"
"""The output template to use for the downloaded files."""
output_template_chapter: str = "%(title)s - %(section_number)s %(section_title)s.%(ext)s"
"""The output template to use for the downloaded files with chapters."""
keep_archive: bool = True
"""Keep the download archive file."""
ytdl_debug: bool = False
"""Enable yt-dlp debugging."""
allow_manifestless: bool = False
"""Allow downloading videos without manifest."""
host: str = "0.0.0.0"
"""The host to bind the server to."""
port: int = 8081
"""The port to bind the server to."""
log_level: str = "info"
"""The log level to use for the application."""
max_workers: int = 1
"""The maximum number of workers to use for downloading."""
streamer_vcodec: str = "libx264"
"""The video codec to use for streaming."""
streamer_acodec: str = "aac"
"""The audio codec to use for streaming."""
auth_username: str | None = None
"""The username to use for basic authentication."""
auth_password: str | None = None
"""The password to use for basic authentication."""
remove_files: bool = False
"""Remove downloaded files when removing the record."""
access_log: bool = True
"""Enable access logging."""
debug: bool = False
"""Enable debugging."""
debugpy_port: int = 5678
"""The port to use for the debugpy server."""
socket_timeout: int = 30
"""The socket timeout to use for yt-dlp."""
extract_info_timeout: int = 70
"""The timeout to use for extracting video information."""
db_file: str = "{config_path}/ytptube.db"
"""The path to the database file."""
manual_archive: str = "{config_path}/archive.manual.log"
"""The path to the manual archive file."""
ui_update_title: bool = True
"""Update the title of the browser tab with the current status."""
pip_packages: str = ""
"""The pip packages to install."""
pip_ignore_updates: bool = False
"""Ignore pip package updates."""
# immutable config vars.
version: str = APP_VERSION
"The version of the application."
__instance = None
"The instance of the class."
ytdl_options: dict = {}
"The options to use for yt-dlp."
new_version_available: bool = False
"A new version of the application is available."
ytdlp_version: str = YTDLP_VERSION
"The version of yt-dlp."
started: int = 0
"The time the application was started."
ignore_ui: bool = False
"Ignore the UI and run the application in the background."
basic_mode: bool = False
"Run the frontend in basic mode."
default_preset: str = "default"
"The default preset to use when no preset is specified."
instance_title: str | None = None
"The title of the instance."
file_logging: bool = False
"Enable file logging."
sentry_dsn: str | None = None
"The Sentry DSN to use for error reporting."
secret_key: str
"The secret key to use for the application."
console_enabled: bool = False
"Enable direct access to yt-dlp console."
_manual_vars: tuple = (
"temp_path",
"config_path",
"download_path",
)
"The variables that are set manually."
_immutable: tuple = (
"version",
"__instance",
"ytdl_options",
"tasks",
"new_version_available",
"ytdlp_version",
"started",
)
"The variables that are immutable."
_int_vars: tuple = (
"port",
"max_workers",
"socket_timeout",
"extract_info_timeout",
"debugpy_port",
)
"The variables that are integers."
_boolean_vars: tuple = (
"keep_archive",
"ytdl_debug",
"debug",
"temp_keep",
"allow_manifestless",
"access_log",
"remove_files",
"ignore_ui",
"ui_update_title",
"pip_ignore_updates",
"basic_mode",
"file_logging",
"console_enabled",
)
"The variables that are booleans."
_frontend_vars: tuple = (
"download_path",
"keep_archive",
"output_template",
"ytdlp_version",
"version",
"started",
"remove_files",
"ui_update_title",
"max_workers",
"basic_mode",
"default_preset",
"instance_title",
"sentry_dsn",
"console_enabled",
)
"The variables that are relevant to the frontend."
_manager: SyncManager | None = None
"The manager instance."
@staticmethod
def get_instance():
"""Static access method."""
return Config() if not Config.__instance else Config.__instance
@staticmethod
def get_manager() -> SyncManager:
if not Config._manager:
Config._manager = multiprocessing.Manager()
return Config._manager
def __init__(self):
"""Virtually private constructor."""
if Config.__instance is not None:
msg = "This class is a singleton. Use Config.get_instance() instead."
raise Exception(msg)
Config.__instance = self
baseDefaultPath: str = str(Path(__file__).parent.parent.parent.absolute())
self.temp_path = os.environ.get("YTP_TEMP_PATH", None) or os.path.join(baseDefaultPath, "var", "tmp")
self.config_path = os.environ.get("YTP_CONFIG_PATH", None) or os.path.join(baseDefaultPath, "var", "config")
self.download_path = os.environ.get("YTP_DOWNLOAD_PATH", None) or os.path.join(
baseDefaultPath, "var", "downloads"
)
envFile: str = os.path.join(self.config_path, ".env")
if os.path.exists(envFile):
logging.info(f"Loading environment variables from '{envFile}'.")
load_dotenv(envFile)
for k, v in self._get_attributes().items():
if k.startswith("_") or k in self._manual_vars:
continue
# If the variable declared as immutable, set it to the default value.
if k in self._immutable:
setattr(self, k, v)
continue
lookUpKey: str = f"YTP_{k}".upper()
setattr(self, k, os.environ.get(lookUpKey, v))
for k, v in self.__dict__.items():
if k.startswith("_") or k in self._immutable or k in self._manual_vars:
continue
if isinstance(v, str) and "{" in v and "}" in v:
for key in re.findall(r"\{.*?\}", v):
localKey: str = key[1:-1]
if localKey not in self.__dict__:
logging.error(f"Config variable '{k}' had non-existing config reference '{key}'.")
sys.exit(1)
v = v.replace(key, getattr(self, localKey))
setattr(self, k, v)
if k in self._boolean_vars:
if str(v).lower() not in (True, False, "true", "false", "on", "off", "1", "0"):
msg = f"Config variable '{k}' is set to a non-boolean value '{v}'."
raise ValueError(msg)
setattr(self, k, str(v).lower() in (True, "true", "on", "1"))
if k in self._int_vars:
setattr(self, k, int(v))
numeric_level = getattr(logging, self.log_level.upper(), None)
if not isinstance(numeric_level, int):
msg = f"Invalid log level '{self.log_level}' specified."
raise TypeError(msg)
coloredlogs.install(
level=numeric_level, fmt="%(asctime)s [%(name)s] [%(levelname)-5.5s] %(message)s", datefmt="%H:%M:%S"
)
LOG = logging.getLogger("config")
if self.debug:
try:
import debugpy
debugpy.listen(("0.0.0.0", self.debugpy_port), in_process_debug_adapter=True)
LOG.info(f"starting debugpy server on '0.0.0.0:{self.debugpy_port}'.")
except ImportError:
LOG.error("debugpy package not found, please install it with 'pip install debugpy'.")
except Exception as e:
LOG.error(f"Error starting debugpy server at '0.0.0.0:{self.debugpy_port}'. {e}")
optsFile: str = os.path.join(self.config_path, "ytdlp.json")
if os.path.exists(optsFile) and os.path.getsize(optsFile) > 5:
LOG.info(f"Loading yt-dlp custom options from '{optsFile}'.")
(opts, status, error) = load_file(optsFile, dict)
if not status:
LOG.error(f"Could not load yt-dlp custom options from '{optsFile}'. {error}")
sys.exit(1)
if isinstance(opts, dict):
for key in IGNORED_KEYS:
if key in opts:
LOG.error(f"Key '{key}' is not allowed to be loaded via 'ytdlp.json' file.")
del opts[key]
self.ytdl_options = merge_dict(self.ytdl_options, opts)
else:
LOG.error(f"Invalid yt-dlp custom options file '{optsFile}'.")
else:
LOG.info(f"No yt-dlp custom options found at '{optsFile}'.")
self.ytdl_options["socket_timeout"] = self.socket_timeout
if self.keep_archive:
LOG.info("keep archive option is enabled.")
self.ytdl_options["download_archive"] = os.path.join(self.config_path, "archive.log")
cookiesFile: str = os.path.join(self.config_path, "cookies.txt")
if os.path.exists(cookiesFile) and self.ytdl_options.get("cookiefile", None) is None:
LOG.info(f"Using cookies from '{cookiesFile}' as default.")
self.ytdl_options["cookiefile"] = cookiesFile
if self.temp_keep:
LOG.info("Keep temp files option is enabled.")
if self.auth_password and self.auth_username:
LOG.warning(f"Basic authentication enabled with username '{self.auth_username}'.")
if self.basic_mode:
LOG.info("The frontend is running in basic mode.")
if self.file_logging:
handler = RotatingFileHandler(
os.path.join(self.config_path, "app.log"), maxBytes=1 * 1024 * 1024, backupCount=3
)
handler.setLevel(logging.ERROR)
formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
handler.setFormatter(formatter)
logging.getLogger().addHandler(handler)
key_file: str = os.path.join(self.config_path, "secret.key")
# save key as bytes.
if os.path.exists(key_file) and os.path.getsize(key_file) > 5:
with open(key_file, "rb") as f:
self.secret_key = f.read().strip()
else:
self.secret_key = os.urandom(32)
with open(key_file, "wb") as f:
f.write(self.secret_key)
self.started = time.time()
def _get_attributes(self) -> dict:
attrs: dict = {}
vClass: str = self.__class__
for attribute in vClass.__dict__:
if attribute.startswith("_"):
continue
value = getattr(vClass, attribute)
if not callable(value):
attrs[attribute] = value
return attrs
def frontend(self) -> dict:
"""
Returns configuration variables relevant to the frontend.
Returns:
dict: A dictionary with the frontend configuration
"""
data = {k: getattr(self, k) for k in self._frontend_vars}
hasCookies = self.ytdl_options.get("cookiefile", None)
data["has_cookies"] = hasCookies is not None and os.path.exists(hasCookies)
return data