Compare commits

...

12 Commits

17 changed files with 2415 additions and 486 deletions

1
.gitignore vendored
View File

@ -30,6 +30,7 @@ env/
.config/ .config/
config*.yaml config*.yaml
!config.yaml.example !config.yaml.example
.archive/
# Logs # Logs
*.log *.log

View File

@ -7,7 +7,56 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [1.0.1] - 2024-02-24 ## [1.2.2] - 2025-02-25
### Added
- Improved stream reconnection verification with log entries
- Enhanced metadata endpoint discovery with multiple URL patterns
- Expanded error handling and logging for metadata fetching
### Changed
- Refactored stream monitoring for better stability
- Updated metadata parsing to handle varied source formats
- General project cleanup and housekeeping
### Fixed
- Stream reconnection edge cases and timeout handling
- JSON metadata parsing reliability
- Various validations
## [1.2.1] - 2025-02-25
### Added
- Improved stream reconnection logic with automatic retry
- Enhanced metadata fetching with better error handling
- Expanded logging for better troubleshooting
### Fixed
- Issue with metadata parsing for certain stream formats
- Stream monitoring stability improvements
- Connection timeout handling
## [1.2.0] - 2025-02-25
### Added
- New admin commands:
- !quiet - Disable song announcements while continuing to monitor the stream
- !unquiet - Re-enable song announcements
- Terminal commands for controlling announcement behavior:
- `quiet` - Disable song announcements via terminal
- `unquiet` - Enable song announcements via terminal
- Configuration option `quiet_on_start` to control whether the bot starts with announcements enabled or disabled
- Background task to monitor for quiet/unquiet requests from terminal commands
- Updated documentation in README.md with new commands
- Updated usage information in install.sh
### Changed
- Enhanced the RestartManager to handle quiet and unquiet commands
- Improved the BotManager class to support the new terminal commands
- Updated configuration example to include the new quiet_on_start option
## [1.0.1] - 2025-02-24
### Added ### Added
- Configurable logging levels via config.yaml - Configurable logging levels via config.yaml
@ -45,7 +94,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Other ### Other
- Added version information - Added version information
## [1.0.0] - 2024-02-23 ## [1.0.0] - 2025-02-23
### Added ### Added
- Initial release - Initial release
@ -56,6 +105,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Multi-bot support - Multi-bot support
- Configuration via YAML files - Configuration via YAML files
[Unreleased]: https://code.cottongin.xyz/cottongin/Icecast-metadata-IRC-announcer/compare/v1.0.1...HEAD [Unreleased]: https://code.cottongin.xyz/cottongin/Icecast-metadata-IRC-announcer/compare/v1.2.2...HEAD
[1.2.2]: https://code.cottongin.xyz/cottongin/Icecast-metadata-IRC-announcer/compare/v1.2.1...v1.2.2
[1.2.1]: https://code.cottongin.xyz/cottongin/Icecast-metadata-IRC-announcer/compare/v1.2.0...v1.2.1
[1.2.0]: https://code.cottongin.xyz/cottongin/Icecast-metadata-IRC-announcer/compare/v1.0.1...v1.2.0
[1.0.1]: https://code.cottongin.xyz/cottongin/Icecast-metadata-IRC-announcer/compare/v1.0.0...v1.0.1 [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 [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,6 +1,6 @@
# Icecast-metadata-IRC-announcer # 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. 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.
@ -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,16 +61,31 @@ 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
## Manager Commands
The bot manager supports the following commands:
```bash
icecast-irc-bot-manager list # List running bots
icecast-irc-bot-manager start --config FILE # Start a bot
icecast-irc-bot-manager stop BOT_ID # Stop a bot
icecast-irc-bot-manager restart BOT_ID # Restart a bot
icecast-irc-bot-manager quiet BOT_ID # Disable song announcements
icecast-irc-bot-manager unquiet BOT_ID # Enable song announcements
```
### Running with Automatic Restart Support ### 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: The recommended way to run the bot is using the provided `bot.sh` script, which handles automatic restarts when using the `!restart` command:
@ -119,35 +134,22 @@ 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) - `!quiet` - Disable song announcements but continue monitoring
- WARNING: Warning messages and potential issues - `!unquiet` - Enable song announcements
- ERROR: Error messages only - `!reconnect` - Reconnect to the stream
- CRITICAL: Critical failures only - `!restart` - Restart the bot
- `!quit` - Shutdown the bot
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

38
TODO.md
View File

@ -1,16 +1,26 @@
## TODO ## TODO 🚧
- Better, more clear, logging - ⭐️ Better, more clear, logging
- Add a help command - Notification system for stream admin (and bot admin)
- Add commands to control the stream connection from IRC - Send notification if unexpected change in metadata
- Also to start/stop the service entirely - Loss of network/stream
- Add a version command - Live notifications (alt. for PodPing ish)
- Add a list of commands to the README - Implement `quiet mode`
- ⭐️ Add whitelist feature
- Add commands:
- To check the status of the bot
- To check the status of the stream
- To check the status of the IRC connection
- Version command
- Enable joining multiple channels with a single bot instance - Enable joining multiple channels with a single bot instance
- Add a command to force the bot to reconnect to the stream
- Add a command to check the status of the bot ## Complete! 🎉
- Add a command to check the status of the stream
- Add a command to check the status of the IRC connection - ✅ Move this to a TODO.md file 😊
- Add a command to check the status of the bot - 🟢 Added commands:
- Add a command to check the status of the stream - ✅ Add `quiet mode` relevant commands
- Move this to a TODO.md file :) - ✅ Add a help command
- ✅ To control the stream connection from IRC
- ✅ To start/stop the service entirely
- ✅ To quiet/unquiet the metadata announcing
- ✅ Add a list of commands to the README

View File

@ -1 +1 @@
1.0.1 -testing

24
bot.sh
View File

@ -1,24 +0,0 @@
#!/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

@ -17,15 +17,18 @@ announce:
- "Unknown" - "Unknown"
- "Unable to fetch metadata" - "Unable to fetch metadata"
- "Error fetching metadata" - "Error fetching metadata"
quiet_on_start: false # If true, bot starts in quiet mode (no announcements)
commands: commands:
prefix: "!" # Command prefix (e.g. !np, !help) prefix: "!" # Command prefix (e.g. !np, !help)
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

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()

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

@ -0,0 +1,654 @@
#!/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())

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=journal
StandardError=journal
# Security settings
NoNewPrivileges=yes
ProtectSystem=full
ProtectHome=yes
PrivateTmp=yes
[Install]
WantedBy=multi-user.target

73
install.sh Executable file
View File

@ -0,0 +1,73 @@
#!/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"
echo "icecast-irc-bot-manager quiet BOT_ID # Disable song announcements"
echo "icecast-irc-bot-manager unquiet BOT_ID # Enable song announcements"

View File

@ -1,12 +0,0 @@
#!/usr/bin/env python3
import asyncio
from main import run_multiple_bots
CONFIG_FILES = [
'config_station1.yaml',
'config_station2.yaml',
'config_station3.yaml'
]
if __name__ == "__main__":
asyncio.run(run_multiple_bots(CONFIG_FILES))

121
logger.py Normal file
View File

@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""
Logger module for the Icecast metadata IRC announcer.
Uses Loguru for simple yet powerful logging.
"""
import os
import sys
from pathlib import Path
from loguru import logger
class LogManager:
"""
Manages logging configuration for the Icecast metadata IRC announcer.
This class provides a simple interface for configuring and using Loguru
throughout the project. It handles log file rotation, formatting, and
different log levels.
"""
def __init__(self, config=None):
"""
Initialize the LogManager with optional configuration.
Args:
config: Optional dictionary with logging configuration.
If None, default configuration is used.
"""
self.config = config or {}
self.log_dir = Path(self.config.get('log_dir', 'logs'))
self.log_level = self.config.get('level', 'INFO').upper()
self.log_format = self.config.get('format',
"<green>{time:YYYY-MM-DD HH:mm:ss}</green> | "
"<level>{level: <8}</level> | "
"<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - "
"<level>{message}</level>"
)
# Create log directory if it doesn't exist
if not self.log_dir.exists():
self.log_dir.mkdir(parents=True, exist_ok=True)
# Configure logger
self._configure_logger()
def _configure_logger(self):
"""Configure Loguru logger with appropriate sinks and formats."""
# Remove default handler
logger.remove()
# Add console handler
logger.add(
sys.stderr,
format=self.log_format,
level=self.log_level,
colorize=True
)
# Add file handler with rotation
log_file = self.log_dir / "icecast_bot.log"
logger.add(
str(log_file),
format=self.log_format,
level=self.log_level,
rotation="10 MB", # Rotate when file reaches 10MB
compression="zip", # Compress rotated logs
retention="1 week", # Keep logs for 1 week
backtrace=True, # Include backtrace in error logs
diagnose=True # Include variables in error logs
)
def get_logger(self, name=None):
"""
Get a logger instance with the given name.
Args:
name: Optional name for the logger. If None, the calling module's name is used.
Returns:
A Loguru logger instance.
"""
if name:
return logger.bind(name=name)
return logger
@staticmethod
def set_level(level):
"""
Set the log level for all handlers.
Args:
level: Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
"""
for handler_id in logger._core.handlers:
logger.configure(handler_id, level=level)
# Create a default instance for easy import
log_manager = LogManager()
get_logger = log_manager.get_logger
# Export the logger directly for simple usage
log = logger
# For convenience, export common log levels
debug = logger.debug
info = logger.info
warning = logger.warning
error = logger.error
critical = logger.critical
exception = logger.exception
# Example usage:
# from logger import log, debug, info, error
# log.info("This is an info message")
# debug("This is a debug message")
#
# # Or with a named logger:
# from logger import get_logger
# logger = get_logger("my_module")
# logger.info("This is a message from my_module")

1512
main.py Normal file → Executable file

File diff suppressed because it is too large Load Diff

36
pyproject.toml Normal file
View File

@ -0,0 +1,36 @@
[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.version]
source = "regex"
path = "VERSION"
pattern = "^(?P<version>[0-9]+\\.[0-9]+\\.[0-9]+)$"
[tool.hatch.build.targets.wheel]
packages = ["."]
[tool.hatch.build]
include = [
"*.py",
"README*",
"LICENSE*",
"config.yaml.example"
]
[tool.setuptools.dynamic]
version = {file = "VERSION"}

View File

@ -1,3 +1,4 @@
asif asif
aiohttp aiohttp
pyyaml pyyaml
loguru>=0.7.0