yt-dlp-dags/ytops_client-source/ytops_client/profile_manager_tool.py
2025-12-26 10:05:00 +03:00

1990 lines
86 KiB
Python

#!/usr/bin/env python3
"""
Profile Management CLI Tool (v2) for yt-ops-client.
"""
import argparse
import base64
import json
import io
import logging
import os
import random
import signal
import sys
import threading
import time
from datetime import datetime
from typing import Dict, List, Optional, Any
import collections
import redis
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)
tabulate = None
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# Graceful shutdown handler for live mode
shutdown_event = threading.Event()
def handle_shutdown(sig, frame):
"""Sets the shutdown_event on SIGINT or SIGTERM."""
if not shutdown_event.is_set():
# Use print to stderr to avoid messing with the live display
print("\nShutdown signal received. Stopping live view...", file=sys.stderr)
shutdown_event.set()
class ProfileManager:
"""Manages profiles in Redis with configurable prefix."""
# Profile states
STATE_ACTIVE = "ACTIVE"
STATE_PAUSED = "PAUSED"
STATE_RESTING = "RESTING"
STATE_BANNED = "BANNED"
STATE_LOCKED = "LOCKED"
STATE_COOLDOWN = "COOLDOWN"
VALID_STATES = [STATE_ACTIVE, STATE_PAUSED, STATE_RESTING, STATE_BANNED, STATE_LOCKED, STATE_COOLDOWN]
def __init__(self, redis_host='localhost', redis_port=6379,
redis_password=None, key_prefix='profile_mgmt_'):
"""Initialize Redis connection and key prefix."""
self.key_prefix = key_prefix
logger.info(f"Attempting to connect to Redis at {redis_host}:{redis_port}...")
try:
self.redis = redis.Redis(
host=redis_host,
port=redis_port,
password=redis_password,
decode_responses=True,
socket_connect_timeout=5,
socket_timeout=5
)
self.redis.ping()
logger.info(f"Successfully connected to Redis.")
logger.info(f"Using key prefix: {key_prefix}")
except redis.exceptions.ConnectionError as e:
logger.error(f"Failed to connect to Redis at {redis_host}:{redis_port}: {e}")
sys.exit(1)
def _profile_key(self, profile_name: str) -> str:
"""Get Redis key for a profile."""
return f"{self.key_prefix}profile:{profile_name}"
def _state_key(self, state: str) -> str:
"""Get Redis key for a state index."""
return f"{self.key_prefix}state:{state}"
def _activity_key(self, profile_name: str, activity_type: str) -> str:
"""Get Redis key for activity timeline."""
return f"{self.key_prefix}activity:{profile_name}:{activity_type}"
def _proxy_state_key(self, proxy_url: str) -> str:
"""Get Redis key for proxy state hash."""
encoded_proxy = base64.urlsafe_b64encode(proxy_url.encode()).decode()
return f"{self.key_prefix}proxy_state:{encoded_proxy}"
def _proxy_group_state_key(self, group_name: str) -> str:
"""Get Redis key for proxy group state hash."""
return f"{self.key_prefix}proxy_group_state:{group_name}"
def _profile_group_state_key(self, group_name: str) -> str:
"""Get Redis key for profile group state hash."""
return f"{self.key_prefix}profile_group_state:{group_name}"
def _proxy_activity_key(self, proxy_url: str, activity_type: str) -> str:
"""Get Redis key for proxy activity."""
# Use base64 to handle special chars in URL
encoded_proxy = base64.urlsafe_b64encode(proxy_url.encode()).decode()
return f"{self.key_prefix}activity:proxy:{encoded_proxy}:{activity_type}"
def _config_key(self) -> str:
"""Get Redis key for shared configuration."""
return f"{self.key_prefix}config"
def _pending_downloads_key(self, profile_name: str) -> str:
"""Get Redis key for a profile's pending downloads counter."""
return f"{self.key_prefix}downloads_pending:{profile_name}"
def increment_pending_downloads(self, profile_name: str, count: int = 1) -> Optional[int]:
"""Atomically increments the pending downloads counter for a profile."""
if count <= 0:
return None
key = self._pending_downloads_key(profile_name)
new_value = self.redis.incrby(key, count)
# Set a TTL on the key to prevent it from living forever if something goes wrong.
# 5 hours is a safe buffer for the 4-hour info.json validity.
self.redis.expire(key, 5 * 3600)
logger.info(f"Incremented pending downloads for '{profile_name}' by {count}. New count: {new_value}")
return new_value
def decrement_pending_downloads(self, profile_name: str) -> Optional[int]:
"""Atomically decrements the pending downloads counter for a profile."""
key = self._pending_downloads_key(profile_name)
# Only decrement if the key exists. This prevents stray calls from creating negative counters.
if not self.redis.exists(key):
logger.warning(f"Attempted to decrement pending downloads for '{profile_name}', but no counter exists. No action taken.")
return None
new_value = self.redis.decr(key)
logger.info(f"Decremented pending downloads for '{profile_name}'. New count: {new_value}")
if new_value <= 0:
# Clean up the key once it reaches zero.
self.redis.delete(key)
logger.info(f"Pending downloads for '{profile_name}' reached zero. Cleared counter key.")
return new_value
def get_pending_downloads(self, profile_name: str) -> int:
"""Retrieves the current pending downloads count for a profile."""
key = self._pending_downloads_key(profile_name)
value = self.redis.get(key)
return int(value) if value else 0
def clear_pending_downloads(self, profile_name: str) -> bool:
"""Deletes the pending downloads counter key for a profile."""
key = self._pending_downloads_key(profile_name)
deleted_count = self.redis.delete(key)
if deleted_count > 0:
logger.info(f"Cleared pending downloads counter for '{profile_name}'.")
return deleted_count > 0
def set_config(self, key: str, value: Any) -> bool:
"""Sets a configuration value in Redis."""
self.redis.hset(self._config_key(), key, str(value))
logger.info(f"Set config '{key}' to '{value}'")
return True
def get_config(self, key: str, default: Optional[Any] = None) -> Optional[Any]:
"""Gets a configuration value from Redis."""
value = self.redis.hget(self._config_key(), key)
if value is None:
return default
return value
def _locks_key(self) -> str:
"""Get Redis key for locks hash."""
return f"{self.key_prefix}locks"
def _failed_lock_attempts_key(self) -> str:
"""Get Redis key for the failed lock attempts counter."""
return f"{self.key_prefix}stats:failed_lock_attempts"
def create_profile(self, name: str, proxy: str, initial_state: str = STATE_ACTIVE) -> bool:
"""Create a new profile."""
if initial_state not in self.VALID_STATES:
logger.error(f"Invalid initial state: {initial_state}")
return False
profile_key = self._profile_key(name)
# Check if profile already exists
if self.redis.exists(profile_key):
logger.error(f"Profile '{name}' already exists")
return False
now = time.time()
profile_data = {
'name': name,
'proxy': proxy,
'state': initial_state,
'created_at': str(now),
'last_used': str(now),
'success_count': '0',
'failure_count': '0',
'tolerated_error_count': '0',
'download_count': '0',
'download_error_count': '0',
'global_success_count': '0',
'global_failure_count': '0',
'global_tolerated_error_count': '0',
'global_download_count': '0',
'global_download_error_count': '0',
'lock_timestamp': '0',
'lock_owner': '',
'rest_until': '0',
'last_rest_timestamp': '0',
'wait_started_at': '0',
'ban_reason': '',
'rest_reason': '',
'reason': '',
'notes': ''
}
# Use pipeline for atomic operations
pipe = self.redis.pipeline()
pipe.hset(profile_key, mapping=profile_data)
# Add to state index
pipe.zadd(self._state_key(initial_state), {name: now})
result = pipe.execute()
if result[0] > 0:
logger.info(f"Created profile '{name}' with proxy '{proxy}' (state: {initial_state})")
return True
else:
logger.error(f"Failed to create profile '{name}'")
return False
def get_profile(self, name: str) -> Optional[Dict[str, Any]]:
"""Get profile details."""
profile_key = self._profile_key(name)
data = self.redis.hgetall(profile_key)
if not data:
return None
# Convert numeric fields
numeric_fields = ['created_at', 'last_used', 'success_count', 'failure_count',
'tolerated_error_count', 'download_count', 'download_error_count',
'global_success_count', 'global_failure_count',
'global_tolerated_error_count', 'global_download_count',
'global_download_error_count',
'lock_timestamp', 'rest_until', 'last_rest_timestamp', 'wait_started_at']
for field in numeric_fields:
if field in data:
try:
data[field] = float(data[field])
except (ValueError, TypeError):
data[field] = 0.0
return data
def list_profiles(self, state_filter: Optional[str] = None,
proxy_filter: Optional[str] = None) -> List[Dict[str, Any]]:
"""List profiles with optional filtering."""
profiles = []
if state_filter:
# Get profiles from specific state index
state_key = self._state_key(state_filter)
profile_names = self.redis.zrange(state_key, 0, -1)
else:
# Get all profiles by scanning keys
pattern = self._profile_key('*')
keys = []
cursor = 0
while True:
cursor, found_keys = self.redis.scan(cursor=cursor, match=pattern, count=100)
keys.extend(found_keys)
if cursor == 0:
break
profile_names = [k.split(':')[-1] for k in keys]
if not profile_names:
return []
# Use a pipeline to fetch all profile data at once for efficiency
pipe = self.redis.pipeline()
for name in profile_names:
pipe.hgetall(self._profile_key(name))
all_profile_data = pipe.execute()
# Also fetch pending download counts for all profiles
pipe = self.redis.pipeline()
for name in profile_names:
pipe.get(self._pending_downloads_key(name))
all_pending_downloads = pipe.execute()
numeric_fields = ['created_at', 'last_used', 'success_count', 'failure_count',
'tolerated_error_count', 'download_count', 'download_error_count',
'global_success_count', 'global_failure_count',
'global_tolerated_error_count', 'global_download_count',
'global_download_error_count',
'lock_timestamp', 'rest_until', 'last_rest_timestamp', 'wait_started_at']
for i, data in enumerate(all_profile_data):
if not data:
continue
# Add pending downloads count to the profile data
pending_downloads = all_pending_downloads[i]
data['pending_downloads'] = int(pending_downloads) if pending_downloads else 0
# Convert numeric fields
for field in numeric_fields:
if field in data:
try:
data[field] = float(data[field])
except (ValueError, TypeError):
data[field] = 0.0
if proxy_filter and proxy_filter not in data.get('proxy', ''):
continue
profiles.append(data)
# Sort by creation time (newest first)
profiles.sort(key=lambda x: x.get('created_at', 0), reverse=True)
return profiles
def update_profile_state(self, name: str, new_state: str,
reason: str = '') -> bool:
"""Update profile state."""
if new_state not in self.VALID_STATES:
logger.error(f"Invalid state: {new_state}")
return False
profile = self.get_profile(name)
if not profile:
logger.error(f"Profile '{name}' not found")
return False
old_state = profile['state']
if old_state == new_state:
logger.info(f"Profile '{name}' already in state {new_state}")
return True
now = time.time()
profile_key = self._profile_key(name)
pipe = self.redis.pipeline()
# Update profile hash
updates = {'state': new_state, 'last_used': str(now)}
if new_state == self.STATE_BANNED and reason:
updates['ban_reason'] = reason
elif new_state == self.STATE_RESTING:
# Set rest_until to 1 hour from now by default
rest_until = now + 3600
updates['rest_until'] = str(rest_until)
if reason:
updates['rest_reason'] = reason
# Handle transitions into ACTIVE state
if new_state == self.STATE_ACTIVE:
# Clear any resting/banned state fields
updates['rest_until'] = '0'
updates['rest_reason'] = ''
updates['reason'] = ''
updates['ban_reason'] = '' # Clear ban reason on manual activation
if old_state in [self.STATE_RESTING, self.STATE_COOLDOWN]:
updates['last_rest_timestamp'] = str(now)
# When activating a profile, ensure its proxy is also active.
proxy_url = profile.get('proxy')
if proxy_url:
logger.info(f"Activating associated proxy '{proxy_url}' for profile '{name}'.")
pipe.hset(self._proxy_state_key(proxy_url), mapping={
'state': self.STATE_ACTIVE,
'rest_until': '0',
'work_start_timestamp': str(now)
})
# If moving to any state that is not LOCKED, ensure any stale lock data is cleared.
# This makes manual state changes (like 'activate' or 'unban') more robust.
if new_state != self.STATE_LOCKED:
updates['lock_owner'] = ''
updates['lock_timestamp'] = '0'
pipe.hdel(self._locks_key(), name)
if old_state == self.STATE_LOCKED:
logger.info(f"Profile '{name}' was in LOCKED state. Clearing global lock.")
pipe.hset(profile_key, mapping=updates)
# Remove from old state index, add to new state index
if old_state in self.VALID_STATES:
pipe.zrem(self._state_key(old_state), name)
pipe.zadd(self._state_key(new_state), {name: now})
result = pipe.execute()
logger.info(f"Updated profile '{name}' from {old_state} to {new_state}")
if reason:
logger.info(f"Reason: {reason}")
return True
def update_profile_field(self, name: str, field: str, value: str) -> bool:
"""Update a specific field in profile."""
profile = self.get_profile(name)
if not profile:
logger.error(f"Profile '{name}' not found")
return False
profile_key = self._profile_key(name)
self.redis.hset(profile_key, field, value)
logger.info(f"Updated profile '{name}' field '{field}' to '{value}'")
return True
def delete_profile(self, name: str) -> bool:
"""Delete a profile and all associated data."""
profile = self.get_profile(name)
if not profile:
logger.error(f"Profile '{name}' not found")
return False
state = profile['state']
pipe = self.redis.pipeline()
# Delete profile hash
profile_key = self._profile_key(name)
pipe.delete(profile_key)
# Remove from state index
if state in self.VALID_STATES:
pipe.zrem(self._state_key(state), name)
# Delete activity keys
for activity_type in ['success', 'failure', 'tolerated_error', 'download', 'download_error']:
activity_key = self._activity_key(name, activity_type)
pipe.delete(activity_key)
# Remove from locks if present
locks_key = self._locks_key()
pipe.hdel(locks_key, name)
result = pipe.execute()
logger.info(f"Deleted profile '{name}' and all associated data")
return True
def delete_all_data(self) -> int:
"""Deletes all keys associated with the current manager's key_prefix."""
logger.warning(f"Deleting all keys with prefix: {self.key_prefix}")
keys_to_delete = []
for key in self.redis.scan_iter(f"{self.key_prefix}*"):
keys_to_delete.append(key)
if not keys_to_delete:
logger.info("No keys found to delete.")
return 0
total_deleted = 0
chunk_size = 500
for i in range(0, len(keys_to_delete), chunk_size):
chunk = keys_to_delete[i:i + chunk_size]
total_deleted += self.redis.delete(*chunk)
logger.info(f"Deleted {total_deleted} key(s).")
return total_deleted
def record_activity(self, name: str, activity_type: str,
timestamp: Optional[float] = None) -> bool:
"""Record activity (success/failure) for a profile."""
if activity_type not in ['success', 'failure', 'tolerated_error', 'download', 'download_error']:
logger.error(f"Invalid activity type: {activity_type}")
return False
profile = self.get_profile(name)
if not profile:
logger.error(f"Profile '{name}' not found")
return False
ts = timestamp or time.time()
activity_key = self._activity_key(name, activity_type)
# Add to sorted set
self.redis.zadd(activity_key, {str(ts): ts})
# Update counters in profile
profile_key = self._profile_key(name)
counter_field = f"{activity_type}_count"
self.redis.hincrby(profile_key, counter_field, 1)
global_counter_field = f"global_{activity_type}_count"
self.redis.hincrby(profile_key, global_counter_field, 1)
# Update last_used
self.redis.hset(profile_key, 'last_used', str(ts))
# Keep only last 1000 activities to prevent unbounded growth
self.redis.zremrangebyrank(activity_key, 0, -1001)
# Also record activity for the proxy
proxy_url = profile.get('proxy')
if proxy_url:
proxy_activity_key = self._proxy_activity_key(proxy_url, activity_type)
pipe = self.redis.pipeline()
pipe.zadd(proxy_activity_key, {str(ts): ts})
# Keep last 5000 activities per proxy (higher limit)
pipe.zremrangebyrank(proxy_activity_key, 0, -5001)
pipe.execute()
logger.debug(f"Recorded {activity_type} for proxy '{proxy_url}'")
logger.debug(f"Recorded {activity_type} for profile '{name}' at {ts}")
return True
def get_activity_rate(self, name: str, activity_type: str,
window_seconds: int) -> int:
"""Get activity count within time window."""
if activity_type not in ['success', 'failure', 'tolerated_error', 'download', 'download_error']:
return 0
activity_key = self._activity_key(name, activity_type)
now = time.time()
start = now - window_seconds
count = self.redis.zcount(activity_key, start, now)
return count
def get_proxy_activity_rate(self, proxy_url: str, activity_type: str,
window_seconds: int) -> int:
"""Get proxy activity count within time window."""
if activity_type not in ['success', 'failure', 'tolerated_error', 'download', 'download_error']:
return 0
activity_key = self._proxy_activity_key(proxy_url, activity_type)
now = time.time()
start = now - window_seconds
count = self.redis.zcount(activity_key, start, now)
return count
def reset_profile_counters(self, name: str) -> bool:
"""Resets the session counters for a single profile (does not affect global counters)."""
profile = self.get_profile(name)
if not profile:
logger.error(f"Profile '{name}' not found")
return False
profile_key = self._profile_key(name)
counters_to_reset = {
'success_count': '0',
'failure_count': '0',
'tolerated_error_count': '0',
'download_count': '0',
'download_error_count': '0',
}
self.redis.hset(profile_key, mapping=counters_to_reset)
logger.info(f"Reset session counters for profile '{name}'.")
return True
def get_failed_lock_attempts(self) -> int:
"""Get the total count of failed lock attempts from Redis."""
count = self.redis.get(self._failed_lock_attempts_key())
return int(count) if count else 0
def get_global_stats(self) -> Dict[str, int]:
"""Get aggregated global stats across all profiles."""
profiles = self.list_profiles()
total_success = sum(int(p.get('global_success_count', 0)) for p in profiles)
total_failure = sum(int(p.get('global_failure_count', 0)) for p in profiles)
total_tolerated_error = sum(int(p.get('global_tolerated_error_count', 0)) for p in profiles)
total_downloads = sum(int(p.get('global_download_count', 0)) for p in profiles)
total_download_errors = sum(int(p.get('global_download_error_count', 0)) for p in profiles)
return {
'total_success': total_success,
'total_failure': total_failure,
'total_tolerated_error': total_tolerated_error,
'total_downloads': total_downloads,
'total_download_errors': total_download_errors,
}
def get_per_proxy_stats(self) -> Dict[str, Dict[str, Any]]:
"""Get aggregated stats per proxy."""
profiles = self.list_profiles()
proxy_stats = collections.defaultdict(lambda: {
'success': 0, 'failure': 0, 'tolerated_error': 0, 'downloads': 0, 'download_errors': 0, 'profiles': 0
})
for p in profiles:
proxy = p.get('proxy')
if proxy:
proxy_stats[proxy]['success'] += int(p.get('global_success_count', 0))
proxy_stats[proxy]['failure'] += int(p.get('global_failure_count', 0))
proxy_stats[proxy]['tolerated_error'] += int(p.get('global_tolerated_error_count', 0))
proxy_stats[proxy]['downloads'] += int(p.get('global_download_count', 0))
proxy_stats[proxy]['download_errors'] += int(p.get('global_download_error_count', 0))
proxy_stats[proxy]['profiles'] += 1
return dict(proxy_stats)
def reset_global_counters(self) -> int:
"""Resets global, non-profile-specific counters."""
logger.info("Resetting global counters...")
keys_to_delete = [self._failed_lock_attempts_key()]
deleted_count = 0
if keys_to_delete:
deleted_count = self.redis.delete(*keys_to_delete)
logger.info(f"Deleted {deleted_count} global counter key(s).")
return deleted_count
def set_proxy_state(self, proxy_url: str, state: str, rest_duration_minutes: Optional[int] = None) -> bool:
"""Set the state of a proxy and propagates it to associated profiles."""
if state not in [self.STATE_ACTIVE, self.STATE_RESTING]:
logger.error(f"Invalid proxy state: {state}. Only ACTIVE and RESTING are supported for proxies.")
return False
proxy_key = self._proxy_state_key(proxy_url)
now = time.time()
updates = {'state': state}
rest_until = 0
if state == self.STATE_RESTING:
if not rest_duration_minutes or rest_duration_minutes <= 0:
logger.error("rest_duration_minutes is required when setting proxy state to RESTING.")
return False
rest_until = now + rest_duration_minutes * 60
updates['rest_until'] = str(rest_until)
updates['work_start_timestamp'] = '0' # Clear work start time
else: # ACTIVE
updates['rest_until'] = '0'
updates['work_start_timestamp'] = str(now)
self.redis.hset(proxy_key, mapping=updates)
logger.info(f"Set proxy '{proxy_url}' state to {state}.")
# Now, update associated profiles
profiles_on_proxy = self.list_profiles(proxy_filter=proxy_url)
if not profiles_on_proxy:
return True
if state == self.STATE_RESTING:
logger.info(f"Propagating RESTING state to profiles on proxy '{proxy_url}'.")
for profile in profiles_on_proxy:
if profile['state'] == self.STATE_ACTIVE:
self.update_profile_state(profile['name'], self.STATE_RESTING, "Proxy resting")
self.update_profile_field(profile['name'], 'rest_until', str(rest_until))
elif state == self.STATE_ACTIVE:
logger.info(f"Propagating ACTIVE state to profiles on proxy '{proxy_url}'.")
for profile in profiles_on_proxy:
if profile['state'] == self.STATE_RESTING and profile.get('rest_reason') == "Proxy resting":
self.update_profile_state(profile['name'], self.STATE_ACTIVE, "Proxy activated")
return True
def get_proxy_states(self, proxy_urls: List[str]) -> Dict[str, Dict[str, Any]]:
"""Get states for multiple proxies."""
if not proxy_urls:
return {}
pipe = self.redis.pipeline()
for proxy_url in proxy_urls:
pipe.hgetall(self._proxy_state_key(proxy_url))
results = pipe.execute()
states = {}
for i, data in enumerate(results):
proxy_url = proxy_urls[i]
if data:
# Convert numeric fields
for field in ['rest_until', 'work_start_timestamp']:
if field in data:
try:
data[field] = float(data[field])
except (ValueError, TypeError):
data[field] = 0.0
states[proxy_url] = data
else:
# Default to ACTIVE if no state is found
states[proxy_url] = {'state': self.STATE_ACTIVE, 'rest_until': 0.0, 'work_start_timestamp': 0.0}
return states
def set_proxy_group_membership(self, proxy_url: str, group_name: str, work_minutes: int) -> bool:
"""Records a proxy's membership in a rotation group by updating its state hash."""
proxy_key = self._proxy_state_key(proxy_url)
updates = {
'group_name': group_name,
'group_work_minutes': str(work_minutes)
}
self.redis.hset(proxy_key, mapping=updates)
logger.debug(f"Set proxy '{proxy_url}' group membership to '{group_name}'.")
return True
def set_proxy_group_state(self, group_name: str, active_proxy_index: int, next_rotation_timestamp: float) -> bool:
"""Set the state of a proxy group."""
group_key = self._proxy_group_state_key(group_name)
updates = {
'active_proxy_index': str(active_proxy_index),
'next_rotation_timestamp': str(next_rotation_timestamp)
}
self.redis.hset(group_key, mapping=updates)
logger.info(f"Set proxy group '{group_name}' state: active_index={active_proxy_index}, next_rotation at {format_timestamp(next_rotation_timestamp)}.")
return True
def get_proxy_group_states(self, group_names: List[str]) -> Dict[str, Dict[str, Any]]:
"""Get states for multiple proxy groups."""
if not group_names:
return {}
pipe = self.redis.pipeline()
for name in group_names:
pipe.hgetall(self._proxy_group_state_key(name))
results = pipe.execute()
states = {}
for i, data in enumerate(results):
group_name = group_names[i]
if data:
# Convert numeric fields
for field in ['active_proxy_index', 'next_rotation_timestamp']:
if field in data:
try:
data[field] = float(data[field])
except (ValueError, TypeError):
data[field] = 0.0
if 'active_proxy_index' in data:
data['active_proxy_index'] = int(data['active_proxy_index'])
states[group_name] = data
else:
# Default to empty dict if no state is found
states[group_name] = {}
return states
def set_profile_group_state(self, group_name: str, state_data: Dict[str, Any]) -> bool:
"""Set or update the state of a profile group."""
group_key = self._profile_group_state_key(group_name)
# Ensure all values are strings for redis hset, and filter out None values
updates = {k: str(v) for k, v in state_data.items() if v is not None}
if not updates:
return True # Nothing to do
self.redis.hset(group_key, mapping=updates)
logger.debug(f"Set profile group '{group_name}' state: {updates}.")
return True
def get_profile_group_states(self, group_names: List[str]) -> Dict[str, Dict[str, Any]]:
"""Get states for multiple profile groups."""
if not group_names:
return {}
pipe = self.redis.pipeline()
for name in group_names:
pipe.hgetall(self._profile_group_state_key(name))
results = pipe.execute()
states = {}
for i, data in enumerate(results):
group_name = group_names[i]
if data:
numeric_fields = {
'active_profile_index': int,
'rotate_after_requests': int,
'max_active_profiles': int,
}
for field, type_converter in numeric_fields.items():
if field in data:
try:
data[field] = type_converter(data[field])
except (ValueError, TypeError):
data[field] = 0
states[group_name] = data
else:
states[group_name] = {}
return states
def lock_profile(self, owner: str, profile_prefix: Optional[str] = None, specific_profile_name: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""
Find and lock an available ACTIVE profile.
If `specific_profile_name` is provided, it will attempt to lock only that profile.
Otherwise, it scans for available profiles, optionally filtered by `profile_prefix`.
"""
profiles_to_check = []
if specific_profile_name:
# If a specific profile is requested, we only check that one.
profiles_to_check = [specific_profile_name]
else:
# Original logic: find all active profiles, optionally filtered by prefix.
active_profiles = self.redis.zrange(self._state_key(self.STATE_ACTIVE), 0, -1)
if not active_profiles:
logger.warning("No active profiles available to lock.")
self.redis.incr(self._failed_lock_attempts_key())
return None
if profile_prefix:
profiles_to_check = [p for p in active_profiles if p.startswith(profile_prefix)]
if not profiles_to_check:
logger.warning(f"No active profiles with prefix '{profile_prefix}' available to lock.")
self.redis.incr(self._failed_lock_attempts_key())
return None
else:
profiles_to_check = active_profiles
# --- Filter by active proxy and prepare for locking ---
full_profiles = [self.get_profile(p) for p in profiles_to_check]
# Filter out any None profiles from a race condition with deletion, and ensure state is ACTIVE.
# This is especially important when locking a specific profile.
full_profiles = [p for p in full_profiles if p and p.get('proxy') and p.get('state') == self.STATE_ACTIVE]
if not full_profiles:
if specific_profile_name:
logger.warning(f"Profile '{specific_profile_name}' is not eligible for locking (e.g., not ACTIVE or missing).")
else:
logger.warning("No active profiles available to lock after filtering.")
self.redis.incr(self._failed_lock_attempts_key())
return None
unique_proxies = sorted(list(set(p['proxy'] for p in full_profiles)))
proxy_states = self.get_proxy_states(unique_proxies)
eligible_profiles = [
p['name'] for p in full_profiles
if proxy_states.get(p['proxy'], {}).get('state', self.STATE_ACTIVE) == self.STATE_ACTIVE
]
if not eligible_profiles:
logger.warning("No active profiles with an active proxy available to lock.")
self.redis.incr(self._failed_lock_attempts_key())
return None
# Make selection deterministic (use Redis's sorted set order) instead of random
# random.shuffle(active_profiles)
locks_key = self._locks_key()
for name in eligible_profiles:
# Try to acquire lock atomically
if self.redis.hsetnx(locks_key, name, owner):
# Lock acquired. Now, re-check state to avoid race condition with enforcer.
profile_key = self._profile_key(name)
current_state = self.redis.hget(profile_key, 'state')
if current_state != self.STATE_ACTIVE:
# Another process (enforcer) changed the state. Release lock and try next.
self.redis.hdel(locks_key, name)
logger.warning(f"Aborted lock for '{name}'; state changed from ACTIVE to '{current_state}' during lock acquisition.")
continue
# State is still ACTIVE, proceed with locking.
now = time.time()
pipe = self.redis.pipeline()
# Update profile state and lock info
pipe.hset(profile_key, mapping={
'state': self.STATE_LOCKED,
'lock_owner': owner,
'lock_timestamp': str(now),
'last_used': str(now)
})
# Move from ACTIVE to LOCKED state index
pipe.zrem(self._state_key(self.STATE_ACTIVE), name)
pipe.zadd(self._state_key(self.STATE_LOCKED), {name: now})
pipe.execute()
logger.info(f"Locked profile '{name}' for owner '{owner}'")
return self.get_profile(name)
logger.warning("Could not lock any active profile (all may have been locked by other workers).")
self.redis.incr(self._failed_lock_attempts_key())
return None
def unlock_profile(self, name: str, owner: Optional[str] = None, rest_for_seconds: Optional[int] = None) -> bool:
"""Unlock a profile. If owner provided, it must match. Can optionally put profile into COOLDOWN state."""
profile = self.get_profile(name)
if not profile:
logger.error(f"Profile '{name}' not found.")
return False
if profile['state'] != self.STATE_LOCKED:
logger.warning(f"Profile '{name}' is not in LOCKED state (current: {profile['state']}).")
# Forcibly remove from locks hash if it's inconsistent
self.redis.hdel(self._locks_key(), name)
return False
if owner and profile['lock_owner'] != owner:
logger.error(f"Owner mismatch: cannot unlock profile '{name}'. Locked by '{profile['lock_owner']}', attempted by '{owner}'.")
return False
now = time.time()
profile_key = self._profile_key(name)
pipe = self.redis.pipeline()
updates = {
'lock_owner': '',
'lock_timestamp': '0',
'last_used': str(now)
}
if rest_for_seconds and rest_for_seconds > 0:
new_state = self.STATE_COOLDOWN
rest_until = now + rest_for_seconds
updates['rest_until'] = str(rest_until)
updates['rest_reason'] = 'Post-task cooldown'
logger_msg = f"Unlocked profile '{name}' into COOLDOWN for {rest_for_seconds}s."
else:
new_state = self.STATE_ACTIVE
# Clear any rest-related fields when moving to ACTIVE
updates['rest_until'] = '0'
updates['rest_reason'] = ''
updates['reason'] = ''
logger_msg = f"Unlocked profile '{name}'"
updates['state'] = new_state
pipe.hset(profile_key, mapping=updates)
# Move from LOCKED to the new state index
pipe.zrem(self._state_key(self.STATE_LOCKED), name)
pipe.zadd(self._state_key(new_state), {name: now})
# Remove from global locks hash
pipe.hdel(self._locks_key(), name)
pipe.execute()
logger.info(logger_msg)
return True
def cleanup_stale_locks(self, max_lock_time_seconds: int) -> int:
"""Find and unlock profiles with stale locks."""
locks_key = self._locks_key()
all_locks = self.redis.hgetall(locks_key)
if not all_locks:
logger.debug("No active locks found to clean up.")
return 0
now = time.time()
cleaned_count = 0
for name, owner in all_locks.items():
profile = self.get_profile(name)
if not profile:
# Lock exists but profile doesn't. Clean up the lock.
self.redis.hdel(locks_key, name)
logger.warning(f"Removed stale lock for non-existent profile '{name}'")
cleaned_count += 1
continue
lock_timestamp = profile.get('lock_timestamp', 0)
if lock_timestamp > 0 and (now - lock_timestamp) > max_lock_time_seconds:
logger.warning(f"Found stale lock for profile '{name}' (locked by '{owner}' for {now - lock_timestamp:.0f}s). Unlocking...")
if self.unlock_profile(name):
cleaned_count += 1
if cleaned_count > 0:
logger.info(f"Cleaned up {cleaned_count} stale lock(s).")
else:
logger.debug("No stale locks found to clean up.")
return cleaned_count
def format_timestamp(ts: float) -> str:
"""Format timestamp for display."""
if not ts or ts == 0:
return "Never"
return datetime.fromtimestamp(ts).strftime('%Y-%m-%d %H:%M:%S')
def format_duration(seconds: float) -> str:
"""Format duration for display."""
if seconds < 60:
return f"{seconds:.0f}s"
elif seconds < 3600:
return f"{seconds/60:.1f}m"
elif seconds < 86400:
return f"{seconds/3600:.1f}h"
else:
return f"{seconds/86400:.1f}d"
def add_profile_manager_parser(subparsers):
"""Adds the parser for the 'profile' command."""
parser = subparsers.add_parser(
'profile',
description='Manage profiles (v2).',
formatter_class=argparse.RawTextHelpFormatter,
help='Manage profiles (v2).'
)
# Common arguments for all profile manager 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('--redis-host', default=None, help='Redis host. Defaults to REDIS_HOST or MASTER_HOST_IP env var, or localhost.')
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('--env', default='dev', help="Environment name for Redis key prefix (e.g., 'stg', 'prod'). Used by all non-list commands, and by 'list' for single-view mode. Defaults to 'dev'.")
common_parser.add_argument('--legacy', action='store_true', help="Use legacy key prefix ('profile_mgmt_') without environment.")
common_parser.add_argument('--key-prefix', default=None, help='Explicit key prefix for Redis. Overrides --env, --legacy and any defaults.')
common_parser.add_argument('--verbose', action='store_true', help='Enable verbose logging')
subparsers = parser.add_subparsers(dest='profile_command', help='Command to execute', required=True)
# Create command
create_parser = subparsers.add_parser('create', help='Create a new profile', parents=[common_parser])
create_parser.add_argument('name', help='Profile name')
create_parser.add_argument('proxy', help='Proxy URL (e.g., sslocal-rust-1090:1090)')
create_parser.add_argument('--state', default='ACTIVE',
choices=['ACTIVE', 'PAUSED', 'RESTING', 'BANNED', 'COOLDOWN'],
help='Initial state (default: ACTIVE)')
# List command
list_parser = subparsers.add_parser('list', help='List profiles', parents=[common_parser])
list_parser.add_argument('--auth-env', help='Environment name for the Auth simulation monitor. Use with --download-env for a merged view.')
list_parser.add_argument('--download-env', help='Environment name for the Download simulation monitor. Use with --auth-env for a merged view.')
list_parser.add_argument('--separate-views', action='store_true', help='In dual-monitor mode, show two separate reports instead of a single merged view.')
list_parser.add_argument('--rest-after-requests', type=int, help='(For display) Show countdown to rest based on this request limit.')
list_parser.add_argument('--state', help='Filter by state')
list_parser.add_argument('--proxy', help='Filter by proxy (substring match)')
list_parser.add_argument('--show-proxy-activity', action='store_true', help='Show a detailed activity summary table for proxies. If --proxy is specified, shows details for that proxy only. Otherwise, shows a summary for all proxies.')
list_parser.add_argument('--format', choices=['table', 'json', 'csv'], default='table',
help='Output format (default: table)')
list_parser.add_argument('--live', action='store_true', help='Run continuously with a non-blinking live-updating display.')
list_parser.add_argument('--no-blink', action='store_true', help='Use ANSI escape codes for smoother screen updates in --live mode (experimental).')
list_parser.add_argument('--interval-seconds', type=int, default=5, help='When in --live mode, how often to refresh in seconds. Default: 5.')
list_parser.add_argument('--hide-active-state', action='store_true', help="Display 'ACTIVE' state as blank for cleaner UI.")
# Get command
get_parser = subparsers.add_parser('get', help='Get profile details', parents=[common_parser])
get_parser.add_argument('name', help='Profile name')
# Set proxy state command
set_proxy_state_parser = subparsers.add_parser('set-proxy-state', help='Set the state of a proxy and propagate to its profiles.', parents=[common_parser])
set_proxy_state_parser.add_argument('proxy_url', help='Proxy URL')
set_proxy_state_parser.add_argument('state', choices=['ACTIVE', 'RESTING'], help='New state for the proxy')
set_proxy_state_parser.add_argument('--duration-minutes', type=int, help='Duration for the RESTING state')
# Update state command
update_state_parser = subparsers.add_parser('update-state', help='Update profile state', parents=[common_parser])
update_state_parser.add_argument('name', help='Profile name')
update_state_parser.add_argument('state', choices=['ACTIVE', 'PAUSED', 'RESTING', 'BANNED', 'LOCKED', 'COOLDOWN'],
help='New state')
update_state_parser.add_argument('--reason', help='Reason for state change (especially for BAN)')
# Update field command
update_field_parser = subparsers.add_parser('update-field', help='Update a profile field', parents=[common_parser])
update_field_parser.add_argument('name', help='Profile name')
update_field_parser.add_argument('field', help='Field name to update')
update_field_parser.add_argument('value', help='New value')
# Pause command (convenience)
pause_parser = subparsers.add_parser('pause', help='Pause a profile (sets state to PAUSED).', parents=[common_parser])
pause_parser.add_argument('name', help='Profile name')
# Activate command (convenience)
activate_parser = subparsers.add_parser('activate', help='Activate a profile (sets state to ACTIVE). Useful for resuming a PAUSED profile or fixing a stale LOCKED one.', parents=[common_parser])
activate_parser.add_argument('name', help='Profile name')
# Ban command (convenience)
ban_parser = subparsers.add_parser('ban', help='Ban a profile (sets state to BANNED).', parents=[common_parser])
ban_parser.add_argument('name', help='Profile name')
ban_parser.add_argument('--reason', required=True, help='Reason for ban')
# Unban command (convenience)
unban_parser = subparsers.add_parser('unban', help='Unban a profile (sets state to ACTIVE and resets session counters).', parents=[common_parser])
unban_parser.add_argument('name', help='Profile name')
# Delete command
delete_parser = subparsers.add_parser('delete', help='Delete a profile', parents=[common_parser])
delete_parser.add_argument('name', help='Profile name')
delete_parser.add_argument('--confirm', action='store_true',
help='Confirm deletion (required)')
# Delete all command
delete_all_parser = subparsers.add_parser('delete-all', help='(Destructive) Delete all profiles and data under the current key prefix.', parents=[common_parser])
delete_all_parser.add_argument('--confirm', action='store_true', help='Confirm this highly destructive action (required)')
# Reset global counters command
reset_global_parser = subparsers.add_parser('reset-global-counters', help='Reset global counters (e.g., failed_lock_attempts).', parents=[common_parser])
# Reset counters command
reset_counters_parser = subparsers.add_parser(
'reset-counters',
help='Reset session counters for profiles or proxies.',
description="Resets session-specific counters (success, failure, etc.) for one or more profiles.\n\nWARNING: This only resets Redis counters. It does not affect any data stored on disk\n(e.g., downloaded files, logs) associated with the profile or proxy.",
formatter_class=argparse.RawTextHelpFormatter,
parents=[common_parser]
)
reset_group = reset_counters_parser.add_mutually_exclusive_group(required=True)
reset_group.add_argument('--profile-name', help='The name of the single profile to reset.')
reset_group.add_argument('--proxy-url', help='Reset all profiles associated with this proxy.')
reset_group.add_argument('--all-profiles', action='store_true', help='Reset all profiles in the environment.')
# Record activity command (for testing)
record_parser = subparsers.add_parser('record-activity', help='(Testing) Record a synthetic activity event for a profile.', parents=[common_parser])
record_parser.add_argument('name', help='Profile name')
record_parser.add_argument('type', choices=['success', 'failure', 'tolerated_error', 'download', 'download_error'], help='Activity type')
record_parser.add_argument('--timestamp', type=float, help='Timestamp (default: now)')
# Get rate command
rate_parser = subparsers.add_parser('get-rate', help='Get activity rate for a profile', parents=[common_parser])
rate_parser.add_argument('name', help='Profile name')
rate_parser.add_argument('type', choices=['success', 'failure', 'tolerated_error', 'download', 'download_error'], help='Activity type')
rate_parser.add_argument('--window', type=int, default=3600,
help='Time window in seconds (default: 3600)')
return parser
def _build_profile_groups_config(manager, profiles):
"""Builds a configuration structure for profile groups by reading state from Redis."""
group_state_keys = [k for k in manager.redis.scan_iter(f"{manager.key_prefix}profile_group_state:*")]
if not group_state_keys:
return []
group_names = [k.split(':')[-1] for k in group_state_keys]
group_states = manager.get_profile_group_states(group_names)
config = []
for name, state in group_states.items():
profiles_in_group = []
prefix = state.get('prefix')
if prefix:
profiles_in_group = [p['name'] for p in profiles if p['name'].startswith(prefix)]
config.append({
'name': name,
'profiles_in_group': profiles_in_group,
**state
})
return config
def _render_all_proxies_activity_summary(manager, simulation_type, file=sys.stdout):
"""Renders a summary of activity rates for all proxies."""
if not manager:
return
print(f"\n--- All Proxies Activity Summary ({simulation_type}) ---", file=file)
all_profiles = manager.list_profiles()
if not all_profiles:
print("No profiles found to determine proxy list.", file=file)
return
unique_proxies = sorted(list(set(p['proxy'] for p in all_profiles if p.get('proxy'))))
if not unique_proxies:
print("No proxies are currently associated with any profiles.", file=file)
return
proxy_states = manager.get_proxy_states(unique_proxies)
is_auth_sim = 'Auth' in simulation_type
# Sum up all relevant activity types for the rate columns
activity_types_to_sum = ['success', 'failure', 'tolerated_error'] if is_auth_sim else ['download', 'download_error', 'tolerated_error']
proxy_work_minutes_str = manager.get_config('proxy_work_minutes')
proxy_work_minutes = 0
if proxy_work_minutes_str and proxy_work_minutes_str.isdigit():
proxy_work_minutes = int(proxy_work_minutes_str)
proxy_rest_minutes_str = manager.get_config('proxy_rest_duration_minutes')
proxy_rest_minutes = 0
if proxy_rest_minutes_str and proxy_rest_minutes_str.isdigit():
proxy_rest_minutes = int(proxy_rest_minutes_str)
table_data = []
headers = ['Proxy URL', 'State', 'Policy', 'State Ends In', 'Reqs (1m)', 'Reqs (5m)', 'Reqs (1h)']
for proxy_url in unique_proxies:
state_data = proxy_states.get(proxy_url, {})
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
countdown_str = "N/A"
now = time.time()
policy_str = "N/A"
group_name = state_data.get('group_name')
work_minutes_for_countdown = 0
if group_name:
group_work_minutes = state_data.get('group_work_minutes', 0)
try:
group_work_minutes = int(group_work_minutes)
work_minutes_for_countdown = group_work_minutes
except (ValueError, TypeError):
group_work_minutes = 0
policy_str = f"Group: {group_name}\n({group_work_minutes}m/proxy)"
elif proxy_work_minutes > 0:
policy_str = f"Work: {proxy_work_minutes}m\nRest: {proxy_rest_minutes}m"
work_minutes_for_countdown = proxy_work_minutes
if state == 'RESTING' and rest_until > now:
countdown_str = format_duration(rest_until - now)
elif state == 'ACTIVE' and work_start > 0 and work_minutes_for_countdown > 0:
work_end_time = work_start + (work_minutes_for_countdown * 60)
if work_end_time > now:
countdown_str = format_duration(work_end_time - now)
else:
countdown_str = "Now"
rate_1m = sum(manager.get_proxy_activity_rate(proxy_url, act_type, 60) for act_type in activity_types_to_sum)
rate_5m = sum(manager.get_proxy_activity_rate(proxy_url, act_type, 300) for act_type in activity_types_to_sum)
rate_1h = sum(manager.get_proxy_activity_rate(proxy_url, act_type, 3600) for act_type in activity_types_to_sum)
row = [
proxy_url,
state_str,
policy_str,
countdown_str,
rate_1m,
rate_5m,
rate_1h,
]
table_data.append(row)
if table_data:
print(tabulate(table_data, headers=headers, tablefmt='grid'), file=file)
def _render_proxy_activity_summary(manager, proxy_url, simulation_type, file=sys.stdout):
"""Renders a detailed activity summary for a single proxy."""
if not manager or not proxy_url:
return
print(f"\n--- Activity Summary for Proxy: {proxy_url} ({simulation_type}) ---", file=file)
proxy_work_minutes_str = manager.get_config('proxy_work_minutes')
proxy_work_minutes = 0
if proxy_work_minutes_str and proxy_work_minutes_str.isdigit():
proxy_work_minutes = int(proxy_work_minutes_str)
proxy_rest_minutes_str = manager.get_config('proxy_rest_duration_minutes')
proxy_rest_minutes = 0
if proxy_rest_minutes_str and proxy_rest_minutes_str.isdigit():
proxy_rest_minutes = int(proxy_rest_minutes_str)
proxy_state_data = manager.get_proxy_states([proxy_url]).get(proxy_url, {})
state = proxy_state_data.get('state', 'N/A')
rest_until = proxy_state_data.get('rest_until', 0)
work_start = proxy_state_data.get('work_start_timestamp', 0)
policy_str = "N/A"
group_name = proxy_state_data.get('group_name')
work_minutes_for_countdown = 0
if group_name:
group_work_minutes = proxy_state_data.get('group_work_minutes', 0)
try:
group_work_minutes = int(group_work_minutes)
work_minutes_for_countdown = group_work_minutes
except (ValueError, TypeError):
group_work_minutes = 0
policy_str = f"Group: {group_name} ({group_work_minutes}m/proxy)"
elif proxy_work_minutes > 0:
policy_str = f"Work: {proxy_work_minutes}m, Rest: {proxy_rest_minutes}m"
work_minutes_for_countdown = proxy_work_minutes
state_str = state
now = time.time()
if state == 'RESTING' and rest_until > now:
state_str += f" (ends in {format_duration(rest_until - now)})"
active_duration_str = "N/A"
time_until_rest_str = "N/A"
if state == 'ACTIVE' and work_start > 0:
active_duration_str = format_duration(now - work_start)
if work_minutes_for_countdown > 0:
work_end_time = work_start + (work_minutes_for_countdown * 60)
if work_end_time > now:
time_until_rest_str = format_duration(work_end_time - now)
else:
time_until_rest_str = "Now"
summary_data = [
("State", state_str),
("Policy", policy_str),
("Active Since", format_timestamp(work_start)),
("Active Duration", active_duration_str),
("Time Until Rest", time_until_rest_str),
]
print(tabulate(summary_data, tablefmt='grid'), file=file)
windows = {
"Last 1 Min": 60,
"Last 5 Min": 300,
"Last 1 Hour": 3600,
"Last 24 Hours": 86400,
}
is_auth_sim = 'Auth' in simulation_type
activity_types = ['success', 'failure', 'tolerated_error'] if is_auth_sim else ['download', 'download_error', 'tolerated_error']
table_data = []
headers = ['Window'] + [act_type.replace('_', ' ').title() for act_type in activity_types]
for name, seconds in windows.items():
row = [name]
for act_type in activity_types:
count = manager.get_proxy_activity_rate(proxy_url, act_type, seconds)
row.append(count)
table_data.append(row)
if table_data:
print(tabulate(table_data, headers=headers, tablefmt='grid'), file=file)
def _render_profile_group_summary_table(manager, all_profiles, profile_groups_config, file=sys.stdout):
"""Renders a summary table for profile groups."""
if not profile_groups_config:
return
print("\nProfile Group Status:", file=file)
table_data = []
all_profiles_map = {p['name']: p for p in all_profiles}
for group in profile_groups_config:
group_name = group.get('name', 'N/A')
profiles_in_group = group.get('profiles_in_group', [])
active_profiles = [
p_name for p_name in profiles_in_group
if all_profiles_map.get(p_name, {}).get('state') in [manager.STATE_ACTIVE, manager.STATE_LOCKED]
]
active_profiles_str = ', '.join(active_profiles) or "None"
max_active = group.get('max_active_profiles', 1)
policy_str = f"{len(active_profiles)}/{max_active} Active"
rotate_after = group.get('rotate_after_requests')
rotation_rule_str = f"After {rotate_after} reqs" if rotate_after else "N/A"
reqs_left_str = "N/A"
if rotate_after and rotate_after > 0 and active_profiles:
# Show countdown for the first active profile
active_profile_name = active_profiles[0]
p = all_profiles_map.get(active_profile_name)
if p:
total_reqs = (
p.get('success_count', 0) + p.get('failure_count', 0) +
p.get('tolerated_error_count', 0) +
p.get('download_count', 0) + p.get('download_error_count', 0)
)
remaining_reqs = rotate_after - total_reqs
reqs_left_str = str(max(0, int(remaining_reqs)))
table_data.append([
group_name,
active_profiles_str,
policy_str,
rotation_rule_str,
reqs_left_str
])
headers = ['Group Name', 'Active Profile(s)', 'Policy', 'Rotation Rule', 'Requests Left ↓']
print(tabulate(table_data, headers=headers, tablefmt='grid'), file=file)
def _render_profile_details_table(manager, args, simulation_type, profile_groups_config, file=sys.stdout):
"""Renders the detailed profile list table for a given manager."""
if not manager:
print("Manager not configured.", file=file)
return
profiles = manager.list_profiles(args.state, args.proxy)
if not profiles:
print("No profiles found matching the criteria.", file=file)
return
table_data = []
is_auth_sim = 'Auth' in simulation_type
for p in profiles:
rest_until_str = 'N/A'
last_rest_ts = p.get('last_rest_timestamp', 0)
last_rest_str = format_timestamp(last_rest_ts)
state_str = p.get('state', 'UNKNOWN')
if state_str in ['RESTING', 'COOLDOWN']:
rest_until = p.get('rest_until', 0)
if rest_until > 0:
remaining = rest_until - time.time()
if remaining > 0:
rest_until_str = f"in {format_duration(remaining)}"
else:
rest_until_str = "Ending now"
if last_rest_ts == 0:
last_rest_str = "NOW"
pending_dl_count = p.get('pending_downloads', 0)
if p.get('rest_reason') == 'waiting_downloads' and pending_dl_count > 0:
state_str += f"\n({pending_dl_count} DLs)"
countdown_str = 'N/A'
# Find the group this profile belongs to and get its rotation policy
profile_group = next((g for g in profile_groups_config if p['name'] in g.get('profiles_in_group', [])), None)
rotate_after = 0
if profile_group:
rotate_after = profile_group.get('rotate_after_requests')
elif args.rest_after_requests and args.rest_after_requests > 0:
rotate_after = args.rest_after_requests
if rotate_after > 0 and state_str != manager.STATE_COOLDOWN:
total_reqs = (
p.get('success_count', 0) + p.get('failure_count', 0) +
p.get('tolerated_error_count', 0) +
p.get('download_count', 0) + p.get('download_error_count', 0)
)
remaining_reqs = rotate_after - total_reqs
countdown_str = str(max(0, int(remaining_reqs)))
if args.hide_active_state and state_str == 'ACTIVE':
state_str = ''
row = [
p.get('name', 'MISSING_NAME'),
p.get('proxy', 'MISSING_PROXY'),
state_str,
format_timestamp(p.get('last_used', 0)),
]
if is_auth_sim:
row.extend([
p.get('success_count', 0),
p.get('failure_count', 0),
p.get('tolerated_error_count', 0),
p.get('global_success_count', 0),
p.get('global_failure_count', 0),
])
else: # is_download_sim or unknown
row.extend([
p.get('download_count', 0),
p.get('download_error_count', 0),
p.get('tolerated_error_count', 0),
p.get('global_download_count', 0),
p.get('global_download_error_count', 0),
])
# Display generic 'reason' field as a fallback for 'rest_reason'
reason_str = p.get('rest_reason') or p.get('reason') or ''
row.extend([
countdown_str,
rest_until_str,
reason_str,
p.get('ban_reason') or ''
])
table_data.append(row)
headers = ['Name', 'Proxy', 'State', 'Last Used']
if is_auth_sim:
headers.extend(['AuthOK', 'AuthFail', 'Skip.Err', 'Tot.AuthOK', 'Tot.AuthFail'])
else: # is_download_sim or unknown
headers.extend(['DataOK', 'DownFail', 'Skip.Err', 'Tot.DataOK', 'Tot.DownFail'])
headers.extend(['ReqCD ↓', 'RestCD ↓', 'R.Reason', 'B.Reason'])
# Using `maxcolwidths` to control column width for backward compatibility
# with older versions of the `tabulate` library. This prevents content
# from making columns excessively wide, but does not guarantee a fixed width.
maxwidths = None
if table_data or headers: # Check headers in case table_data is empty
# Transpose table to get columns, including headers.
# This handles empty table_data correctly.
columns = list(zip(*([headers] + table_data)))
# Calculate max width for each column based on its content.
maxwidths = [max(len(str(x)) for x in col) if col else 0 for col in columns]
# Enforce a minimum width for the reason columns to keep table width stable.
DEFAULT_REASON_WIDTH = 25
try:
r_reason_idx = headers.index('R.Reason')
b_reason_idx = headers.index('B.Reason')
maxwidths[r_reason_idx] = max(DEFAULT_REASON_WIDTH, maxwidths[r_reason_idx])
maxwidths[b_reason_idx] = max(DEFAULT_REASON_WIDTH, maxwidths[b_reason_idx])
except (ValueError, IndexError):
# This should not happen if headers are constructed as expected.
pass
print(tabulate(table_data, headers=headers, tablefmt='grid', maxcolwidths=maxwidths), file=file)
def _render_simulation_view(title, manager, args, file=sys.stdout):
"""Helper function to render the list of profiles for a single simulation environment."""
if not manager:
print(f"\n--- {title} (Environment Not Configured) ---", file=file)
return 0
if not tabulate:
print("'tabulate' library is required for table format. Please install it.", file=sys.stderr)
return 1
print(f"\n--- {title} ---", file=file)
profiles = manager.list_profiles(args.state, args.proxy)
if args.format == 'json':
print(json.dumps(profiles, indent=2, default=str), file=file)
return 0
elif args.format == 'csv':
if profiles:
headers = profiles[0].keys()
print(','.join(headers), file=file)
for p in profiles:
print(','.join(str(p.get(h, '')) for h in headers), file=file)
return 0
# --- Table Format with Summaries ---
if args.show_proxy_activity:
if args.proxy:
_render_proxy_activity_summary(manager, args.proxy, title, file=file)
else:
_render_all_proxies_activity_summary(manager, title, file=file)
profile_groups_config = _build_profile_groups_config(manager, profiles)
_render_profile_group_summary_table(manager, profiles, profile_groups_config, file=file)
failed_lock_attempts = manager.get_failed_lock_attempts()
global_stats = manager.get_global_stats()
per_proxy_stats = manager.get_per_proxy_stats()
unique_proxies = sorted(per_proxy_stats.keys())
proxy_states = manager.get_proxy_states(unique_proxies)
# Build global summary
total_reqs = global_stats['total_success'] + global_stats['total_failure']
success_rate = (global_stats['total_success'] / total_reqs * 100) if total_reqs > 0 else 100
global_summary_str = (
f"Total Requests: {total_reqs} | "
f"Success: {global_stats['total_success']} | "
f"Failure: {global_stats['total_failure']} | "
f"Tolerated Error: {global_stats['total_tolerated_error']} | "
f"Downloads: {global_stats['total_downloads']} | "
f"Download Errors: {global_stats.get('total_download_errors', 0)} | "
f"Success Rate: {success_rate:.2f}% | "
f"Failed Lock Attempts: {failed_lock_attempts}"
)
print("Global Stats:", global_summary_str, file=file)
# Build per-proxy summary
if per_proxy_stats:
print("\nPer-Proxy Stats:", file=file)
proxy_table_data = []
for proxy_url in unique_proxies:
stats = per_proxy_stats[proxy_url]
state_info = proxy_states.get(proxy_url, {})
state = state_info.get('state', 'ACTIVE')
cooldown_str = 'N/A'
if state == 'RESTING':
rest_until = state_info.get('rest_until', 0)
if rest_until > time.time():
cooldown_str = f"in {format_duration(rest_until - time.time())}"
else:
cooldown_str = "Ending now"
proxy_total_auth = stats['success'] + stats['failure']
proxy_total_downloads = stats['downloads'] + stats['download_errors']
proxy_total_reqs = proxy_total_auth + proxy_total_downloads
proxy_success_rate = (stats['success'] / proxy_total_auth * 100) if proxy_total_auth > 0 else 100
proxy_table_data.append([
proxy_url,
state,
cooldown_str,
stats['profiles'],
proxy_total_reqs,
stats['success'],
stats['failure'],
stats['tolerated_error'],
stats['downloads'],
stats['download_errors'],
f"{proxy_success_rate:.1f}%"
])
proxy_headers = ['Proxy', 'State', 'Cooldown', 'Profiles', 'Total Reqs', 'AuthOK', 'AuthFail', 'Skip.Err', 'DataOK', 'DownFail', 'OK %']
print(tabulate(proxy_table_data, headers=proxy_headers, tablefmt='grid'), file=file)
print("\nProfile Details:", file=file)
_render_profile_details_table(manager, args, title, profile_groups_config, file=file)
return 0
def _render_merged_view(auth_manager, download_manager, args, file=sys.stdout):
"""Renders a merged, unified view for both auth and download simulations."""
# --- 1. Fetch ALL data first to prevent delays during rendering ---
auth_stats = auth_manager.get_global_stats()
auth_failed_locks = auth_manager.get_failed_lock_attempts()
dl_stats = download_manager.get_global_stats()
dl_failed_locks = download_manager.get_failed_lock_attempts()
auth_proxy_stats = auth_manager.get_per_proxy_stats()
dl_proxy_stats = download_manager.get_per_proxy_stats()
all_proxies = sorted(list(set(auth_proxy_stats.keys()) | set(dl_proxy_stats.keys())))
auth_proxy_states, dl_proxy_states = {}, {}
if all_proxies:
auth_proxy_states = auth_manager.get_proxy_states(all_proxies)
dl_proxy_states = download_manager.get_proxy_states(all_proxies)
auth_profiles = auth_manager.list_profiles(args.state, args.proxy)
auth_groups_config = _build_profile_groups_config(auth_manager, auth_profiles)
dl_profiles = download_manager.list_profiles(args.state, args.proxy)
dl_groups_config = _build_profile_groups_config(download_manager, dl_profiles)
# --- 2. Prepare all display data using fetched information ---
total_reqs = auth_stats['total_success'] + auth_stats['total_failure']
success_rate = (auth_stats['total_success'] / total_reqs * 100) if total_reqs > 0 else 100
total_dls = dl_stats['total_downloads'] + dl_stats['total_download_errors']
dl_success_rate = (dl_stats['total_downloads'] / total_dls * 100) if total_dls > 0 else 100
global_summary_str = (
f"Auth: {total_reqs} reqs ({auth_stats['total_success']} OK, {auth_stats['total_failure']} Fail, {auth_stats['total_tolerated_error']} Tol.Err) | "
f"OK Rate: {success_rate:.2f}% | "
f"Failed Locks: {auth_failed_locks} || "
f"Download: {total_dls} attempts ({dl_stats['total_downloads']} OK, {dl_stats['total_download_errors']} Fail) | "
f"OK Rate: {dl_success_rate:.2f}% | "
f"Failed Locks: {dl_failed_locks}"
)
proxy_table_data = []
if all_proxies:
for proxy in all_proxies:
astats = auth_proxy_stats.get(proxy, {})
dstats = dl_proxy_stats.get(proxy, {})
astate = auth_proxy_states.get(proxy, {})
dstate = dl_proxy_states.get(proxy, {})
state_str = f"{astate.get('state', 'N/A')} / {dstate.get('state', 'N/A')}"
proxy_table_data.append([
proxy,
state_str,
astats.get('profiles', 0),
dstats.get('profiles', 0),
astats.get('success', 0),
astats.get('failure', 0),
astats.get('tolerated_error', 0),
dstats.get('downloads', 0),
dstats.get('download_errors', 0),
dstats.get('tolerated_error', 0)
])
# --- 3. Render everything to the buffer at once ---
print("--- Global Simulation Stats ---", file=file)
print(global_summary_str, file=file)
if args.show_proxy_activity:
if args.proxy:
_render_proxy_activity_summary(auth_manager, args.proxy, "Auth", file=file)
_render_proxy_activity_summary(download_manager, args.proxy, "Download", file=file)
else:
# In merged view, it makes sense to show both summaries if requested.
_render_all_proxies_activity_summary(auth_manager, "Auth", file=file)
_render_all_proxies_activity_summary(download_manager, "Download", file=file)
if all_proxies:
print("\n--- Per-Proxy Stats (Merged) ---", file=file)
proxy_headers = ['Proxy', 'State (A/D)', 'Profiles (A)', 'Profiles (D)', 'AuthOK', 'AuthFail', 'Skip.Err(A)', 'DataOK', 'DownFail', 'Skip.Err(D)']
print(tabulate(proxy_table_data, headers=proxy_headers, tablefmt='grid'), file=file)
print(f"\n--- Auth Simulation Profile Details ({args.auth_env}) ---", file=file)
_render_profile_group_summary_table(auth_manager, auth_profiles, auth_groups_config, file=file)
_render_profile_details_table(auth_manager, args, "Auth", auth_groups_config, file=file)
print(f"\n--- Download Simulation Profile Details ({args.download_env}) ---", file=file)
_render_profile_group_summary_table(download_manager, dl_profiles, dl_groups_config, file=file)
_render_profile_details_table(download_manager, args, "Download", dl_groups_config, file=file)
return 0
def _print_profile_list(manager, args, title="Profile Status"):
"""Helper function to print the list of profiles in the desired format."""
return _render_simulation_view(title, manager, args, file=sys.stdout)
def main_profile_manager(args):
"""Main dispatcher for 'profile' command."""
if load_dotenv:
env_file = args.env_file
if not env_file and args.env and '.env' in args.env and os.path.exists(args.env):
print(f"WARNING: --env should be an environment name (e.g., 'dev'), not a file path. Treating '{args.env}' as --env-file. The environment name will default to 'dev'.", file=sys.stderr)
env_file = args.env
args.env = 'dev'
was_loaded = load_dotenv(env_file)
if was_loaded:
print(f"Loaded environment variables from {env_file or '.env file'}", file=sys.stderr)
elif args.env_file:
print(f"ERROR: The specified --env-file was not found: {args.env_file}", file=sys.stderr)
return 1
if args.redis_host is None:
args.redis_host = os.getenv('REDIS_HOST', os.getenv('MASTER_HOST_IP', 'localhost'))
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)
if args.key_prefix:
key_prefix = args.key_prefix
elif args.legacy:
key_prefix = 'profile_mgmt_'
else:
key_prefix = f"{args.env}_profile_mgmt_"
manager = ProfileManager(
redis_host=args.redis_host,
redis_port=args.redis_port,
redis_password=args.redis_password,
key_prefix=key_prefix
)
if args.profile_command == 'create':
success = manager.create_profile(args.name, args.proxy, args.state)
return 0 if success else 1
elif args.profile_command == 'list':
is_dual_mode = args.auth_env and args.download_env
def _create_manager(env_name, is_for_dual_mode):
if not env_name:
return None
# For dual mode, we ignore --legacy and --key-prefix from CLI, and derive from env name.
# This is opinionated but makes dual-mode behavior predictable.
if is_for_dual_mode:
key_prefix = f"{env_name}_profile_mgmt_"
else:
# Single mode respects all CLI flags
if args.key_prefix:
key_prefix = args.key_prefix
elif args.legacy:
key_prefix = 'profile_mgmt_'
else:
key_prefix = f"{env_name}_profile_mgmt_"
return ProfileManager(
redis_host=args.redis_host, redis_port=args.redis_port,
redis_password=args.redis_password, key_prefix=key_prefix
)
if not args.live:
if is_dual_mode and not args.separate_views:
auth_manager = _create_manager(args.auth_env, is_for_dual_mode=True)
download_manager = _create_manager(args.download_env, is_for_dual_mode=True)
return _render_merged_view(auth_manager, download_manager, args)
elif is_dual_mode and args.separate_views:
auth_manager = _create_manager(args.auth_env, is_for_dual_mode=True)
download_manager = _create_manager(args.download_env, is_for_dual_mode=True)
_render_simulation_view(f"Auth Simulation ({args.auth_env})", auth_manager, args)
_render_simulation_view(f"Download Simulation ({args.download_env})", download_manager, args)
return 0
else:
# Single view mode
single_env = args.auth_env or args.download_env or args.env
manager = _create_manager(single_env, is_for_dual_mode=False)
# Determine the title for correct table headers
title = f"Profile Status ({single_env})"
if args.auth_env:
title = f"Auth Simulation ({args.auth_env})"
elif args.download_env:
title = f"Download Simulation ({args.download_env})"
return _print_profile_list(manager, args, title=title)
# --- Live Mode ---
pm_logger = logging.getLogger(__name__)
original_log_level = pm_logger.level
try:
if args.no_blink:
sys.stdout.write('\033[?25l') # Hide cursor
sys.stdout.flush()
# Register signal handlers for graceful shutdown in live mode
signal.signal(signal.SIGINT, handle_shutdown)
signal.signal(signal.SIGTERM, handle_shutdown)
while not shutdown_event.is_set():
pm_logger.setLevel(logging.WARNING) # Suppress connection logs for cleaner UI
start_time = time.time()
output_buffer = io.StringIO()
print(f"--- Profile Status (auto-refreshing every {args.interval_seconds}s, Ctrl+C to exit) | Last updated: {datetime.now().strftime('%H:%M:%S')} ---", file=output_buffer)
if is_dual_mode and not args.separate_views:
auth_manager = _create_manager(args.auth_env, is_for_dual_mode=True)
download_manager = _create_manager(args.download_env, is_for_dual_mode=True)
_render_merged_view(auth_manager, download_manager, args, file=output_buffer)
elif is_dual_mode and args.separate_views:
auth_manager = _create_manager(args.auth_env, is_for_dual_mode=True)
download_manager = _create_manager(args.download_env, is_for_dual_mode=True)
_render_simulation_view(f"Auth Simulation ({args.auth_env})", auth_manager, args, file=output_buffer)
_render_simulation_view(f"Download Simulation ({args.download_env})", download_manager, args, file=output_buffer)
else:
# Single view mode
single_env = args.auth_env or args.download_env or args.env
manager = _create_manager(single_env, is_for_dual_mode=False)
# Determine the title for correct table headers
title = f"Profile Status ({single_env})"
if args.auth_env:
title = f"Auth Simulation ({args.auth_env})"
elif args.download_env:
title = f"Download Simulation ({args.download_env})"
_render_simulation_view(title, manager, args, file=output_buffer)
pm_logger.setLevel(original_log_level) # Restore log level
fetch_and_render_duration = time.time() - start_time
if args.no_blink:
sys.stdout.write('\033[2J\033[H') # Clear screen, move to top
else:
os.system('cls' if os.name == 'nt' else 'clear')
sys.stdout.write(output_buffer.getvalue())
sys.stdout.flush()
# --- Adaptive Countdown ---
remaining_sleep = args.interval_seconds - fetch_and_render_duration
if remaining_sleep > 0:
end_time = time.time() + remaining_sleep
while time.time() < end_time and not shutdown_event.is_set():
time_left = end_time - time.time()
sys.stdout.write(f"\rRefreshing in {int(time_left)}s... (fetch took {fetch_and_render_duration:.2f}s) ")
sys.stdout.flush()
time.sleep(min(1, time_left if time_left > 0 else 1))
elif not shutdown_event.is_set():
sys.stdout.write(f"\rRefreshing now... (fetch took {fetch_and_render_duration:.2f}s, behind by {-remaining_sleep:.2f}s) ")
sys.stdout.flush()
time.sleep(0.5) # Brief pause to make message readable
sys.stdout.write("\r" + " " * 80 + "\r") # Clear line
sys.stdout.flush()
except KeyboardInterrupt:
# This can be triggered by Ctrl+C during a time.sleep().
# The signal handler will have already set the shutdown_event and printed a message.
# This block is a fallback.
if not shutdown_event.is_set():
print("\nKeyboardInterrupt received. Stopping live view...", file=sys.stderr)
shutdown_event.set() # Ensure event is set if handler didn't run
return 0
finally:
pm_logger.setLevel(original_log_level)
if args.live and args.no_blink:
sys.stdout.write('\033[?25h') # Restore cursor
sys.stdout.flush()
elif args.profile_command == 'set-proxy-state':
success = manager.set_proxy_state(args.proxy_url, args.state, args.duration_minutes)
return 0 if success else 1
elif args.profile_command == 'get':
profile = manager.get_profile(args.name)
if not profile:
print(f"Profile '{args.name}' not found")
return 1
print(f"Profile: {profile['name']}")
print(f"Proxy: {profile['proxy']}")
print(f"State: {profile['state']}")
print(f"Created: {format_timestamp(profile['created_at'])}")
print(f"Last Used: {format_timestamp(profile['last_used'])}")
print(f"Success Count: {profile['success_count']}")
print(f"Failure Count: {profile['failure_count']}")
if profile.get('rest_until', 0) > 0:
remaining = profile['rest_until'] - time.time()
if remaining > 0:
print(f"Resting for: {format_duration(remaining)} more")
else:
print(f"Rest period ended: {format_timestamp(profile['rest_until'])}")
if profile.get('ban_reason'):
print(f"Ban Reason: {profile['ban_reason']}")
if profile.get('lock_timestamp', 0) > 0:
print(f"Locked since: {format_timestamp(profile['lock_timestamp'])}")
print(f"Lock Owner: {profile['lock_owner']}")
if profile.get('notes'):
print(f"Notes: {profile['notes']}")
return 0
elif args.profile_command == 'update-state':
success = manager.update_profile_state(args.name, args.state, args.reason or '')
return 0 if success else 1
elif args.profile_command == 'update-field':
success = manager.update_profile_field(args.name, args.field, args.value)
return 0 if success else 1
elif args.profile_command == 'pause':
success = manager.update_profile_state(args.name, manager.STATE_PAUSED, 'Manual pause')
return 0 if success else 1
elif args.profile_command == 'activate':
success = manager.update_profile_state(args.name, manager.STATE_ACTIVE, 'Manual activation')
return 0 if success else 1
elif args.profile_command == 'ban':
success = manager.update_profile_state(args.name, manager.STATE_BANNED, args.reason)
return 0 if success else 1
elif args.profile_command == 'unban':
# First activate, then reset session counters. The ban reason is cleared by update_profile_state.
success = manager.update_profile_state(args.name, manager.STATE_ACTIVE, 'Manual unban')
if success:
manager.reset_profile_counters(args.name)
return 0 if success else 1
elif args.profile_command == 'delete':
if not args.confirm:
print("Error: --confirm flag is required for deletion", file=sys.stderr)
return 1
success = manager.delete_profile(args.name)
return 0 if success else 1
elif args.profile_command == 'delete-all':
if not args.confirm:
print("Error: --confirm flag is required for this destructive action.", file=sys.stderr)
return 1
deleted_count = manager.delete_all_data()
print(f"Deleted {deleted_count} key(s) with prefix '{manager.key_prefix}'.")
return 0
elif args.profile_command == 'reset-global-counters':
manager.reset_global_counters()
return 0
elif args.profile_command == 'reset-counters':
profiles_to_reset = []
if args.profile_name:
profile = manager.get_profile(args.profile_name)
if profile:
profiles_to_reset.append(profile)
elif args.proxy_url:
profiles_to_reset = manager.list_profiles(proxy_filter=args.proxy_url)
elif args.all_profiles:
profiles_to_reset = manager.list_profiles()
if not profiles_to_reset:
print("No profiles found to reset.", file=sys.stderr)
return 1
print(f"Found {len(profiles_to_reset)} profile(s) to reset. This action is not reversible.")
confirm = input("Continue? (y/N): ")
if confirm.lower() != 'y':
print("Aborted.")
return 1
success_count = 0
for profile in profiles_to_reset:
if manager.reset_profile_counters(profile['name']):
success_count += 1
print(f"Successfully reset session counters for {success_count} profile(s).")
return 0
elif args.profile_command == 'record-activity':
success = manager.record_activity(args.name, args.type, args.timestamp)
return 0 if success else 1
elif args.profile_command == 'get-rate':
rate = manager.get_activity_rate(args.name, args.type, args.window)
print(f"{args.type.capitalize()} rate for '{args.name}' over {args.window}s: {rate}")
return 0
return 1 # Should not be reached