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.ten_seconds_announced = False
70 self.song_start_at = -1
71 self.last_ffmpeg_err = ""
72 self.read_pcm_size = 0
73 # self.download_threads = []
74 self.wait_for_downloading = False # flag for the loop are waiting for download to complete in the other thread
76 if var.config.getboolean("webinterface", "enabled"):
77 wi_addr = var.config.get("webinterface", "listening_addr")
78 wi_port = var.config.getint("webinterface", "listening_port")
79 tt = threading.Thread(
80 target=start_web_interface, name="WebThread", args=(wi_addr, wi_port))
82 self.log.info('Starting web interface on {}:{}'.format(wi_addr, wi_port))
85 if var.config.getboolean("bot", "auto_check_update"):
86 th = threading.Thread(target=self.check_update, name="UpdateThread")
93 host = var.config.get("server", "host")
98 port = var.config.getint("server", "port")
101 password = args.password
103 password = var.config.get("server", "password")
106 self.channel = args.channel
108 self.channel = var.config.get("server", "channel")
111 certificate = args.certificate
113 certificate = util.solve_filepath(var.config.get("server", "certificate"))
118 tokens = var.config.get("server", "tokens")
119 tokens = tokens.split(',')
122 self.username = args.user
124 self.username = var.config.get("bot", "username")
126 self.mumble = pymumble.Mumble(host, user=self.username, port=port, password=password, tokens=tokens,
127 debug=var.config.getboolean('debug', 'mumbleConnection'), certfile=certificate)
128 self.mumble.callbacks.set_callback(pymumble.constants.PYMUMBLE_CLBK_TEXTMESSAGERECEIVED, self.message_received)
130 self.mumble.set_codec_profile("audio")
131 self.mumble.start() # start the mumble thread
132 self.mumble.is_ready() # wait for the connection
134 self.mumble.users.myself.unmute() # by sure the user is not muted
136 self.mumble.channels.find_by_name(self.channel).move_in()
137 self.mumble.set_bandwidth(200000)
139 self.is_ducking = False
140 self.on_ducking = False
141 self.ducking_release = time.time()
143 if not var.db.has_option("bot", "ducking") and var.config.getboolean("bot", "ducking", fallback=False)\
144 or var.config.getboolean("bot", "ducking"):
145 self.is_ducking = True
146 self.ducking_volume = var.config.getfloat("bot", "ducking_volume", fallback=0.05)
147 self.ducking_volume = var.db.getfloat("bot", "ducking_volume", fallback=self.ducking_volume)
148 self.ducking_threshold = var.config.getfloat("bot", "ducking_threshold", fallback=5000)
149 self.ducking_threshold = var.db.getfloat("bot", "ducking_threshold", fallback=self.ducking_threshold)
150 self.mumble.callbacks.set_callback(pymumble.constants.PYMUMBLE_CLBK_SOUNDRECEIVED,
151 self.ducking_sound_received)
152 self.mumble.set_receive_sound(True)
155 self._loop_status = 'Idle'
156 self._display_rms = False
159 # Set the CTRL+C shortcut
160 def ctrl_caught(self, signal, frame):
163 "\nSIGINT caught, quitting, {} more to kill".format(2 - self.nb_exit))
167 self.log.info("Forced Quit")
171 if var.config.getboolean('bot', 'save_playlist', fallback=True) \
172 and var.config.get("bot", "save_music_library", fallback=True):
173 self.log.info("bot: save playlist into database")
176 def check_update(self):
177 self.log.debug("update: checking for updates...")
178 new_version = util.new_release_version()
179 if version.parse(new_version) > version.parse(self.version):
180 self.log.info("update: new version %s found, current installed version %s." % (new_version, self.version))
181 self.send_msg(constants.strings('new_version_found'))
183 self.log.debug("update: no new version found.")
185 def register_command(self, cmd, handle, no_partial_match=False, access_outside_channel=False):
186 cmds = cmd.split(",")
188 command = command.strip()
190 self.cmd_handle[command] = {'handle': handle,
191 'partial_match': not no_partial_match,
192 'access_outside_channel': access_outside_channel}
193 self.log.debug("bot: command added: " + command)
195 def set_comment(self):
196 self.mumble.users.myself.comment(var.config.get('bot', 'comment'))
198 # =======================
200 # =======================
202 # All text send to the chat is analysed by this function
203 def message_received(self, text):
204 message = text.message.strip()
205 user = self.mumble.users[text.actor]['name']
207 if var.config.getboolean('commands', 'split_username_at_space'):
208 # in can you use https://github.com/Natenom/mumblemoderator-module-collection/tree/master/os-suffixes ,
209 # you want to split the username
210 user = user.split()[0]
212 if message[0] in var.config.get('commands', 'command_symbol'):
213 # remove the symbol from the message
214 message = message[1:].split(' ', 1)
216 # use the first word as a command, the others one as parameters
218 command = message[0].lower()
221 parameter = message[1].rstrip()
225 self.log.info('bot: received command ' + command + ' - ' + parameter + ' by ' + user)
227 # Anti stupid guy function
228 if not self.is_admin(user) and not var.config.getboolean('bot', 'allow_private_message') and text.session:
229 self.mumble.users[text.actor].send_text_message(
230 constants.strings('pm_not_allowed'))
233 for i in var.db.items("user_ban"):
234 if user.lower() == i[0]:
235 self.mumble.users[text.actor].send_text_message(
236 constants.strings('user_ban'))
239 if not self.is_admin(user) and parameter:
240 input_url = util.get_url_from_input(parameter)
242 for i in var.db.items("url_ban"):
243 if input_url == i[0]:
244 self.mumble.users[text.actor].send_text_message(
245 constants.strings('url_ban'))
250 if command in self.cmd_handle:
251 command_exc = command
253 if not self.cmd_handle[command]['access_outside_channel'] \
254 and not self.is_admin(user) \
255 and not var.config.getboolean('bot', 'allow_other_channel_message') \
256 and self.mumble.users[text.actor]['channel_id'] != self.mumble.users.myself['channel_id']:
257 self.mumble.users[text.actor].send_text_message(
258 constants.strings('not_in_my_channel'))
261 self.cmd_handle[command]['handle'](self, user, text, command, parameter)
264 cmds = self.cmd_handle.keys()
267 if cmd.startswith(command) and self.cmd_handle[cmd]['partial_match']:
270 if len(matches) == 1:
271 self.log.info("bot: {:s} matches {:s}".format(command, matches[0]))
272 command_exc = matches[0]
274 if not self.cmd_handle[command_exc]['access_outside_channel'] \
275 and not self.is_admin(user) \
276 and not var.config.getboolean('bot', 'allow_other_channel_message') \
277 and self.mumble.users[text.actor]['channel_id'] != self.mumble.users.myself[
279 self.mumble.users[text.actor].send_text_message(
280 constants.strings('not_in_my_channel'))
283 self.cmd_handle[command_exc]['handle'](self, user, text, command_exc, parameter)
284 elif len(matches) > 1:
285 self.mumble.users[text.actor].send_text_message(
286 constants.strings('which_command', commands="<br>".join(matches)))
288 self.mumble.users[text.actor].send_text_message(
289 constants.strings('bad_command', command=command))
291 error_traceback = traceback.format_exc()
292 error = error_traceback.rstrip().split("\n")[-1]
293 self.log.error("bot: command %s failed with error: %s\n" % (command_exc, error_traceback))
294 self.send_msg(constants.strings('error_executing_command', command=command_exc, error=error), text)
296 def send_msg(self, msg, text=None):
297 msg = msg.encode('utf-8', 'ignore').decode('utf-8')
298 # text if the object message, contain information if direct message or channel message
300 own_channel = self.mumble.channels[self.mumble.users.myself['channel_id']]
301 own_channel.send_text_message(msg)
303 self.mumble.users[text.actor].send_text_message(msg)
305 def is_admin(self, user):
306 list_admin = var.config.get('bot', 'admin').rstrip().split(';')
307 if user in list_admin:
312 # =======================
313 # Launch and Download
314 # =======================
316 def launch_music(self):
317 if var.playlist.is_empty():
319 assert self.wait_for_downloading is False
321 music_wrapper = var.playlist.current_item()
322 uri = music_wrapper.uri()
324 self.log.info("bot: play music " + music_wrapper.format_debug_string())
325 self.ten_seconds_announced = False
327 if var.config.getboolean('bot', 'announce_current_music'):
328 self.send_msg(music_wrapper.format_current_playing())
330 if var.config.getboolean('debug', 'ffmpeg'):
331 ffmpeg_debug = "debug"
333 ffmpeg_debug = "warning"
335 command = ("ffmpeg", '-v', ffmpeg_debug, '-nostdin', '-i',
336 uri, '-ac', '1', '-f', 's16le', '-ar', '48000', '-')
337 self.log.debug("bot: execute ffmpeg command: " + " ".join(command))
339 # The ffmpeg process is a thread
340 # prepare pipe for catching stderr of ffmpeg
341 pipe_rd, pipe_wd = os.pipe()
342 util.pipe_no_wait(pipe_rd) # Let the pipe work in non-blocking mode
343 self.thread_stderr = os.fdopen(pipe_rd)
344 self.thread = sp.Popen(command, stdout=sp.PIPE, stderr=pipe_wd, bufsize=480)
345 self.is_pause = False
346 self.read_pcm_size = 0
347 self.song_start_at = -1
349 self.last_volume_cycle_time = time.time()
351 def async_download_next(self):
352 # Function start if the next music isn't ready
353 # Do nothing in case the next music is already downloaded
354 self.log.debug("bot: Async download next asked ")
355 while var.playlist.next_item() and var.playlist.next_item().type in ['url', 'url_from_playlist']:
356 # usually, all validation will be done when adding to the list.
357 # however, for performance consideration, youtube playlist won't be validate when added.
358 # the validation has to be done here.
359 next = var.playlist.next_item()
361 if not next.is_ready():
362 var.playlist.async_prepare(var.playlist.next_index())
365 var.playlist.remove_by_id(next.id)
366 var.cache.free_and_delete(next.id)
368 # =======================
370 # =======================
372 # Main loop of the Bot
375 while not self.exit and self.mumble.is_alive():
377 while self.thread and self.mumble.sound_output.get_buffer_size() > 0.5 and not self.exit:
378 # If the buffer isn't empty, I cannot send new music part, so I wait
379 self._loop_status = 'Wait for buffer %.3f' % self.mumble.sound_output.get_buffer_size()
383 # I get raw from ffmpeg thread
384 # move playhead forward
385 self._loop_status = 'Reading raw'
386 if self.song_start_at == -1:
387 self.song_start_at = time.time() - self.playhead
388 self.playhead = time.time() - self.song_start_at
389 if not self.ten_seconds_announced:
390 current_item = var.playlist.current_item()
392 item = current_item.item()
393 if item and item.duration and item.duration > 10 and (item.duration - self.playhead) < 10:
394 self.log.info("bot: 10 seconds left")
395 self.send_msg("10 seconds left")
396 self.ten_seconds_announced = True
398 raw_music = self.thread.stdout.read(480)
399 self.read_pcm_size += 480
402 self.last_ffmpeg_err = self.thread_stderr.readline()
403 if self.last_ffmpeg_err:
404 self.log.debug("ffmpeg: " + self.last_ffmpeg_err.strip("\n"))
409 # Adjust the volume and send it to mumble
411 self.mumble.sound_output.add_sound(
412 audioop.mul(raw_music, 2, self.volume))
418 if not self.is_pause and (self.thread is None or not raw_music):
419 # ffmpeg thread has gone. indicate that last song has finished, or something is wrong.
420 if self.read_pcm_size < 481 and len(var.playlist) > 0 and var.playlist.current_index != -1 \
421 and self.last_ffmpeg_err:
422 current = var.playlist.current_item()
423 self.log.error("bot: cannot play music %s", current.format_debug_string())
424 self.log.error("bot: with ffmpeg error: %s", self.last_ffmpeg_err)
425 self.last_ffmpeg_err = ""
427 self.send_msg(constants.strings('unable_play', item=current.format_short_string()))
428 var.playlist.remove_by_id(current.id)
429 var.cache.free_and_delete(current.id)
431 # move to the next song.
432 if not self.wait_for_downloading:
433 if var.playlist.next():
434 current = var.playlist.current_item()
435 if current.validate():
436 if current.is_ready():
438 self.async_download_next()
440 self.log.info("bot: current music isn't ready, start downloading.")
441 self.wait_for_downloading = True
442 var.playlist.async_prepare(var.playlist.current_index)
443 self.send_msg(constants.strings('download_in_progress', item=current.format_short_string()))
445 var.playlist.remove_by_id(current.id)
446 var.cache.free_and_delete(current.id)
448 self._loop_status = 'Empty queue'
450 current = var.playlist.current_item()
452 if current.is_ready():
453 self.wait_for_downloading = False
454 var.playlist.version += 1
456 self.async_download_next()
457 elif current.is_failed():
458 var.playlist.remove_by_id(current.id)
460 self._loop_status = 'Wait for downloading'
462 self.wait_for_downloading = False
464 while self.mumble.sound_output.get_buffer_size() > 0:
465 # Empty the buffer before exit
470 self._loop_status = "exited"
471 if var.config.getboolean('bot', 'save_playlist', fallback=True) \
472 and var.config.get("bot", "save_music_library", fallback=True):
473 self.log.info("bot: save playlist into database")
476 def volume_cycle(self):
477 delta = time.time() - self.last_volume_cycle_time
479 if self.on_ducking and self.ducking_release < time.time():
480 self.on_ducking = False
484 if self.is_ducking and self.on_ducking:
485 self.volume = (self.volume - self.ducking_volume) * math.exp(- delta / 0.2) + self.ducking_volume
487 self.volume = self.volume_set - (self.volume_set - self.volume) * math.exp(- delta / 0.5)
489 self.last_volume_cycle_time = time.time()
491 def ducking_sound_received(self, user, sound):
492 rms = audioop.rms(sound.pcm, 2)
493 self._max_rms = max(rms, self._max_rms)
494 if self._display_rms:
495 if rms < self.ducking_threshold:
496 print('%6d/%6d ' % (rms, self._max_rms) + '-'*int(rms/200), end='\r')
498 print('%6d/%6d ' % (rms, self._max_rms) + '-'*int(self.ducking_threshold/200)
499 + '+'*int((rms - self.ducking_threshold)/200), end='\r')
501 if rms > self.ducking_threshold:
502 if self.on_ducking is False:
503 self.log.debug("bot: ducking triggered")
504 self.on_ducking = True
505 self.ducking_release = time.time() + 1 # ducking release after 1s
507 # =======================
509 # =======================
512 # Kill the ffmpeg thread and empty the playlist
517 self.log.info("bot: music stopped. playlist trashed.")
522 self.log.info("bot: music stopped.")
525 # Kill the ffmpeg thread
529 self.song_start_at = -1
533 # Kill the ffmpeg thread
535 self.pause_at_id = var.playlist.current_item().id
539 self.song_start_at = -1
540 self.log.info("bot: music paused at %.2f seconds." % self.playhead)
543 self.is_pause = False
545 if var.playlist.current_index == -1:
548 music_wrapper = var.playlist.current_item()
550 if not music_wrapper or not music_wrapper.id == self.pause_at_id or not music_wrapper.is_ready():
554 if var.config.getboolean('debug', 'ffmpeg'):
555 ffmpeg_debug = "debug"
557 ffmpeg_debug = "warning"
559 self.log.info("bot: resume music at %.2f seconds" % self.playhead)
561 uri = music_wrapper.uri()
563 command = ("ffmpeg", '-v', ffmpeg_debug, '-nostdin', '-ss', "%f" % self.playhead, '-i',
564 uri, '-ac', '1', '-f', 's16le', '-ar', '48000', '-')
566 if var.config.getboolean('bot', 'announce_current_music'):
567 self.send_msg(var.playlist.current_item().format_current_playing())
569 self.log.info("bot: execute ffmpeg command: " + " ".join(command))
570 # The ffmpeg process is a thread
571 # prepare pipe for catching stderr of ffmpeg
572 pipe_rd, pipe_wd = os.pipe()
573 util.pipe_no_wait(pipe_rd) # Let the pipe work in non-blocking mode
574 self.thread_stderr = os.fdopen(pipe_rd)
575 self.thread = sp.Popen(command, stdout=sp.PIPE, stderr=pipe_wd, bufsize=480)
576 self.last_volume_cycle_time = time.time()
577 self.pause_at_id = ""
580 def start_web_interface(addr, port):
585 werkzeug_logger = logging.getLogger('werkzeug')
586 logfile = util.solve_filepath(var.config.get('webinterface', 'web_logfile'))
588 handler = logging.handlers.RotatingFileHandler(logfile, mode='a', maxBytes=10240) # Rotate after 10KB
590 handler = logging.StreamHandler()
592 werkzeug_logger.addHandler(handler)
594 interface.init_proxy()
595 interface.web.env = 'development'
596 interface.web.run(port=port, host=addr)
599 if __name__ == '__main__':
600 parser = argparse.ArgumentParser(
601 description='Bot for playing music on Mumble')
604 parser.add_argument("--config", dest='config', type=str, default='configuration.ini',
605 help='Load configuration from this file. Default: configuration.ini')
606 parser.add_argument("--db", dest='db', type=str,
607 default=None, help='database file. Default: database.db')
609 parser.add_argument("-q", "--quiet", dest="quiet",
610 action="store_true", help="Only Error logs")
611 parser.add_argument("-v", "--verbose", dest="verbose",
612 action="store_true", help="Show debug log")
615 parser.add_argument("-s", "--server", dest="host",
616 type=str, help="Hostname of the Mumble server")
617 parser.add_argument("-u", "--user", dest="user",
618 type=str, help="Username for the bot")
619 parser.add_argument("-P", "--password", dest="password",
620 type=str, help="Server password, if required")
621 parser.add_argument("-T", "--tokens", dest="tokens",
622 type=str, help="Server tokens, if required")
623 parser.add_argument("-p", "--port", dest="port",
624 type=int, help="Port for the Mumble server")
625 parser.add_argument("-c", "--channel", dest="channel",
626 type=str, help="Default channel for the bot")
627 parser.add_argument("-C", "--cert", dest="certificate",
628 type=str, default=None, help="Certificate file")
630 args = parser.parse_args()
632 config = configparser.ConfigParser(interpolation=None, allow_no_value=True)
633 parsed_configs = config.read([util.solve_filepath('configuration.default.ini'), util.solve_filepath(args.config)],
635 var.dbfile = args.db if args.db is not None else util.solve_filepath(
636 config.get("bot", "database_path", fallback="database.db"))
638 if len(parsed_configs) == 0:
639 logging.error('Could not read configuration from file \"{}\"'.format(args.config))
643 var.db = SettingsDatabase(var.dbfile)
646 bot_logger = logging.getLogger("bot")
647 formatter = logging.Formatter('[%(asctime)s %(levelname)s %(threadName)s] %(message)s', "%b %d %H:%M:%S")
648 bot_logger.setLevel(logging.INFO)
650 logfile = util.solve_filepath(var.config.get('bot', 'logfile'))
653 handler = logging.handlers.RotatingFileHandler(logfile, mode='a', maxBytes=10240) # Rotate after 10KB
655 handler = logging.StreamHandler()
657 handler.setFormatter(formatter)
658 bot_logger.addHandler(handler)
659 var.bot_logger = bot_logger
661 if var.config.get("bot", "save_music_library", fallback=True):
662 var.music_db = MusicDatabase(var.dbfile)
664 var.music_db = MusicDatabase(":memory:")
666 var.cache = MusicCache(var.music_db)
670 if var.db.has_option("playlist", "playback_mode"):
671 playback_mode = var.db.get('playlist', 'playback_mode')
673 playback_mode = var.config.get('bot', 'playback_mode', fallback="one-shot")
675 if playback_mode in ["one-shot", "repeat", "random", "autoplay"]:
676 var.playlist = media.playlist.get_playlist(playback_mode)
678 raise KeyError("Unknown playback mode '%s'" % playback_mode)
680 var.bot = MumbleBot(args)
681 command.register_all_commands(var.bot)
683 if var.config.get("bot", "refresh_cache_on_startup", fallback=True)\
684 or not var.db.has_option("dir_cache", "files"):
685 var.cache.build_dir_cache(var.bot)
687 var.cache.load_dir_cache(var.bot)
690 if var.config.getboolean('bot', 'save_playlist', fallback=True):
691 var.bot_logger.info("bot: load playlist from previous session")
694 # Start the main loop.