396 lines
12 KiB
Python
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
|