Compare commits

..

No commits in common. "HEAD" and "v1.2.0" have entirely different histories.
HEAD ... v1.2.0

7 changed files with 113 additions and 230 deletions

1
.gitignore vendored
View File

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

View File

@ -7,37 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [1.2.2] - 2025-02-25 ## [1.2.0] - 2024-07-01
### 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 ### Added
- New admin commands: - New admin commands:
@ -56,7 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Improved the BotManager class to support the new terminal commands - Improved the BotManager class to support the new terminal commands
- Updated configuration example to include the new quiet_on_start option - Updated configuration example to include the new quiet_on_start option
## [1.0.1] - 2025-02-24 ## [1.0.1] - 2024-02-24
### Added ### Added
- Configurable logging levels via config.yaml - Configurable logging levels via config.yaml
@ -94,7 +64,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] - 2025-02-23 ## [1.0.0] - 2024-02-23
### Added ### Added
- Initial release - Initial release
@ -105,9 +75,7 @@ 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.2.2...HEAD [Unreleased]: https://code.cottongin.xyz/cottongin/Icecast-metadata-IRC-announcer/compare/v1.2.0...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.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

38
TODO.md
View File

@ -1,26 +1,16 @@
## TODO 🚧 ## TODO
- ⭐️ Better, more clear, logging - Better, more clear, logging
- Notification system for stream admin (and bot admin) - Add a help command
- Send notification if unexpected change in metadata - Add commands to control the stream connection from IRC
- Loss of network/stream - Also to start/stop the service entirely
- Live notifications (alt. for PodPing ish) - Add a version command
- Implement `quiet mode` - Add a list of commands to the README
- ⭐️ 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
## Complete! 🎉 - 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 😊 - Add a command to check the status of the IRC connection
- 🟢 Added commands: - Add a command to check the status of the bot
- ✅ Add `quiet mode` relevant commands - Add a command to check the status of the stream
- ✅ Add a help command - Move this to a TODO.md file :)
- ✅ 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 @@
-testing 1.2.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

12
launch.py Normal file
View 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))

222
main.py
View File

@ -11,6 +11,7 @@ import argparse
import yaml import yaml
from pathlib import Path from pathlib import Path
from typing import List, Optional from typing import List, Optional
import sys
import inspect import inspect
import socket import socket
import tempfile import tempfile
@ -58,6 +59,17 @@ def silent_client_init(self, *args, bot_name: str = None, config: dict = None, *
original_init = Client.__init__ original_init = Client.__init__
Client.__init__ = silent_client_init Client.__init__ = silent_client_init
import asyncio
import aiohttp
import time
import argparse
import yaml
from pathlib import Path
from typing import List, Optional
import sys
import inspect
import socket
import tempfile
class RestartManager: class RestartManager:
"""Manages restart requests for the bot. """Manages restart requests for the bot.
@ -431,26 +443,14 @@ class IcecastBot:
# Check if recipient is a Channel object # Check if recipient is a Channel object
is_channel = hasattr(recipient, 'name') and recipient.name.startswith('#') is_channel = hasattr(recipient, 'name') and recipient.name.startswith('#')
# Always allow np command in private messages if not self.allow_private_commands and not is_channel:
# Only check allow_private_commands for other commands return
try: try:
# For private messages, we need to ensure we have a valid recipient if is_channel:
if not is_channel:
# In private messages, send the response to the sender
self.logger.debug(f"Sending now playing info to user {message.sender}")
await message.sender.message(self.reply.format(song=self.current_song))
else:
# In channels, send to the channel
self.logger.debug(f"Sending now playing info to channel {recipient}")
await recipient.message(self.reply.format(song=self.current_song)) await recipient.message(self.reply.format(song=self.current_song))
except Exception as e: except Exception as e:
self.logger.error(f"Error sending now playing info: {str(e)}") pass
try:
# Try to send an error message back to the user
await message.sender.message(f"Error sending now playing info: {str(e)}")
except Exception:
pass
self.command_handlers['now_playing'] = now_playing self.command_handlers['now_playing'] = now_playing
@self.bot.on_message(create_command_pattern('help')) @self.bot.on_message(create_command_pattern('help'))
@ -468,8 +468,8 @@ class IcecastBot:
# Check if recipient is a Channel object # Check if recipient is a Channel object
is_channel = hasattr(recipient, 'name') and recipient.name.startswith('#') is_channel = hasattr(recipient, 'name') and recipient.name.startswith('#')
# Always allow help command in private messages if not self.allow_private_commands and not is_channel:
# Only check allow_private_commands for non-help commands return
try: try:
# Parse message to check if a specific command was requested # Parse message to check if a specific command was requested
@ -488,23 +488,21 @@ class IcecastBot:
if method_name: if method_name:
handler = self.command_handlers.get(method_name) handler = self.command_handlers.get(method_name)
if handler and handler.__doc__: if handler and handler.__doc__:
# Check if command is hidden and we're in a channel # Get the first line of the docstring
if is_channel and hasattr(handler, 'is_hidden_command') and handler.is_hidden_command: first_line = handler.__doc__.strip().split('\n')[0]
help_text = f"Unknown command: {pattern}" # Format it using the template and add (admin only) if needed
else: desc = first_line.split(':', 1)[1].strip()
# Get the first line of the docstring if pattern in self.admin_commands:
first_line = handler.__doc__.strip().split('\n')[0] desc = f"{desc} (admin only)"
# Format it using the template and add (admin only) if needed help_text = self.help_specific_format.format(
desc = first_line.split(':', 1)[1].strip() prefix=self.cmd_prefix,
help_text = self.help_specific_format.format( cmd=pattern,
prefix=self.cmd_prefix, desc=desc
cmd=pattern, )
desc=desc
)
# Check if user has permission for this command # Check if user has permission for this command
if pattern in self.admin_commands and not self.is_admin(message.sender): if pattern in self.admin_commands and not self.is_admin(message.sender):
help_text = "You don't have permission to use this command." help_text = "You don't have permission to use this command."
else: else:
help_text = f"No help available for command: {pattern}" help_text = f"No help available for command: {pattern}"
else: else:
@ -519,10 +517,6 @@ class IcecastBot:
if pattern not in self.admin_commands: # If not an admin command if pattern not in self.admin_commands: # If not an admin command
handler = self.command_handlers.get(method_name) handler = self.command_handlers.get(method_name)
if handler and handler.__doc__: if handler and handler.__doc__:
# Skip hidden commands in channel help
if is_channel and hasattr(handler, 'is_hidden_command') and handler.is_hidden_command:
continue
first_line = handler.__doc__.strip().split('\n')[0] first_line = handler.__doc__.strip().split('\n')[0]
desc = first_line.split(':', 1)[1].strip() desc = first_line.split(':', 1)[1].strip()
general_commands.append( general_commands.append(
@ -542,10 +536,6 @@ class IcecastBot:
if method_name: if method_name:
handler = self.command_handlers.get(method_name) handler = self.command_handlers.get(method_name)
if handler and handler.__doc__: if handler and handler.__doc__:
# Skip hidden commands in channel help
if is_channel and hasattr(handler, 'is_hidden_command') and handler.is_hidden_command:
continue
first_line = handler.__doc__.strip().split('\n')[0] first_line = handler.__doc__.strip().split('\n')[0]
desc = first_line.split(':', 1)[1].strip() desc = first_line.split(':', 1)[1].strip()
# Don't add (admin only) in the list view # Don't add (admin only) in the list view
@ -560,28 +550,16 @@ class IcecastBot:
help_text = f"\x02Icecast Bot v{self.VERSION}\x02 | " + " | ".join(formatted_groups) help_text = f"\x02Icecast Bot v{self.VERSION}\x02 | " + " | ".join(formatted_groups)
# Send the help text to the appropriate recipient if is_channel:
if not is_channel:
# In private messages, send the response to the sender
self.logger.debug(f"Sending help info to user {message.sender}")
await message.sender.message(help_text)
else:
# In channels, send to the channel
self.logger.debug(f"Sending help info to channel {recipient}")
await recipient.message(help_text) await recipient.message(help_text)
except Exception as e: except Exception as e:
self.logger.error(f"Error sending help info: {str(e)}") pass
try:
# Try to send an error message back to the user
await message.sender.message(f"Error sending help info: {str(e)}")
except Exception:
pass
self.command_handlers['help_command'] = help_command self.command_handlers['help_command'] = help_command
@self.bot.on_message(create_command_pattern('restart')) @self.bot.on_message(create_command_pattern('restart'))
@self.admin_required @self.admin_required
async def restart_bot(message): async def restart_bot(message):
"""!restart: Restart the bot """!restart: Restart the bot (admin only)
Gracefully shuts down the bot and signals the bot.sh script Gracefully shuts down the bot and signals the bot.sh script
to restart it. This ensures a clean restart. to restart it. This ensures a clean restart.
@ -603,7 +581,7 @@ class IcecastBot:
@self.bot.on_message(create_command_pattern('quit')) @self.bot.on_message(create_command_pattern('quit'))
@self.admin_required @self.admin_required
async def quit_bot(message): async def quit_bot(message):
"""!quit: Shutdown the bot """!quit: Shutdown the bot (admin only)
Gracefully shuts down the bot and exits without restarting. Gracefully shuts down the bot and exits without restarting.
@ -622,7 +600,7 @@ class IcecastBot:
@self.bot.on_message(create_command_pattern('reconnect')) @self.bot.on_message(create_command_pattern('reconnect'))
@self.admin_required @self.admin_required
async def reconnect_stream(message): async def reconnect_stream(message):
"""!reconnect: Reconnect to the stream """!reconnect: Reconnect to the stream (admin only)
Attempts to reconnect to the stream and verifies the connection. Attempts to reconnect to the stream and verifies the connection.
Reports success or failure back to the channel. Reports success or failure back to the channel.
@ -638,7 +616,7 @@ class IcecastBot:
@self.bot.on_message(create_command_pattern('stop')) @self.bot.on_message(create_command_pattern('stop'))
@self.admin_required @self.admin_required
async def stop_monitoring(message): async def stop_monitoring(message):
"""!stop: Stop stream monitoring """!stop: Stop stream monitoring (admin only)
Stops monitoring the stream for metadata changes. Stops monitoring the stream for metadata changes.
The bot remains connected to IRC. The bot remains connected to IRC.
@ -654,7 +632,7 @@ class IcecastBot:
@self.bot.on_message(create_command_pattern('start')) @self.bot.on_message(create_command_pattern('start'))
@self.admin_required @self.admin_required
async def start_monitoring(message): async def start_monitoring(message):
"""!start: Start stream monitoring """!start: Start stream monitoring (admin only)
Starts monitoring the stream for metadata changes. Starts monitoring the stream for metadata changes.
Will announce new songs in the channel. Will announce new songs in the channel.
@ -670,7 +648,7 @@ class IcecastBot:
@self.bot.on_message(create_command_pattern('quiet')) @self.bot.on_message(create_command_pattern('quiet'))
@self.admin_required @self.admin_required
async def quiet_bot(message): async def quiet_bot(message):
"""!quiet: Disable song announcements """!quiet: Disable song announcements (admin only)
Continues monitoring the stream for metadata changes, Continues monitoring the stream for metadata changes,
but stops announcing songs in the channel. but stops announcing songs in the channel.
@ -687,59 +665,20 @@ class IcecastBot:
@self.bot.on_message(create_command_pattern('unquiet')) @self.bot.on_message(create_command_pattern('unquiet'))
@self.admin_required @self.admin_required
async def unquiet_bot(message): async def unquiet_bot(message):
"""!unquiet: Re-enable song announcements """!unquiet: Enable song announcements (admin only)
The opposite of !quiet - allows the bot to resume announcing songs. Resumes announcing songs in the channel.
The bot must already be monitoring the stream.
Args: Args:
message: IRC message object message: IRC message object
""" """
try: self.should_announce = True
self.should_announce = True self.logger.info("Song announcements enabled by admin command")
await message.recipient.message("Song announcements re-enabled.") if hasattr(message.recipient, 'name') and message.recipient.name.startswith('#'):
except Exception as e: await message.recipient.message("Song announcements enabled. Bot will now announce songs.")
pass
self.command_handlers['unquiet_bot'] = unquiet_bot self.command_handlers['unquiet_bot'] = unquiet_bot
@self.bot.on_message(create_command_pattern('announce'))
@self.hidden_command
@self.admin_required
async def announce_message(message):
"""!announce: Send a message from the bot to all channels
Allows admins to use the bot to send a message to all channels.
Can be used in private message or in a channel.
Usage: !announce <message>
Args:
message: IRC message object
"""
try:
# Extract the message to announce (everything after the command)
match = re.match(r"^.*?announce\s+(.*?)$", message.text)
if match and match.group(1).strip():
announcement = match.group(1).strip()
# Always send to the joined channel, regardless of where the command was received
if self.channel and hasattr(self.channel, 'name') and self.channel.name.startswith('#'):
await self.channel.message(announcement)
else:
# If we somehow don't have a valid channel, inform the admin
self.logger.warning("No valid channel to announce to")
# Only send this response if in a private message
if hasattr(message.recipient, 'name') and not message.recipient.name.startswith('#'):
await message.recipient.message("No valid channel to announce to.")
else:
await message.recipient.message("Usage: !announce <message>")
except Exception as e:
self.logger.error(f"Error in announce_message: {str(e)}")
try:
await message.recipient.message(f"Error processing announcement: {str(e)}")
except Exception:
pass
self.command_handlers['announce_message'] = announce_message
def _build_command_mappings(self): def _build_command_mappings(self):
"""Build bidirectional mappings between command patterns and method names. """Build bidirectional mappings between command patterns and method names.
@ -1285,44 +1224,19 @@ class IcecastBot:
base_cmds = f"\x02{prefix}np\x02 (current song) • \x02{prefix}help\x02 (this help)" base_cmds = f"\x02{prefix}np\x02 (current song) • \x02{prefix}help\x02 (this help)"
help_text = f"\x02Icecast Bot v{self.VERSION}\x02 | Commands: {base_cmds}" help_text = f"\x02Icecast Bot v{self.VERSION}\x02 | Commands: {base_cmds}"
# Check if we're in a channel
recipient = getattr(message, 'recipient', None)
is_channel = hasattr(recipient, 'name') and recipient.name.startswith('#')
if self.is_admin(message.sender): if self.is_admin(message.sender):
admin_cmds = [] admin_cmds = (
# Basic admin commands
basic_admin = (
f"\x02{prefix}start\x02/\x02stop\x02 (monitoring) • " f"\x02{prefix}start\x02/\x02stop\x02 (monitoring) • "
f"\x02{prefix}reconnect\x02 (stream) • " f"\x02{prefix}reconnect\x02 (stream) • "
f"\x02{prefix}restart\x02 (bot) • " f"\x02{prefix}restart\x02 (bot) • "
f"\x02{prefix}quit\x02 (shutdown)" f"\x02{prefix}quit\x02 (shutdown)"
) )
admin_cmds.append(basic_admin) help_text += f" | Admin: {admin_cmds}"
# Add announce command only in private messages if hasattr(message.recipient, 'name') and message.recipient.name.startswith('#'):
if not is_channel: await message.recipient.message(help_text)
admin_cmds.append(f"\x02{prefix}announce\x02 (send message)")
help_text += f" | Admin: {''.join(admin_cmds)}"
# Send the help text to the appropriate recipient
if not is_channel:
# In private messages, send the response to the sender
self.logger.debug(f"Sending fallback help info to user {message.sender}")
await message.sender.message(help_text)
else:
# In channels, send to the channel
self.logger.debug(f"Sending fallback help info to channel {recipient}")
await recipient.message(help_text)
except Exception as e: except Exception as e:
self.logger.error(f"Error sending fallback help info: {str(e)}") pass
try:
# Try to send an error message back to the user
await message.sender.message(f"Error sending fallback help info: {str(e)}")
except Exception:
pass
def admin_required(self, f): def admin_required(self, f):
"""Decorator to mark a command as requiring admin privileges. """Decorator to mark a command as requiring admin privileges.
@ -1340,16 +1254,12 @@ class IcecastBot:
recipient = getattr(message, 'recipient', None) recipient = getattr(message, 'recipient', None)
is_channel = hasattr(recipient, 'name') and recipient.name.startswith('#') is_channel = hasattr(recipient, 'name') and recipient.name.startswith('#')
# For admin commands, allow them in private messages regardless of allow_private_commands setting if not self.allow_private_commands and not is_channel:
# Just check if the user is an admin return
if not is_channel: # If it's a private message
if not self.is_admin(message.sender):
return # Silently ignore if not an admin
return await f(message, *args, **kwargs) # Allow command if admin
# Channel message handling remains the same
if not self.is_admin(message.sender): if not self.is_admin(message.sender):
await recipient.message("You don't have permission to use this command.") if is_channel:
await recipient.message("You don't have permission to use this command.")
return return
return await f(message, *args, **kwargs) return await f(message, *args, **kwargs)
@ -1358,10 +1268,6 @@ class IcecastBot:
wrapped.__doc__ = f.__doc__ wrapped.__doc__ = f.__doc__
wrapped.__name__ = f.__name__ wrapped.__name__ = f.__name__
# Preserve the is_hidden_command attribute if it exists
if hasattr(f, 'is_hidden_command'):
wrapped.is_hidden_command = f.is_hidden_command
# Add the command pattern to admin_commands set # Add the command pattern to admin_commands set
if f.__doc__ and f.__doc__.strip().startswith('!'): if f.__doc__ and f.__doc__.strip().startswith('!'):
pattern = f.__doc__.strip().split(':', 1)[0].strip('!') pattern = f.__doc__.strip().split(':', 1)[0].strip('!')
@ -1369,22 +1275,6 @@ class IcecastBot:
return wrapped return wrapped
def hidden_command(self, f):
"""Decorator to mark a command as hidden from help output in channels.
Hidden commands will only appear in help output when the help command
is used in a private message.
Args:
f: The command handler function to wrap.
Returns:
The wrapped function with a hidden flag.
"""
# Set a flag on the function to mark it as hidden
f.is_hidden_command = True
return f
async def run_multiple_bots(config_paths: List[str]): async def run_multiple_bots(config_paths: List[str]):
"""Run multiple bot instances concurrently. """Run multiple bot instances concurrently.