]> git.0d.be Git - botaradio.git/blob - mumbleBot.py
0cc6b68cc2274b4c6629911427b31d31464bdbc6
[botaradio.git] / mumbleBot.py
1 #!/usr/bin/env python3
2 # -*- coding: utf-8 -*-
3
4 import threading
5 import time
6 import sys
7 import math
8 import signal
9 import configparser
10 import audioop
11 import subprocess as sp
12 import argparse
13 import os
14 import os.path
15 import pymumble.pymumble_py3 as pymumble
16 import variables as var
17 import hashlib
18 import youtube_dl
19 import logging
20 import logging.handlers
21 import traceback
22 from packaging import version
23
24 import util
25 import command
26 import constants
27 from database import SettingsDatabase, MusicDatabase
28 import media.url
29 import media.file
30 import media.radio
31 import media.system
32 from media.playlist import BasePlayList
33 from media.library import MusicLibrary
34
35
36 class MumbleBot:
37     version = '5.2'
38
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)
43         self.cmd_handle = {}
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')
47
48         self.volume = self.volume_set
49
50         if args.channel:
51             self.channel = args.channel
52         else:
53             self.channel = var.config.get("server", "channel", fallback=None)
54
55         if args.verbose:
56             self.log.setLevel(logging.DEBUG)
57             self.log.debug("Starting in DEBUG loglevel")
58         elif args.quiet:
59             self.log.setLevel(logging.ERROR)
60             self.log.error("Starting in ERROR loglevel")
61
62         var.user = args.user
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")
67         self.exit = False
68         self.nb_exit = 0
69         self.thread = None
70         self.thread_stderr = None
71         self.is_pause = False
72         self.pause_at_id = ""
73         self.playhead = -1
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
77
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))
83             tt.daemon = True
84             self.log.info('Starting web interface on {}:{}'.format(wi_addr, wi_port))
85             tt.start()
86
87         if var.config.getboolean("bot", "auto_check_update"):
88             th = threading.Thread(target=self.check_update, name="UpdateThread")
89             th.daemon = True
90             th.start()
91
92         if args.host:
93             host = args.host
94         else:
95             host = var.config.get("server", "host")
96
97         if args.port:
98             port = args.port
99         else:
100             port = var.config.getint("server", "port")
101
102         if args.password:
103             password = args.password
104         else:
105             password = var.config.get("server", "password")
106
107         if args.channel:
108             self.channel = args.channel
109         else:
110             self.channel = var.config.get("server", "channel")
111
112         if args.certificate:
113             certificate = args.certificate
114         else:
115             certificate = util.solve_filepath(var.config.get("server", "certificate"))
116
117         if args.tokens:
118             tokens = args.tokens
119         else:
120             tokens = var.config.get("server", "tokens")
121             tokens = tokens.split(',')
122
123         if args.user:
124             self.username = args.user
125         else:
126             self.username = var.config.get("bot", "username")
127
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)
131
132         self.mumble.set_codec_profile("audio")
133         self.mumble.start()  # start the mumble thread
134         self.mumble.is_ready()  # wait for the connection
135         self.set_comment()
136         self.mumble.users.myself.unmute()  # by sure the user is not muted
137         if self.channel:
138             self.mumble.channels.find_by_name(self.channel).move_in()
139         self.mumble.set_bandwidth(200000)
140
141         self.is_ducking = False
142         self.on_ducking = False
143         self.ducking_release = time.time()
144
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)
155
156         # Debug use
157         self._loop_status = 'Idle'
158         self._display_rms = False
159         self._max_rms = 0
160
161     # Set the CTRL+C shortcut
162     def ctrl_caught(self, signal, frame):
163
164         self.log.info(
165             "\nSIGINT caught, quitting, {} more to kill".format(2 - self.nb_exit))
166         self.exit = True
167         self.pause()
168         if self.nb_exit > 1:
169             self.log.info("Forced Quit")
170             sys.exit(0)
171         self.nb_exit += 1
172
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'))
179         else:
180             self.log.debug("update: no new version found.")
181
182     def register_command(self, cmd, handle):
183         cmds = cmd.split(",")
184         for command in cmds:
185             command = command.strip()
186             if command:
187                 self.cmd_handle[command] = handle
188                 self.log.debug("bot: command added: " + command)
189
190     def set_comment(self):
191         self.mumble.users.myself.comment(var.config.get('bot', 'comment'))
192
193     # =======================
194     #         Message
195     # =======================
196
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']
201
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]
206
207         if message[0] in var.config.get('commands', 'command_symbol'):
208             # remove the symbol from the message
209             message = message[1:].split(' ', 1)
210
211             # use the first word as a command, the others one as  parameters
212             if len(message) > 0:
213                 command = message[0]
214                 parameter = ''
215                 if len(message) > 1:
216                     parameter = message[1].rstrip()
217             else:
218                 return
219
220             self.log.info('bot: received command ' + command + ' - ' + parameter + ' by ' + user)
221
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'))
227                 return
228
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'))
232                 return
233
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'))
238                     return
239
240             if parameter:
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'))
245                         return
246
247
248             command_exc = ""
249             try:
250                 if command in self.cmd_handle:
251                     command_exc = command
252                     self.cmd_handle[command](self, user, text, command, parameter)
253                 else:
254                     # try partial match
255                     cmds = self.cmd_handle.keys()
256                     matches = []
257                     for cmd in cmds:
258                         if cmd.startswith(command):
259                             matches.append(cmd)
260
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)))
268                     else:
269                         self.mumble.users[text.actor].send_text_message(
270                             constants.strings('bad_command', command=command))
271             except:
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)
276
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)
283         else:
284             self.mumble.users[text.actor].send_text_message(msg)
285
286     def is_admin(self, user):
287         list_admin = var.config.get('bot', 'admin').rstrip().split(';')
288         if user in list_admin:
289             return True
290         else:
291             return False
292
293     # =======================
294     #   Launch and Download
295     # =======================
296
297     def launch_music(self):
298         if var.playlist.is_empty():
299             return
300         assert self.wait_for_downloading == False
301
302         music_wrapper = var.playlist.current_item()
303         uri = music_wrapper.uri()
304
305         self.log.info("bot: play music " + music_wrapper.format_debug_string())
306
307         if var.config.getboolean('bot', 'announce_current_music'):
308             self.send_msg(music_wrapper.format_current_playing())
309
310         if var.config.getboolean('debug', 'ffmpeg'):
311             ffmpeg_debug = "debug"
312         else:
313             ffmpeg_debug = "warning"
314
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))
318
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
327         self.playhead = 0
328         self.last_volume_cycle_time = time.time()
329
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()
339             if next.validate():
340                 if not next.is_ready():
341                     next.async_prepare()
342                 break
343             else:
344                 var.playlist.remove_by_id(next.id)
345
346
347     # =======================
348     #          Loop
349     # =======================
350
351     # Main loop of the Bot
352     def loop(self):
353         raw_music = ""
354         while not self.exit and self.mumble.is_alive():
355
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()
359                 time.sleep(0.01)
360
361             if self.thread:
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
368
369                 raw_music = self.thread.stdout.read(480)
370
371                 try:
372                     stderr_msg = self.thread_stderr.readline()
373                     if stderr_msg:
374                         self.log.debug("ffmpeg: " + stderr_msg.strip("\n"))
375                 except:
376                     pass
377
378                 if raw_music:
379                     # Adjust the volume and send it to mumble
380                     self.volume_cycle()
381                     self.mumble.sound_output.add_sound(
382                         audioop.mul(raw_music, 2, self.volume))
383                 else:
384                     time.sleep(0.1)
385             else:
386                 time.sleep(0.1)
387
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():
395                                 self.launch_music()
396                                 self.async_download_next()
397                             else:
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()))
402                         else:
403                             var.playlist.remove_by_id(current.id)
404                     else:
405                         self._loop_status = 'Empty queue'
406                 else:
407                     current = var.playlist.current_item()
408                     if current:
409                         if current.is_ready():
410                             self.wait_for_downloading = False
411                             self.launch_music()
412                             self.async_download_next()
413                         elif current.is_failed():
414                             var.playlist.remove_by_id(current.id)
415                         else:
416                             self._loop_status = 'Wait for downloading'
417                     else:
418                         self.wait_for_downloading = False
419
420         while self.mumble.sound_output.get_buffer_size() > 0:
421             # Empty the buffer before exit
422             time.sleep(0.01)
423         time.sleep(0.5)
424
425         if self.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")
429                 var.playlist.save()
430
431     def volume_cycle(self):
432         delta = time.time() - self.last_volume_cycle_time
433
434         if self.on_ducking and self.ducking_release < time.time():
435             self._clear_pymumble_soundqueue()
436             self.on_ducking = False
437             self._max_rms = 0
438
439         if delta > 0.001:
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
442             else:
443                 self.volume = self.volume_set - (self.volume_set - self.volume) * math.exp(- delta / 0.5)
444
445         self.last_volume_cycle_time = time.time()
446
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')
453             else:
454                 print('%6d/%6d  ' % (rms, self._max_rms) + '-'*int(self.ducking_threshold/200) \
455                       + '+'*int((rms - self.ducking_threshold)/200), end='\r')
456
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
462
463     # =======================
464     #      Play Control
465     # =======================
466
467     def clear(self):
468         # Kill the ffmpeg thread and empty the playlist
469         if self.thread:
470             self.thread.kill()
471             self.thread = None
472         var.playlist.clear()
473         self.log.info("bot: music stopped. playlist trashed.")
474
475     def stop(self):
476         self.interrupt()
477         self.is_pause = True
478         self.log.info("bot: music stopped.")
479
480     def interrupt(self):
481         # Kill the ffmpeg thread
482         if self.thread:
483             self.thread.kill()
484             self.thread = None
485         self.song_start_at = -1
486         self.playhead = 0
487
488     def pause(self):
489         # Kill the ffmpeg thread
490         if self.thread:
491             self.pause_at_id = var.playlist.current_item()
492             self.thread.kill()
493             self.thread = None
494         self.is_pause = True
495         self.song_start_at = -1
496         self.log.info("bot: music paused at %.2f seconds." % self.playhead)
497
498     def resume(self):
499         self.is_pause = False
500
501         if var.playlist.current_index == -1:
502             var.playlist.next()
503
504         music_wrapper = var.playlist.current_item()
505
506         if not music_wrapper or not music_wrapper.id == self.pause_at_id or not music_wrapper.is_ready():
507             self.playhead = 0
508             return
509
510         if var.config.getboolean('debug', 'ffmpeg'):
511             ffmpeg_debug = "debug"
512         else:
513             ffmpeg_debug = "warning"
514
515         self.log.info("bot: resume music at %.2f seconds" % self.playhead)
516
517         uri = music_wrapper.uri()
518
519         command = ("ffmpeg", '-v', ffmpeg_debug, '-nostdin', '-ss', "%f" % self.playhead, '-i',
520                    uri, '-ac', '1', '-f', 's16le', '-ar', '48000', '-')
521
522
523         if var.config.getboolean('bot', 'announce_current_music'):
524             self.send_msg(var.playlist.current_item().format_current_playing())
525
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 = ""
535
536
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.")
544
545
546
547 def start_web_interface(addr, port):
548     global formatter
549     import interface
550
551     # setup logger
552     werkzeug_logger = logging.getLogger('werkzeug')
553     logfile = util.solve_filepath(var.config.get('webinterface', 'web_logfile'))
554     handler = None
555     if logfile:
556         handler = logging.handlers.RotatingFileHandler(logfile, mode='a', maxBytes=10240) # Rotate after 10KB
557     else:
558         handler = logging.StreamHandler()
559
560     werkzeug_logger.addHandler(handler)
561
562     interface.init_proxy()
563     interface.web.env = 'development'
564     interface.web.run(port=port, host=addr)
565
566
567 if __name__ == '__main__':
568     parser = argparse.ArgumentParser(
569         description='Bot for playing music on Mumble')
570
571     # General arguments
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')
576
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")
581
582     # Mumble arguments
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")
597
598     args = parser.parse_args()
599
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)],
602                                  encoding='utf-8')
603     var.dbfile = args.db if args.db is not None else util.solve_filepath(
604         config.get("bot", "database_path", fallback="database.db"))
605
606     if len(parsed_configs) == 0:
607         logging.error('Could not read configuration from file \"{}\"'.format(args.config))
608         sys.exit()
609
610     var.config = config
611     var.db = SettingsDatabase(var.dbfile)
612
613     # Setup logger
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)
617
618     logfile = util.solve_filepath(var.config.get('bot', 'logfile'))
619     handler = None
620     if logfile:
621         handler = logging.handlers.RotatingFileHandler(logfile, mode='a', maxBytes=10240) # Rotate after 10KB
622     else:
623         handler = logging.StreamHandler()
624
625     handler.setFormatter(formatter)
626     bot_logger.addHandler(handler)
627     var.bot_logger = bot_logger
628
629     if var.config.get("bot", "save_music_library", fallback=True):
630         var.music_db = MusicDatabase(var.dbfile)
631     else:
632         var.music_db = MusicDatabase(":memory:")
633
634     var.library = MusicLibrary(var.music_db)
635
636     # load playback mode
637     playback_mode = None
638     if var.db.has_option("playlist", "playback_mode"):
639         playback_mode = var.db.get('playlist', 'playback_mode')
640     else:
641         playback_mode = var.config.get('bot', 'playback_mode', fallback="one-shot")
642
643     if playback_mode in ["one-shot", "repeat", "random"]:
644         var.playlist = media.playlist.get_playlist(playback_mode)
645     else:
646         raise KeyError("Unknown playback mode '%s'" % playback_mode)
647
648     var.bot = MumbleBot(args)
649     command.register_all_commands(var.bot)
650
651     # load playlist
652     if var.config.getboolean('bot', 'save_playlist', fallback=True):
653         var.bot_logger.info("bot: load playlist from previous session")
654         var.playlist.load()
655
656     # Start the main loop.
657     var.bot.loop()