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 if var.config.getboolean('bot', 'save_playlist', fallback=True) \
174 and var.config.get("bot", "save_music_library", fallback=True):
175 self.log.info("bot: save playlist into database")
178 def check_update(self):
179 self.log.debug("update: checking for updates...")
180 new_version = util.new_release_version()
181 if version.parse(new_version) > version.parse(self.version):
182 self.log.info("update: new version %s found, current installed version %s." % (new_version, self.version))
183 self.send_msg(constants.strings('new_version_found'))
185 self.log.debug("update: no new version found.")
187 def register_command(self, cmd, handle, no_partial_match=False):
188 cmds = cmd.split(",")
190 command = command.strip()
192 self.cmd_handle[command] = { 'handle': handle, 'partial_match': not no_partial_match}
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
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_other_channel_message') \
229 and self.mumble.users[text.actor]['channel_id'] != self.mumble.users.myself['channel_id']:
230 self.mumble.users[text.actor].send_text_message(
231 constants.strings('not_in_my_channel'))
234 if not self.is_admin(user) and not var.config.getboolean('bot', 'allow_private_message') and text.session:
235 self.mumble.users[text.actor].send_text_message(
236 constants.strings('pm_not_allowed'))
239 for i in var.db.items("user_ban"):
240 if user.lower() == i[0]:
241 self.mumble.users[text.actor].send_text_message(
242 constants.strings('user_ban'))
246 for i in var.db.items("url_ban"):
247 if util.get_url_from_input(parameter.lower()) == i[0]:
248 self.mumble.users[text.actor].send_text_message(
249 constants.strings('url_ban'))
255 if command in self.cmd_handle:
256 command_exc = command
257 self.cmd_handle[command]['handle'](self, user, text, command, parameter)
260 cmds = self.cmd_handle.keys()
263 if cmd.startswith(command) and self.cmd_handle[cmd]['partial_match']:
266 if len(matches) == 1:
267 self.log.info("bot: {:s} matches {:s}".format(command, matches[0]))
268 command_exc = matches[0]
269 self.cmd_handle[command_exc]['handle'](self, user, text, command_exc, parameter)
270 elif len(matches) > 1:
271 self.mumble.users[text.actor].send_text_message(
272 constants.strings('which_command', commands="<br>".join(matches)))
274 self.mumble.users[text.actor].send_text_message(
275 constants.strings('bad_command', command=command))
277 error_traceback = traceback.format_exc()
278 error = error_traceback.rstrip().split("\n")[-1]
279 self.log.error("bot: command %s failed with error: %s\n" % (command_exc, error_traceback))
280 self.send_msg(constants.strings('error_executing_command', command=command_exc, error=error), text)
282 def send_msg(self, msg, text=None):
283 msg = msg.encode('utf-8', 'ignore').decode('utf-8')
284 # text if the object message, contain information if direct message or channel message
285 if not text or not text.session:
286 own_channel = self.mumble.channels[self.mumble.users.myself['channel_id']]
287 own_channel.send_text_message(msg)
289 self.mumble.users[text.actor].send_text_message(msg)
291 def is_admin(self, user):
292 list_admin = var.config.get('bot', 'admin').rstrip().split(';')
293 if user in list_admin:
298 # =======================
299 # Launch and Download
300 # =======================
302 def launch_music(self):
303 if var.playlist.is_empty():
305 assert self.wait_for_downloading == False
307 music_wrapper = var.playlist.current_item()
308 uri = music_wrapper.uri()
310 self.log.info("bot: play music " + music_wrapper.format_debug_string())
312 if var.config.getboolean('bot', 'announce_current_music'):
313 self.send_msg(music_wrapper.format_current_playing())
315 if var.config.getboolean('debug', 'ffmpeg'):
316 ffmpeg_debug = "debug"
318 ffmpeg_debug = "warning"
320 command = ("ffmpeg", '-v', ffmpeg_debug, '-nostdin', '-i',
321 uri, '-ac', '1', '-f', 's16le', '-ar', '48000', '-')
322 self.log.debug("bot: execute ffmpeg command: " + " ".join(command))
324 # The ffmpeg process is a thread
325 # prepare pipe for catching stderr of ffmpeg
326 pipe_rd, pipe_wd = os.pipe()
327 util.pipe_no_wait(pipe_rd) # Let the pipe work in non-blocking mode
328 self.thread_stderr = os.fdopen(pipe_rd)
329 self.thread = sp.Popen(command, stdout=sp.PIPE, stderr=pipe_wd, bufsize=480)
330 self.is_pause = False
331 self.song_start_at = -1
333 self.last_volume_cycle_time = time.time()
335 def async_download_next(self):
336 # Function start if the next music isn't ready
337 # Do nothing in case the next music is already downloaded
338 self.log.debug("bot: Async download next asked ")
339 while var.playlist.next_item() and var.playlist.next_item().type in ['url', 'url_from_playlist']:
340 # usually, all validation will be done when adding to the list.
341 # however, for performance consideration, youtube playlist won't be validate when added.
342 # the validation has to be done here.
343 next = var.playlist.next_item()
345 if not next.is_ready():
349 var.playlist.remove_by_id(next.id)
350 var.library.delete(next.id)
353 # =======================
355 # =======================
357 # Main loop of the Bot
360 while not self.exit and self.mumble.is_alive():
362 while self.thread and self.mumble.sound_output.get_buffer_size() > 0.5 and not self.exit:
363 # If the buffer isn't empty, I cannot send new music part, so I wait
364 self._loop_status = 'Wait for buffer %.3f' % self.mumble.sound_output.get_buffer_size()
368 # I get raw from ffmpeg thread
369 # move playhead forward
370 self._loop_status = 'Reading raw'
371 if self.song_start_at == -1:
372 self.song_start_at = time.time() - self.playhead
373 self.playhead = time.time() - self.song_start_at
375 raw_music = self.thread.stdout.read(480)
378 stderr_msg = self.thread_stderr.readline()
380 self.log.debug("ffmpeg: " + stderr_msg.strip("\n"))
385 # Adjust the volume and send it to mumble
387 self.mumble.sound_output.add_sound(
388 audioop.mul(raw_music, 2, self.volume))
394 if not self.is_pause and (self.thread is None or not raw_music):
395 # ffmpeg thread has gone. indicate that last song has finished. move to the next song.
396 if not self.wait_for_downloading:
397 if var.playlist.next():
398 current = var.playlist.current_item()
399 if current.validate():
400 if current.is_ready():
402 self.async_download_next()
404 self.log.info("bot: current music isn't ready, start downloading.")
405 self.wait_for_downloading = True
406 current.async_prepare()
407 self.send_msg(constants.strings('download_in_progress', item=current.format_short_string()))
409 var.playlist.remove_by_id(current.id)
410 var.library.delete(current.id)
412 self._loop_status = 'Empty queue'
414 current = var.playlist.current_item()
416 if current.is_ready():
417 self.wait_for_downloading = False
419 self.async_download_next()
420 elif current.is_failed():
421 var.playlist.remove_by_id(current.id)
423 self._loop_status = 'Wait for downloading'
425 self.wait_for_downloading = False
427 while self.mumble.sound_output.get_buffer_size() > 0:
428 # Empty the buffer before exit
433 self._loop_status = "exited"
434 if var.config.getboolean('bot', 'save_playlist', fallback=True) \
435 and var.config.get("bot", "save_music_library", fallback=True):
436 self.log.info("bot: save playlist into database")
439 def volume_cycle(self):
440 delta = time.time() - self.last_volume_cycle_time
442 if self.on_ducking and self.ducking_release < time.time():
443 self._clear_pymumble_soundqueue()
444 self.on_ducking = False
448 if self.is_ducking and self.on_ducking:
449 self.volume = (self.volume - self.ducking_volume) * math.exp(- delta / 0.2) + self.ducking_volume
451 self.volume = self.volume_set - (self.volume_set - self.volume) * math.exp(- delta / 0.5)
453 self.last_volume_cycle_time = time.time()
455 def ducking_sound_received(self, user, sound):
456 rms = audioop.rms(sound.pcm, 2)
457 self._max_rms = max(rms, self._max_rms)
458 if self._display_rms:
459 if rms < self.ducking_threshold:
460 print('%6d/%6d ' % (rms, self._max_rms) + '-'*int(rms/200), end='\r')
462 print('%6d/%6d ' % (rms, self._max_rms) + '-'*int(self.ducking_threshold/200) \
463 + '+'*int((rms - self.ducking_threshold)/200), end='\r')
465 if rms > self.ducking_threshold:
466 if self.on_ducking is False:
467 self.log.debug("bot: ducking triggered")
468 self.on_ducking = True
469 self.ducking_release = time.time() + 1 # ducking release after 1s
471 # =======================
473 # =======================
476 # Kill the ffmpeg thread and empty the playlist
481 self.log.info("bot: music stopped. playlist trashed.")
486 self.log.info("bot: music stopped.")
489 # Kill the ffmpeg thread
493 self.song_start_at = -1
497 # Kill the ffmpeg thread
499 self.pause_at_id = var.playlist.current_item()
503 self.song_start_at = -1
504 self.log.info("bot: music paused at %.2f seconds." % self.playhead)
507 self.is_pause = False
509 if var.playlist.current_index == -1:
512 music_wrapper = var.playlist.current_item()
514 if not music_wrapper or not music_wrapper.id == self.pause_at_id or not music_wrapper.is_ready():
518 if var.config.getboolean('debug', 'ffmpeg'):
519 ffmpeg_debug = "debug"
521 ffmpeg_debug = "warning"
523 self.log.info("bot: resume music at %.2f seconds" % self.playhead)
525 uri = music_wrapper.uri()
527 command = ("ffmpeg", '-v', ffmpeg_debug, '-nostdin', '-ss', "%f" % self.playhead, '-i',
528 uri, '-ac', '1', '-f', 's16le', '-ar', '48000', '-')
531 if var.config.getboolean('bot', 'announce_current_music'):
532 self.send_msg(var.playlist.current_item().format_current_playing())
534 self.log.info("bot: execute ffmpeg command: " + " ".join(command))
535 # The ffmpeg process is a thread
536 # prepare pipe for catching stderr of ffmpeg
537 pipe_rd, pipe_wd = os.pipe()
538 util.pipe_no_wait(pipe_rd) # Let the pipe work in non-blocking mode
539 self.thread_stderr = os.fdopen(pipe_rd)
540 self.thread = sp.Popen(command, stdout=sp.PIPE, stderr=pipe_wd, bufsize=480)
541 self.last_volume_cycle_time = time.time()
542 self.pause_at_id = ""
545 # TODO: this is a temporary workaround for issue #44 of pymumble.
546 def _clear_pymumble_soundqueue(self):
547 for id, user in self.mumble.users.items():
548 user.sound.lock.acquire()
549 user.sound.queue.clear()
550 user.sound.lock.release()
551 self.log.debug("bot: pymumble soundqueue cleared.")
555 def start_web_interface(addr, port):
560 werkzeug_logger = logging.getLogger('werkzeug')
561 logfile = util.solve_filepath(var.config.get('webinterface', 'web_logfile'))
564 handler = logging.handlers.RotatingFileHandler(logfile, mode='a', maxBytes=10240) # Rotate after 10KB
566 handler = logging.StreamHandler()
568 werkzeug_logger.addHandler(handler)
570 interface.init_proxy()
571 interface.web.env = 'development'
572 interface.web.run(port=port, host=addr)
575 if __name__ == '__main__':
576 parser = argparse.ArgumentParser(
577 description='Bot for playing music on Mumble')
580 parser.add_argument("--config", dest='config', type=str, default='configuration.ini',
581 help='Load configuration from this file. Default: configuration.ini')
582 parser.add_argument("--db", dest='db', type=str,
583 default=None, help='database file. Default: database.db')
585 parser.add_argument("-q", "--quiet", dest="quiet",
586 action="store_true", help="Only Error logs")
587 parser.add_argument("-v", "--verbose", dest="verbose",
588 action="store_true", help="Show debug log")
591 parser.add_argument("-s", "--server", dest="host",
592 type=str, help="Hostname of the Mumble server")
593 parser.add_argument("-u", "--user", dest="user",
594 type=str, help="Username for the bot")
595 parser.add_argument("-P", "--password", dest="password",
596 type=str, help="Server password, if required")
597 parser.add_argument("-T", "--tokens", dest="tokens",
598 type=str, help="Server tokens, if required")
599 parser.add_argument("-p", "--port", dest="port",
600 type=int, help="Port for the Mumble server")
601 parser.add_argument("-c", "--channel", dest="channel",
602 type=str, help="Default channel for the bot")
603 parser.add_argument("-C", "--cert", dest="certificate",
604 type=str, default=None, help="Certificate file")
606 args = parser.parse_args()
608 config = configparser.ConfigParser(interpolation=None, allow_no_value=True)
609 parsed_configs = config.read([util.solve_filepath('configuration.default.ini'), util.solve_filepath(args.config)],
611 var.dbfile = args.db if args.db is not None else util.solve_filepath(
612 config.get("bot", "database_path", fallback="database.db"))
614 if len(parsed_configs) == 0:
615 logging.error('Could not read configuration from file \"{}\"'.format(args.config))
619 var.db = SettingsDatabase(var.dbfile)
622 bot_logger = logging.getLogger("bot")
623 formatter = logging.Formatter('[%(asctime)s %(levelname)s %(threadName)s] %(message)s', "%b %d %H:%M:%S")
624 bot_logger.setLevel(logging.INFO)
626 logfile = util.solve_filepath(var.config.get('bot', 'logfile'))
629 handler = logging.handlers.RotatingFileHandler(logfile, mode='a', maxBytes=10240) # Rotate after 10KB
631 handler = logging.StreamHandler()
633 handler.setFormatter(formatter)
634 bot_logger.addHandler(handler)
635 var.bot_logger = bot_logger
637 if var.config.get("bot", "save_music_library", fallback=True):
638 var.music_db = MusicDatabase(var.dbfile)
640 var.music_db = MusicDatabase(":memory:")
642 var.library = MusicLibrary(var.music_db)
646 if var.db.has_option("playlist", "playback_mode"):
647 playback_mode = var.db.get('playlist', 'playback_mode')
649 playback_mode = var.config.get('bot', 'playback_mode', fallback="one-shot")
651 if playback_mode in ["one-shot", "repeat", "random", "autoplay"]:
652 var.playlist = media.playlist.get_playlist(playback_mode)
654 raise KeyError("Unknown playback mode '%s'" % playback_mode)
656 var.bot = MumbleBot(args)
657 command.register_all_commands(var.bot)
659 if var.config.get("bot", "refresh_cache_on_startup", fallback=True)\
660 or not var.db.has_option("dir_cache", "files"):
661 var.library.build_dir_cache(var.bot)
663 var.library.load_dir_cache(var.bot)
666 if var.config.getboolean('bot', 'save_playlist', fallback=True):
667 var.bot_logger.info("bot: load playlist from previous session")
670 # Start the main loop.