removed all logging due to broken implementation - known good state

This commit is contained in:
cottongin 2025-02-24 21:48:51 -08:00
parent 252fdf4db1
commit afff3d1161
Signed by: cottongin
GPG Key ID: A0BD18428A296890
7 changed files with 682 additions and 493 deletions

View File

@ -8,14 +8,14 @@ Note: This is a work in progress. It has only been tested on **Python 3.12.6**.
## Features ## Features
- Monitors Icecast stream metadata and announces track changes - Monitors Icecast streams for metadata changes
- Configurable via YAML files and command line arguments - Announces new tracks in IRC channels
- Supports running multiple bot instances simultaneously - Supports multiple IRC networks and channels
- Pattern-based song title filtering - Customizable announcement formats
- Configurable logging levels and output - Command system with admin privileges
- Smart URL resolution for metadata fetching - Automatic reconnection on network issues
- Automatic reconnection and error recovery with status reporting - Multiple bot instances can be managed together
- Admin commands with permission system - Systemd service integration
## Dependencies ## Dependencies
@ -37,21 +37,21 @@ Create a YAML config file (default: `config.yaml`):
```yaml ```yaml
irc: irc:
host: "irc.example.net" host: "irc.libera.chat"
port: 6667 port: 6667
nick: "MusicBot" nick: "IcecastBot"
user: "musicbot" user: "icecastbot"
realname: "Music Announcer Bot" realname: "Icecast IRC Bot"
channel: "#music" channel: "#yourchannel"
stream: stream:
url: "https://stream.example.com" url: "https://your.stream.url" # Base URL without /stream or .mp3
endpoint: "stream" endpoint: "/stream" # The endpoint part (e.g. /stream, /radio.mp3)
health_check_interval: 300 health_check_interval: 300 # How often to check health status (in seconds)
announce: announce:
format: "\x02Now playing:\x02 {song}" format: "\x02Now playing:\x02 {song}" # Format for song announcements
ignore_patterns: ignore_patterns: # Don't announce songs matching these patterns
- "Unknown" - "Unknown"
- "Unable to fetch metadata" - "Unable to fetch metadata"
- "Error fetching metadata" - "Error fetching metadata"
@ -61,12 +61,14 @@ commands:
require_nick_prefix: false # If true, commands must be prefixed with "botname: " or "botname, " require_nick_prefix: false # If true, commands must be prefixed with "botname: " or "botname, "
allow_private_commands: false # If true, allows commands in private messages allow_private_commands: false # If true, allows commands in private messages
help: # Help message templates
specific_format: "\x02{prefix}{cmd}\x02: {desc}" # Format for specific command help
list_format: "(\x02{cmd}\x02, {desc})" # Format for commands in list
list_separator: " | " # Separator between commands in list
admin: admin:
users: # List of users who can use admin commands (use "*" for anyone) users: # List of users who can use admin commands (use "*" for anyone)
- "*" - "*"
logging:
level: "INFO" # Logging level: DEBUG, INFO, WARNING, ERROR, or CRITICAL
``` ```
## Usage ## Usage
@ -119,35 +121,20 @@ Run multiple instances with different configs:
python main.py config1.yaml config2.yaml config3.yaml python main.py config1.yaml config2.yaml config3.yaml
``` ```
## IRC Commands ## Commands
Regular commands: The bot supports the following commands:
- `!np`: Shows the currently playing track
- `!help`: Shows available commands
Admin commands: - `!np` - Show the currently playing song
- `!start`: Start stream monitoring - `!help` - Show available commands or help for a specific command
- `!stop`: Stop stream monitoring
- `!reconnect`: Reconnect to stream (with status feedback)
- `!restart`: Restart the bot (requires using bot.sh)
- `!quit`: Shutdown the bot
## Logging Admin commands (only available to users listed in the `admin.users` config):
The bot supports different logging levels configurable in the config.yaml: - `!start` - Start stream monitoring
- DEBUG: Detailed information for troubleshooting - `!stop` - Stop stream monitoring
- INFO: General operational messages (default) - `!reconnect` - Reconnect to the stream
- WARNING: Warning messages and potential issues - `!restart` - Restart the bot
- ERROR: Error messages only - `!quit` - Shutdown the bot
- CRITICAL: Critical failures only
Logs include:
- Stream health status
- Command processing
- Connection status
- Error details
The bot also maintains an ERROR.log file for critical issues.
## Error Handling ## Error Handling

View File

@ -1 +1 @@
1.1.0 1.1.0

View File

@ -30,10 +30,4 @@ help: # Help message templates
admin: admin:
users: # List of users who can use admin commands (use "*" for anyone) users: # List of users who can use admin commands (use "*" for anyone)
- "*" - "*"
logging:
level: "INFO" # Logging level: DEBUG, INFO, WARNING, ERROR, or CRITICAL
format: "%(asctime)s - %(levelname)s - %(message)s" # Format for console logs
error_format: "%(asctime)s - %(name)s - %(levelname)s - %(message)s" # Format for error logs
datefmt: "%H:%M:%S" # Date/time format for log timestamps

View File

@ -3,93 +3,368 @@
import argparse import argparse
import asyncio import asyncio
import json import json
import logging
import os import os
import signal import signal
import sys import sys
import tempfile import tempfile
import time
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional from typing import Dict, List, Optional
# Configure logging # ANSI color codes for terminal output if needed
logging.basicConfig( class Colors:
level=logging.INFO, COLORS = [
format='%(asctime)s - %(levelname)s - %(message)s', '\033[94m', # BLUE
datefmt='%Y-%m-%d %H:%M:%S' '\033[92m', # GREEN
) '\033[95m', # MAGENTA
logger = logging.getLogger('icecast-irc-bot-manager') '\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: class BotManager:
"""Manages multiple Icecast IRC bot instances.""" """Manages multiple Icecast IRC bot instances."""
def __init__(self): def __init__(self):
self.bots: Dict[str, asyncio.subprocess.Process] = {} self.bots: Dict[str, asyncio.subprocess.Process] = {}
self.config_dir = Path('/etc/icecast-irc-bot') self.config_dir = Path('.') # Use current directory instead of /etc/icecast-irc-bot
self.socket_dir = Path(tempfile.gettempdir()) 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
async def start_bot(self, config_path: Path) -> bool: def _save_state(self):
"""Start a bot instance with the given config. """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: Args:
config_path: Path to the bot's config file 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: Returns:
bool: True if bot was started successfully bool: True if bot was started successfully
""" """
try: try:
# Create unique name for this bot instance # Generate a unique ID for this bot based on the config file name
bot_id = config_path.stem bot_id = config_path.stem
# Check if bot is already running # Check if a bot with this ID is already running
if bot_id in self.bots: state = self._load_state()
logger.warning(f"Bot {bot_id} is already running") if bot_id in state:
return False 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 # Start the bot process using venv Python
process = await asyncio.create_subprocess_exec( process = await asyncio.create_subprocess_exec(
sys.executable, '-m', 'main', self.venv_python, 'main.py',
'--config', str(config_path), '--config', str(config_path),
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE stderr=asyncio.subprocess.PIPE
) )
self.bots[bot_id] = process # Verify process started successfully
logger.info(f"Started bot {bot_id} (PID: {process.pid})") try:
return True os.kill(process.pid, 0)
except ProcessLookupError:
await self._cleanup_process(process)
return False
except Exception as e: # Start the monitor task if not already running
logger.error(f"Failed to start bot with config {config_path}: {e}") 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 return False
async def stop_bot(self, bot_id: str) -> bool: async def stop_bot(self, bot_id: str) -> bool:
"""Stop a running bot instance. """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: Args:
bot_id: ID of the bot to stop bot_id: ID of the bot to stop
Returns: Returns:
bool: True if bot was stopped successfully bool: True if bot was stopped successfully
""" """
if bot_id not in self.bots: # Check both local bots and state file
logger.warning(f"Bot {bot_id} is not running") state = self._load_state()
return False process = None
try: if bot_id in self.bots:
process = self.bots[bot_id] process = self.bots[bot_id]
process.terminate() elif bot_id in state:
try: try:
await asyncio.wait_for(process.wait(), timeout=5.0) pid = state[bot_id]['pid']
except asyncio.TimeoutError: print(f"Stopping bot {bot_id} with PID {pid}")
process.kill() # Try to kill the process
await process.wait() try:
# First try a gentle termination
del self.bots[bot_id] os.kill(pid, signal.SIGTERM)
logger.info(f"Stopped bot {bot_id}") print(f"Sent SIGTERM to process {pid}")
return True
# Wait a bit for the process to terminate
except Exception as e: for i in range(50): # 5 seconds
logger.error(f"Failed to stop bot {bot_id}: {e}") 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 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: async def restart_bot(self, bot_id: str) -> bool:
"""Restart a running bot instance. """Restart a running bot instance.
@ -103,7 +378,6 @@ class BotManager:
# Find the config file for this bot # Find the config file for this bot
config_path = self.config_dir / f"{bot_id}.yaml" config_path = self.config_dir / f"{bot_id}.yaml"
if not config_path.exists(): if not config_path.exists():
logger.error(f"Config file not found for bot {bot_id}")
return False return False
# Stop the bot if it's running # Stop the bot if it's running
@ -114,81 +388,175 @@ class BotManager:
# Start the bot with the same config # Start the bot with the same config
return await self.start_bot(config_path) return await self.start_bot(config_path)
async def list_bots(self) -> List[Dict]: async def list_bots(self) -> bool:
"""List all running bot instances. """List all running bots.
Returns: Returns:
List[Dict]: List of bot info dictionaries bool: True if any bots are running
""" """
bot_info = [] state = self._load_state()
for bot_id, process in self.bots.items():
info = { # Check if any bots are running
'id': bot_id, if not state:
'pid': process.pid, print("No bots running")
'running': process.returncode is None return False
}
bot_info.append(info) # Track unique PIDs to avoid duplicates
return bot_info 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): async def cleanup(self):
"""Clean up all running bots.""" """Clean up all running bots."""
for bot_id in list(self.bots.keys()): if self.monitor_task:
await self.stop_bot(bot_id) self.monitor_task.cancel()
try:
await self.monitor_task
except asyncio.CancelledError:
pass
await self.stop_bot("all")
async def main(): async def main():
parser = argparse.ArgumentParser(description='Icecast IRC Bot Manager') parser = argparse.ArgumentParser(description='Icecast IRC Bot Manager')
parser.add_argument('--config', type=str, help='Path to config file or directory') parser.add_argument('--config', type=str, help='Path to config file or directory')
parser.add_argument('command', choices=['start', 'stop', 'restart', 'list'], help='Command to execute') parser.add_argument('command', choices=['start', 'stop', 'restart', 'list'], help='Command to execute')
parser.add_argument('bot_id', nargs='?', help='Bot ID for start/stop/restart commands') parser.add_argument('bot_id', nargs='?', help='Bot ID for start/stop/restart commands, or "all" to stop all bots')
args = parser.parse_args() args = parser.parse_args()
manager = BotManager() manager = BotManager()
should_cleanup = False # Only cleanup for certain commands
try: try:
if args.command == 'list': if args.command == 'list':
bot_info = await manager.list_bots() if await manager.list_bots():
if bot_info: # If list_bots returns True, we've printed the list
print(json.dumps(bot_info, indent=2)) pass
else:
print("No bots running")
elif args.command == 'start': elif args.command == 'start':
should_cleanup = True # Need cleanup for start command
if not args.config: if not args.config:
print("Error: --config required for start command") print("Error: --config required for start command")
sys.exit(1) sys.exit(1)
config_path = Path(args.config) config_path = Path(args.config)
if config_path.is_dir(): if config_path.is_dir():
# Start all bots in directory # Start all bots in directory
success = True
for config_file in config_path.glob('*.yaml'): for config_file in config_path.glob('*.yaml'):
await manager.start_bot(config_file) if not await manager.start_bot(config_file):
success = False
if not success:
sys.exit(1)
else: else:
# Start single bot # Start single bot
await manager.start_bot(config_path) 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': elif args.command == 'stop':
# Don't need cleanup for stop command as it already cleans up
should_cleanup = False
if not args.bot_id: if not args.bot_id:
print("Error: bot_id required for stop command") 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) sys.exit(1)
await manager.stop_bot(args.bot_id)
elif args.command == 'restart': elif args.command == 'restart':
should_cleanup = True # Need cleanup for restart command
if not args.bot_id: if not args.bot_id:
print("Error: bot_id required for restart command") print("Error: bot_id required for restart command")
sys.exit(1) sys.exit(1)
await manager.restart_bot(args.bot_id) 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
except KeyboardInterrupt: except KeyboardInterrupt:
logger.info("Shutting down...") should_cleanup = True # Need cleanup for keyboard interrupt
await manager.cleanup() except Exception:
except Exception as e: should_cleanup = True # Need cleanup for errors
logger.error(f"Unhandled error: {e}")
sys.exit(1) sys.exit(1)
finally:
# Only clean up if we need to
if should_cleanup:
await manager.cleanup()
if __name__ == '__main__': if __name__ == '__main__':
# Set up signal handlers # 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): for sig in (signal.SIGTERM, signal.SIGINT):
signal.signal(sig, lambda s, f: sys.exit(0)) signal.signal(sig, signal_handler)
# Run the manager # Run the manager
asyncio.run(main()) asyncio.run(main())

View File

@ -13,8 +13,8 @@ Environment=PATH=/opt/icecast-irc-bot/venv/bin:$PATH
ExecStart=/opt/icecast-irc-bot/venv/bin/python3 /usr/local/bin/icecast-irc-bot-manager --config /etc/icecast-irc-bot/config.yaml ExecStart=/opt/icecast-irc-bot/venv/bin/python3 /usr/local/bin/icecast-irc-bot-manager --config /etc/icecast-irc-bot/config.yaml
Restart=on-failure Restart=on-failure
RestartSec=5s RestartSec=5s
StandardOutput=append:/var/log/icecast-irc-bot/bot.log StandardOutput=journal
StandardError=append:/var/log/icecast-irc-bot/error.log StandardError=journal
# Security settings # Security settings
NoNewPrivileges=yes NoNewPrivileges=yes

559
main.py Normal file → Executable file

File diff suppressed because it is too large Load Diff

View File

@ -16,6 +16,11 @@ requires-python = ">=3.11"
requires = ["hatchling"] requires = ["hatchling"]
build-backend = "hatchling.build" build-backend = "hatchling.build"
[tool.hatch.version]
source = "regex"
path = "VERSION"
pattern = "^(?P<version>[0-9]+\\.[0-9]+\\.[0-9]+)$"
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
packages = ["."] packages = ["."]