Icecast-metadata-IRC-announcer/icecast-irc-bot-manager.py

654 lines
24 KiB
Python

#!/usr/bin/env python3
import argparse
import asyncio
import json
import os
import signal
import sys
import tempfile
import time
from pathlib import Path
from typing import Dict, List, Optional
# ANSI color codes for terminal output if needed
class Colors:
COLORS = [
'\033[94m', # BLUE
'\033[92m', # GREEN
'\033[95m', # MAGENTA
'\033[93m', # YELLOW
'\033[96m', # CYAN
'\033[91m', # RED
'\033[38;5;208m', # ORANGE
'\033[38;5;165m', # PURPLE
'\033[38;5;39m', # DEEP BLUE
'\033[38;5;82m', # LIME
]
ENDC = '\033[0m'
BOLD = '\033[1m'
# Additional colors for output
GREY = '\033[37m'
WHITE = '\033[97m'
RED = '\033[91m'
ORANGE = '\033[38;5;208m' # Using 256-color code for orange
CYAN = '\033[96m'
MAGENTA = '\033[95m'
class BotManager:
"""Manages multiple Icecast IRC bot instances."""
def __init__(self):
self.bots: Dict[str, asyncio.subprocess.Process] = {}
self.config_dir = Path('.') # Use current directory instead of /etc/icecast-irc-bot
self.socket_dir = Path(tempfile.gettempdir())
self.state_file = Path(tempfile.gettempdir()) / 'icecast-irc-bots.json'
self.monitor_task = None
self.venv_python = os.getenv('VIRTUAL_ENV', '/opt/icecast-irc-bot/venv') + '/bin/python3'
# If venv_python doesn't exist, use the current Python interpreter
if not os.path.exists(self.venv_python):
self.venv_python = sys.executable
def _save_state(self):
"""Save the current state of running bots."""
try:
# Load existing state
existing_state = self._load_state()
# Update with current bots
current_state = {
bot_id: {
'pid': process.pid,
'config': str(self.config_dir / f"{bot_id}.yaml")
}
for bot_id, process in self.bots.items()
}
# Merge states, with current bots taking precedence
merged_state = {**existing_state, **current_state}
# Save merged state
with open(self.state_file, 'w') as f:
json.dump(merged_state, f)
print(f"Saved state with {len(merged_state)} bots")
except Exception as e:
print(f"Error saving state: {e}")
pass
def _load_state(self):
"""Load the state of running bots."""
try:
if self.state_file.exists():
with open(self.state_file, 'r') as f:
state = json.load(f)
return state
return {}
except Exception:
return {}
async def monitor_processes(self):
"""Monitor running bot processes and clean up dead ones."""
while True:
try:
for bot_id in list(self.bots.keys()):
process = self.bots[bot_id]
try:
# Check if process exists in system
os.kill(process.pid, 0)
# Check if process has terminated
if process.returncode is not None:
await self._cleanup_process(process)
del self.bots[bot_id]
except ProcessLookupError:
await self._cleanup_process(process)
del self.bots[bot_id]
except Exception:
pass
except Exception:
pass
await asyncio.sleep(5) # Check every 5 seconds
async def _cleanup_process(self, process: asyncio.subprocess.Process):
"""Clean up a process and its resources.
Args:
process: The process to clean up
"""
try:
print(f"Cleaning up process with PID {process.pid}")
# Wait for the process to finish if it hasn't
if process.returncode is None:
try:
print(f"Terminating process {process.pid}")
process.terminate()
await asyncio.wait_for(process.wait(), timeout=5.0)
except (asyncio.TimeoutError, ProcessLookupError) as e:
print(f"Error terminating process: {e}")
try:
print(f"Killing process {process.pid}")
process.kill()
await process.wait()
except ProcessLookupError as e:
print(f"Process already gone: {e}")
pass
# Drain any remaining output to prevent deadlocks
if process.stdout:
try:
data = await process.stdout.read()
if data:
print(f"Remaining stdout: {data.decode().strip()}")
except (ValueError, IOError) as e:
print(f"Error reading stdout: {e}")
pass
if process.stderr:
try:
data = await process.stderr.read()
if data:
print(f"Remaining stderr: {data.decode().strip()}")
except (ValueError, IOError) as e:
print(f"Error reading stderr: {e}")
pass
except Exception as e:
print(f"Exception during cleanup: {e}")
pass
async def start_bot(self, config_path: Path) -> bool:
"""Start a new bot instance with the given config file.
Args:
config_path: Path to the config file
Returns:
bool: True if bot was started successfully
"""
try:
# Generate a unique ID for this bot based on the config file name
bot_id = config_path.stem
# Check if a bot with this ID is already running
state = self._load_state()
if bot_id in state:
try:
pid = state[bot_id]['pid']
os.kill(pid, 0) # Check if process exists
return False
except ProcessLookupError:
# Process doesn't exist, remove from state
pass
# Start the bot process using venv Python
process = await asyncio.create_subprocess_exec(
self.venv_python, 'main.py',
'--config', str(config_path),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
# Verify process started successfully
try:
os.kill(process.pid, 0)
except ProcessLookupError:
await self._cleanup_process(process)
return False
# Start the monitor task if not already running
if self.monitor_task is None:
self.monitor_task = asyncio.create_task(self.monitor_processes())
# Monitor the process output for a short time to ensure it starts properly
try:
startup_timeout = 5.0 # Give it 5 seconds to start
start_time = time.time()
success = False
while time.time() - start_time < startup_timeout:
# Check if process has exited
if process.returncode is not None:
await self._cleanup_process(process)
return False
# Read any available output during startup
try:
line = await asyncio.wait_for(process.stderr.readline(), timeout=0.1)
if line:
line = line.decode().strip()
print(f"Bot output: {line}")
# Consider the bot started successfully after a short delay
# instead of looking for specific output
success = True
except asyncio.TimeoutError:
# No output available, continue monitoring
continue
except Exception as e:
print(f"Error reading bot output: {e}")
pass
# Consider the bot started successfully if it's still running after the timeout
if not success and process.returncode is None:
success = True
# Start background task to monitor process output
async def monitor_output(process, bot_id):
try:
while True:
if process.returncode is not None:
break
try:
line = await process.stderr.readline()
if not line:
break
# Print the bot's output
print(f"Bot {bot_id} output: {line.decode().strip()}")
except Exception as e:
print(f"Error monitoring bot {bot_id}: {e}")
break
except Exception as e:
print(f"Monitor task exception for bot {bot_id}: {e}")
pass
asyncio.create_task(monitor_output(process, bot_id))
# Store the process
self.bots[bot_id] = process
self._save_state()
return True
except Exception:
await self._cleanup_process(process)
return False
except Exception:
return False
async def stop_bot(self, bot_id: str) -> bool:
"""Stop a running bot instance.
Args:
bot_id: ID of the bot to stop, or "all" to stop all bots
Returns:
bool: True if bot(s) were stopped successfully
"""
if bot_id == "all":
print("Stopping all bots...")
success = True
state = self._load_state()
for bid in list(state.keys()):
print(f"Stopping bot {bid}...")
if not await self._stop_single_bot(bid):
success = False
return success
# Stop a single bot
return await self._stop_single_bot(bot_id)
async def _stop_single_bot(self, bot_id: str) -> bool:
"""Stop a single bot instance.
Args:
bot_id: ID of the bot to stop
Returns:
bool: True if bot was stopped successfully
"""
# Check both local bots and state file
state = self._load_state()
process = None
if bot_id in self.bots:
process = self.bots[bot_id]
elif bot_id in state:
try:
pid = state[bot_id]['pid']
print(f"Stopping bot {bot_id} with PID {pid}")
# Try to kill the process
try:
# First try a gentle termination
os.kill(pid, signal.SIGTERM)
print(f"Sent SIGTERM to process {pid}")
# Wait a bit for the process to terminate
for i in range(50): # 5 seconds
await asyncio.sleep(0.1)
try:
os.kill(pid, 0) # Check if process exists
except ProcessLookupError:
print(f"Process {pid} terminated successfully")
break
else:
# Process didn't terminate, force kill
print(f"Process {pid} didn't terminate, sending SIGKILL")
try:
os.kill(pid, signal.SIGKILL)
print(f"Sent SIGKILL to process {pid}")
except ProcessLookupError:
print(f"Process {pid} already terminated")
pass
except ProcessLookupError:
print(f"Process {pid} not found")
pass
# Remove only this bot from state
if bot_id in state:
print(f"Removing {bot_id} from state file")
del state[bot_id]
with open(self.state_file, 'w') as f:
json.dump(state, f)
print(f"State file updated, remaining bots: {list(state.keys())}")
return True
except Exception as e:
print(f"Error stopping bot {bot_id}: {e}")
return False
else:
print(f"Bot {bot_id} not found")
return False
if process:
try:
print(f"Cleaning up process for bot {bot_id}")
await self._cleanup_process(process)
del self.bots[bot_id]
# Update state file - only remove this bot
state = self._load_state()
if bot_id in state:
print(f"Removing {bot_id} from state file")
del state[bot_id]
with open(self.state_file, 'w') as f:
json.dump(state, f)
print(f"State file updated, remaining bots: {list(state.keys())}")
return True
except Exception as e:
print(f"Error cleaning up process for bot {bot_id}: {e}")
return False
async def restart_bot(self, bot_id: str) -> bool:
"""Restart a running bot instance.
Args:
bot_id: ID of the bot to restart
Returns:
bool: True if bot was restarted successfully
"""
# Find the config file for this bot
config_path = self.config_dir / f"{bot_id}.yaml"
if not config_path.exists():
return False
# Stop the bot if it's running
if bot_id in self.bots:
if not await self.stop_bot(bot_id):
return False
# Start the bot with the same config
return await self.start_bot(config_path)
async def quiet_bot(self, bot_id: str) -> bool:
"""Disable song announcements for a running bot.
Args:
bot_id: ID of the bot to quiet
Returns:
bool: True if command was sent successfully
"""
# Check if bot exists in state
state = self._load_state()
if bot_id not in state:
print(f"Bot {bot_id} not found")
return False
try:
# Get the socket path for this bot
socket_path = Path(tempfile.gettempdir()) / f"icecast_bot_{bot_id}.sock"
if not socket_path.exists():
print(f"Socket for bot {bot_id} not found at {socket_path}")
return False
# Send quiet command
print(f"Sending quiet command to bot {bot_id}")
reader, writer = await asyncio.open_unix_connection(str(socket_path))
writer.write(b'quiet')
await writer.drain()
writer.close()
await writer.wait_closed()
print(f"Quiet command sent to bot {bot_id}")
return True
except Exception as e:
print(f"Error sending quiet command to bot {bot_id}: {e}")
return False
async def unquiet_bot(self, bot_id: str) -> bool:
"""Enable song announcements for a running bot.
Args:
bot_id: ID of the bot to unquiet
Returns:
bool: True if command was sent successfully
"""
# Check if bot exists in state
state = self._load_state()
if bot_id not in state:
print(f"Bot {bot_id} not found")
return False
try:
# Get the socket path for this bot
socket_path = Path(tempfile.gettempdir()) / f"icecast_bot_{bot_id}.sock"
if not socket_path.exists():
print(f"Socket for bot {bot_id} not found at {socket_path}")
return False
# Send unquiet command
print(f"Sending unquiet command to bot {bot_id}")
reader, writer = await asyncio.open_unix_connection(str(socket_path))
writer.write(b'unquiet')
await writer.drain()
writer.close()
await writer.wait_closed()
print(f"Unquiet command sent to bot {bot_id}")
return True
except Exception as e:
print(f"Error sending unquiet command to bot {bot_id}: {e}")
return False
async def list_bots(self) -> bool:
"""List all running bots.
Returns:
bool: True if any bots are running
"""
state = self._load_state()
# Check if any bots are running
if not state:
print("No bots running")
return False
# Track unique PIDs to avoid duplicates
seen_pids = set()
unique_bots = {}
# Filter out duplicates based on PID
for bot_id, info in state.items():
pid = info.get('pid')
if pid and pid not in seen_pids:
seen_pids.add(pid)
unique_bots[bot_id] = info
if not unique_bots:
print("No bots running")
return False
# Print header
print("\nRunning Bots:")
print("-" * 80)
print(f"{'ID':<20} {'PID':<8} {'Status':<10} {'Command':<40}")
print("-" * 80)
# Print each bot's status
for bot_id, info in unique_bots.items():
pid = info.get('pid')
config = info.get('config', '')
# Check if process is still running
try:
os.kill(pid, 0)
status = "running"
except ProcessLookupError:
status = "stopped"
continue # Skip stopped processes
except Exception:
status = "unknown"
# Get command line
try:
with open(f"/proc/{pid}/cmdline", 'rb') as f:
cmdline = f.read().replace(b'\0', b' ').decode()
except Exception:
cmdline = f"Unknown (PID: {pid})"
print(f"{bot_id:<20} {pid:<8} {status:<10} {cmdline:<40}")
print("-" * 80)
print()
return True
async def cleanup(self):
"""Clean up all running bots."""
if self.monitor_task:
self.monitor_task.cancel()
try:
await self.monitor_task
except asyncio.CancelledError:
pass
await self.stop_bot("all")
async def main():
parser = argparse.ArgumentParser(description='Icecast IRC Bot Manager')
parser.add_argument('--config', type=str, help='Path to config file or directory')
parser.add_argument('command', choices=['start', 'stop', 'restart', 'list', 'quiet', 'unquiet'], help='Command to execute')
parser.add_argument('bot_id', nargs='?', help='Bot ID for start/stop/restart/quiet/unquiet commands, or "all" to stop all bots')
args = parser.parse_args()
manager = BotManager()
should_cleanup = False # Only cleanup for certain commands
try:
if args.command == 'list':
if await manager.list_bots():
# If list_bots returns True, we've printed the list
pass
elif args.command == 'start':
should_cleanup = True # Need cleanup for start command
if not args.config:
print("Error: --config required for start command")
sys.exit(1)
config_path = Path(args.config)
if config_path.is_dir():
# Start all bots in directory
success = True
for config_file in config_path.glob('*.yaml'):
if not await manager.start_bot(config_file):
success = False
if not success:
sys.exit(1)
else:
# Start single bot
if not await manager.start_bot(config_path):
sys.exit(1)
# If we started any bots successfully, keep running until interrupted
if manager.bots:
try:
# Keep the manager running
while True:
await asyncio.sleep(1)
if not manager.bots:
break
except asyncio.CancelledError:
pass
elif args.command == 'stop':
# Don't need cleanup for stop command as it already cleans up
should_cleanup = False
if not args.bot_id:
print("Error: bot_id required for stop command (use 'all' to stop all bots)")
sys.exit(1)
if not await manager.stop_bot(args.bot_id):
sys.exit(1)
elif args.command == 'restart':
should_cleanup = True # Need cleanup for restart command
if not args.bot_id:
print("Error: bot_id required for restart command")
sys.exit(1)
if args.bot_id == "all":
print("Error: restart all is not supported")
sys.exit(1)
if not await manager.restart_bot(args.bot_id):
sys.exit(1)
# If we restarted successfully, keep running until interrupted
if manager.bots:
try:
# Keep the manager running
while True:
await asyncio.sleep(1)
if not manager.bots:
break
except asyncio.CancelledError:
pass
elif args.command == 'quiet':
should_cleanup = False # Don't need cleanup for quiet command
if not args.bot_id:
print("Error: bot_id required for quiet command")
sys.exit(1)
if args.bot_id == "all":
print("Error: quiet all is not supported")
sys.exit(1)
if not await manager.quiet_bot(args.bot_id):
sys.exit(1)
elif args.command == 'unquiet':
should_cleanup = False # Don't need cleanup for unquiet command
if not args.bot_id:
print("Error: bot_id required for unquiet command")
sys.exit(1)
if args.bot_id == "all":
print("Error: unquiet all is not supported")
sys.exit(1)
if not await manager.unquiet_bot(args.bot_id):
sys.exit(1)
except KeyboardInterrupt:
should_cleanup = True # Need cleanup for keyboard interrupt
except Exception:
should_cleanup = True # Need cleanup for errors
sys.exit(1)
finally:
# Only clean up if we need to
if should_cleanup:
await manager.cleanup()
if __name__ == '__main__':
# Set up signal handlers for graceful shutdown
def signal_handler(sig, frame):
# Don't exit immediately, let the cleanup happen
asyncio.get_event_loop().stop()
for sig in (signal.SIGTERM, signal.SIGINT):
signal.signal(sig, signal_handler)
# Run the manager
asyncio.run(main())