improved logging, added docs, better help support, much improved instancing, pip installable

This commit is contained in:
cottongin 2025-02-24 14:26:48 -08:00
parent 6817437380
commit 252fdf4db1
Signed by: cottongin
GPG Key ID: A0BD18428A296890
10 changed files with 1313 additions and 180 deletions

137
DOCS.md Normal file
View File

@ -0,0 +1,137 @@
# Icecast IRC Bot Documentation v1.1.0
This document is automatically generated from the codebase.
## Overview
An IRC bot that monitors an Icecast stream and announces track changes.
## Configuration
See `config.yaml.example` for a full example configuration file.
No command documentation available (help template not found)
## Methods
### admin_required
Decorator to mark a command as requiring admin privileges.
**Arguments:**
- `f`: The command handler function to wrap.
- `Returns`:
### announce_song
Announce a song in the IRC channel.
**Arguments:**
- `song`: The song title to announce.
- `Only announces if`:
### fetch_json_metadata
Fetch metadata from the Icecast JSON status endpoint.
**Returns:**
str: The current song title, or an error message if fetching failed.
### format_help_section
Format a help section according to the template.
**Arguments:**
- `section_config`: Configuration dictionary for the section.
- `prefix`: Command prefix to use.
- `Returns`:
- `List[str]`: List of formatted help lines for each command.
### get_version
Get the current version from VERSION file.
### help_command_fallback
Fallback help command implementation using hardcoded format.
**Arguments:**
- `message`: The IRC message object that triggered this command.
### is_admin
Check if a user has admin privileges.
**Arguments:**
- `user`: Full IRC user string (nickname!username@hostname) or User object.
- `Returns`:
- `bool`: True if user has admin privileges, False otherwise.
### load_config
Load and validate the bot configuration from a YAML file.
**Arguments:**
- `config_path`: Path to the YAML configuration file. If None, uses default path.
- `Returns`:
- `dict`: The loaded and validated configuration dictionary with default values applied.
### monitor_metadata
Monitor the Icecast stream for metadata changes.
### restart_monitoring
Restart the metadata monitoring task and verify the reconnection.
**Returns:**
bool: True if reconnection was successful and verified, False otherwise.
### setup_handlers
Set up all IRC event handlers and command patterns.
### should_announce_song
Check if a song should be announced based on configured ignore patterns.
**Arguments:**
- `song`: The song title to check.
- `Returns`:
- `bool`: True if the song should be announced, False if it matches any ignore patterns.
### start
Start the IRC bot and begin processing events.
### start_monitoring
Start the metadata monitoring task.
### stop_monitoring
Stop the metadata monitoring task.

View File

@ -1,6 +1,6 @@
# Icecast-metadata-IRC-announcer
[![Version](https://img.shields.io/badge/version-1.0.1-blue.svg)](https://code.cottongin.xyz/cottongin/Icecast-metadata-IRC-announcer/releases/tag/v1.0.1)
[![Version](https://img.shields.io/badge/dynamic/raw?color=blue&label=version&query=.&url=https%3A%2F%2Fcode.cottongin.xyz%2Fcottongin%2FIcecast-metadata-IRC-announcer%2Fraw%2Fbranch%2Fmaster%2FVERSION)](https://code.cottongin.xyz/cottongin/Icecast-metadata-IRC-announcer/releases)
A simple asynchronous Python bot that monitors an Icecast stream and announces track changes to an IRC channel. Supports running multiple instances with different configurations.

View File

@ -1 +1 @@
1.0.1
1.1.0

View File

@ -23,9 +23,17 @@ commands:
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
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:
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

99
generate_docs.py Executable file
View File

@ -0,0 +1,99 @@
#!/usr/bin/env python3
import inspect
import yaml
from pathlib import Path
from main import IcecastBot
def get_version():
"""Get the current version from VERSION file."""
try:
with open('VERSION') as f:
return f.read().strip()
except FileNotFoundError:
return "Unknown"
def format_docstring(obj):
"""Format a docstring into markdown."""
doc = inspect.getdoc(obj)
if not doc:
return ""
# Split into description and args
parts = doc.split('\n\n')
formatted = [parts[0]] # Description
for part in parts[1:]:
if part.startswith('Args:'):
formatted.append("\n**Arguments:**\n")
# Parse args section
args = part.replace('Args:', '').strip().split('\n')
for arg in args:
if ':' in arg:
name, desc = arg.split(':', 1)
formatted.append(f"- `{name.strip()}`: {desc.strip()}")
elif part.startswith('Returns:'):
formatted.append("\n**Returns:**\n")
formatted.append(part.replace('Returns:', '').strip())
return '\n'.join(formatted)
def generate_command_docs(config_path='config.yaml.example'):
"""Generate command documentation from help templates."""
try:
with open(config_path) as f:
config = yaml.safe_load(f)
except FileNotFoundError:
return "No command documentation available (config file not found)"
help_config = config.get('commands', {}).get('help', {})
if not help_config:
return "No command documentation available (help template not found)"
docs = ["## Commands\n"]
# Regular commands
if 'commands' in help_config.get('sections', {}):
docs.append("### Regular Commands\n")
for cmd, desc in help_config['sections']['commands']['commands'].items():
docs.append(f"- `{cmd}`: {desc}")
docs.append("")
# Admin commands
if 'admin' in help_config.get('sections', {}):
docs.append("### Admin Commands\n")
docs.append("These commands are only available to users listed in the `admin.users` config section.\n")
for cmd, desc in help_config['sections']['admin']['commands'].items():
docs.append(f"- `{cmd}`: {desc}")
docs.append("")
return '\n'.join(docs)
def generate_docs():
"""Generate full documentation in markdown format."""
version = get_version()
docs = [
f"# Icecast IRC Bot Documentation v{version}\n",
"This document is automatically generated from the codebase.\n",
"## Overview\n",
format_docstring(IcecastBot),
"\n## Configuration\n",
"See `config.yaml.example` for a full example configuration file.\n",
generate_command_docs(),
"\n## Methods\n"
]
# Document public methods
for name, method in inspect.getmembers(IcecastBot, predicate=inspect.isfunction):
if not name.startswith('_'): # Only public methods
docs.append(f"### {name}\n")
docs.append(format_docstring(method))
docs.append("\n")
# Write to DOCS.md
with open('DOCS.md', 'w') as f:
f.write('\n'.join(docs))
if __name__ == '__main__':
generate_docs()

194
icecast-irc-bot-manager.py Normal file
View File

@ -0,0 +1,194 @@
#!/usr/bin/env python3
import argparse
import asyncio
import json
import logging
import os
import signal
import sys
import tempfile
from pathlib import Path
from typing import Dict, List, Optional
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
logger = logging.getLogger('icecast-irc-bot-manager')
class BotManager:
"""Manages multiple Icecast IRC bot instances."""
def __init__(self):
self.bots: Dict[str, asyncio.subprocess.Process] = {}
self.config_dir = Path('/etc/icecast-irc-bot')
self.socket_dir = Path(tempfile.gettempdir())
async def start_bot(self, config_path: Path) -> bool:
"""Start a bot instance with the given config.
Args:
config_path: Path to the bot's config file
Returns:
bool: True if bot was started successfully
"""
try:
# Create unique name for this bot instance
bot_id = config_path.stem
# Check if bot is already running
if bot_id in self.bots:
logger.warning(f"Bot {bot_id} is already running")
return False
# Start the bot process
process = await asyncio.create_subprocess_exec(
sys.executable, '-m', 'main',
'--config', str(config_path),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
self.bots[bot_id] = process
logger.info(f"Started bot {bot_id} (PID: {process.pid})")
return True
except Exception as e:
logger.error(f"Failed to start bot with config {config_path}: {e}")
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
Returns:
bool: True if bot was stopped successfully
"""
if bot_id not in self.bots:
logger.warning(f"Bot {bot_id} is not running")
return False
try:
process = self.bots[bot_id]
process.terminate()
try:
await asyncio.wait_for(process.wait(), timeout=5.0)
except asyncio.TimeoutError:
process.kill()
await process.wait()
del self.bots[bot_id]
logger.info(f"Stopped bot {bot_id}")
return True
except Exception as e:
logger.error(f"Failed to stop 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():
logger.error(f"Config file not found for bot {bot_id}")
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 list_bots(self) -> List[Dict]:
"""List all running bot instances.
Returns:
List[Dict]: List of bot info dictionaries
"""
bot_info = []
for bot_id, process in self.bots.items():
info = {
'id': bot_id,
'pid': process.pid,
'running': process.returncode is None
}
bot_info.append(info)
return bot_info
async def cleanup(self):
"""Clean up all running bots."""
for bot_id in list(self.bots.keys()):
await self.stop_bot(bot_id)
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'], help='Command to execute')
parser.add_argument('bot_id', nargs='?', help='Bot ID for start/stop/restart commands')
args = parser.parse_args()
manager = BotManager()
try:
if args.command == 'list':
bot_info = await manager.list_bots()
if bot_info:
print(json.dumps(bot_info, indent=2))
else:
print("No bots running")
elif args.command == 'start':
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
for config_file in config_path.glob('*.yaml'):
await manager.start_bot(config_file)
else:
# Start single bot
await manager.start_bot(config_path)
elif args.command == 'stop':
if not args.bot_id:
print("Error: bot_id required for stop command")
sys.exit(1)
await manager.stop_bot(args.bot_id)
elif args.command == 'restart':
if not args.bot_id:
print("Error: bot_id required for restart command")
sys.exit(1)
await manager.restart_bot(args.bot_id)
except KeyboardInterrupt:
logger.info("Shutting down...")
await manager.cleanup()
except Exception as e:
logger.error(f"Unhandled error: {e}")
sys.exit(1)
if __name__ == '__main__':
# Set up signal handlers
for sig in (signal.SIGTERM, signal.SIGINT):
signal.signal(sig, lambda s, f: sys.exit(0))
# Run the manager
asyncio.run(main())

View File

@ -0,0 +1,26 @@
[Unit]
Description=Icecast IRC Bot Manager
After=network.target
Wants=network.target
Documentation=https://code.cottongin.xyz/cottongin/Icecast-metadata-IRC-announcer
[Service]
Type=simple
User=icecast-bot
Group=icecast-bot
Environment=VIRTUAL_ENV=/opt/icecast-irc-bot/venv
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
Restart=on-failure
RestartSec=5s
StandardOutput=append:/var/log/icecast-irc-bot/bot.log
StandardError=append:/var/log/icecast-irc-bot/error.log
# Security settings
NoNewPrivileges=yes
ProtectSystem=full
ProtectHome=yes
PrivateTmp=yes
[Install]
WantedBy=multi-user.target

71
install.sh Executable file
View File

@ -0,0 +1,71 @@
#!/bin/bash
# Exit on any error
set -e
# Check if running as root
if [ "$EUID" -ne 0 ]; then
echo "Please run as root"
exit 1
fi
# Create icecast-bot user and group if they don't exist
if ! getent group icecast-bot >/dev/null; then
groupadd icecast-bot
fi
if ! getent passwd icecast-bot >/dev/null; then
useradd -r -g icecast-bot -s /bin/false icecast-bot
fi
# Create necessary directories
mkdir -p /etc/icecast-irc-bot
mkdir -p /var/log/icecast-irc-bot
mkdir -p /opt/icecast-irc-bot
# Set ownership
chown icecast-bot:icecast-bot /etc/icecast-irc-bot
chown icecast-bot:icecast-bot /var/log/icecast-irc-bot
chown icecast-bot:icecast-bot /opt/icecast-irc-bot
# Create and activate virtual environment
python3 -m venv /opt/icecast-irc-bot/venv
export VIRTUAL_ENV="/opt/icecast-irc-bot/venv"
export PATH="$VIRTUAL_ENV/bin:$PATH"
unset PYTHONHOME
# Install Python dependencies in virtual environment
pip3 install --upgrade pip
pip3 install hatchling
pip3 install -r requirements.txt
pip3 install .
# Install manager script
cp icecast-irc-bot-manager.py /usr/local/bin/icecast-irc-bot-manager
chmod +x /usr/local/bin/icecast-irc-bot-manager
# Update manager script shebang to use venv Python
sed -i "1c#\!$VIRTUAL_ENV/bin/python3" /usr/local/bin/icecast-irc-bot-manager
# Install service file
cp icecast-irc-bot-manager.service /etc/systemd/system/
systemctl daemon-reload
# Copy example config if it doesn't exist
if [ ! -f /etc/icecast-irc-bot/config.yaml ]; then
cp config.yaml.example /etc/icecast-irc-bot/config.yaml
chown icecast-bot:icecast-bot /etc/icecast-irc-bot/config.yaml
chmod 640 /etc/icecast-irc-bot/config.yaml
fi
echo "Installation complete!"
echo
echo "Next steps:"
echo "1. Configure your bot(s) in /etc/icecast-irc-bot/config.yaml"
echo "2. Start the service: systemctl start icecast-irc-bot-manager"
echo "3. Enable at boot: systemctl enable icecast-irc-bot-manager"
echo
echo "Usage:"
echo "icecast-irc-bot-manager list # List running bots"
echo "icecast-irc-bot-manager start --config FILE # Start a bot"
echo "icecast-irc-bot-manager stop BOT_ID # Stop a bot"
echo "icecast-irc-bot-manager restart BOT_ID # Restart a bot"

883
main.py

File diff suppressed because it is too large Load Diff

31
pyproject.toml Normal file
View File

@ -0,0 +1,31 @@
[project]
name = "icecast-irc-bot"
dynamic = ["version"]
description = "Icecast metadata IRC announcer bot"
authors = [
{name = "cottongin", email = "cottongin@cottongin.xyz"},
]
dependencies = [
"asif",
"aiohttp",
"pyyaml",
]
requires-python = ">=3.11"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["."]
[tool.hatch.build]
include = [
"*.py",
"README*",
"LICENSE*",
"config.yaml.example"
]
[tool.setuptools.dynamic]
version = {file = "VERSION"}