import base64 import copy import glob import ipaddress import json import logging import os import re import shlex import socket import time import uuid from datetime import UTC, datetime, timedelta from functools import lru_cache, wraps from http.cookiejar import MozillaCookieJar from pathlib import Path from typing import Any, TypeVar from Crypto.Cipher import AES from yt_dlp.utils import age_restricted from .LogWrapper import LogWrapper from .mini_filter import match_str from .ytdlp import YTDLP LOG: logging.Logger = logging.getLogger("Utils") REMOVE_KEYS: list = [ { "paths": "-P, --paths", "outtmpl": "-o, --output", "progress_hooks": "--progress_hooks", "postprocessor_hooks": "--postprocessor_hooks", "post_hooks": "--post_hooks", }, { "quiet": "-q, --quiet", "no_warnings": "--no-warnings", "skip_download": "--skip-download", "forceprint": "-O, --print", "simulate": "--simulate", "noprogress": "--no-progress", "wait_for_video": "--wait-for-video", "progress_delta": " --progress-delta", "progress_template": "--progress-template", "consoletitle": "--console-title", "progress_with_newline": "--newline", "forcejson": "-j, --dump-single-json", "opt_update_to": "--update-to", "opt_ap_list_mso": "--ap-list-mso", "opt_batch_file": "-a, --batch-file", "opt_alias": "--alias", "opt_list_extractors": "--list-extractors", "opt_version": "--version", "opt_help": "-h, --help", "opt_update": "-U, --update", "opt_list_subtitles": "--list-subs", "opt_list_thumbnails": "--list-thumbnails", "opt_list_format": "-F, --list-formats", "opt_dump_agent": "--dump-user-agent", "opt_extractor_descriptions": "--extractor-descriptions", "opt_list_impersonate_targets": "--list-impersonate-targets", }, ] "Keys to remove from yt-dlp options at various levels." YTDLP_INFO_CLS: YTDLP = None "Cached YTDLP info class." ALLOWED_SUBS_EXTENSIONS: set[str] = {".srt", ".vtt", ".ass"} "Allowed subtitle file extensions." FILES_TYPE: list = [ {"rx": re.compile(r"\.(avi|ts|mkv|mp4|mp3|mpv|ogm|m4v|webm|m4b)$", re.IGNORECASE), "type": "video"}, {"rx": re.compile(r"\.(mp3|flac|aac|opus|wav|m4a)$", re.IGNORECASE), "type": "audio"}, {"rx": re.compile(r"\.(srt|ass|ssa|smi|sub|idx)$", re.IGNORECASE), "type": "subtitle"}, {"rx": re.compile(r"\.(jpg|jpeg|png|gif|bmp|webp)$", re.IGNORECASE), "type": "image"}, {"rx": re.compile(r"\.(txt|nfo|md|json|yml|yaml|plexmatch)$", re.IGNORECASE), "type": "text"}, {"rx": re.compile(r"\.(nfo|json|jpg|torrent|\.info\.json)$", re.IGNORECASE), "type": "metadata"}, ] "File type patterns for sidecar files." TAG_REGEX: re.Pattern[str] = re.compile(r"%{([^:}]+)(?::([^}]*))?}c") "Regex to find tags in templates." DT_PATTERN: re.Pattern[str] = re.compile(r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[+-]\d{2}:\d{2}))\s?") "Regex to match ISO 8601 datetime strings." T = TypeVar("T") "Generic type variable." class StreamingError(Exception): """Raised when an error occurs during streaming.""" class FileLogFormatter(logging.Formatter): def formatTime(self, record, datefmt=None): # noqa: ARG002, N802 return datetime.fromtimestamp(record.created).astimezone().isoformat(timespec="milliseconds") def timed_lru_cache(ttl_seconds: int, max_size: int = 128): """ Decorator that applies an LRU cache with a time-to-live (TTL) to a function. Supports both synchronous and asynchronous functions. Args: ttl_seconds (int): Time-to-live in seconds. max_size (int): Maximum size of the cache. Returns: Decorated function with caching capabilities. """ import inspect def decorator(func): is_async = inspect.iscoroutinefunction(func) if is_async: # For async functions, we need to cache the actual result, not the coroutine cache = {} cache_expiry = {} @wraps(func) async def async_wrapper(*args, **kwargs): key = str(args) + str(sorted(kwargs.items())) current_time = time.monotonic() # Check if we have a cached result that hasn't expired if key in cache and key in cache_expiry and current_time < cache_expiry[key]: cached_result = cache[key] try: return copy.deepcopy(cached_result) except Exception: return cached_result # Call the function and cache the result result = await func(*args, **kwargs) try: cached_value = copy.deepcopy(result) except Exception: cached_value = result cache[key] = cached_value cache_expiry[key] = current_time + ttl_seconds # Limit cache size if len(cache) > max_size: # Remove oldest entries oldest_keys = sorted(cache_expiry.keys(), key=lambda k: cache_expiry[k])[: len(cache) - max_size] for old_key in oldest_keys: cache.pop(old_key, None) cache_expiry.pop(old_key, None) try: return copy.deepcopy(cached_value) except Exception: return cached_value # Expose cache management methods def cache_clear(): cache.clear() cache_expiry.clear() def cache_info(): # Use functools._CacheInfo for compatibility with standard lru_cache from functools import _CacheInfo return _CacheInfo(hits=0, misses=len(cache), maxsize=max_size, currsize=len(cache)) async_wrapper.cache_clear = cache_clear async_wrapper.cache_info = cache_info return async_wrapper # For sync functions, use the original implementation @lru_cache(maxsize=max_size) def cached(*args, **kwargs): result = func(*args, **kwargs) try: return copy.deepcopy(result) except Exception: return result cached_expiry = time.monotonic() + ttl_seconds @wraps(func) def sync_wrapper(*args, **kwargs): nonlocal cached_expiry if time.monotonic() >= cached_expiry: cached.cache_clear() cached_expiry = time.monotonic() + ttl_seconds result = cached(*args, **kwargs) try: return copy.deepcopy(result) except Exception: return result # expose cache_clear, cache_info sync_wrapper.cache_clear = cached.cache_clear sync_wrapper.cache_info = cached.cache_info return sync_wrapper return decorator def calc_download_path(base_path: str | Path, folder: str | None = None, create_path: bool = True) -> str: """ Calculates download path and prevents folder traversal. Args: base_path (str): Base download path. folder (str): Folder to add to the base path. create_path (bool): Create the path if it does not exist. Returns: Download path with base folder factored in. """ if not isinstance(base_path, Path): base_path = Path(base_path) if not folder: return str(base_path) folder = folder.removeprefix("/") realBasePath: Path = base_path.resolve() download_path: Path = Path(realBasePath).joinpath(folder).resolve(strict=False) if not str(download_path).startswith(str(realBasePath)): msg: str = f'Folder "{folder}" must resolve inside the base download folder "{realBasePath}".' raise Exception(msg) try: download_path.relative_to(realBasePath) except ValueError as e: msg = f'Folder "{folder}" must resolve inside the base download folder "{realBasePath}".' raise Exception(msg) from e if not download_path.is_dir() and create_path: download_path.mkdir(parents=True, exist_ok=True) return str(download_path) def extract_info( config: dict, url: str, debug: bool = False, no_archive: bool = False, follow_redirect: bool = False, sanitize_info: bool = False, **kwargs, # noqa: ARG001 ) -> dict: """ Extracts video information from the given URL. Args: config (dict): Configuration options. url (str): URL to extract information from. debug (bool): Enable debug logging. no_archive (bool): Disable download archive. follow_redirect (bool): Follow URL redirects. sanitize_info (bool): Sanitize the extracted information **kwargs: Additional arguments. Returns: dict: Video information. """ params: dict = { **config, "color": "no_color", "extract_flat": True, "skip_download": True, "ignoreerrors": True, "ignore_no_formats_error": True, } # Remove keys that are not needed for info extraction. keys_to_remove: list = [key for key in params if str(key).startswith("write") or key in ["postprocessors"]] for key in keys_to_remove: params.pop(key, None) if debug: params["verbose"] = True else: params["quiet"] = True log_wrapper = LogWrapper() idDict: dict[str, str | None] = get_archive_id(url=url) archive_id: str | None = f".{idDict['id']}" if idDict.get("id") else None log_wrapper.add_target( target=logging.getLogger(f"yt-dlp{archive_id if archive_id else '.extract_info'}"), level=logging.DEBUG if debug else logging.WARNING, ) if "callback" in params: if isinstance(params["callback"], dict): log_wrapper.add_target( target=params["callback"]["func"], level=params["callback"]["level"] or logging.ERROR, name=params["callback"]["name"] or "callback", ) else: log_wrapper.add_target(target=params["callback"], level=logging.ERROR, name="callback") params.pop("callback", None) if log_wrapper.has_targets(): if "logger" in params: log_wrapper.add_target(target=params["logger"], level=logging.DEBUG) params["logger"] = log_wrapper if no_archive and "download_archive" in params: del params["download_archive"] data: dict[str, Any] | None = YTDLP(params=params).extract_info(url, download=False) if data and follow_redirect and "_type" in data and "url" == data["_type"]: return extract_info( config, data["url"], debug=debug, no_archive=no_archive, follow_redirect=follow_redirect, sanitize_info=sanitize_info, ) if not data: return data data["is_premiere"] = match_str("media_type=video & duration & is_live", data) if not data["is_premiere"]: data["is_premiere"] = "video" == data.get("media_type") and "is_upcoming" == data.get("live_status") return YTDLP.sanitize_info(data) if sanitize_info else data def _is_safe_key(key: any) -> bool: """ Check if a dictionary key is safe for merging. Blocks: - All dunder attributes (__*__) - Non-string keys (for consistency) - Keys that could be dangerous variations """ # Only allow string keys if not isinstance(key, str): return False # Block empty keys and whitespace-only keys if not key or not key.strip(): return False # Block only truly dangerous dunder patterns key_stripped: str = key.strip() # Block dunder attributes (starts AND ends with __) return not (key_stripped.startswith("__") and key_stripped.endswith("__")) def merge_dict( source: dict, destination: dict, max_depth: int = 50, max_list_size: int = 10000, _depth: int = 0, _seen: set = None ) -> dict: """ Merge data from source into destination safely with protection against DoS attacks. Args: source (dict): Source data destination (dict): Destination data max_depth (int): Maximum recursion depth allowed (default: 50) max_list_size (int): Maximum list size allowed (default: 10000) _depth (int): Internal recursion depth counter _seen (set): Internal circular reference tracker Returns: dict: The merged dictionary Raises: TypeError: If source or destination are not dictionaries RecursionError: If recursion depth exceeds safety limit ValueError: If circular reference is detected """ if not isinstance(source, dict) or not isinstance(destination, dict): msg = "Both source and destination must be dictionaries." raise TypeError(msg) # Prevent deep recursion DoS if _depth > max_depth: msg: str = f"Recursion depth limit exceeded ({max_depth})" raise RecursionError(msg) # Initialize circular reference tracking if _seen is None: _seen = set() # Check for circular references source_id = id(source) dest_id = id(destination) if source_id in _seen or dest_id in _seen: msg = "Circular reference detected" raise ValueError(msg) # Track current objects current_seen: set[Any | int] = _seen | {source_id, dest_id} # Create a clean copy of destination with only safe keys destination_copy: dict = {} for k, v in destination.items(): if _is_safe_key(k): # Prevent memory DoS from large lists if isinstance(v, list) and len(v) > max_list_size: destination_copy[k] = v[:max_list_size] # Truncate large lists else: destination_copy[k] = copy.deepcopy(v) for key, value in source.items(): # Skip unsafe keys if not _is_safe_key(key): continue destination_value: Any | None = destination_copy.get(key) # Recursively merge dictionaries with safety checks if isinstance(value, dict) and isinstance(destination_value, dict): destination_copy[key] = merge_dict( value, destination_value, max_depth, max_list_size, _depth + 1, current_seen ) # Safely extend lists with size limits elif isinstance(value, list) and isinstance(destination_value, list): # Prevent memory DoS combined_size = len(value) + len(destination_value) if combined_size > max_list_size: # Truncate to stay within limits available_space: int = max_list_size - len(destination_value) truncated_value: list = value[: max(0, available_space)] destination_copy[key] = copy.deepcopy(destination_value) + copy.deepcopy(truncated_value) else: destination_copy[key] = copy.deepcopy(destination_value) + copy.deepcopy(value) # For non-dict values or when destination doesn't have the key elif isinstance(value, dict): destination_copy[key] = merge_dict(value, {}, max_depth, max_list_size, _depth + 1, current_seen) elif isinstance(value, list) and len(value) > max_list_size: # Truncate large lists destination_copy[key] = copy.deepcopy(value[:max_list_size]) else: destination_copy[key] = copy.deepcopy(value) return destination_copy def check_id(file: Path) -> bool | str: """ Check if we are able to get an id from the file name. if so check if any video file with the same id exists. Args: file (Path): File to check. Returns: bool|str: False if no file found, else the file path. """ match: re.Match[str] | None = re.search( r"(?<=\[)(?:youtube-)?(?P[a-zA-Z0-9\-_]{11})(?=\])", file.stem, re.IGNORECASE ) if not match: return False id: str | None = match.groupdict().get("id") try: for f in file.parent.iterdir(): if id not in f.stem: continue if f.suffix != file.suffix: continue return f.absolute() except OSError as e: LOG.error(f"Error checking file '{file}': {e!s}") return False return False @timed_lru_cache(ttl_seconds=300, max_size=256) def is_private_address(hostname: str) -> bool: ip: str = socket.gethostbyname(hostname) ip_obj: ipaddress.IPv4Address | ipaddress.IPv6Address = ipaddress.ip_address(ip) return ip_obj.is_private or ip_obj.is_loopback or ip_obj.is_reserved or ip_obj.is_link_local def validate_url(url: str, allow_internal: bool = False) -> bool: """ Validate if the url is valid and allowed. Args: url (str): URL to validate. allow_internal (bool): If True, allow internal URLs or private networks. Returns: bool: True if the URL is valid and allowed. Raises: ValueError: If the URL is invalid or not allowed. """ if not url: msg = "URL is required." raise ValueError(msg) try: from yarl import URL parsed_url = URL(url) except ValueError: msg = "Invalid URL." raise ValueError(msg) # noqa: B904 # Check allowed schemes if parsed_url.scheme not in ["http", "https"]: msg = "Invalid scheme usage. Only HTTP or HTTPS allowed." raise ValueError(msg) hostname: str | None = parsed_url.host if not hostname: msg = "Invalid hostname." raise ValueError(msg) if allow_internal is False: try: if is_private_address(hostname): msg = "Access to internal urls or private networks is not allowed." raise ValueError(msg) except socket.gaierror as e: LOG.error(f"Error resolving hostname '{hostname}': {e!s}") msg = "Invalid hostname." raise ValueError(msg) from e return True def arg_converter( args: str, level: int | bool | None = None, dumps: bool = False, removed_options: list | None = None, keep_defaults: bool = False, ) -> dict: """ Convert yt-dlp options to a dictionary. Args: args (str): yt-dlp options string. level (int|bool|None): Level of options to remove, True for all. dumps (bool): Dump options as JSON. removed_options (list|None): List of removed options. keep_defaults (bool): Keep default options. Returns: dict: yt-dlp options dictionary. """ import yt_dlp.options create_parser = yt_dlp.options.create_parser def _default_opts(args: str): patched_parser = create_parser() try: yt_dlp.options.create_parser = lambda: patched_parser return yt_dlp.parse_options(args) finally: yt_dlp.options.create_parser = create_parser default_opts = _default_opts([]).ydl_opts if args: # important to ignore external config files. args = "--ignore-config " + args opts = yt_dlp.parse_options(shlex.split(args)).ydl_opts diff = {k: v for k, v in opts.items() if default_opts[k] != v} if not keep_defaults else opts.items() if "postprocessors" in diff: diff["postprocessors"] = [pp for pp in diff["postprocessors"] if pp not in default_opts["postprocessors"]] if "_warnings" in diff: diff.pop("_warnings", None) if level is True or isinstance(level, int): bad_options = {} if isinstance(level, bool) or not isinstance(level, int): level = len(REMOVE_KEYS) for i, item in enumerate(REMOVE_KEYS): if i > level: break bad_options.update(item.items()) for key in diff.copy(): if key not in bad_options: continue if isinstance(removed_options, list): removed_options.append(bad_options[key]) diff.pop(key, None) if dumps is True: from .encoder import Encoder if "match_filter" in diff: import inspect matchFilter = inspect.getclosurevars(diff["match_filter"].func).nonlocals["filters"] if isinstance(matchFilter, set): diff["match_filter"] = {"filters": list(matchFilter)} return json.loads(json.dumps(diff, cls=Encoder, default=str)) return diff def validate_uuid(uuid_str: str, version: int = 4) -> bool: """ Validate if the UUID is valid. Args: uuid_str (str): UUID to validate. version (int): The UUID version Returns: bool: True if the UUID is valid. """ try: uuid.UUID(uuid_str, version=version) return True except ValueError: return False @timed_lru_cache(ttl_seconds=60, max_size=256) def get_file_sidecar(file: Path | None = None) -> dict[dict]: """ Get sidecar files for the given file. Args: file (Path|None): The video file. Returns: dict: A dictionary with sidecar files categorized by type. """ files: dict = {} if not file: return files for i, f in enumerate(file.parent.glob(f"{glob.escape(file.stem)}.*")): if f == file or f.is_file() is False or f.stem.startswith("."): continue if f.stat().st_size < 1: continue content_type = "Unknown" for pattern in FILES_TYPE: if pattern["rx"].search(f.name): content_type: str = pattern["type"] break if content_type == "subtitle": if f.suffix not in ALLOWED_SUBS_EXTENSIONS: continue lg: re.Match[str] | None = re.search(r"\.(?P\w{2,3})\.\w{3}$", f.name) lang: str | None = lg.groupdict().get("lang") if lg else "und" content: dict[str, Any] = {"file": f, "lang": lang, "name": f"{f.suffix[1:].upper()} ({i}) - {lang}"} else: content = {"file": f} if content_type not in files: files[content_type] = [] files[content_type].append(content) images: list[dict] = get_possible_images(str(file.parent)) if len(images) > 0: if "image" not in files: files["image"] = [] files["image"].extend(images) return files def get_possible_images(dir: str) -> list[dict]: images: list = [] path_loc = Path(dir, "test.jpg") for filename in ["poster", "thumbnail", "artwork", "cover", "fanart"]: for ext in [".jpg", ".jpeg", ".png", ".webp"]: f = path_loc.with_stem(filename).with_suffix(ext) if f.exists(): images.append({"file": f}) return images def get_mime_type(metadata: dict, file_path: Path) -> str: """ Determine the correct MIME type for a video file based on ffprobe metadata. Args: metadata (dict): Parsed JSON output from ffprobe. file_path (str): The path to the video file for fallback detection. Returns: str: MIME type compatible with HTML5