#!/usr/bin/env python3 """ Tool for managing the ytdlp-ops-server via its Thrift management interface. """ import argparse import json import logging import os import re import sys import time from datetime import datetime try: from dotenv import load_dotenv except ImportError: load_dotenv = None try: from tabulate import tabulate except ImportError: print("'tabulate' library not found. Please install it with: pip install tabulate", file=sys.stderr) sys.exit(1) try: import redis except ImportError: print("'redis' library not found. Please install it with: pip install redis", file=sys.stderr) sys.exit(1) # Add project's thrift gen_py path script_dir = os.path.dirname(os.path.abspath(__file__)) project_root = os.path.abspath(os.path.join(script_dir, '..')) sys.path.insert(0, os.path.join(project_root, 'thrift_model', 'gen_py')) try: from yt_ops_services.client_utils import get_thrift_client, format_timestamp from pangramia.yt.exceptions.ttypes import PBServiceException, PBUserException from .profile_manager_tool import ProfileManager, format_duration from .stress_policy import utils as sp_utils except ImportError: print("Could not import Thrift modules. Ensure this script is run in an environment where 'yt_ops_services' is installed.", file=sys.stderr) sys.exit(1) logger = logging.getLogger('manage_tool') # --- Helper Functions (adapted from regression.py) --- def _get_redis_client(redis_host, redis_port, redis_password): """Gets a Redis client.""" if not redis_host: return None try: client = redis.Redis(host=redis_host, port=redis_port, password=redis_password, decode_responses=True) client.ping() logger.info(f"Successfully connected to Redis at {client.connection_pool.connection_kwargs.get('host')}:{client.connection_pool.connection_kwargs.get('port')}") return client except redis.exceptions.ConnectionError as e: logger.error(f"Failed to connect to Redis: {e}") return None except Exception as e: logger.error(f"An unexpected error occurred while connecting to Redis: {e}") return None def _list_proxy_statuses(client, server_identity=None, auth_manager=None, download_manager=None): """Lists proxy statuses by connecting directly to the Thrift service.""" print("\n--- Proxy Statuses ---") try: statuses = client.getProxyStatus(server_identity) if not statuses: print("No proxy statuses found.") return # Enrich with Redis data from simulation environments all_proxy_urls = [s.proxyUrl for s in statuses] auth_proxy_states, download_proxy_states = {}, {} auth_work_minutes, download_work_minutes = 0, 0 if auth_manager: auth_proxy_states = auth_manager.get_proxy_states(all_proxy_urls) work_minutes_str = auth_manager.get_config('proxy_work_minutes') if work_minutes_str and work_minutes_str.isdigit(): auth_work_minutes = int(work_minutes_str) if download_manager: download_proxy_states = download_manager.get_proxy_states(all_proxy_urls) work_minutes_str = download_manager.get_config('proxy_work_minutes') if work_minutes_str and work_minutes_str.isdigit(): download_work_minutes = int(work_minutes_str) status_list = [] # This is forward-compatible: it checks for new attributes before using them. has_extended_info = hasattr(statuses[0], 'recentAccounts') or hasattr(statuses[0], 'recentMachines') headers = ["Server", "Proxy URL", "Status", "Success", "Failures", "Last Success", "Last Failure"] if auth_manager: headers.append("Auth State") if download_manager: headers.append("Download State") if has_extended_info: headers.extend(["Recent Accounts", "Recent Machines"]) for s in statuses: status_item = { "Server": s.serverIdentity, "Proxy URL": s.proxyUrl, "Status": s.status, "Success": s.successCount, "Failures": s.failureCount, "Last Success": format_timestamp(s.lastSuccessTimestamp), "Last Failure": format_timestamp(s.lastFailureTimestamp), } now = time.time() if auth_manager: state_data = auth_proxy_states.get(s.proxyUrl, {}) state = state_data.get('state', 'N/A') rest_until = state_data.get('rest_until', 0) work_start = state_data.get('work_start_timestamp', 0) state_str = state if state == 'RESTING' and rest_until > now: state_str += f"\n(ends in {format_duration(rest_until - now)})" elif state == 'ACTIVE' and work_start > 0 and auth_work_minutes > 0: work_end_time = work_start + (auth_work_minutes * 60) if work_end_time > now: state_str += f"\n(ends in {format_duration(work_end_time - now)})" status_item["Auth State"] = state_str if download_manager: state_data = download_proxy_states.get(s.proxyUrl, {}) state = state_data.get('state', 'N/A') rest_until = state_data.get('rest_until', 0) work_start = state_data.get('work_start_timestamp', 0) state_str = state if state == 'RESTING' and rest_until > now: state_str += f"\n(ends in {format_duration(rest_until - now)})" elif state == 'ACTIVE' and work_start > 0 and download_work_minutes > 0: work_end_time = work_start + (download_work_minutes * 60) if work_end_time > now: state_str += f"\n(ends in {format_duration(work_end_time - now)})" status_item["Download State"] = state_str if has_extended_info: recent_accounts = getattr(s, 'recentAccounts', []) recent_machines = getattr(s, 'recentMachines', []) status_item["Recent Accounts"] = "\n".join(recent_accounts) if recent_accounts else "N/A" status_item["Recent Machines"] = "\n".join(recent_machines) if recent_machines else "N/A" status_list.append(status_item) # Stabilize column widths to reduce jitter in --watch mode if status_list: headers = list(status_list[0].keys()) table_data = [[item.get(h) for h in headers] for item in status_list] # Calculate max width for each column based on its content, accounting for multi-line cells. columns = list(zip(*([headers] + table_data))) maxwidths = [] for col in columns: max_w = 0 for cell in col: cell_w = max((len(line) for line in str(cell).split('\n')), default=0) if cell else 0 if cell_w > max_w: max_w = cell_w maxwidths.append(max_w) # Enforce a minimum width for columns that can have variable content MIN_WIDTH = 25 if "Recent Accounts" in headers: idx = headers.index("Recent Accounts") maxwidths[idx] = max(MIN_WIDTH, maxwidths[idx]) if "Recent Machines" in headers: idx = headers.index("Recent Machines") maxwidths[idx] = max(MIN_WIDTH, maxwidths[idx]) print(tabulate(table_data, headers=headers, tablefmt='grid', maxcolwidths=maxwidths)) else: print(tabulate(status_list, headers='keys', tablefmt='grid')) if not has_extended_info: print("\nNOTE: To see Recent Accounts/Machines, the server's `getProxyStatus` method must be updated to return these fields.") except (PBServiceException, PBUserException) as e: logger.error(f"Failed to get proxy statuses: {e.message}") except Exception as e: logger.error(f"An unexpected error occurred while getting proxy statuses: {e}", exc_info=True) def _list_account_statuses(client, redis_client, account_id=None): """Lists account statuses from Thrift, optionally enriched with live Redis data.""" print(f"\n--- Account Statuses ---") try: statuses = client.getAccountStatus(accountId=account_id, accountPrefix=None) if not statuses: print("No account statuses found.") return status_list = [] for s in statuses: status_str = s.status if redis_client and 'RESTING' in status_str: try: status_key = f"account_status:{s.accountId}" expiry_ts_bytes = redis_client.hget(status_key, "resting_until") if expiry_ts_bytes: expiry_ts = float(expiry_ts_bytes) now = datetime.now().timestamp() if now >= expiry_ts: status_str = "ACTIVE (was RESTING)" else: remaining_seconds = int(expiry_ts - now) if remaining_seconds > 3600: status_str = f"RESTING (active in {remaining_seconds // 3600}h {remaining_seconds % 3600 // 60}m)" elif remaining_seconds > 60: status_str = f"RESTING (active in {remaining_seconds // 60}m {remaining_seconds % 60}s)" else: status_str = f"RESTING (active in {remaining_seconds}s)" except Exception as e: logger.warning(f"Could not parse resting time for {s.accountId} from Redis: {e}. Using server status.") last_success = float(s.lastSuccessTimestamp) if s.lastSuccessTimestamp else 0 last_failure = float(s.lastFailureTimestamp) if s.lastFailureTimestamp else 0 last_activity = max(last_success, last_failure) status_list.append({ "Account ID": s.accountId, "Status": status_str, "Success": s.successCount, "Failures": s.failureCount, "Last Success": format_timestamp(s.lastSuccessTimestamp), "Last Failure": format_timestamp(s.lastFailureTimestamp), "Last Proxy": s.lastUsedProxy or "N/A", "_last_activity": last_activity, }) status_list.sort(key=lambda item: item.get('_last_activity', 0), reverse=True) for item in status_list: if '_last_activity' in item: del item['_last_activity'] # Stabilize column widths to reduce jitter in --watch mode if status_list: headers = list(status_list[0].keys()) table_data = [[item.get(h) for h in headers] for item in status_list] columns = list(zip(*([headers] + table_data))) maxwidths = [max((len(str(x)) for x in col), default=0) if col else 0 for col in columns] # Enforce a minimum width for the Status column to prevent it from changing size STATUS_MIN_WIDTH = 30 if "Status" in headers: idx = headers.index("Status") maxwidths[idx] = max(STATUS_MIN_WIDTH, maxwidths[idx]) print(tabulate(table_data, headers=headers, tablefmt='grid', maxcolwidths=maxwidths)) else: print(tabulate(status_list, headers='keys', tablefmt='grid')) except (PBServiceException, PBUserException) as e: logger.error(f"Failed to get account statuses: {e.message}") except Exception as e: logger.error(f"An unexpected error occurred while getting account statuses: {e}", exc_info=True) def _list_client_statuses(redis_client): """Lists client statistics from Redis.""" if not redis_client: return print("\n--- Client Statuses (from Redis) ---") try: stats_key = "client_stats" all_stats_raw = redis_client.hgetall(stats_key) if not all_stats_raw: print("No client stats found in Redis.") return status_list = [] for client, stats_json in all_stats_raw.items(): try: stats = json.loads(stats_json) def format_latest(data): if not data: return "N/A" ts = format_timestamp(data.get('timestamp')) url = data.get('url', 'N/A') video_id = sp_utils.get_video_id(url) if video_id == "unknown_video_id": video_id = "N/A" return f"{ts} ({video_id})" status_list.append({ "Client": client, "Success": stats.get('success_count', 0), "Failures": stats.get('failure_count', 0), "Last Success": format_latest(stats.get('latest_success')), "Last Failure": format_latest(stats.get('latest_failure')), }) except (json.JSONDecodeError, AttributeError): status_list.append({"Client": client, "Success": "ERROR", "Failures": "ERROR", "Last Success": "Parse Error", "Last Failure": "Parse Error"}) status_list.sort(key=lambda item: item.get('Client', '')) print(tabulate(status_list, headers='keys', tablefmt='grid')) except Exception as e: logger.error(f"An unexpected error occurred while getting client statuses: {e}", exc_info=True) def _list_activity_counters(redis_client): """Lists current activity rates for proxies and accounts from Redis.""" if not redis_client: print("\n--- Activity Counters ---\nRedis is not configured. Cannot show activity counters.\n---------------------------\n") return print("\n--- Activity Counters ---") now = time.time() def process_keys(pattern, entity_name): try: keys = redis_client.scan_iter(pattern) except redis.exceptions.RedisError as e: logger.error(f"Redis error scanning for keys with pattern '{pattern}': {e}") return status_list = [] for key in keys: entity_id = key.split(':', 2)[-1] try: count_1m = redis_client.zcount(key, now - 60, now) count_5m = redis_client.zcount(key, now - 300, now) count_1h = redis_client.zcount(key, now - 3600, now) if count_1h == 0: # Don't show entities with no recent activity continue status_list.append({ entity_name: entity_id, "Activity (Last 1m)": count_1m, "Activity (Last 5m)": count_5m, "Activity (Last 1h)": count_1h, }) except redis.exceptions.RedisError as e: logger.error(f"Redis error processing key '{key}': {e}") status_list.sort(key=lambda item: item.get(entity_name, '')) print(f"\n--- {entity_name} Activity Counters ---") if not status_list: print(f"No recent activity found for {entity_name.lower()}s.") else: print(f"\n{tabulate(status_list, headers='keys', tablefmt='grid')}") print("-----------------------------------\n") try: process_keys("activity:per_proxy:*", "Proxy URL") process_keys("activity:per_account:*", "Account ID") except Exception as e: logger.error(f"An unexpected error occurred while getting activity counters: {e}", exc_info=True) print(f"\nERROR: An unexpected error occurred: {e}\n") def get_system_status(args): """Connects to services and prints status tables.""" logger.info("--- Getting System Status ---") client, transport = None, None redis_client = _get_redis_client(args.redis_host, args.redis_port, args.redis_password) auth_manager, download_manager = None, None def _create_manager(env_name): if not env_name or not args.redis_host: return None if args.key_prefix: key_prefix = args.key_prefix else: key_prefix = f"{env_name}_profile_mgmt_" return ProfileManager(args.redis_host, args.redis_port, args.redis_password, key_prefix) # Precedence: --auth-env > --env auth_env_to_use = args.auth_env or args.env if auth_env_to_use: auth_manager = _create_manager(auth_env_to_use) # Precedence: --download-env > --env download_env_to_use = args.download_env or args.env if download_env_to_use: # If it's the same env, reuse the manager instance if download_env_to_use == auth_env_to_use and auth_manager: download_manager = auth_manager else: download_manager = _create_manager(download_env_to_use) try: client, transport = get_thrift_client(args.host, args.port) _list_proxy_statuses(client, args.server_identity, auth_manager=auth_manager, download_manager=download_manager) _list_account_statuses(client, redis_client) _list_client_statuses(redis_client) except Exception as e: logger.error(f"Could not get system status: {e}") finally: if transport and transport.isOpen(): transport.close() def main_activity_counters(args): """Main logic for the 'activity-counters' command.""" redis_client = _get_redis_client(args.redis_host, args.redis_port, args.redis_password) _list_activity_counters(redis_client) return 0 def main_status(args): """Main logic for the 'status' command.""" if not args.watch: get_system_status(args) return 0 try: while True: os.system('cls' if os.name == 'nt' else 'clear') print(f"--- System Status (auto-refreshing every {args.watch} seconds, press Ctrl+C to exit) ---") print(f"--- Last updated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} ---") get_system_status(args) for i in range(args.watch, 0, -1): sys.stdout.write(f"\rRefreshing in {i} seconds... ") sys.stdout.flush() time.sleep(1) # Clear the countdown line before the next refresh sys.stdout.write("\r" + " " * 30 + "\r") sys.stdout.flush() except KeyboardInterrupt: print("\nStopping status watch.") return 0 def main_ban_proxy(args): """Main logic for the 'ban-proxy' command.""" client, transport = None, None try: client, transport = get_thrift_client(args.host, args.port) success = client.banProxy(args.proxy_url, args.server_identity) if success: print(f"Successfully banned proxy '{args.proxy_url}' for server '{args.server_identity}'.") return 0 else: logger.error("Server reported failure to ban proxy.") return 1 except (PBServiceException, PBUserException) as e: logger.error(f"Failed to ban proxy: {e.message}") return 1 finally: if transport and transport.isOpen(): transport.close() def main_unban_proxy(args): """Main logic for the 'unban-proxy' command.""" client, transport = None, None try: client, transport = get_thrift_client(args.host, args.port) success = client.unbanProxy(args.proxy_url, args.server_identity) if success: print(f"Successfully unbanned proxy '{args.proxy_url}' for server '{args.server_identity}'.") return 0 else: logger.error("Server reported failure to unban proxy.") return 1 except (PBServiceException, PBUserException) as e: logger.error(f"Failed to unban proxy: {e.message}") return 1 finally: if transport and transport.isOpen(): transport.close() def main_reset_proxies(args): """Main logic for the 'reset-proxies' command.""" client, transport = None, None try: client, transport = get_thrift_client(args.host, args.port) success = client.resetAllProxyStatuses(args.server_identity) if success: print(f"Successfully reset all proxy statuses for server '{args.server_identity}'.") return 0 else: logger.error("Server reported failure to reset proxies.") return 1 except (PBServiceException, PBUserException) as e: logger.error(f"Failed to reset proxies: {e.message}") return 1 finally: if transport and transport.isOpen(): transport.close() def main_ban_all_proxies(args): """Main logic for the 'ban-all-proxies' command.""" client, transport = None, None try: client, transport = get_thrift_client(args.host, args.port) if args.server_identity: print(f"Banning all proxies for server '{args.server_identity}'...") client.banAllProxies(args.server_identity) print(f"Successfully sent request to ban all proxies for '{args.server_identity}'.") else: print("Banning all proxies for ALL servers...") all_statuses = client.getProxyStatus(None) if not all_statuses: print("No proxies found for any server. Nothing to ban.") return 0 all_server_identities = sorted(list(set(s.serverIdentity for s in all_statuses))) print(f"Found {len(all_server_identities)} server identities. Sending ban request for each...") success_count, fail_count = 0, 0 for identity in all_server_identities: try: client.banAllProxies(identity) print(f" - Sent ban_all for '{identity}'.") success_count += 1 except Exception as e: logger.error(f" - Failed to ban all proxies for '{identity}': {e}") fail_count += 1 print(f"\nSuccessfully sent ban_all requests for {success_count} server identities.") if fail_count > 0: print(f"Failed to send ban_all requests for {fail_count} server identities. See logs for details.") return 0 except (PBServiceException, PBUserException) as e: logger.error(f"Failed to ban all proxies: {e.message}") return 1 finally: if transport and transport.isOpen(): transport.close() def main_unban_all_proxies(args): """Main logic for the 'unban-all-proxies' command.""" client, transport = None, None try: client, transport = get_thrift_client(args.host, args.port) if args.server_identity: print(f"Unbanning all proxies for server '{args.server_identity}'...") client.resetAllProxyStatuses(args.server_identity) print(f"Successfully sent request to unban all proxies for '{args.server_identity}'.") else: print("Unbanning all proxies for ALL servers...") all_statuses = client.getProxyStatus(None) if not all_statuses: print("No proxies found for any server. Nothing to unban.") return 0 all_server_identities = sorted(list(set(s.serverIdentity for s in all_statuses))) print(f"Found {len(all_server_identities)} server identities. Sending unban request for each...") success_count, fail_count = 0, 0 for identity in all_server_identities: try: client.resetAllProxyStatuses(identity) print(f" - Sent unban_all for '{identity}'.") success_count += 1 except Exception as e: logger.error(f" - Failed to unban all proxies for '{identity}': {e}") fail_count += 1 print(f"\nSuccessfully sent unban_all requests for {success_count} server identities.") if fail_count > 0: print(f"Failed to send unban_all requests for {fail_count} server identities. See logs for details.") return 0 except (PBServiceException, PBUserException) as e: logger.error(f"Failed to unban all proxies: {e.message}") return 1 finally: if transport and transport.isOpen(): transport.close() def main_ban_account(args): """Main logic for the 'ban-account' command.""" client, transport = None, None try: client, transport = get_thrift_client(args.host, args.port) reason = f"Manual ban from yt-ops-client by {os.getlogin() if hasattr(os, 'getlogin') else 'unknown_user'}" client.banAccount(accountId=args.account_id, reason=reason) print(f"Successfully sent request to ban account '{args.account_id}'.") return 0 except (PBServiceException, PBUserException) as e: logger.error(f"Failed to ban account: {e.message}") return 1 finally: if transport and transport.isOpen(): transport.close() def main_unban_account(args): """Main logic for the 'unban-account' command.""" client, transport = None, None redis_client = _get_redis_client(args.redis_host, args.redis_port, args.redis_password) if not redis_client: logger.error("Redis connection is required to correctly unban an account (to reset success_count_at_activation).") return 1 try: client, transport = get_thrift_client(args.host, args.port) reason = f"Manual un-ban from yt-ops-client by {os.getlogin() if hasattr(os, 'getlogin') else 'unknown_user'}" statuses = client.getAccountStatus(accountId=args.account_id, accountPrefix=None) if not statuses: logger.error(f"Account '{args.account_id}' not found.") return 1 current_success_count = statuses[0].successCount or 0 client.unbanAccount(accountId=args.account_id, reason=reason) print(f"Successfully sent request to unban account '{args.account_id}'.") redis_client.hset(f"account_status:{args.account_id}", "success_count_at_activation", current_success_count) print(f"Set 'success_count_at_activation' for '{args.account_id}' to {current_success_count}.") return 0 except (PBServiceException, PBUserException) as e: logger.error(f"Failed to unban account: {e.message}") return 1 finally: if transport and transport.isOpen(): transport.close() def main_unban_all_accounts(args): """Main logic for the 'unban-all-accounts' command.""" client, transport = None, None redis_client = _get_redis_client(args.redis_host, args.redis_port, args.redis_password) if not redis_client: logger.error("Redis connection is required to correctly unban accounts.") return 1 try: client, transport = get_thrift_client(args.host, args.port) account_prefix = args.account_id # can be prefix print(f"Unbanning all accounts (prefix: '{account_prefix or 'ALL'}')...") all_statuses = client.getAccountStatus(accountId=None, accountPrefix=account_prefix) if not all_statuses: print(f"No accounts found with prefix '{account_prefix or 'ALL'}' to unban.") return 0 accounts_to_unban = [s.accountId for s in all_statuses] account_map = {s.accountId: s for s in all_statuses} print(f"Found {len(accounts_to_unban)} accounts. Sending unban request for each...") unban_count, fail_count = 0, 0 reason = f"Manual unban_all from yt-ops-client by {os.getlogin() if hasattr(os, 'getlogin') else 'unknown_user'}" for acc_id in accounts_to_unban: try: client.unbanAccount(accountId=acc_id, reason=reason) current_success_count = account_map[acc_id].successCount or 0 redis_client.hset(f"account_status:{acc_id}", "success_count_at_activation", current_success_count) unban_count += 1 except Exception as e: logger.error(f" - Failed to unban account '{acc_id}': {e}") fail_count += 1 print(f"\nSuccessfully sent unban requests for {unban_count} accounts.") if fail_count > 0: print(f"Failed to send unban requests for {fail_count} accounts. See logs for details.") return 0 except (PBServiceException, PBUserException) as e: logger.error(f"Failed to unban all accounts: {e.message}") return 1 finally: if transport and transport.isOpen(): transport.close() def main_delete(args): """Main logic for 'delete' commands.""" if not args.yes: print("This is a destructive action. Use --yes to confirm.", file=sys.stderr) return 1 client, transport = None, None try: # For Redis-only actions, we don't need a Thrift client. if args.delete_entity not in ['client-stats', 'activity-counters']: client, transport = get_thrift_client(args.host, args.port) if args.delete_entity == 'proxies': if args.proxy_url and args.server_identity: print(f"Deleting proxy '{args.proxy_url}' for server '{args.server_identity}'...") result = client.deleteProxyFromRedis(args.proxy_url, args.server_identity) if result: print(f"Successfully deleted proxy '{args.proxy_url}' for server '{args.server_identity}' from Redis.") else: print(f"Failed to delete proxy '{args.proxy_url}'. It may not have existed.") else: print(f"Deleting all proxies from Redis (server filter: {args.server_identity or 'ALL'})...") result = client.deleteAllProxiesFromRedis(args.server_identity) print(f"Successfully deleted {result} proxy key(s) from Redis.") elif args.delete_entity == 'accounts': if args.account_id: if args.prefix: print(f"Deleting accounts with prefix '{args.account_id}' from Redis...") result = client.deleteAllAccountsFromRedis(args.account_id) print(f"Successfully deleted {result} account(s) with prefix '{args.account_id}' from Redis.") else: print(f"Deleting account '{args.account_id}' from Redis...") result = client.deleteAccountFromRedis(args.account_id) if result: print(f"Successfully deleted account '{args.account_id}' from Redis.") else: print(f"Failed to delete account '{args.account_id}'. It may not have existed.") else: # Delete all print("Deleting ALL accounts from Redis...") result = client.deleteAllAccountsFromRedis(None) print(f"Successfully deleted {result} account(s) from Redis.") elif args.delete_entity == 'client-stats': redis_client = _get_redis_client(args.redis_host, args.redis_port, args.redis_password) if not redis_client: logger.error("Redis connection is required to delete client stats.") return 1 print("Deleting all client stats from Redis...") result = redis_client.delete("client_stats") if result > 0: print("Successfully deleted 'client_stats' key from Redis.") else: print("Key 'client_stats' not found in Redis. Nothing to delete.") elif args.delete_entity == 'activity-counters': redis_client = _get_redis_client(args.redis_host, args.redis_port, args.redis_password) if not redis_client: logger.error("Redis connection is required to delete activity counters.") return 1 print("Deleting all activity counters from Redis...") deleted_count = 0 for pattern in ["activity:per_proxy:*", "activity:per_account:*"]: keys_to_delete_chunk = [] for key in redis_client.scan_iter(pattern): keys_to_delete_chunk.append(key) if len(keys_to_delete_chunk) >= 500: deleted_count += redis_client.delete(*keys_to_delete_chunk) keys_to_delete_chunk = [] if keys_to_delete_chunk: deleted_count += redis_client.delete(*keys_to_delete_chunk) if deleted_count > 0: print(f"Successfully deleted {deleted_count} activity counter keys from Redis.") else: print("No activity counter keys found to delete.") return 0 except (PBServiceException, PBUserException) as e: logger.error(f"Thrift error performing delete action: {e.message}") return 1 finally: if transport and transport.isOpen(): transport.close() def add_manage_parser(subparsers): """Add the parser for the 'manage' command.""" parser = subparsers.add_parser( 'manage', description='Manage the ytdlp-ops-server.', formatter_class=argparse.RawTextHelpFormatter, help='Manage the ytdlp-ops-server.' ) # Common arguments for all manage subcommands common_parser = argparse.ArgumentParser(add_help=False) common_parser.add_argument('--env-file', help='Path to a .env file to load environment variables from.') common_parser.add_argument('--host', default=None, help="Thrift management server host. Defaults to MASTER_HOST_IP env var or 127.0.0.1.") common_parser.add_argument('--port', type=int, default=9090, help='Thrift management server port.') common_parser.add_argument('--redis-host', default=None, help='Redis host for client stats. Defaults to REDIS_HOST env var.') common_parser.add_argument('--redis-port', type=int, default=None, help='Redis port. Defaults to REDIS_PORT env var or 6379.') common_parser.add_argument('--redis-password', default=None, help='Redis password. Defaults to REDIS_PASSWORD env var.') common_parser.add_argument('--verbose', action='store_true', help='Enable verbose output.') common_parser.add_argument('--env', default=None, help="Default environment name for Redis key prefix. Used if --auth-env or --download-env are not specified.") common_parser.add_argument('--auth-env', help="Environment for the Auth simulation to enrich status from.") common_parser.add_argument('--download-env', help="Environment for the Download simulation to enrich status from.") common_parser.add_argument('--key-prefix', default=None, help='Explicit key prefix for Redis. Overrides --env and any defaults.') manage_subparsers = parser.add_subparsers(dest='manage_command', help='Available management commands', required=True) # --- Status and Listing Commands --- status_parser = manage_subparsers.add_parser('status', help='View system status.', parents=[common_parser]) status_parser.add_argument('--server-identity', help='Filter status for a specific server identity.') status_parser.add_argument('--watch', type=int, nargs='?', const=5, help='Periodically refresh status every N seconds. Default: 5.') activity_parser = manage_subparsers.add_parser('activity-counters', help='View current activity rates for proxies and accounts.', parents=[common_parser]) # --- Proxy Management Commands --- ban_proxy_parser = manage_subparsers.add_parser('ban-proxy', help='Ban a proxy.', parents=[common_parser]) ban_proxy_parser.add_argument('proxy_url', help='The full proxy URL to ban (e.g., "socks5://host:port").') ban_proxy_parser.add_argument('--server-identity', required=True, help='The server identity for which to ban the proxy.') unban_proxy_parser = manage_subparsers.add_parser('unban-proxy', help='Unban a proxy.', parents=[common_parser]) unban_proxy_parser.add_argument('proxy_url', help='The full proxy URL to unban.') unban_proxy_parser.add_argument('--server-identity', required=True, help='The server identity for which to unban the proxy.') ban_all_proxies_parser = manage_subparsers.add_parser('ban-all-proxies', help='Ban all proxies for one or all servers.', parents=[common_parser]) ban_all_proxies_parser.add_argument('--server-identity', help='Optional server identity to ban all proxies for. If omitted, bans for all servers.') unban_all_proxies_parser = manage_subparsers.add_parser('unban-all-proxies', help='Unban all proxies for one or all servers.', parents=[common_parser]) unban_all_proxies_parser.add_argument('--server-identity', help='Optional server identity to unban all proxies for. If omitted, unbans for all servers.') # --- Account Management Commands --- ban_account_parser = manage_subparsers.add_parser('ban-account', help='Ban an account.', parents=[common_parser]) ban_account_parser.add_argument('account_id', help='The account ID to ban.') unban_account_parser = manage_subparsers.add_parser('unban-account', help='Unban an account.', parents=[common_parser]) unban_account_parser.add_argument('account_id', help='The account ID to unban.') unban_all_accounts_parser = manage_subparsers.add_parser('unban-all-accounts', help='Unban all accounts, optionally filtered by a prefix.', parents=[common_parser]) unban_all_accounts_parser.add_argument('account_id', nargs='?', help='Optional account prefix to filter which accounts to unban.') # --- Destructive Delete Commands --- delete_parser = manage_subparsers.add_parser('delete', help='(Destructive) Delete entities from Redis.') delete_subparsers = delete_parser.add_subparsers(dest='delete_entity', help='Entity to delete', required=True) # Create a parent for the confirmation flag, so it can be used on sub-subcommands confirm_parser = argparse.ArgumentParser(add_help=False) confirm_parser.add_argument('--yes', action='store_true', help='Confirm the destructive action.') delete_proxies_parser = delete_subparsers.add_parser('proxies', help='Delete one or all proxies from Redis.', parents=[common_parser, confirm_parser]) delete_proxies_parser.add_argument('--proxy-url', help='The proxy URL to delete.') delete_proxies_parser.add_argument('--server-identity', help='The server identity of the proxy to delete. Required if --proxy-url is given. If omitted, deletes all proxies for all servers.') delete_accounts_parser = delete_subparsers.add_parser('accounts', help='Delete one or all accounts from Redis.', parents=[common_parser, confirm_parser]) delete_accounts_parser.add_argument('--account-id', help='The account ID to delete. If --prefix is used, this is treated as a prefix.') delete_accounts_parser.add_argument('--prefix', action='store_true', help='Treat --account-id as a prefix to delete multiple accounts.') delete_client_stats_parser = delete_subparsers.add_parser('client-stats', help='Delete all client stats from Redis.', parents=[common_parser, confirm_parser]) delete_activity_counters_parser = delete_subparsers.add_parser('activity-counters', help='Delete all activity counter stats from Redis.', description='Deletes all activity counter stats (keys matching "activity:*") from Redis. This does NOT delete account or proxy statuses.', parents=[common_parser, confirm_parser]) return parser def main_manage(args): """Main dispatcher for 'manage' command.""" if load_dotenv: # load_dotenv() with no args will search for a .env file. # If args.env_file is provided, it will use that specific file. was_loaded = load_dotenv(args.env_file) if was_loaded: logger.info(f"Loaded environment variables from {args.env_file or '.env file'}") elif args.env_file: # If a specific file was requested but not found, it's an error. logger.error(f"The specified --env-file was not found: {args.env_file}") return 1 elif args.env_file: logger.warning("'python-dotenv' is not installed, but --env-file was provided. Please install it with: pip install python-dotenv") else: logger.info("'python-dotenv' not installed. Relying on shell environment variables and command-line arguments.") # Set defaults for args that were not provided, now that .env is loaded. if args.host is None: args.host = os.getenv('MASTER_HOST_IP', '127.0.0.1') if args.redis_host is None: # Default REDIS_HOST to the management host if not explicitly set args.redis_host = os.getenv('REDIS_HOST', args.host) if args.redis_port is None: args.redis_port = int(os.getenv('REDIS_PORT', 6379)) if args.redis_password is None: args.redis_password = os.getenv('REDIS_PASSWORD') if args.verbose: logging.getLogger().setLevel(logging.DEBUG) # Log the effective connection parameters being used. logger.info(f"Using management host: {args.host}:{args.port}") if args.redis_host: redis_password_status = "provided" if args.redis_password else "not provided" logger.info(f"Using Redis host: {args.redis_host}:{args.redis_port} (password: {redis_password_status})") else: logger.warning("Redis host not configured (via --redis-host or REDIS_HOST env var). Redis-dependent features will be unavailable.") if args.manage_command == 'status': return main_status(args) elif args.manage_command == 'activity-counters': return main_activity_counters(args) elif args.manage_command == 'ban-proxy': return main_ban_proxy(args) elif args.manage_command == 'unban-proxy': return main_unban_proxy(args) elif args.manage_command == 'ban-all-proxies': return main_ban_all_proxies(args) elif args.manage_command == 'unban-all-proxies': return main_unban_all_proxies(args) elif args.manage_command == 'ban-account': return main_ban_account(args) elif args.manage_command == 'unban-account': return main_unban_account(args) elif args.manage_command == 'unban-all-accounts': return main_unban_all_accounts(args) elif args.manage_command == 'delete': return main_delete(args) return 1 # Should not be reached