2 # -*- coding: utf-8 -*-
11 import subprocess as sp
15 import pymumble.pymumble_py3 as pymumble
16 import variables as var
18 import logging.handlers
20 from packaging import version
25 from database import SettingsDatabase, MusicDatabase
27 from media.playlist import BasePlaylist
28 from media.cache import MusicCache
34 def __init__(self, args):
35 self.log = logging.getLogger("bot")
36 self.log.info("bot: botamusique version %s, starting..." % self.version)
37 signal.signal(signal.SIGINT, self.ctrl_caught)
39 self.volume_set = var.config.getfloat('bot', 'volume')
40 if var.db.has_option('bot', 'volume'):
41 self.volume_set = var.db.getfloat('bot', 'volume')
43 self.volume = self.volume_set
46 self.channel = args.channel
48 self.channel = var.config.get("server", "channel", fallback=None)
51 self.log.setLevel(logging.DEBUG)
52 self.log.debug("Starting in DEBUG loglevel")
54 self.log.setLevel(logging.ERROR)
55 self.log.error("Starting in ERROR loglevel")
58 var.music_folder = util.solve_filepath(var.config.get('bot', 'music_folder'))
59 var.tmp_folder = util.solve_filepath(var.config.get('bot', 'tmp_folder'))
60 var.is_proxified = var.config.getboolean(
61 "webinterface", "is_web_proxified")
65 self.thread_stderr = None
69 self.song_start_at = -1
70 self.last_ffmpeg_err = ""
71 self.read_pcm_size = 0
72 # self.download_threads = []
73 self.wait_for_downloading = False # flag for the loop are waiting for download to complete in the other thread
75 if var.config.getboolean("webinterface", "enabled"):
76 wi_addr = var.config.get("webinterface", "listening_addr")
77 wi_port = var.config.getint("webinterface", "listening_port")
78 tt = threading.Thread(
79 target=start_web_interface, name="WebThread", args=(wi_addr, wi_port))
81 self.log.info('Starting web interface on {}:{}'.format(wi_addr, wi_port))
84 if var.config.getboolean("bot", "auto_check_update"):
85 th = threading.Thread(target=self.check_update, name="UpdateThread")
92 host = var.config.get("server", "host")
97 port = var.config.getint("server", "port")
100 password = args.password
102 password = var.config.get("server", "password")
105 self.channel = args.channel
107 self.channel = var.config.get("server", "channel")
110 certificate = args.certificate
112 certificate = util.solve_filepath(var.config.get("server", "certificate"))
117 tokens = var.config.get("server", "tokens")
118 tokens = tokens.split(',')
121 self.username = args.user
123 self.username = var.config.get("bot", "username")
125 self.mumble = pymumble.Mumble(host, user=self.username, port=port, password=password, tokens=tokens,
126 debug=var.config.getboolean('debug', 'mumbleConnection'), certfile=certificate)
127 self.mumble.callbacks.set_callback(pymumble.constants.PYMUMBLE_CLBK_TEXTMESSAGERECEIVED, self.message_received)
129 self.mumble.set_codec_profile("audio")
130 self.mumble.start() # start the mumble thread
131 self.mumble.is_ready() # wait for the connection
133 self.mumble.users.myself.unmute() # by sure the user is not muted
135 self.mumble.channels.find_by_name(self.channel).move_in()
136 self.mumble.set_bandwidth(200000)
138 self.is_ducking = False
139 self.on_ducking = False
140 self.ducking_release = time.time()
142 if not var.db.has_option("bot", "ducking") and var.config.getboolean("bot", "ducking", fallback=False)\
143 or var.config.getboolean("bot", "ducking"):
144 self.is_ducking = True
145 self.ducking_volume = var.config.getfloat("bot", "ducking_volume", fallback=0.05)
146 self.ducking_volume = var.db.getfloat("bot", "ducking_volume", fallback=self.ducking_volume)
147 self.ducking_threshold = var.config.getfloat("bot", "ducking_threshold", fallback=5000)
148 self.ducking_threshold = var.db.getfloat("bot", "ducking_threshold", fallback=self.ducking_threshold)
149 self.mumble.callbacks.set_callback(pymumble.constants.PYMUMBLE_CLBK_SOUNDRECEIVED,
150 self.ducking_sound_received)
151 self.mumble.set_receive_sound(True)
154 self._loop_status = 'Idle'
155 self._display_rms = False
158 # Set the CTRL+C shortcut
159 def ctrl_caught(self, signal, frame):
162 "\nSIGINT caught, quitting, {} more to kill".format(2 - self.nb_exit))
166 self.log.info("Forced Quit")
170 if var.config.getboolean('bot', 'save_playlist', fallback=True) \
171 and var.config.get("bot", "save_music_library", fallback=True):
172 self.log.info("bot: save playlist into database")
175 def check_update(self):
176 self.log.debug("update: checking for updates...")
177 new_version = util.new_release_version()
178 if version.parse(new_version) > version.parse(self.version):
179 self.log.info("update: new version %s found, current installed version %s." % (new_version, self.version))
180 self.send_msg(constants.strings('new_version_found'))
182 self.log.debug("update: no new version found.")
184 def register_command(self, cmd, handle, no_partial_match=False, access_outside_channel=False):
185 cmds = cmd.split(",")
187 command = command.strip()
189 self.cmd_handle[command] = {'handle': handle,
190 'partial_match': not no_partial_match,
191 'access_outside_channel': access_outside_channel}
192 self.log.debug("bot: command added: " + command)
194 def set_comment(self):
195 self.mumble.users.myself.comment(var.config.get('bot', 'comment'))
197 # =======================
199 # =======================
201 # All text send to the chat is analysed by this function
202 def message_received(self, text):
203 message = text.message.strip()
204 user = self.mumble.users[text.actor]['name']
206 if var.config.getboolean('commands', 'split_username_at_space'):
207 # in can you use https://github.com/Natenom/mumblemoderator-module-collection/tree/master/os-suffixes ,
208 # you want to split the username
209 user = user.split()[0]
211 if message[0] in var.config.get('commands', 'command_symbol'):
212 # remove the symbol from the message
213 message = message[1:].split(' ', 1)
215 # use the first word as a command, the others one as parameters
217 command = message[0].lower()
220 parameter = message[1].rstrip()
224 self.log.info('bot: received command ' + command + ' - ' + parameter + ' by ' + user)
226 # Anti stupid guy function
227 if not self.is_admin(user) and not var.config.getboolean('bot', 'allow_private_message') and text.session:
228 self.mumble.users[text.actor].send_text_message(
229 constants.strings('pm_not_allowed'))
232 for i in var.db.items("user_ban"):
233 if user.lower() == i[0]:
234 self.mumble.users[text.actor].send_text_message(
235 constants.strings('user_ban'))
238 if not self.is_admin(user) and parameter:
239 input_url = util.get_url_from_input(parameter)
241 for i in var.db.items("url_ban"):
242 if input_url == i[0]:
243 self.mumble.users[text.actor].send_text_message(
244 constants.strings('url_ban'))
249 if command in self.cmd_handle:
250 command_exc = command
252 if not self.cmd_handle[command]['access_outside_channel'] \
253 and not self.is_admin(user) \
254 and not var.config.getboolean('bot', 'allow_other_channel_message') \
255 and self.mumble.users[text.actor]['channel_id'] != self.mumble.users.myself['channel_id']:
256 self.mumble.users[text.actor].send_text_message(
257 constants.strings('not_in_my_channel'))
260 self.cmd_handle[command]['handle'](self, user, text, command, parameter)
263 cmds = self.cmd_handle.keys()
266 if cmd.startswith(command) and self.cmd_handle[cmd]['partial_match']:
269 if len(matches) == 1:
270 self.log.info("bot: {:s} matches {:s}".format(command, matches[0]))
271 command_exc = matches[0]
273 if not self.cmd_handle[command_exc]['access_outside_channel'] \
274 and not self.is_admin(user) \
275 and not var.config.getboolean('bot', 'allow_other_channel_message') \
276 and self.mumble.users[text.actor]['channel_id'] != self.mumble.users.myself[
278 self.mumble.users[text.actor].send_text_message(
279 constants.strings('not_in_my_channel'))
282 self.cmd_handle[command_exc]['handle'](self, user, text, command_exc, parameter)
283 elif len(matches) > 1:
284 self.mumble.users[text.actor].send_text_message(
285 constants.strings('which_command', commands="<br>".join(matches)))
287 self.mumble.users[text.actor].send_text_message(
288 constants.strings('bad_command', command=command))
290 error_traceback = traceback.format_exc()
291 error = error_traceback.rstrip().split("\n")[-1]
292 self.log.error("bot: command %s failed with error: %s\n" % (command_exc, error_traceback))
293 self.send_msg(constants.strings('error_executing_command', command=command_exc, error=error), text)
295 def send_msg(self, msg, text=None):
296 msg = msg.encode('utf-8', 'ignore').decode('utf-8')
297 # text if the object message, contain information if direct message or channel message
299 own_channel = self.mumble.channels[self.mumble.users.myself['channel_id']]
300 own_channel.send_text_message(msg)
302 self.mumble.users[text.actor].send_text_message(msg)
304 def is_admin(self, user):
305 list_admin = var.config.get('bot', 'admin').rstrip().split(';')
306 if user in list_admin:
311 # =======================
312 # Launch and Download
313 # =======================
315 def launch_music(self):
316 if var.playlist.is_empty():
318 assert self.wait_for_downloading is False
320 music_wrapper = var.playlist.current_item()
321 uri = music_wrapper.uri()
323 self.log.info("bot: play music " + music_wrapper.format_debug_string())
325 if var.config.getboolean('bot', 'announce_current_music'):
326 self.send_msg(music_wrapper.format_current_playing())
328 if var.config.getboolean('debug', 'ffmpeg'):
329 ffmpeg_debug = "debug"
331 ffmpeg_debug = "warning"
333 command = ("ffmpeg", '-v', ffmpeg_debug, '-nostdin', '-i',
334 uri, '-ac', '1', '-f', 's16le', '-ar', '48000', '-')
335 self.log.debug("bot: execute ffmpeg command: " + " ".join(command))
337 # The ffmpeg process is a thread
338 # prepare pipe for catching stderr of ffmpeg
339 pipe_rd, pipe_wd = os.pipe()
340 util.pipe_no_wait(pipe_rd) # Let the pipe work in non-blocking mode
341 self.thread_stderr = os.fdopen(pipe_rd)
342 self.thread = sp.Popen(command, stdout=sp.PIPE, stderr=pipe_wd, bufsize=480)
343 self.is_pause = False
344 self.read_pcm_size = 0
345 self.song_start_at = -1
347 self.last_volume_cycle_time = time.time()
349 def async_download_next(self):
350 # Function start if the next music isn't ready
351 # Do nothing in case the next music is already downloaded
352 self.log.debug("bot: Async download next asked ")
353 while var.playlist.next_item() and var.playlist.next_item().type in ['url', 'url_from_playlist']:
354 # usually, all validation will be done when adding to the list.
355 # however, for performance consideration, youtube playlist won't be validate when added.
356 # the validation has to be done here.
357 next = var.playlist.next_item()
359 if not next.is_ready():
360 var.playlist.async_prepare(var.playlist.next_index())
363 var.playlist.remove_by_id(next.id)
364 var.cache.free_and_delete(next.id)
366 # =======================
368 # =======================
370 # Main loop of the Bot
373 while not self.exit and self.mumble.is_alive():
375 while self.thread and self.mumble.sound_output.get_buffer_size() > 0.5 and not self.exit:
376 # If the buffer isn't empty, I cannot send new music part, so I wait
377 self._loop_status = 'Wait for buffer %.3f' % self.mumble.sound_output.get_buffer_size()
381 # I get raw from ffmpeg thread
382 # move playhead forward
383 self._loop_status = 'Reading raw'
384 if self.song_start_at == -1:
385 self.song_start_at = time.time() - self.playhead
386 self.playhead = time.time() - self.song_start_at
388 raw_music = self.thread.stdout.read(480)
389 self.read_pcm_size += 480
392 self.last_ffmpeg_err = self.thread_stderr.readline()
393 if self.last_ffmpeg_err:
394 self.log.debug("ffmpeg: " + self.last_ffmpeg_err.strip("\n"))
399 # Adjust the volume and send it to mumble
401 self.mumble.sound_output.add_sound(
402 audioop.mul(raw_music, 2, self.volume))
408 if not self.is_pause and (self.thread is None or not raw_music):
409 # ffmpeg thread has gone. indicate that last song has finished, or something is wrong.
410 if self.read_pcm_size < 481 and len(var.playlist) > 0 and var.playlist.current_index != -1 \
411 and self.last_ffmpeg_err:
412 current = var.playlist.current_item()
413 self.log.error("bot: cannot play music %s", current.format_debug_string())
414 self.log.error("bot: with ffmpeg error: %s", self.last_ffmpeg_err)
415 self.last_ffmpeg_err = ""
417 self.send_msg(constants.strings('unable_play', item=current.format_short_string()))
418 var.playlist.remove_by_id(current.id)
419 var.cache.free_and_delete(current.id)
421 # move to the next song.
422 if not self.wait_for_downloading:
423 if var.playlist.next():
424 current = var.playlist.current_item()
425 if current.validate():
426 if current.is_ready():
428 self.async_download_next()
430 self.log.info("bot: current music isn't ready, start downloading.")
431 self.wait_for_downloading = True
432 var.playlist.async_prepare(var.playlist.current_index)
433 self.send_msg(constants.strings('download_in_progress', item=current.format_short_string()))
435 var.playlist.remove_by_id(current.id)
436 var.cache.free_and_delete(current.id)
438 self._loop_status = 'Empty queue'
440 current = var.playlist.current_item()
442 if current.is_ready():
443 self.wait_for_downloading = False
444 var.playlist.version += 1
446 self.async_download_next()
447 elif current.is_failed():
448 var.playlist.remove_by_id(current.id)
450 self._loop_status = 'Wait for downloading'
452 self.wait_for_downloading = False
454 while self.mumble.sound_output.get_buffer_size() > 0:
455 # Empty the buffer before exit
460 self._loop_status = "exited"
461 if var.config.getboolean('bot', 'save_playlist', fallback=True) \
462 and var.config.get("bot", "save_music_library", fallback=True):
463 self.log.info("bot: save playlist into database")
466 def volume_cycle(self):
467 delta = time.time() - self.last_volume_cycle_time
469 if self.on_ducking and self.ducking_release < time.time():
470 self.on_ducking = False
474 if self.is_ducking and self.on_ducking:
475 self.volume = (self.volume - self.ducking_volume) * math.exp(- delta / 0.2) + self.ducking_volume
477 self.volume = self.volume_set - (self.volume_set - self.volume) * math.exp(- delta / 0.5)
479 self.last_volume_cycle_time = time.time()
481 def ducking_sound_received(self, user, sound):
482 rms = audioop.rms(sound.pcm, 2)
483 self._max_rms = max(rms, self._max_rms)
484 if self._display_rms:
485 if rms < self.ducking_threshold:
486 print('%6d/%6d ' % (rms, self._max_rms) + '-'*int(rms/200), end='\r')
488 print('%6d/%6d ' % (rms, self._max_rms) + '-'*int(self.ducking_threshold/200)
489 + '+'*int((rms - self.ducking_threshold)/200), end='\r')
491 if rms > self.ducking_threshold:
492 if self.on_ducking is False:
493 self.log.debug("bot: ducking triggered")
494 self.on_ducking = True
495 self.ducking_release = time.time() + 1 # ducking release after 1s
497 # =======================
499 # =======================
502 # Kill the ffmpeg thread and empty the playlist
507 self.log.info("bot: music stopped. playlist trashed.")
512 self.log.info("bot: music stopped.")
515 # Kill the ffmpeg thread
519 self.song_start_at = -1
523 # Kill the ffmpeg thread
525 self.pause_at_id = var.playlist.current_item().id
529 self.song_start_at = -1
530 self.log.info("bot: music paused at %.2f seconds." % self.playhead)
533 self.is_pause = False
535 if var.playlist.current_index == -1:
538 music_wrapper = var.playlist.current_item()
540 if not music_wrapper or not music_wrapper.id == self.pause_at_id or not music_wrapper.is_ready():
544 if var.config.getboolean('debug', 'ffmpeg'):
545 ffmpeg_debug = "debug"
547 ffmpeg_debug = "warning"
549 self.log.info("bot: resume music at %.2f seconds" % self.playhead)
551 uri = music_wrapper.uri()
553 command = ("ffmpeg", '-v', ffmpeg_debug, '-nostdin', '-ss', "%f" % self.playhead, '-i',
554 uri, '-ac', '1', '-f', 's16le', '-ar', '48000', '-')
556 if var.config.getboolean('bot', 'announce_current_music'):
557 self.send_msg(var.playlist.current_item().format_current_playing())
559 self.log.info("bot: execute ffmpeg command: " + " ".join(command))
560 # The ffmpeg process is a thread
561 # prepare pipe for catching stderr of ffmpeg
562 pipe_rd, pipe_wd = os.pipe()
563 util.pipe_no_wait(pipe_rd) # Let the pipe work in non-blocking mode
564 self.thread_stderr = os.fdopen(pipe_rd)
565 self.thread = sp.Popen(command, stdout=sp.PIPE, stderr=pipe_wd, bufsize=480)
566 self.last_volume_cycle_time = time.time()
567 self.pause_at_id = ""
570 def start_web_interface(addr, port):
575 werkzeug_logger = logging.getLogger('werkzeug')
576 logfile = util.solve_filepath(var.config.get('webinterface', 'web_logfile'))
578 handler = logging.handlers.RotatingFileHandler(logfile, mode='a', maxBytes=10240) # Rotate after 10KB
580 handler = logging.StreamHandler()
582 werkzeug_logger.addHandler(handler)
584 interface.init_proxy()
585 interface.web.env = 'development'
586 interface.web.run(port=port, host=addr)
589 if __name__ == '__main__':
590 parser = argparse.ArgumentParser(
591 description='Bot for playing music on Mumble')
594 parser.add_argument("--config", dest='config', type=str, default='configuration.ini',
595 help='Load configuration from this file. Default: configuration.ini')
596 parser.add_argument("--db", dest='db', type=str,
597 default=None, help='database file. Default: database.db')
599 parser.add_argument("-q", "--quiet", dest="quiet",
600 action="store_true", help="Only Error logs")
601 parser.add_argument("-v", "--verbose", dest="verbose",
602 action="store_true", help="Show debug log")
605 parser.add_argument("-s", "--server", dest="host",
606 type=str, help="Hostname of the Mumble server")
607 parser.add_argument("-u", "--user", dest="user",
608 type=str, help="Username for the bot")
609 parser.add_argument("-P", "--password", dest="password",
610 type=str, help="Server password, if required")
611 parser.add_argument("-T", "--tokens", dest="tokens",
612 type=str, help="Server tokens, if required")
613 parser.add_argument("-p", "--port", dest="port",
614 type=int, help="Port for the Mumble server")
615 parser.add_argument("-c", "--channel", dest="channel",
616 type=str, help="Default channel for the bot")
617 parser.add_argument("-C", "--cert", dest="certificate",
618 type=str, default=None, help="Certificate file")
620 args = parser.parse_args()
622 config = configparser.ConfigParser(interpolation=None, allow_no_value=True)
623 parsed_configs = config.read([util.solve_filepath('configuration.default.ini'), util.solve_filepath(args.config)],
625 var.dbfile = args.db if args.db is not None else util.solve_filepath(
626 config.get("bot", "database_path", fallback="database.db"))
628 if len(parsed_configs) == 0:
629 logging.error('Could not read configuration from file \"{}\"'.format(args.config))
633 var.db = SettingsDatabase(var.dbfile)
636 bot_logger = logging.getLogger("bot")
637 formatter = logging.Formatter('[%(asctime)s %(levelname)s %(threadName)s] %(message)s', "%b %d %H:%M:%S")
638 bot_logger.setLevel(logging.INFO)
640 logfile = util.solve_filepath(var.config.get('bot', 'logfile'))
643 handler = logging.handlers.RotatingFileHandler(logfile, mode='a', maxBytes=10240) # Rotate after 10KB
645 handler = logging.StreamHandler()
647 handler.setFormatter(formatter)
648 bot_logger.addHandler(handler)
649 var.bot_logger = bot_logger
651 if var.config.get("bot", "save_music_library", fallback=True):
652 var.music_db = MusicDatabase(var.dbfile)
654 var.music_db = MusicDatabase(":memory:")
656 var.cache = MusicCache(var.music_db)
660 if var.db.has_option("playlist", "playback_mode"):
661 playback_mode = var.db.get('playlist', 'playback_mode')
663 playback_mode = var.config.get('bot', 'playback_mode', fallback="one-shot")
665 if playback_mode in ["one-shot", "repeat", "random", "autoplay"]:
666 var.playlist = media.playlist.get_playlist(playback_mode)
668 raise KeyError("Unknown playback mode '%s'" % playback_mode)
670 var.bot = MumbleBot(args)
671 command.register_all_commands(var.bot)
673 if var.config.get("bot", "refresh_cache_on_startup", fallback=True)\
674 or not var.db.has_option("dir_cache", "files"):
675 var.cache.build_dir_cache(var.bot)
677 var.cache.load_dir_cache(var.bot)
680 if var.config.getboolean('bot', 'save_playlist', fallback=True):
681 var.bot_logger.info("bot: load playlist from previous session")
684 # Start the main loop.