diff --git a/bragi.py b/bragi.py index b5008d4..4e80cef 100755 --- a/bragi.py +++ b/bragi.py @@ -33,9 +33,9 @@ import command import constants import media.playlist from constants import tr_cli as tr -from database import SettingsDatabase, MusicDatabase, DatabaseMigration +from database import SettingsDatabase, MusicDatabase, DatabaseMigration, Condition from media.item import ValidationFailedError, PreparationFailedError -from media.cache import MusicCache +from media.cache import MusicCache, get_cached_wrapper_from_scrap, get_cached_wrapper_from_dict class MumbleBot: @@ -76,6 +76,7 @@ class MumbleBot: # self.on_interrupting = False + self.is_ready = False # Flag to prevent join sounds during bot initialization if args.host: host = args.host @@ -154,6 +155,10 @@ class MumbleBot: self.bots = set(bots.split(',')) self._user_in_channel = self.get_user_count_in_channel() + # Mark bot as ready - initialization complete, can now play join sounds + self.is_ready = True + self.log.info("bot: Initialization complete, ready to play join sounds") + # ====== Volume ====== self.volume_helper = util.VolumeHelper() @@ -190,12 +195,20 @@ class MumbleBot: assert var.config.get("bot", "when_nobody_in_channel") in ['pause', 'pause_resume', 'stop', 'nothing', ''], \ "Unknown action for when_nobody_in_channel" - if var.config.get("bot", "when_nobody_in_channel") in ['pause', 'pause_resume', 'stop']: - user_change_callback = \ - lambda user, action: threading.Thread(target=self.users_changed, - args=(user, action), daemon=True).start() - self.mumble.callbacks.set_callback(pymumble.constants.PYMUMBLE_CLBK_USERREMOVED, user_change_callback) - self.mumble.callbacks.set_callback(pymumble.constants.PYMUMBLE_CLBK_USERUPDATED, user_change_callback) + # Always register user change callback for join sounds and when_nobody_in_channel features + # USERUPDATED and USERREMOVED pass (user, action) + user_change_callback = \ + lambda user, action: threading.Thread(target=self.users_changed, + args=(user, action), daemon=True).start() + # USERCREATED only passes (user) with no action + user_created_callback = \ + lambda user: threading.Thread(target=self.users_changed, + args=(user, None), daemon=True).start() + + self.mumble.callbacks.set_callback(pymumble.constants.PYMUMBLE_CLBK_USERREMOVED, user_change_callback) + self.mumble.callbacks.set_callback(pymumble.constants.PYMUMBLE_CLBK_USERUPDATED, user_change_callback) + self.mumble.callbacks.set_callback(pymumble.constants.PYMUMBLE_CLBK_USERCREATED, user_created_callback) + self.log.info("bot: User change callbacks registered for join sounds") # Debug use self._loop_status = 'Idle' @@ -389,7 +402,76 @@ class MumbleBot: return len(users) + def _play_join_sound(self, username): + """Play a configured join sound for a user if bot is idle""" + self.log.debug(f"bot: _play_join_sound called for username: {username}") + + # Only play if bot is idle (not currently playing music) + if self.thread is not None: + self.log.debug(f"bot: Not playing join sound for {username} - bot is currently playing music") + return + + # Check if user has a configured join sound + if not var.config.has_option('user_join_sounds', username): + self.log.debug(f"bot: No join sound configured for {username}") + return + + sound_config = var.config.get('user_join_sounds', username).strip() + self.log.debug(f"bot: Found join sound config for {username}: {sound_config}") + if not sound_config: + self.log.debug(f"bot: Join sound config for {username} is empty") + return + + try: + # Determine if it's a URL or file path + if sound_config.startswith('http://') or sound_config.startswith('https://'): + # It's a URL + self.log.info(f'bot: Playing join sound URL for {username}: {sound_config}') + music_wrapper = get_cached_wrapper_from_scrap(type='url', url=sound_config, user='system') + else: + # It's a file path - search database for first match + matches = var.music_db.query_music(Condition() + .and_equal('type', 'file') + .and_like('path', '%' + sound_config + '%', case_sensitive=False)) + if not matches: + self.log.warning(f'bot: Join sound file not found for {username}: {sound_config}') + return + + # Use first match + self.log.info(f'bot: Playing join sound file for {username}: {matches[0]["path"]}') + music_wrapper = get_cached_wrapper_from_dict(matches[0], 'system') + + # Add to playlist + var.playlist.append(music_wrapper) + + except Exception as e: + self.log.error(f'bot: Error playing join sound for {username}: {e}') + def users_changed(self, user, message): + self.log.info(f"bot: users_changed called - user: {user.get('name', 'unknown')}, message type: {type(message)}, message: {message}") + + # Don't play join sounds during bot initialization + if not self.is_ready: + self.log.debug(f"bot: Skipping join sound check - bot not ready yet") + # Continue with normal user count logic below + # Check if user joined/created in the bot's channel + # For USERCREATED: message is None (new user connecting) + # For USERUPDATED: message is the actions dict containing changed fields + elif message is None: + # User was created (connected to server), check if they're in our channel + bot_channel_id = self.mumble.users.myself['channel_id'] + if user.get('channel_id') == bot_channel_id and user.get('name') != self.mumble.users.myself.get('name'): + self.log.info(f"bot: User {user['name']} connected in our channel, attempting to play join sound") + self._play_join_sound(user['name']) + elif isinstance(message, dict) and 'channel_id' in message: + # User changed channels + bot_channel_id = self.mumble.users.myself['channel_id'] + self.log.debug(f"bot: User {user.get('name', 'unknown')} changed to channel {user['channel_id']}, bot is in {bot_channel_id}") + if user['channel_id'] == bot_channel_id and user.get('name') != self.mumble.users.myself.get('name'): + # User joined our channel, check for join sound + self.log.info(f"bot: User {user['name']} joined our channel, attempting to play join sound") + self._play_join_sound(user['name']) + # only check if there is one more user currently in the channel # else when the music is paused and somebody joins, music would start playing again user_count = self.get_user_count_in_channel() diff --git a/configuration.default.ini b/configuration.default.ini index 6f73e39..af235a7 100644 --- a/configuration.default.ini +++ b/configuration.default.ini @@ -137,3 +137,17 @@ volume = volume yt_play = yplay yt_search = ysearch + +[user_join_sounds] +# Play a sound when a specific user joins the channel where the bot is located +# Sounds only play when the bot is idle (not currently playing music) +# Format: username = file_path_or_url +# +# Examples: +# Storm = misc/welcome.opus +# Jack = https://site.com/soundboard/howling.wav +# Alice = sounds/hello.mp3 +# +# File paths use fuzzy matching - the first matching file in the music database will be used +# URLs are downloaded and played like the !url command + diff --git a/util.py b/util.py index 1916ce0..d7139d1 100644 --- a/util.py +++ b/util.py @@ -400,12 +400,12 @@ def get_supported_language(): def set_logging_formatter(handler: logging.Handler, logging_level): if logging_level == logging.DEBUG: formatter = logging.Formatter( - "[%(asctime)s] > [%(threadName)s] > " - "[%(filename)s:%(lineno)d] %(message)s" + "%(message)s [%(asctime)s] > [%(threadName)s] > " + "[%(filename)s:%(lineno)d]" ) else: formatter = logging.Formatter( - '[%(asctime)s %(levelname)s] %(message)s', "%b %d %H:%M:%S") + '%(message)s [%(asctime)s %(levelname)s]', "%b %d %H:%M:%S") handler.setFormatter(formatter) @@ -534,7 +534,7 @@ def check_extra_config(config, template): extra = [] for key in config.sections(): - if key in ['radio']: + if key in ['radio', 'user_join_sounds']: continue for opt in config.options(key): if not template.has_option(key, opt):