import yt_dlp import random import logging import os # Added for cookie rotation import random # Ensure random is imported from collections import deque from yt_dlp.utils import DownloadError from yt_dlp.networking.impersonate import ImpersonateTarget # Added for impersonation # Configure basic logging for this utility log = logging.getLogger(__name__) PROXY_FILE_PATH = 'proxies.txt' # Relative to project root PROXY_FORMAT = 'socks5://{}:1080' PROXY_SIGN_IN_WARNING = 'Sign in to confirm you’re not a bot' PROXY_TIMEOUT_STRINGS = ['timed out. Retrying', 'Unable to download webpage: timed out'] class ProxyRotationError(Exception): """Custom exception for when all proxies fail.""" pass class YtdlpProxyLogger: """Custom logger to detect proxy-related warnings.""" def __init__(self): self.proxy_error_detected = False def debug(self, msg): # You can optionally log debug messages if needed # log.debug(f"YTDLP_DEBUG: {msg}") pass def warning(self, msg): log.warning(f"YTDLP_WARN: {msg}") if PROXY_SIGN_IN_WARNING in msg or any(timeout_str in msg for timeout_str in PROXY_TIMEOUT_STRINGS): self.proxy_error_detected = True def error(self, msg): log.error(f"YTDLP_ERROR: {msg}") # Optionally, consider certain errors as proxy failures too # if "some other error text" in msg: # self.proxy_error_detected = True class ProxyRotator: """Manages a list of proxies, rotates them, and handles banning/recycling.""" def __init__(self, proxy_file=PROXY_FILE_PATH, proxy_format=PROXY_FORMAT): self.proxy_format = proxy_format self.active_proxies = deque() self.banned_proxies = set() self._load_proxies(proxy_file) log.info(f"ProxyRotator initialized with {len(self.active_proxies)} active proxies.") def _load_proxies(self, proxy_file): """Loads proxies from the file.""" try: with open(proxy_file, 'r') as f: hostnames = [line.strip() for line in f if line.strip()] if not hostnames: log.warning(f"Proxy file '{proxy_file}' is empty or contains no valid hostnames.") return formatted_proxies = [self.proxy_format.format(host) for host in hostnames] random.shuffle(formatted_proxies) # Shuffle initially self.active_proxies.extend(formatted_proxies) except FileNotFoundError: log.error(f"Proxy file '{proxy_file}' not found. Proxy rotation disabled.") except Exception as e: log.error(f"Error loading proxy file '{proxy_file}': {e}") def get_next_proxy(self): """Gets the next active proxy, recycling if necessary.""" if not self.active_proxies: log.warning("Active proxy list is empty. Attempting to recycle banned proxies.") self.recycle_proxies() if not self.active_proxies: log.error("No active proxies available, even after recycling.") return None # Rotate the deque: move the first element to the end self.active_proxies.rotate(-1) next_proxy = self.active_proxies[0] log.debug(f"Next proxy: {next_proxy}") return next_proxy def ban_proxy(self, proxy): """Moves a proxy from the active list to the banned set.""" if proxy in self.active_proxies: try: # Deques don't have a direct remove, so convert, remove, convert back temp_list = list(self.active_proxies) temp_list.remove(proxy) self.active_proxies = deque(temp_list) self.banned_proxies.add(proxy) log.warning(f"Banned proxy: {proxy}. Active: {len(self.active_proxies)}, Banned: {len(self.banned_proxies)}") except ValueError: # Should not happen if proxy was in active_proxies, but handle defensively log.warning(f"Proxy {proxy} already removed from active list?") elif proxy in self.banned_proxies: log.debug(f"Proxy {proxy} was already banned.") else: log.warning(f"Attempted to ban unknown proxy: {proxy}") def recycle_proxies(self): """Moves all banned proxies back to the active list and shuffles.""" if not self.banned_proxies: log.info("No proxies in the banned list to recycle.") return log.info(f"Recycling {len(self.banned_proxies)} banned proxies.") recycled_list = list(self.banned_proxies) random.shuffle(recycled_list) self.active_proxies.extend(recycled_list) self.banned_proxies.clear() log.info(f"Recycling complete. Active proxies: {len(self.active_proxies)}") def get_active_proxy_count(self): """Returns the number of currently active proxies.""" return len(self.active_proxies) # --- Impersonation Targets --- # List of available targets (use underscores as required by yt-dlp option) # List of simple browser identifiers expected by ImpersonateTarget.from_str() IMPERSONATE_TARGET_STRINGS = [ 'chrome', 'firefox', 'edge', 'safari' # Add more simple identifiers if needed and supported ] # --- Global Proxy Rotator Instance --- # Instantiate it once when the module is loaded proxy_rotator_instance = ProxyRotator() # --- Cookie File Rotation --- COOKIE_DIR_PATH = '/cookies' # Directory inside the container where cookie files are mounted class CookieRotator: """Manages a list of cookie files found in a directory and rotates them.""" def __init__(self, cookie_dir=COOKIE_DIR_PATH): self.cookie_dir = cookie_dir self.cookie_files = deque() self._load_cookie_files() log.info(f"CookieRotator initialized with {len(self.cookie_files)} cookie files from '{self.cookie_dir}'.") def _load_cookie_files(self): """Scans the directory for .txt cookie files.""" self.cookie_files.clear() try: if not os.path.isdir(self.cookie_dir): log.warning(f"Cookie directory '{self.cookie_dir}' not found or not a directory. Cookie rotation disabled.") return found_files = [ os.path.join(self.cookie_dir, f) for f in os.listdir(self.cookie_dir) if os.path.isfile(os.path.join(self.cookie_dir, f)) and f.lower().endswith('.txt') ] if not found_files: log.warning(f"No .txt cookie files found in '{self.cookie_dir}'. Cookie rotation disabled.") return random.shuffle(found_files) # Shuffle initially self.cookie_files.extend(found_files) log.info(f"Loaded {len(self.cookie_files)} cookie files: {', '.join(os.path.basename(f) for f in self.cookie_files)}") except Exception as e: log.error(f"Error loading cookie files from '{self.cookie_dir}': {e}") def get_next_cookie_file(self): """Gets the next cookie file path, reloading if the list is empty.""" if not self.cookie_files: log.warning("Cookie file list is empty. Attempting to reload.") self._load_cookie_files() # Reload the list if empty if not self.cookie_files: log.error("No cookie files available, even after reload.") return None # Rotate the deque: move the first element to the end self.cookie_files.rotate(-1) next_cookie_file = self.cookie_files[0] log.debug(f"Next cookie file: {os.path.basename(next_cookie_file)}") return next_cookie_file def get_cookie_file_count(self): """Returns the number of currently loaded cookie files.""" return len(self.cookie_files) # --- Global Cookie Rotator Instance --- # Instantiate it once when the module is loaded cookie_rotator_instance = CookieRotator() # --- End Cookie File Rotation --- # --- Wrapper Function --- def execute_ytdl_with_retry(base_options, url, action='extract_info'): """ Executes a yt-dlp action (extract_info or download) with optional proxy rotation, cookie rotation. Proxy rotation is OFF by default, enable with ENABLE_PROXY_ROTATION=true env var. Args: base_options (dict): The base yt-dlp options (without proxy or logger). url (str or list): The URL(s) to process. action (str): 'extract_info' or 'download'. Returns: The result of the yt-dlp action. Raises: ProxyRotationError: If all proxies fail during proxy rotation. DownloadError: If a non-proxy-related yt-dlp error occurs. ValueError: If an invalid action is specified. """ global proxy_rotator_instance, cookie_rotator_instance # Use global instances # --- Prepare Base Options for this execution --- # Start with a copy of the provided base options options = base_options.copy() # --- Add Impersonation Target for this call --- try: selected_target_str = random.choice(IMPERSONATE_TARGET_STRINGS) impersonate_target_obj = ImpersonateTarget.from_str(selected_target_str) options['impersonate'] = impersonate_target_obj log.debug(f"Using impersonate target for this call: {selected_target_str}") except NameError: # Handle case where IMPERSONATE_TARGET_STRINGS might not be defined log.error("IMPERSONATE_TARGET_STRINGS not defined. Impersonation disabled.") except ValueError: log.error(f"Failed to create ImpersonateTarget from string: {selected_target_str}. Impersonation disabled for this call.") # Optionally remove the key if it might exist from base_options options.pop('impersonate', None) except ImportError: log.error("ImpersonateTarget could not be imported. Is 'yt-dlp[curl-cffi]' installed correctly? Impersonation disabled.") options.pop('impersonate', None) # --- Proxy Enable Switch --- # Check environment variable to enable proxy rotation; default is disabled. enable_proxy = os.environ.get('ENABLE_PROXY_ROTATION', 'false').lower() == 'true' # --- Proxy Rotation Path (Only if enabled and proxies exist) --- if enable_proxy and proxy_rotator_instance and proxy_rotator_instance.get_active_proxy_count() > 0: log.info("ENABLE_PROXY_ROTATION is true and proxies are available. Proceeding with proxy rotation.") max_attempts = proxy_rotator_instance.get_active_proxy_count() + len(proxy_rotator_instance.banned_proxies) if max_attempts == 0: # Should not happen due to check above, but safety first raise ProxyRotationError("No proxies configured or loaded despite enable flag.") log.info(f"Attempting yt-dlp action '{action}' for '{url}' with proxy rotation (max attempts: {max_attempts})") last_error = None tried_proxies = set() while True: current_proxy = proxy_rotator_instance.get_next_proxy() if current_proxy is None: log.error(f"Failed to get any proxy during rotation loop. Aborting.") break if current_proxy in tried_proxies: log.warning(f"Already tried proxy {current_proxy} in this cycle. All active proxies failed.") break tried_proxies.add(current_proxy) log.info(f"Attempting with proxy {current_proxy} ({len(tried_proxies)}/{max_attempts} unique proxies tried this cycle)") # Add proxy for this attempt options['proxy'] = current_proxy # Add Cookie Rotation for this attempt if cookie_rotator_instance and cookie_rotator_instance.get_cookie_file_count() > 0: selected_cookie_file = cookie_rotator_instance.get_next_cookie_file() if selected_cookie_file: options['cookies'] = selected_cookie_file log.debug(f"Using Cookie File: {os.path.basename(selected_cookie_file)}") else: log.warning("Failed to get a cookie file from rotator during proxy loop.") # Remove cookies key if no file was selected for this attempt (prevents carrying over old value) elif 'cookies' in options: options.pop('cookies') log.debug("No cookie file selected for this attempt.") logger = YtdlpProxyLogger() options['logger'] = logger options['quiet'] = True options['socket_timeout'] = 10 log.debug(f"Final options for yt-dlp (attempt with proxy): {options}") logger.proxy_error_detected = False try: with yt_dlp.YoutubeDL(options) as ydl: if action == 'extract_info': url_to_extract = url[0] if isinstance(url, list) else url result = ydl.extract_info(url_to_extract, download=False) if result: log.debug(f"[Proxy Path] Extracted Info - ID: {result.get('id')}, Webpage URL: {result.get('webpage_url')}") elif action == 'download': result = ydl.download(url) else: raise ValueError(f"Invalid action specified: {action}") # Check logger *after* potential error but before returning success if logger.proxy_error_detected: log.warning(f"Proxy error detected with {current_proxy} even on success? Banning.") proxy_rotator_instance.ban_proxy(current_proxy) last_error = ProxyRotationError(f"Proxy error detected post-action with {current_proxy}") options.pop('proxy', None) # Remove failed proxy before continuing if 'cookies' in options: options.pop('cookies') # Remove cookies too for next attempt continue # Try next proxy log.info(f"Action '{action}' succeeded with proxy {current_proxy}") return result # Success! except DownloadError as e: last_error = e log.warning(f"DownloadError occurred with proxy {current_proxy}: {e}") error_str = str(e) # Check logger flag OR error message content for sign-in OR timeout strings is_proxy_related_error = ( logger.proxy_error_detected or PROXY_SIGN_IN_WARNING in error_str or any(timeout_str in error_str for timeout_str in PROXY_TIMEOUT_STRINGS) ) if is_proxy_related_error: log.warning(f"Proxy failure confirmed for {current_proxy}. Banning and retrying.") proxy_rotator_instance.ban_proxy(current_proxy) options.pop('proxy', None) # Remove failed proxy before continuing if 'cookies' in options: options.pop('cookies') # Remove cookies too for next attempt continue # Try next proxy else: log.error(f"Non-proxy related DownloadError with {current_proxy}. Aborting rotation.") raise e # Re-raise error if it wasn't the specific proxy warning except Exception as e: # Catch other potential errors during instantiation or download last_error = e log.error(f"Unexpected error with proxy {current_proxy}: {e}. Banning and retrying.") proxy_rotator_instance.ban_proxy(current_proxy) options.pop('proxy', None) # Remove failed proxy before continuing if 'cookies' in options: options.pop('cookies') # Remove cookies too for next attempt continue # Try next proxy # If loop finishes without success (meaning all active proxies failed in this cycle) log.error(f"All available proxy attempts failed for action '{action}' on '{url}'. Last error: {last_error}") raise ProxyRotationError("Proxy error occurred. Please try again.") from last_error # --- Direct Execution Path (Default or if proxy enabled but none available) --- else: if not enable_proxy: log.debug("Proxy rotation is disabled (default). Running directly.") else: # This means enable_proxy was true, but no proxies were found/loaded log.warning("ENABLE_PROXY_ROTATION is true, but no proxies available. Running directly.") # Add Cookie Rotation for direct execution if cookie_rotator_instance and cookie_rotator_instance.get_cookie_file_count() > 0: selected_cookie_file = cookie_rotator_instance.get_next_cookie_file() if selected_cookie_file: options['cookies'] = selected_cookie_file # Modify the global 'options' log.debug(f"[Direct] Using Cookie File: {os.path.basename(selected_cookie_file)}") else: log.warning("[Direct] Failed to get a cookie file from rotator.") else: log.debug("[Direct] Cookie rotation disabled or no cookie files found.") # Execute directly using the prepared 'options' try: # Set logger/quiet options just before execution logger = YtdlpProxyLogger() # Still useful for potential non-proxy warnings options['logger'] = logger options['quiet'] = True # No socket timeout needed here usually, rely on base_options or yt-dlp defaults log.debug(f"[Direct] Final options for yt-dlp: {options}") with yt_dlp.YoutubeDL(options) as ydl: if action == 'extract_info': url_to_extract = url[0] if isinstance(url, list) else url result = ydl.extract_info(url_to_extract, download=False) if result: log.debug(f"[Direct Path] Extracted Info - ID: {result.get('id')}, Webpage URL: {result.get('webpage_url')}") return result elif action == 'download': return ydl.download(url) else: raise ValueError(f"Invalid action specified: {action}") except DownloadError as e: log.error(f"yt-dlp failed during direct execution (no proxy): {e}") raise # Re-raise the original error except Exception as e: log.exception(f"Unexpected error during direct execution: {e}") raise Mythic Mobs Mod (1.21) | New Unique Mythical Creatures
Home Minecraft ModsMythic Mobs Mod (1.21) | New Unique Mythical Creatures