3 Commits

12 changed files with 1910 additions and 179 deletions

61
CHANGELOG.md Normal file
View File

@@ -0,0 +1,61 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [1.0.1] - 2024-02-24
### Added
- Configurable logging levels via config.yaml
- Smart URL resolution for metadata fetching
- Connection status verification and reporting
- Admin command system with permissions
- Automatic restart functionality via bot.sh
- Enhanced command handling with prefix configuration
- Private message command control
- New admin commands:
- !start - Start stream monitoring
- !stop - Stop stream monitoring
- !reconnect - Reconnect to stream with status feedback
- !restart - Restart the bot (requires bot.sh)
- !quit - Shutdown the bot
- !help command showing available commands based on user permissions
- ERROR.log file for critical issues
- Detailed debug logging for troubleshooting
### Changed
- Improved metadata fetching with multiple URL patterns
- Enhanced error handling and reporting
- Better stream connection status feedback
- More informative health check messages
- Cleaner logging output with configurable verbosity
- Updated documentation with new features and configuration options
### Fixed
- Metadata fetching issues with different URL patterns
- Command handling in channels vs private messages
- Stream reconnection verification
- Error reporting and logging clarity
- Configuration file structure and validation
### Other
- Added version information
## [1.0.0] - 2024-02-23
### Added
- Initial release
- Basic Icecast stream monitoring
- IRC channel announcements
- Simple command system (!np)
- Basic error handling and reconnection
- Multi-bot support
- Configuration via YAML files
[Unreleased]: https://code.cottongin.xyz/cottongin/Icecast-metadata-IRC-announcer/compare/v1.0.1...HEAD
[1.0.1]: https://code.cottongin.xyz/cottongin/Icecast-metadata-IRC-announcer/compare/v1.0.0...v1.0.1
[1.0.0]: https://code.cottongin.xyz/cottongin/Icecast-metadata-IRC-announcer/releases/tag/v1.0.0

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,5 +1,7 @@
# Icecast-metadata-IRC-announcer
[![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.
Note: This is a work in progress. It has only been tested on **Python 3.12.6**.
@@ -10,8 +12,10 @@ Note: This is a work in progress. It has only been tested on **Python 3.12.6**.
- Configurable via YAML files and command line arguments
- Supports running multiple bot instances simultaneously
- Pattern-based song title filtering
- Responds to !np commands in IRC channels
- Automatic reconnection and error recovery
- Configurable logging levels and output
- Smart URL resolution for metadata fetching
- Automatic reconnection and error recovery with status reporting
- Admin commands with permission system
## Dependencies
@@ -50,12 +54,43 @@ announce:
ignore_patterns:
- "Unknown"
- "Unable to fetch metadata"
# Add more patterns to ignore
- "Error fetching metadata"
commands:
prefix: "!" # Command prefix (e.g. !np, !help)
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
admin:
users: # List of users who can use admin commands (use "*" for anyone)
- "*"
logging:
level: "INFO" # Logging level: DEBUG, INFO, WARNING, ERROR, or CRITICAL
```
## Usage
### Single Bot Mode
### Running with Automatic Restart Support
The recommended way to run the bot is using the provided `bot.sh` script, which handles automatic restarts when using the `!restart` command:
```bash
# Make the script executable
chmod +x bot.sh
# Run a single bot
./bot.sh config.yaml
# Run multiple bots
./bot.sh config1.yaml config2.yaml config3.yaml
```
### Manual Running
You can also run the bot directly with Python:
#### Single Bot Mode
Run with config file:
```bash
@@ -75,32 +110,51 @@ Available command line arguments:
- `--irc-channel`: IRC channel to join
- `--stream-url`: Icecast base URL
- `--stream-endpoint`: Stream endpoint
- `--cmd-prefix`: Command prefix character(s)
### Multiple Bot Mode
#### Multiple Bot Mode
Run multiple instances with different configs:
```bash
python main.py config1.yaml config2.yaml config3.yaml
```
Or use the launcher script:
```bash
python launch.py
```
## IRC Commands
Regular commands:
- `!np`: Shows the currently playing track
- `!help`: Shows available commands
Admin commands:
- `!start`: Start stream monitoring
- `!stop`: Stop stream monitoring
- `!reconnect`: Reconnect to stream (with status feedback)
- `!restart`: Restart the bot (requires using bot.sh)
- `!quit`: Shutdown the bot
## Logging
The bot logs important events to stdout with timestamps. Log level is set to INFO by default.
The bot supports different logging levels configurable in the config.yaml:
- DEBUG: Detailed information for troubleshooting
- INFO: General operational messages (default)
- WARNING: Warning messages and potential issues
- ERROR: Error messages only
- 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
- Automatically reconnects on connection drops
- Retries stream monitoring on errors
- Logs errors for debugging
- Smart metadata URL resolution
- Connection status verification and reporting
- Health checks every 5 minutes (configurable)
## License

1
VERSION Normal file
View File

@@ -0,0 +1 @@
1.1.0

24
bot.sh Executable file
View File

@@ -0,0 +1,24 @@
#!/bin/bash
# Check if any arguments were provided
if [ $# -eq 0 ]; then
echo "Usage: $0 <config.yaml> [additional configs...]"
exit 1
fi
while true; do
# Remove any existing restart flags before starting
rm -f .restart_flag_*
python main.py "$@"
# Check for any restart flags
if ls .restart_flag_* 1> /dev/null 2>&1; then
echo "Restart flag(s) found, restarting bot(s)..."
sleep 1
continue
else
echo "Bot(s) exited without restart flag, stopping..."
break
fi
done

View File

@@ -1,19 +1,39 @@
irc:
host: "irc.someircserver.net"
port: 6667 # asif does not support ssl as of 2025-02-23
nick: "bot"
user: "bot"
realname: "Botty Bot - https://bot.site"
channel: "#channel"
host: "irc.libera.chat"
port: 6667
nick: "IcecastBot"
user: "icecastbot"
realname: "Icecast IRC Bot"
channel: "#yourchannel"
stream:
url: "http://your-icecast-server.com:8000/" # this is the *base* url of the icecast server
endpoint: "stream" # this is the endpoint of the icecast server (e.g. /stream or .mp3)
health_check_interval: 300
url: "https://your.stream.url" # Base URL without /stream or .mp3
endpoint: "/stream" # The endpoint part (e.g. /stream, /radio.mp3)
health_check_interval: 300 # How often to log health status (in seconds)
announce:
format: "\x02Now playing:\x02 {song}"
ignore_patterns:
format: "\x02Now playing:\x02 {song}" # Format for song announcements
ignore_patterns: # Don't announce songs matching these patterns
- "Unknown"
- "Unable to fetch metadata"
# Add more patterns to ignore here
- "Error fetching metadata"
commands:
prefix: "!" # Command prefix (e.g. !np, !help)
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"

1189
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"}