initial commit
This commit is contained in:
commit
eca2b4f873
53
.gitignore
vendored
Normal file
53
.gitignore
vendored
Normal file
@ -0,0 +1,53 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# Distribution / packaging
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
env/
|
||||
.env/
|
||||
.venv/
|
||||
|
||||
# IDE/OS specific files
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
|
||||
# Local development settings
|
||||
*.env
|
||||
.env
|
||||
.config/
|
||||
config*.yaml
|
||||
!config.yaml.example
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
coverage.xml
|
||||
*.cover
|
||||
.pytest_cache/
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# pyenv
|
||||
.python-version
|
||||
21
LICENSE.md
Normal file
21
LICENSE.md
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 cottongin
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
124
README.md
Normal file
124
README.md
Normal file
@ -0,0 +1,124 @@
|
||||
# Icecast-metadata-IRC-announcer
|
||||
|
||||
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.
|
||||
|
||||
## Features
|
||||
|
||||
- Monitors Icecast stream metadata and announces track changes
|
||||
- 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
|
||||
|
||||
## Dependencies
|
||||
|
||||
- [asif](https://github.com/minus7/asif)
|
||||
- [aiohttp](https://github.com/aio-libs/aiohttp)
|
||||
- [pyyaml](https://github.com/yaml/pyyaml)
|
||||
|
||||
## Installation
|
||||
|
||||
1. Clone the repository
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
pip install asif aiohttp pyyaml
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Create a YAML config file (default: `config.yaml`):
|
||||
|
||||
```yaml
|
||||
irc:
|
||||
host: "irc.example.net"
|
||||
port: 6667
|
||||
nick: "MusicBot"
|
||||
user: "musicbot"
|
||||
realname: "Music Announcer Bot"
|
||||
channel: "#music"
|
||||
|
||||
stream:
|
||||
url: "https://stream.example.com"
|
||||
endpoint: "stream"
|
||||
health_check_interval: 300
|
||||
|
||||
announce:
|
||||
format: "\x02Now playing:\x02 {song}"
|
||||
ignore_patterns:
|
||||
- "Unknown"
|
||||
- "Unable to fetch metadata"
|
||||
# Add more patterns to ignore
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Single Bot Mode
|
||||
|
||||
Run with config file:
|
||||
```bash
|
||||
python main.py --config myconfig.yaml
|
||||
```
|
||||
|
||||
Override config with command line arguments:
|
||||
```bash
|
||||
python main.py --config myconfig.yaml --irc-nick CustomNick --irc-channel "#mychannel"
|
||||
```
|
||||
|
||||
Available command line arguments:
|
||||
- `--config`: Path to config file
|
||||
- `--irc-host`: IRC server hostname
|
||||
- `--irc-port`: IRC server port
|
||||
- `--irc-nick`: IRC nickname
|
||||
- `--irc-channel`: IRC channel to join
|
||||
- `--stream-url`: Icecast base URL
|
||||
- `--stream-endpoint`: Stream endpoint
|
||||
|
||||
### 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
|
||||
|
||||
- `!np`: Shows the currently playing track
|
||||
|
||||
## Logging
|
||||
|
||||
The bot logs important events to stdout with timestamps. Log level is set to INFO by default.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Automatically reconnects on connection drops
|
||||
- Retries stream monitoring on errors
|
||||
- Logs errors for debugging
|
||||
- Health checks every 5 minutes (configurable)
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License. See the LICENSE file for details.
|
||||
|
||||
-------
|
||||
|
||||
## TODO
|
||||
|
||||
- Add a help command
|
||||
- Add commands to control the stream connection from IRC
|
||||
- Also to start/stop the service entirely
|
||||
- Add a version command
|
||||
- Add a list of commands to the README
|
||||
- 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
|
||||
- Add a command to check the status of the stream
|
||||
- Add a command to check the status of the IRC connection
|
||||
- Add a command to check the status of the bot
|
||||
- Add a command to check the status of the stream
|
||||
- Move this to a TODO.md file :)
|
||||
19
config.yaml.example
Normal file
19
config.yaml.example
Normal file
@ -0,0 +1,19 @@
|
||||
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"
|
||||
|
||||
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
|
||||
|
||||
announce:
|
||||
format: "\x02Now playing:\x02 {song}"
|
||||
ignore_patterns:
|
||||
- "Unknown"
|
||||
- "Unable to fetch metadata"
|
||||
# Add more patterns to ignore here
|
||||
12
launch.py
Normal file
12
launch.py
Normal file
@ -0,0 +1,12 @@
|
||||
#!/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))
|
||||
249
main.py
Normal file
249
main.py
Normal file
@ -0,0 +1,249 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from asif import Client
|
||||
import asyncio
|
||||
import re
|
||||
import aiohttp
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
import argparse
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
# Set up logging - only show important info
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(message)s',
|
||||
datefmt='%H:%M:%S'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class IcecastBot:
|
||||
def __init__(self, config_path: Optional[str] = None):
|
||||
# Load config
|
||||
self.config = self.load_config(config_path)
|
||||
|
||||
# Initialize IRC bot with config
|
||||
self.bot = Client(
|
||||
host=self.config['irc']['host'],
|
||||
port=self.config['irc']['port'],
|
||||
user=self.config['irc']['user'],
|
||||
realname=self.config['irc']['realname'],
|
||||
nick=self.config['irc']['nick']
|
||||
)
|
||||
|
||||
# Set up instance variables from config
|
||||
self.channel_name = self.config['irc']['channel']
|
||||
self.stream_url = self.config['stream']['url']
|
||||
self.stream_endpoint = self.config['stream']['endpoint']
|
||||
self.current_song = "Unknown"
|
||||
self.reply = self.config['announce']['format']
|
||||
self.ignore_patterns = self.config['announce']['ignore_patterns']
|
||||
self.channel = None
|
||||
self.last_health_check = time.time()
|
||||
self.health_check_interval = self.config['stream']['health_check_interval']
|
||||
|
||||
self.setup_handlers()
|
||||
|
||||
@staticmethod
|
||||
def load_config(config_path: Optional[str] = None) -> dict:
|
||||
"""Load configuration from file and/or command line arguments."""
|
||||
if config_path is None:
|
||||
config_path = Path(__file__).parent / 'config.yaml'
|
||||
|
||||
# Load config file
|
||||
try:
|
||||
with open(config_path) as f:
|
||||
config = yaml.safe_load(f)
|
||||
except FileNotFoundError:
|
||||
logger.warning(f"Config file not found at {config_path}, using defaults")
|
||||
config = {
|
||||
'irc': {},
|
||||
'stream': {},
|
||||
'announce': {
|
||||
'format': "\x02Now playing:\x02 {song}",
|
||||
'ignore_patterns': ['Unknown', 'Unable to fetch metadata']
|
||||
}
|
||||
}
|
||||
|
||||
return config
|
||||
|
||||
def should_announce_song(self, song: str) -> bool:
|
||||
"""Check if the song should be announced based on ignore patterns."""
|
||||
return not any(pattern.lower() in song.lower() for pattern in self.ignore_patterns)
|
||||
|
||||
def setup_handlers(self):
|
||||
@self.bot.on_connected()
|
||||
async def connected():
|
||||
try:
|
||||
self.channel = await self.bot.join(self.channel_name)
|
||||
logger.info(f"Connected to IRC and joined {self.channel_name}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error joining channel: {e}")
|
||||
|
||||
asyncio.create_task(self.monitor_metadata())
|
||||
|
||||
@self.bot.on_join()
|
||||
async def on_join(channel):
|
||||
if not self.channel:
|
||||
self.channel = channel
|
||||
|
||||
@self.bot.on_message(re.compile("^!np"))
|
||||
async def now_playing(message):
|
||||
await message.reply(self.reply.format(song=self.current_song))
|
||||
|
||||
async def fetch_json_metadata(self):
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
url = f"{self.stream_url}/status-json.xsl"
|
||||
async with session.get(url) as response:
|
||||
if response.status == 200:
|
||||
data = await response.text()
|
||||
json_data = json.loads(data)
|
||||
|
||||
if 'icestats' in json_data:
|
||||
sources = json_data['icestats'].get('source', [])
|
||||
if isinstance(sources, list):
|
||||
for src in sources:
|
||||
if src['listenurl'].endswith(f'{self.stream_endpoint}'):
|
||||
source = src
|
||||
else:
|
||||
source = sources
|
||||
|
||||
title = source.get('title') or source.get('song') or source.get('current_song')
|
||||
if title:
|
||||
return title
|
||||
|
||||
return "Unable to fetch metadata"
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching JSON metadata: {e}")
|
||||
return "Error fetching metadata"
|
||||
|
||||
async def monitor_metadata(self):
|
||||
await asyncio.sleep(5)
|
||||
|
||||
while True:
|
||||
try:
|
||||
cmd = [
|
||||
'curl',
|
||||
'-s',
|
||||
'-H', 'Icy-MetaData: 1',
|
||||
'--no-buffer',
|
||||
f"{self.stream_url}/{self.stream_endpoint}"
|
||||
]
|
||||
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
|
||||
logger.info("Started stream monitoring")
|
||||
|
||||
buffer = b""
|
||||
last_json_check = time.time()
|
||||
json_check_interval = 60 # Fallback interval if ICY updates fail
|
||||
|
||||
while True:
|
||||
chunk = await process.stdout.read(8192)
|
||||
if not chunk:
|
||||
break
|
||||
|
||||
buffer += chunk
|
||||
current_time = time.time()
|
||||
|
||||
# Periodic health check
|
||||
if current_time - self.last_health_check >= self.health_check_interval:
|
||||
logger.info("Monitor status: Active - processing stream data")
|
||||
self.last_health_check = current_time
|
||||
|
||||
# Look for metadata marker but fetch from JSON
|
||||
if b"StreamTitle='" in buffer:
|
||||
new_song = await self.fetch_json_metadata()
|
||||
if new_song and new_song != self.current_song and "Unable to fetch metadata" not in new_song:
|
||||
logger.info(f"Now Playing: {new_song}")
|
||||
self.current_song = new_song
|
||||
await self.announce_song(new_song)
|
||||
|
||||
# Clear buffer after metadata marker
|
||||
buffer = buffer[buffer.find(b"';", buffer.find(b"StreamTitle='")) + 2:]
|
||||
last_json_check = current_time
|
||||
|
||||
# Keep buffer size reasonable
|
||||
if len(buffer) > 65536:
|
||||
buffer = buffer[-32768:]
|
||||
|
||||
# Fallback JSON check if ICY updates aren't coming through
|
||||
if current_time - last_json_check >= json_check_interval:
|
||||
new_song = await self.fetch_json_metadata()
|
||||
if "Unable to fetch metadata" in new_song:
|
||||
break
|
||||
if new_song and new_song != self.current_song:
|
||||
logger.info(f"Now Playing (fallback): {new_song}")
|
||||
self.current_song = new_song
|
||||
await self.announce_song(new_song)
|
||||
last_json_check = current_time
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
await process.wait()
|
||||
logger.warning("Stream monitor ended, restarting...")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Stream monitor error: {e}")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
async def announce_song(self, song: str):
|
||||
"""Announce song if it doesn't match any ignore patterns."""
|
||||
try:
|
||||
if self.channel and self.should_announce_song(song):
|
||||
await self.channel.message(self.reply.format(song=song))
|
||||
except Exception as e:
|
||||
logger.error(f"Error announcing song: {e}")
|
||||
|
||||
async def start(self):
|
||||
await self.bot.run()
|
||||
|
||||
async def run_multiple_bots(config_paths: List[str]):
|
||||
"""Run multiple bot instances concurrently."""
|
||||
bots = [IcecastBot(config_path) for config_path in config_paths]
|
||||
await asyncio.gather(*(bot.start() for bot in bots))
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description='Icecast IRC Bot')
|
||||
parser.add_argument('configs', nargs='*', help='Paths to config files')
|
||||
parser.add_argument('--config', type=str, help='Path to single config file')
|
||||
parser.add_argument('--irc-host', type=str, help='IRC server host')
|
||||
parser.add_argument('--irc-port', type=int, help='IRC server port')
|
||||
parser.add_argument('--irc-nick', type=str, help='IRC nickname')
|
||||
parser.add_argument('--irc-channel', type=str, help='IRC channel')
|
||||
parser.add_argument('--stream-url', type=str, help='Icecast stream URL (base url; do not include /stream or .mp3)')
|
||||
parser.add_argument('--stream-endpoint', type=str, help='Stream endpoint (e.g. /stream)')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.configs:
|
||||
# Multi-bot mode
|
||||
asyncio.run(run_multiple_bots(args.configs))
|
||||
else:
|
||||
# Single-bot mode
|
||||
bot = IcecastBot(args.config)
|
||||
|
||||
# Apply any command line overrides to the config
|
||||
if args.irc_host:
|
||||
bot.config['irc']['host'] = args.irc_host
|
||||
if args.irc_port:
|
||||
bot.config['irc']['port'] = args.irc_port
|
||||
if args.irc_nick:
|
||||
bot.config['irc']['nick'] = args.irc_nick
|
||||
if args.irc_channel:
|
||||
bot.config['irc']['channel'] = args.irc_channel
|
||||
if args.stream_url:
|
||||
bot.config['stream']['url'] = args.stream_url
|
||||
if args.stream_endpoint:
|
||||
bot.config['stream']['endpoint'] = args.stream_endpoint
|
||||
|
||||
asyncio.run(bot.start())
|
||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
@ -0,0 +1,3 @@
|
||||
asif
|
||||
aiohttp
|
||||
pyyaml
|
||||
Loading…
x
Reference in New Issue
Block a user