2 # -*- coding: utf-8 -*-
11 import subprocess as sp
15 import pymumble.pymumble_py3 as pymumble
16 import variables as var
20 import logging.handlers
22 from packaging import version
27 from database import SettingsDatabase, MusicDatabase
32 from media.playlist import BasePlayList
33 from media.library import MusicLibrary
39 def __init__(self, args):
40 self.log = logging.getLogger("bot")
41 self.log.info("bot: botamusique version %s, starting..." % self.version)
42 signal.signal(signal.SIGINT, self.ctrl_caught)
44 self.volume_set = var.config.getfloat('bot', 'volume')
45 if var.db.has_option('bot', 'volume'):
46 self.volume_set = var.db.getfloat('bot', 'volume')
48 self.volume = self.volume_set
51 self.channel = args.channel
53 self.channel = var.config.get("server", "channel", fallback=None)
56 self.log.setLevel(logging.DEBUG)
57 self.log.debug("Starting in DEBUG loglevel")
59 self.log.setLevel(logging.ERROR)
60 self.log.error("Starting in ERROR loglevel")
63 var.music_folder = util.solve_filepath(var.config.get('bot', 'music_folder'))
64 var.tmp_folder = util.solve_filepath(var.config.get('bot', 'tmp_folder'))
65 var.is_proxified = var.config.getboolean(
66 "webinterface", "is_web_proxified")
70 self.thread_stderr = None
74 self.song_start_at = -1
75 #self.download_threads = []
76 self.wait_for_downloading = False # flag for the loop are waiting for download to complete in the other thread
78 if var.config.getboolean("webinterface", "enabled"):
79 wi_addr = var.config.get("webinterface", "listening_addr")
80 wi_port = var.config.getint("webinterface", "listening_port")
81 tt = threading.Thread(
82 target=start_web_interface, name="WebThread", args=(wi_addr, wi_port))
84 self.log.info('Starting web interface on {}:{}'.format(wi_addr, wi_port))
87 if var.config.getboolean("bot", "auto_check_update"):
88 th = threading.Thread(target=self.check_update, name="UpdateThread")
95 host = var.config.get("server", "host")
100 port = var.config.getint("server", "port")
103 password = args.password
105 password = var.config.get("server", "password")
108 self.channel = args.channel
110 self.channel = var.config.get("server", "channel")
113 certificate = args.certificate
115 certificate = util.solve_filepath(var.config.get("server", "certificate"))
120 tokens = var.config.get("server", "tokens")
121 tokens = tokens.split(',')
124 self.username = args.user
126 self.username = var.config.get("bot", "username")
128 self.mumble = pymumble.Mumble(host, user=self.username, port=port, password=password, tokens=tokens,
129 debug=var.config.getboolean('debug', 'mumbleConnection'), certfile=certificate)
130 self.mumble.callbacks.set_callback(pymumble.constants.PYMUMBLE_CLBK_TEXTMESSAGERECEIVED, self.message_received)
132 self.mumble.set_codec_profile("audio")
133 self.mumble.start() # start the mumble thread
134 self.mumble.is_ready() # wait for the connection
136 self.mumble.users.myself.unmute() # by sure the user is not muted
138 self.mumble.channels.find_by_name(self.channel).move_in()
139 self.mumble.set_bandwidth(200000)
141 self.is_ducking = False
142 self.on_ducking = False
143 self.ducking_release = time.time()
145 if not var.db.has_option("bot", "ducking") and var.config.getboolean("bot", "ducking", fallback=False)\
146 or var.config.getboolean("bot", "ducking"):
147 self.is_ducking = True
148 self.ducking_volume = var.config.getfloat("bot", "ducking_volume", fallback=0.05)
149 self.ducking_volume = var.db.getfloat("bot", "ducking_volume", fallback=self.ducking_volume)
150 self.ducking_threshold = var.config.getfloat("bot", "ducking_threshold", fallback=5000)
151 self.ducking_threshold = var.db.getfloat("bot", "ducking_threshold", fallback=self.ducking_threshold)
152 self.mumble.callbacks.set_callback(pymumble.constants.PYMUMBLE_CLBK_SOUNDRECEIVED, \
153 self.ducking_sound_received)
154 self.mumble.set_receive_sound(True)
157 self._loop_status = 'Idle'
158 self._display_rms = False
161 # Set the CTRL+C shortcut
162 def ctrl_caught(self, signal, frame):
165 "\nSIGINT caught, quitting, {} more to kill".format(2 - self.nb_exit))
169 self.log.info("Forced Quit")
173 def check_update(self):
174 self.log.debug("update: checking for updates...")
175 new_version = util.new_release_version()
176 if version.parse(new_version) > version.parse(self.version):
177 self.log.info("update: new version %s found, current installed version %s." % (new_version, self.version))
178 self.send_msg(constants.strings('new_version_found'))
180 self.log.debug("update: no new version found.")
182 def register_command(self, cmd, handle):
183 cmds = cmd.split(",")
185 command = command.strip()
187 self.cmd_handle[command] = handle
188 self.log.debug("bot: command added: " + command)
190 def set_comment(self):
191 self.mumble.users.myself.comment(var.config.get('bot', 'comment'))
193 # =======================
195 # =======================
197 # All text send to the chat is analysed by this function
198 def message_received(self, text):
199 message = text.message.strip()
200 user = self.mumble.users[text.actor]['name']
202 if var.config.getboolean('commands', 'split_username_at_space'):
203 # in can you use https://github.com/Natenom/mumblemoderator-module-collection/tree/master/os-suffixes ,
204 # you want to split the username
205 user = user.split()[0]
207 if message[0] in var.config.get('commands', 'command_symbol'):
208 # remove the symbol from the message
209 message = message[1:].split(' ', 1)
211 # use the first word as a command, the others one as parameters
216 parameter = message[1].rstrip()
220 self.log.info('bot: received command ' + command + ' - ' + parameter + ' by ' + user)
222 # Anti stupid guy function
223 if not self.is_admin(user) and not var.config.getboolean('bot', 'allow_other_channel_message') \
224 and self.mumble.users[text.actor]['channel_id'] != self.mumble.users.myself['channel_id']:
225 self.mumble.users[text.actor].send_text_message(
226 constants.strings('not_in_my_channel'))
229 if not self.is_admin(user) and not var.config.getboolean('bot', 'allow_private_message') and text.session:
230 self.mumble.users[text.actor].send_text_message(
231 constants.strings('pm_not_allowed'))
234 for i in var.db.items("user_ban"):
235 if user.lower() == i[0]:
236 self.mumble.users[text.actor].send_text_message(
237 constants.strings('user_ban'))
241 for i in var.db.items("url_ban"):
242 if util.get_url_from_input(parameter.lower()) == i[0]:
243 self.mumble.users[text.actor].send_text_message(
244 constants.strings('url_ban'))
250 if command in self.cmd_handle:
251 command_exc = command
252 self.cmd_handle[command](self, user, text, command, parameter)
255 cmds = self.cmd_handle.keys()
258 if cmd.startswith(command):
261 if len(matches) == 1:
262 self.log.info("bot: {:s} matches {:s}".format(command, matches[0]))
263 command_exc = matches[0]
264 self.cmd_handle[command_exc](self, user, text, command_exc, parameter)
265 elif len(matches) > 1:
266 self.mumble.users[text.actor].send_text_message(
267 constants.strings('which_command', commands="<br>".join(matches)))
269 self.mumble.users[text.actor].send_text_message(
270 constants.strings('bad_command', command=command))
272 error_traceback = traceback.format_exc()
273 error = error_traceback.rstrip().split("\n")[-1]
274 self.log.error("bot: command %s failed with error: %s\n" % (command_exc, error_traceback))
275 self.send_msg(constants.strings('error_executing_command', command=command_exc, error=error), text)
277 def send_msg(self, msg, text=None):
278 msg = msg.encode('utf-8', 'ignore').decode('utf-8')
279 # text if the object message, contain information if direct message or channel message
280 if not text or not text.session:
281 own_channel = self.mumble.channels[self.mumble.users.myself['channel_id']]
282 own_channel.send_text_message(msg)
284 self.mumble.users[text.actor].send_text_message(msg)
286 def is_admin(self, user):
287 list_admin = var.config.get('bot', 'admin').rstrip().split(';')
288 if user in list_admin:
293 # =======================
294 # Launch and Download
295 # =======================
297 def launch_music(self):
298 if var.playlist.is_empty():
300 assert self.wait_for_downloading == False
302 music_wrapper = var.playlist.current_item()
303 uri = music_wrapper.uri()
305 self.log.info("bot: play music " + music_wrapper.format_debug_string())
307 if var.config.getboolean('bot', 'announce_current_music'):
308 self.send_msg(music_wrapper.format_current_playing())
310 if var.config.getboolean('debug', 'ffmpeg'):
311 ffmpeg_debug = "debug"
313 ffmpeg_debug = "warning"
315 command = ("ffmpeg", '-v', ffmpeg_debug, '-nostdin', '-i',
316 uri, '-ac', '1', '-f', 's16le', '-ar', '48000', '-')
317 self.log.debug("bot: execute ffmpeg command: " + " ".join(command))
319 # The ffmpeg process is a thread
320 # prepare pipe for catching stderr of ffmpeg
321 pipe_rd, pipe_wd = os.pipe()
322 util.pipe_no_wait(pipe_rd) # Let the pipe work in non-blocking mode
323 self.thread_stderr = os.fdopen(pipe_rd)
324 self.thread = sp.Popen(command, stdout=sp.PIPE, stderr=pipe_wd, bufsize=480)
325 self.is_pause = False
326 self.song_start_at = -1
328 self.last_volume_cycle_time = time.time()
330 def async_download_next(self):
331 # Function start if the next music isn't ready
332 # Do nothing in case the next music is already downloaded
333 self.log.debug("bot: Async download next asked ")
334 while var.playlist.next_item() and var.playlist.next_item().type in ['url', 'url_from_playlist']:
335 # usually, all validation will be done when adding to the list.
336 # however, for performance consideration, youtube playlist won't be validate when added.
337 # the validation has to be done here.
338 next = var.playlist.next_item()
340 if not next.is_ready():
344 var.playlist.remove_by_id(next.id)
347 # =======================
349 # =======================
351 # Main loop of the Bot
354 while not self.exit and self.mumble.is_alive():
356 while self.thread and self.mumble.sound_output.get_buffer_size() > 0.5 and not self.exit:
357 # If the buffer isn't empty, I cannot send new music part, so I wait
358 self._loop_status = 'Wait for buffer %.3f' % self.mumble.sound_output.get_buffer_size()
362 # I get raw from ffmpeg thread
363 # move playhead forward
364 self._loop_status = 'Reading raw'
365 if self.song_start_at == -1:
366 self.song_start_at = time.time() - self.playhead
367 self.playhead = time.time() - self.song_start_at
369 raw_music = self.thread.stdout.read(480)
372 stderr_msg = self.thread_stderr.readline()
374 self.log.debug("ffmpeg: " + stderr_msg.strip("\n"))
379 # Adjust the volume and send it to mumble
381 self.mumble.sound_output.add_sound(
382 audioop.mul(raw_music, 2, self.volume))
388 if not self.is_pause and (self.thread is None or not raw_music):
389 # ffmpeg thread has gone. indicate that last song has finished. move to the next song.
390 if not self.wait_for_downloading:
391 if var.playlist.next():
392 current = var.playlist.current_item()
393 if current.validate():
394 if current.is_ready():
396 self.async_download_next()
398 self.log.info("bot: current music isn't ready, start downloading.")
399 self.wait_for_downloading = True
400 current.async_prepare()
401 self.send_msg(constants.strings('download_in_progress', item=current.format_short_string()))
403 var.playlist.remove_by_id(current.id)
405 self._loop_status = 'Empty queue'
407 current = var.playlist.current_item()
409 if current.is_ready():
410 self.wait_for_downloading = False
412 self.async_download_next()
413 elif current.is_failed():
414 var.playlist.remove_by_id(current.id)
416 self._loop_status = 'Wait for downloading'
418 self.wait_for_downloading = False
420 while self.mumble.sound_output.get_buffer_size() > 0:
421 # Empty the buffer before exit
426 self._loop_status = "exited"
427 if var.config.getboolean('bot', 'save_playlist', fallback=True):
428 self.log.info("bot: save playlist into database")
431 def volume_cycle(self):
432 delta = time.time() - self.last_volume_cycle_time
434 if self.on_ducking and self.ducking_release < time.time():
435 self._clear_pymumble_soundqueue()
436 self.on_ducking = False
440 if self.is_ducking and self.on_ducking:
441 self.volume = (self.volume - self.ducking_volume) * math.exp(- delta / 0.2) + self.ducking_volume
443 self.volume = self.volume_set - (self.volume_set - self.volume) * math.exp(- delta / 0.5)
445 self.last_volume_cycle_time = time.time()
447 def ducking_sound_received(self, user, sound):
448 rms = audioop.rms(sound.pcm, 2)
449 self._max_rms = max(rms, self._max_rms)
450 if self._display_rms:
451 if rms < self.ducking_threshold:
452 print('%6d/%6d ' % (rms, self._max_rms) + '-'*int(rms/200), end='\r')
454 print('%6d/%6d ' % (rms, self._max_rms) + '-'*int(self.ducking_threshold/200) \
455 + '+'*int((rms - self.ducking_threshold)/200), end='\r')
457 if rms > self.ducking_threshold:
458 if self.on_ducking is False:
459 self.log.debug("bot: ducking triggered")
460 self.on_ducking = True
461 self.ducking_release = time.time() + 1 # ducking release after 1s
463 # =======================
465 # =======================
468 # Kill the ffmpeg thread and empty the playlist
473 self.log.info("bot: music stopped. playlist trashed.")
478 self.log.info("bot: music stopped.")
481 # Kill the ffmpeg thread
485 self.song_start_at = -1
489 # Kill the ffmpeg thread
491 self.pause_at_id = var.playlist.current_item()
495 self.song_start_at = -1
496 self.log.info("bot: music paused at %.2f seconds." % self.playhead)
499 self.is_pause = False
501 if var.playlist.current_index == -1:
504 music_wrapper = var.playlist.current_item()
506 if not music_wrapper or not music_wrapper.id == self.pause_at_id or not music_wrapper.is_ready():
510 if var.config.getboolean('debug', 'ffmpeg'):
511 ffmpeg_debug = "debug"
513 ffmpeg_debug = "warning"
515 self.log.info("bot: resume music at %.2f seconds" % self.playhead)
517 uri = music_wrapper.uri()
519 command = ("ffmpeg", '-v', ffmpeg_debug, '-nostdin', '-ss', "%f" % self.playhead, '-i',
520 uri, '-ac', '1', '-f', 's16le', '-ar', '48000', '-')
523 if var.config.getboolean('bot', 'announce_current_music'):
524 self.send_msg(var.playlist.current_item().format_current_playing())
526 self.log.info("bot: execute ffmpeg command: " + " ".join(command))
527 # The ffmpeg process is a thread
528 # prepare pipe for catching stderr of ffmpeg
529 pipe_rd, pipe_wd = os.pipe()
530 util.pipe_no_wait(pipe_rd) # Let the pipe work in non-blocking mode
531 self.thread_stderr = os.fdopen(pipe_rd)
532 self.thread = sp.Popen(command, stdout=sp.PIPE, stderr=pipe_wd, bufsize=480)
533 self.last_volume_cycle_time = time.time()
534 self.pause_at_id = ""
537 # TODO: this is a temporary workaround for issue #44 of pymumble.
538 def _clear_pymumble_soundqueue(self):
539 for id, user in self.mumble.users.items():
540 user.sound.lock.acquire()
541 user.sound.queue.clear()
542 user.sound.lock.release()
543 self.log.debug("bot: pymumble soundqueue cleared.")
547 def start_web_interface(addr, port):
552 werkzeug_logger = logging.getLogger('werkzeug')
553 logfile = util.solve_filepath(var.config.get('webinterface', 'web_logfile'))
556 handler = logging.handlers.RotatingFileHandler(logfile, mode='a', maxBytes=10240) # Rotate after 10KB
558 handler = logging.StreamHandler()
560 werkzeug_logger.addHandler(handler)
562 interface.init_proxy()
563 interface.web.env = 'development'
564 interface.web.run(port=port, host=addr)
567 if __name__ == '__main__':
568 parser = argparse.ArgumentParser(
569 description='Bot for playing music on Mumble')
572 parser.add_argument("--config", dest='config', type=str, default='configuration.ini',
573 help='Load configuration from this file. Default: configuration.ini')
574 parser.add_argument("--db", dest='db', type=str,
575 default=None, help='database file. Default: database.db')
577 parser.add_argument("-q", "--quiet", dest="quiet",
578 action="store_true", help="Only Error logs")
579 parser.add_argument("-v", "--verbose", dest="verbose",
580 action="store_true", help="Show debug log")
583 parser.add_argument("-s", "--server", dest="host",
584 type=str, help="Hostname of the Mumble server")
585 parser.add_argument("-u", "--user", dest="user",
586 type=str, help="Username for the bot")
587 parser.add_argument("-P", "--password", dest="password",
588 type=str, help="Server password, if required")
589 parser.add_argument("-T", "--tokens", dest="tokens",
590 type=str, help="Server tokens, if required")
591 parser.add_argument("-p", "--port", dest="port",
592 type=int, help="Port for the Mumble server")
593 parser.add_argument("-c", "--channel", dest="channel",
594 type=str, help="Default channel for the bot")
595 parser.add_argument("-C", "--cert", dest="certificate",
596 type=str, default=None, help="Certificate file")
598 args = parser.parse_args()
600 config = configparser.ConfigParser(interpolation=None, allow_no_value=True)
601 parsed_configs = config.read([util.solve_filepath('configuration.default.ini'), util.solve_filepath(args.config)],
603 var.dbfile = args.db if args.db is not None else util.solve_filepath(
604 config.get("bot", "database_path", fallback="database.db"))
606 if len(parsed_configs) == 0:
607 logging.error('Could not read configuration from file \"{}\"'.format(args.config))
611 var.db = SettingsDatabase(var.dbfile)
614 bot_logger = logging.getLogger("bot")
615 formatter = logging.Formatter('[%(asctime)s %(levelname)s %(threadName)s] %(message)s', "%b %d %H:%M:%S")
616 bot_logger.setLevel(logging.INFO)
618 logfile = util.solve_filepath(var.config.get('bot', 'logfile'))
621 handler = logging.handlers.RotatingFileHandler(logfile, mode='a', maxBytes=10240) # Rotate after 10KB
623 handler = logging.StreamHandler()
625 handler.setFormatter(formatter)
626 bot_logger.addHandler(handler)
627 var.bot_logger = bot_logger
629 if var.config.get("bot", "save_music_library", fallback=True):
630 var.music_db = MusicDatabase(var.dbfile)
632 var.music_db = MusicDatabase(":memory:")
634 var.library = MusicLibrary(var.music_db)
638 if var.db.has_option("playlist", "playback_mode"):
639 playback_mode = var.db.get('playlist', 'playback_mode')
641 playback_mode = var.config.get('bot', 'playback_mode', fallback="one-shot")
643 if playback_mode in ["one-shot", "repeat", "random"]:
644 var.playlist = media.playlist.get_playlist(playback_mode)
646 raise KeyError("Unknown playback mode '%s'" % playback_mode)
648 var.bot = MumbleBot(args)
649 command.register_all_commands(var.bot)
652 if var.config.getboolean('bot', 'save_playlist', fallback=True):
653 var.bot_logger.info("bot: load playlist from previous session")
656 # Start the main loop.