From 97f2326d9b7ba7816541f0b167084529d35da950 Mon Sep 17 00:00:00 2001 From: Terry Geng Date: Tue, 25 Feb 2020 02:17:02 +0800 Subject: [PATCH] Add more controls (#71) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add more ducking command * fix current music command * provide more controls, like pause, resume, clear. * add more controls in the web interface * refactored and improved: 1. move get_music_tag_info to util, and 2. refined logic related to it. 3. now playlist will check for tag info thus update_music_tag_info is useless and was removed 4. add "add folder" feature to !file asked in #65, 5. fix bugs related to !file * truncate file list if too long * fixed several tiny bugs * fixed several tiny bugs continue * fixed several tiny bugs continue continue * fixed several tiny bugs continue**3 * fixed several tiny bugs continue**4 * added !filematch command to add files that match a regex pattern. * truncate long message * fix web interface delete file issue * refresh after delete file * add regex to listfile command * refactored command part, added partial match support for commands * organized * added random command * typo * typo * Fixed many bugs. * Added workaround for azlux/pymumble#44 to fix the memory leak. * changed logging style. * fixed bugs related to random and resume * fix now playing * fixed issue related to download * fixed issue related to download 2 * fixed thumbnail issue * fixed add url problem in web interface * REFACTORED, turned db.ini into sqlite3 database. * fixed remove song problem * fixed radio get title problem. auto download if tmp file is deleted * fixed current index not loaded from database * fixed: order of songs loaded from the database * fixed: some obscure bugs. beautified error of commands * added a workaround for TerryGeng/botamusique#1. * beautified * fixed: channel not loaded in the config * fixed: auto checked for updates * fixed: mysterious bug: sometimes "now playing" string cannot be properly displayed. The real reason is: do use
, do not use
. I tried hours to find out this. * chore: unified debug messages that refer to music items * feav: fetch ffmpeg stderr mentioned in #72, reformatted logs. * fix: async download not working * fix: async download not working, still * fix: async download not working, finished * feat: queue command: ▶current playing item◀ * feat: support more than one command prefix * chore: added some WARNINGs into default config file to avoid people to touch it. * refactor: packed all string contants into constants.py, just to avoid people messing it around. * refactor: required by azlux. Added a configuration.example.ini to keep people away from configuration.default.ini --- .gitignore | 4 +- command.py | 602 +++++++++++++++++++++++++ configuration.default.ini | 218 ++++++--- configuration.example.ini | 126 ++++++ constants.py | 32 ++ database.py | 93 ++++ interface.py | 97 +++-- media/playlist.py | 104 +++-- media/radio.py | 4 +- mumbleBot.py | 897 +++++++++++--------------------------- templates/index.html | 102 +++-- templates/playlist.html | 2 +- util.py | 238 +++++++++- 13 files changed, 1684 insertions(+), 835 deletions(-) create mode 100644 command.py create mode 100644 configuration.example.ini create mode 100644 constants.py create mode 100644 database.py diff --git a/.gitignore b/.gitignore index 73fbb64..2c63931 100644 --- a/.gitignore +++ b/.gitignore @@ -110,4 +110,6 @@ configuration.ini 2019-07-27 22_09_08-radiobrowser.py - botamusique - Visual Studio Code.png music_folder/ -tmp/ \ No newline at end of file +tmp/ + +database.db \ No newline at end of file diff --git a/command.py b/command.py new file mode 100644 index 0000000..58f38dc --- /dev/null +++ b/command.py @@ -0,0 +1,602 @@ +# coding=utf-8 +import logging +import os.path +import pymumble.pymumble_py3 as pymumble +import re + +import constants +import media.file +import media.playlist +import media.radio +import media.system +import media.url +import util +import variables as var +from librb import radiobrowser +from database import Database + + +def register_all_commands(bot): + bot.register_command(constants.commands('joinme'), cmd_joinme) + bot.register_command(constants.commands('user_ban'), cmd_user_ban) + bot.register_command(constants.commands('user_unban'), cmd_user_unban) + bot.register_command(constants.commands('url_ban'), cmd_url_ban) + bot.register_command(constants.commands('url_unban'), cmd_url_unban) + bot.register_command(constants.commands('play'), cmd_play) + bot.register_command(constants.commands('pause'), cmd_pause) + bot.register_command(constants.commands('play_file'), cmd_play_file) + bot.register_command(constants.commands('play_file_match'), cmd_play_file_match) + bot.register_command(constants.commands('play_url'), cmd_play_url) + bot.register_command(constants.commands('play_playlist'), cmd_play_playlist) + bot.register_command(constants.commands('play_radio'), cmd_play_radio) + bot.register_command(constants.commands('rb_query'), cmd_rb_query) + bot.register_command(constants.commands('rb_play'), cmd_rb_play) + bot.register_command(constants.commands('help'), cmd_help) + bot.register_command(constants.commands('stop'), cmd_stop) + bot.register_command(constants.commands('clear'), cmd_clear) + bot.register_command(constants.commands('kill'), cmd_kill) + bot.register_command(constants.commands('update'), cmd_update) + bot.register_command(constants.commands('stop_and_getout'), cmd_stop_and_getout) + bot.register_command(constants.commands('volume'), cmd_volume) + bot.register_command(constants.commands('ducking'), cmd_ducking) + bot.register_command(constants.commands('ducking_threshold'), cmd_ducking_threshold) + bot.register_command(constants.commands('ducking_volume'), cmd_ducking_volume) + bot.register_command(constants.commands('current_music'), cmd_current_music) + bot.register_command(constants.commands('skip'), cmd_skip) + bot.register_command(constants.commands('remove'), cmd_remove) + bot.register_command(constants.commands('list_file'), cmd_list_file) + bot.register_command(constants.commands('queue'), cmd_queue) + bot.register_command(constants.commands('random'), cmd_random) + bot.register_command(constants.commands('drop_database'), cmd_drop_database) + +def send_multi_lines(bot, lines, text): + msg = "" + br = "" + for newline in lines: + msg += br + br = "
" + if len(msg) + len(newline) > 5000: + bot.send_msg(msg, text) + msg = "" + msg += newline + + bot.send_msg(msg, text) + +# ---------------- Commands ------------------ + + +def cmd_joinme(bot, user, text, command, parameter): + channel_id = bot.mumble.users[text.actor]['channel_id'] + bot.mumble.channels[channel_id].move_in() + + +def cmd_user_ban(bot, user, text, command, parameter): + if bot.is_admin(user): + if parameter: + bot.mumble.users[text.actor].send_text_message(util.user_ban(parameter)) + else: + bot.mumble.users[text.actor].send_text_message(util.get_user_ban()) + else: + bot.mumble.users[text.actor].send_text_message(constants.strings('not_admin')) + return + + +def cmd_user_unban(bot, user, text, command, parameter): + if bot.is_admin(user): + if parameter: + bot.mumble.users[text.actor].send_text_message(util.user_unban(parameter)) + else: + bot.mumble.users[text.actor].send_text_message(constants.strings('not_admin')) + return + + +def cmd_url_ban(bot, user, text, command, parameter): + if bot.is_admin(user): + if parameter: + bot.mumble.users[text.actor].send_text_message(util.url_ban(util.get_url_from_input(parameter))) + else: + bot.mumble.users[text.actor].send_text_message(util.get_url_ban()) + else: + bot.mumble.users[text.actor].send_text_message(constants.strings('not_admin')) + return + + +def cmd_url_unban(bot, user, text, command, parameter): + if bot.is_admin(user): + if parameter: + bot.mumble.users[text.actor].send_text_message(util.url_unban(util.get_url_from_input(parameter))) + else: + bot.mumble.users[text.actor].send_text_message(constants.strings('not_admin')) + return + + +def cmd_play(bot, user, text, command, parameter): + if var.playlist.length() > 0: + if parameter is not None and parameter.isdigit() and int(parameter) > 0 \ + and int(parameter) <= len(var.playlist.playlist): + bot.stop() + bot.launch_music(int(parameter) - 1) + elif bot.is_pause: + bot.resume() + else: + bot.send_msg(util.format_current_playing(), text) + else: + bot.send_msg(constants.strings('queue_empty'), text) + + +def cmd_pause(bot, user, text, command, parameter): + bot.pause() + bot.send_msg(constants.strings('paused')) + + +def cmd_play_file(bot, user, text, command, parameter): + music_folder = var.config.get('bot', 'music_folder') + # if parameter is {index} + if parameter.isdigit(): + files = util.get_recursive_filelist_sorted(music_folder) + if int(parameter) < len(files): + filename = files[int(parameter)].replace(music_folder, '') + music = {'type': 'file', + 'path': filename, + 'user': user} + logging.info("cmd: add to playlist: " + filename) + music = var.playlist.append(music) + bot.send_msg(constants.strings('file_added', item=util.format_song_string(music)), text) + + # if parameter is {path} + else: + # sanitize "../" and so on + path = os.path.abspath(os.path.join(music_folder, parameter)) + if not path.startswith(os.path.abspath(music_folder)): + bot.send_msg(constants.strings('no_file'), text) + return + + if os.path.isfile(path): + music = {'type': 'file', + 'path': parameter, + 'user': user} + logging.info("cmd: add to playlist: " + parameter) + music = var.playlist.append(music) + bot.send_msg(constants.strings('file_added', item=util.format_song_string(music)), text) + return + + # if parameter is {folder} + elif os.path.isdir(path): + if parameter != '.' and parameter != './': + if not parameter.endswith("/"): + parameter += "/" + else: + parameter = "" + + files = util.get_recursive_filelist_sorted(music_folder) + music_library = util.Dir(music_folder) + for file in files: + music_library.add_file(file) + + files = music_library.get_files(parameter) + msgs = [constants.strings('multiple_file_added')] + count = 0 + + for file in files: + count += 1 + music = {'type': 'file', + 'path': file, + 'user': user} + logging.info("cmd: add to playlist: " + file) + music = var.playlist.append(music) + + msgs.append("{} ({})".format(music['title'], music['path'])) + + if count != 0: + send_multi_lines(bot, msgs, text) + else: + bot.send_msg(constants.strings('no_file'), text) + + else: + # try to do a partial match + files = util.get_recursive_filelist_sorted(music_folder) + matches = [(index, file) for index, file in enumerate(files) if parameter.lower() in file.lower()] + if len(matches) == 0: + bot.send_msg(constants.strings('no_file'), text) + elif len(matches) == 1: + music = {'type': 'file', + 'path': matches[0][1], + 'user': user} + logging.info("cmd: add to playlist: " + matches[0][1]) + music = var.playlist.append(music) + bot.send_msg(constants.strings('file_added', item=util.format_song_string(music)), text) + else: + msgs = [ constants.strings('multiple_matches')] + for match in matches: + msgs.append("{:0>3d} - {:s}".format(match[0], match[1])) + send_multi_lines(bot, msgs, text) + + +def cmd_play_file_match(bot, user, text, command, parameter): + music_folder = var.config.get('bot', 'music_folder') + if parameter is not None: + files = util.get_recursive_filelist_sorted(music_folder) + msgs = [ constants.strings('file_added')] + count = 0 + try: + for file in files: + match = re.search(parameter, file) + if match: + count += 1 + music = {'type': 'file', + 'path': file, + 'user': user} + logging.info("cmd: add to playlist: " + file) + music = var.playlist.append(music) + + msgs.append("{} ({})".format(music['title'], music['path'])) + + if count != 0: + send_multi_lines(bot, msgs, text) + else: + bot.send_msg(constants.strings('no_file'), text) + + except re.error as e: + msg = constants.strings('wrong_pattern', error=str(e)) + bot.send_msg(msg, text) + else: + bot.send_msg(constants.strings('bad_parameter', command)) + + +def cmd_play_url(bot, user, text, command, parameter): + music = {'type': 'url', + # grab the real URL + 'url': util.get_url_from_input(parameter), + 'user': user, + 'ready': 'validation'} + + if media.url.get_url_info(music): + if music['duration'] > var.config.getint('bot', 'max_track_duration'): + bot.send_msg(constants.strings('too_long'), text) + else: + music['ready'] = "no" + var.playlist.append(music) + logging.info("cmd: add to playlist: " + music['url']) + bot.async_download_next() + else: + bot.send_msg(constants.strings('unable_download'), text) + + +def cmd_play_playlist(bot, user, text, command, parameter): + offset = 0 # if you want to start the playlist at a specific index + try: + offset = int(parameter.split(" ")[-1]) + except ValueError: + pass + + url = util.get_url_from_input(parameter) + logging.debug("cmd: fetching media info from playlist url %s" % url) + items = media.playlist.get_playlist_info(url=url, start_index=offset, user=user) + if len(items) > 0: + var.playlist.extend(items) + for music in items: + logging.info("cmd: add to playlist: " + util.format_debug_song_string(music)) + + +def cmd_play_radio(bot, user, text, command, parameter): + if not parameter: + all_radio = var.config.items('radio') + msg = constants.strings('preconfigurated_radio') + for i in all_radio: + comment = "" + if len(i[1].split(maxsplit=1)) == 2: + comment = " - " + i[1].split(maxsplit=1)[1] + msg += "
" + i[0] + comment + bot.send_msg(msg, text) + else: + if var.config.has_option('radio', command, parameter): + parameter = var.config.get('radio', parameter) + parameter = parameter.split()[0] + url = util.get_url_from_input(parameter) + if url: + music = {'type': 'radio', + 'url': url, + 'user': user} + var.playlist.append(music) + logging.info("cmd: add to playlist: " + music['url']) + bot.async_download_next() + else: + bot.send_msg(constants.strings('bad_url')) + + +def cmd_rb_query(bot, user, text, command, parameter): + logging.info('cmd: Querying radio stations') + if not parameter: + logging.debug('rbquery without parameter') + msg = constants.strings('rb_query_empty') + bot.send_msg(msg, text) + else: + logging.debug('cmd: Found query parameter: ' + parameter) + # bot.send_msg('Searching for stations - this may take some seconds...', text) + rb_stations = radiobrowser.getstations_byname(parameter) + msg = constants.strings('rb_query_result') + msg += '\n' + if not rb_stations: + logging.debug('cmd: No matches found for rbquery ' + parameter) + bot.send_msg('Radio-Browser found no matches for ' + parameter, text) + else: + for s in rb_stations: + stationid = s['id'] + stationname = s['stationname'] + country = s['country'] + codec = s['codec'] + bitrate = s['bitrate'] + genre = s['genre'] + # msg += f'' + msg += '' % ( + stationid, stationname, genre, codec, bitrate, country) + msg += '
!rbplay IDStation NameGenreCodec/BitrateCountry
{stationid}{stationname}{genre}{codec}/{bitrate}{country}
%s%s%s%s/%s%s
' + # Full message as html table + if len(msg) <= 5000: + bot.send_msg(msg, text) + # Shorten message if message too long (stage I) + else: + logging.debug('Result too long stage I') + msg = constants.strings('rb_query_result') + ' (shortened L1)' + msg += '\n' + for s in rb_stations: + stationid = s['id'] + stationname = s['stationname'] + # msg += f'' + msg += '' % (stationid, stationname) + msg += '
!rbplay IDStation Name
{stationid}{stationname}
%s%s
' + if len(msg) <= 5000: + bot.send_msg(msg, text) + # Shorten message if message too long (stage II) + else: + logging.debug('Result too long stage II') + msg = constants.strings('rb_query_result') + ' (shortened L2)' + msg += '!rbplay ID - Station Name' + for s in rb_stations: + stationid = s['id'] + stationname = s['stationname'][:12] + # msg += f'{stationid} - {stationname}' + msg += '%s - %s' % (stationid, stationname) + if len(msg) <= 5000: + bot.send_msg(msg, text) + # Message still too long + else: + bot.send_msg('Query result too long to post (> 5000 characters), please try another query.', + text) + + +def cmd_rb_play(bot, user, text, command, parameter): + logging.debug('cmd: Play a station by ID') + if not parameter: + logging.debug('rbplay without parameter') + msg = constants.strings('rb_play_empty') + bot.send_msg(msg, text) + else: + logging.debug('cmd: Retreiving url for station ID ' + parameter) + rstation = radiobrowser.getstationname_byid(parameter) + stationname = rstation[0]['name'] + country = rstation[0]['country'] + codec = rstation[0]['codec'] + bitrate = rstation[0]['bitrate'] + genre = rstation[0]['tags'] + homepage = rstation[0]['homepage'] + msg = 'Radio station added to playlist:' + # msg += '' + \ + # f'
IDStation NameGenreCodec/BitrateCountryHomepage
{parameter}{stationname}{genre}{codec}/{bitrate}{country}{homepage}
' + msg += '' + \ + '
IDStation NameGenreCodec/BitrateCountryHomepage
%s%s%s%s/%s%s%s
' \ + % (parameter, stationname, genre, codec, bitrate, country, homepage) + logging.debug('cmd: Added station to playlist %s' % stationname) + bot.send_msg(msg, text) + url = radiobrowser.geturl_byid(parameter) + if url != "-1": + logging.info('cmd: Found url: ' + url) + music = {'type': 'radio', + 'title': stationname, + 'artist': homepage, + 'url': url, + 'user': user} + var.playlist.append(music) + logging.info("cmd: add to playlist: " + music['url']) + bot.async_download_next() + else: + logging.info('cmd: No playable url found.') + msg += "No playable url found for this station, please try another station." + bot.send_msg(msg, text) + + +def cmd_help(bot, user, text, command, parameter): + bot.send_msg(constants.strings('help'), text) + if bot.is_admin(user): + bot.send_msg(constants.strings('admin_help'), text) + + +def cmd_stop(bot, user, text, command, parameter): + bot.stop() + bot.send_msg(constants.strings('stopped'), text) + + +def cmd_clear(bot, user, text, command, parameter): + bot.clear() + bot.send_msg(constants.strings('cleared'), text) + + +def cmd_kill(bot, user, text, command, parameter): + if bot.is_admin(user): + bot.pause() + bot.exit = True + else: + bot.mumble.users[text.actor].send_text_message( + constants.strings('not_admin')) + + +def cmd_update(bot, user, text, command, parameter): + if bot.is_admin(user): + bot.mumble.users[text.actor].send_text_message( + constants.strings('start_updating')) + msg = util.update(bot.version) + bot.mumble.users[text.actor].send_text_message(msg) + else: + bot.mumble.users[text.actor].send_text_message( + constants.strings('not_admin')) + + +def cmd_stop_and_getout(bot, user, text, command, parameter): + bot.stop() + if bot.channel: + bot.mumble.channels.find_by_name(bot.channel).move_in() + + +def cmd_volume(bot, user, text, command, parameter): + # The volume is a percentage + if parameter is not None and parameter.isdigit() and 0 <= int(parameter) <= 100: + bot.volume_set = float(float(parameter) / 100) + bot.send_msg(constants.strings('change_volume', + volume=int(bot.volume_set * 100), user=bot.mumble.users[text.actor]['name']), text) + var.db.set('bot', 'volume', str(bot.volume_set)) + logging.info('cmd: volume set to %d' % (bot.volume_set * 100)) + else: + bot.send_msg(constants.strings('current_volume', volume=int(bot.volume_set * 100)), text) + + +def cmd_ducking(bot, user, text, command, parameter): + if parameter == "" or parameter == "on": + bot.is_ducking = True + var.db.set('bot', 'ducking', True) + bot.ducking_volume = var.config.getfloat("bot", "ducking_volume", fallback=0.05) + bot.ducking_threshold = var.config.getint("bot", "ducking_threshold", fallback=5000) + bot.mumble.callbacks.set_callback(pymumble.constants.PYMUMBLE_CLBK_SOUNDRECEIVED, + bot.ducking_sound_received) + bot.mumble.set_receive_sound(True) + logging.info('cmd: ducking is on') + msg = "Ducking on." + bot.send_msg(msg, text) + elif parameter == "off": + bot.is_ducking = False + bot.mumble.set_receive_sound(False) + var.db.set('bot', 'ducking', False) + msg = "Ducking off." + logging.info('cmd: ducking is off') + bot.send_msg(msg, text) + + +def cmd_ducking_threshold(bot, user, text, command, parameter): + if parameter is not None and parameter.isdigit(): + bot.ducking_threshold = int(parameter) + var.db.set('bot', 'ducking_threshold', str(bot.ducking_threshold)) + msg = "Ducking threshold set to %d." % bot.ducking_threshold + bot.send_msg(msg, text) + else: + msg = "Current ducking threshold is %d." % bot.ducking_threshold + bot.send_msg(msg, text) + + +def cmd_ducking_volume(bot, user, text, command, parameter): + # The volume is a percentage + if parameter is not None and parameter.isdigit() and 0 <= int(parameter) <= 100: + bot.ducking_volume = float(float(parameter) / 100) + bot.send_msg(constants.strings('change_ducking_volume', + volume=int(bot.ducking_volume * 100), user=bot.mumble.users[text.actor]['name']), text) + # var.db.set('bot', 'volume', str(bot.volume_set)) + var.db.set('bot', 'ducking_volume', str(bot.ducking_volume)) + logging.info('cmd: volume on ducking set to %d' % (bot.ducking_volume * 100)) + else: + bot.send_msg(constants.strings('current_ducking_volume', volume=int(bot.ducking_volume * 100)), text) + + +def cmd_current_music(bot, user, text, command, parameter): + reply = "" + if var.playlist.length() > 0: + bot.send_msg(util.format_current_playing()) + else: + reply = constants.strings('not_playing') + bot.send_msg(reply, text) + + +def cmd_skip(bot, user, text, command, parameter): + if bot.next(): # Is no number send, just skip the current music + bot.launch_music() + bot.async_download_next() + else: + bot.send_msg(constants.strings('queue_empty'), text) + + +def cmd_remove(bot, user, text, command, parameter): + # Allow to remove specific music into the queue with a number + if parameter is not None and parameter.isdigit() and int(parameter) > 0 \ + and int(parameter) <= var.playlist.length(): + + index = int(parameter) - 1 + + removed = None + if index == var.playlist.current_index: + removed = var.playlist.remove(index) + var.botamusique.stop() + var.botamusique.launch_music(index) + else: + removed = var.playlist.remove(index) + + # the Title isn't here if the music wasn't downloaded + bot.send_msg(constants.strings('removing_item', + item=removed['title'] if 'title' in removed else removed['url']), text) + + logging.info("cmd: delete from playlist: " + str(removed['path'] if 'path' in removed else removed['url'])) + else: + bot.send_msg(constants.strings('bad_parameter', command=command)) + + +def cmd_list_file(bot, user, text, command, parameter): + folder_path = var.config.get('bot', 'music_folder') + + files = util.get_recursive_filelist_sorted(folder_path) + msgs = [ "
Files available:" if not parameter else "
Matched files:" ] + try: + count = 0 + for index, file in enumerate(files): + if parameter: + match = re.search(parameter, file) + if not match: + continue + + count += 1 + msgs.append("{:0>3d} - {:s}".format(index, file)) + + if count != 0: + send_multi_lines(bot, msgs, text) + else: + bot.send_msg(constants.strings('no_file'), text) + + except re.error as e: + msg = constants.strings('wrong_pattern', error=str(e)) + bot.send_msg(msg, text) + + +def cmd_queue(bot, user, text, command, parameter): + if len(var.playlist.playlist) == 0: + msg = constants.strings('queue_empty') + bot.send_msg(msg, text) + else: + msgs = [ constants.strings('queue_contents')] + for i, value in enumerate(var.playlist.playlist): + newline = '' + if i == var.playlist.current_index: + newline = '{} ▶ ({}) {} ◀'.format(i + 1, value['type'], + value['title'] if 'title' in value else value['url']) + else: + newline = '{} ({}) {}'.format(i + 1, value['type'], + value['title'] if 'title' in value else value['url']) + + msgs.append(newline) + + send_multi_lines(bot, msgs, text) + + +def cmd_random(bot, user, text, command, parameter): + bot.stop() + var.playlist.randomize() + bot.launch_music(0) + +def cmd_drop_database(bot, user, text, command, parameter): + var.db.drop_table() + var.db = Database(var.dbfile) + bot.send_msg(constants.strings('database_dropped'), text) diff --git a/configuration.default.ini b/configuration.default.ini index 9ab470c..7a46224 100644 --- a/configuration.default.ini +++ b/configuration.default.ini @@ -1,20 +1,45 @@ +# ======================================================== +# botamusique Default Configuration File +# Version 6 +# ======================================================== +# WARNING: +# ****************************** +# **DO NOT MODIFIED THIS FILE.** +# ****************************** +# +# Please create your own configuration file, and +# ONLY ADD ITEMS YOU WANT TO MODIFY into it. Other +# items will be loaded from this file automatically. +# DO NOT DIRECTLY COPY THIS FILE. +# +# That is because this file will be overridden +# during updates. New options will be added and +# old options (like [strings]) will be updated. +# ======================================================== + [server] host = 127.0.0.1 port = 64738 password = channel = -tokens = # example: token1,token2 +# example: token1,token2 +tokens = certificate = [bot] username = botamusique -comment = Hi, I'm here to play radio, local music or youtube/soundcloud music. Have fun ! +comment = Hi, I'm here to play radio, local music or youtube/soundcloud music. Have fun! +# default volume from 0 to 1. volume = 0.1 -admin = User1;User2; # Allow user to kill the bot + +# Users allowed to kill the bot, or ban URLs. +admin = User1;User2; +# Folder that stores your local songs. music_folder = music_folder/ +# Folder that stores the downloaded music. tmp_folder = /tmp/ pip3_path = venv/bin/pip -auto_update = True +auto_check_update = True logfile = # in MB, 0 for no cache, -1 for unlimited size @@ -27,6 +52,10 @@ announce_current_music = True allow_other_channel_message = False allow_private_message = True +# If save_playlist is set True, the bot will save current +# playlist before quitting and reload it the next time it start. +save_playlist = True + # Maximum track played when a playlist is added. max_track_playlist = 20 @@ -39,6 +68,7 @@ ducking_volume = 0.05 ducking_threshold = 3000 [webinterface] +# Set enabled to True if you'd like to use the web interface to manage your playlist, upload files, etc. enabled = False is_web_proxified = True listening_addr = 127.0.0.1 @@ -47,16 +77,38 @@ listening_port = 8181 # Set this option to True to enable password protection for the web interface require_auth = False user = -password = +password = -[command] -#This it the char (only on letter) the bot will recognize as a command -command_symbol = ! -#this option split username, in case you use such kind of mumo plugins https://wiki.mumble.info/wiki/Mumo#Set_Status +[debug] +# Set ffmpeg to True if you want to display DEBUG level log of ffmpeg. +ffmpeg = False +mumbleConnection = False + +# This is a list of default radio stations. +[radio] +ponyville = http://192.99.131.205:8000/stream.mp3 "Here a command of !radio comment" +luna = http://radio.ponyvillelive.com:8002/stream "calm and orchestra music" +radiobrony = http://62.210.138.34:8000/live "Borny music of a friend" +celestiaradio = http://celestia.aiverse.org:8000/mp3_256 +jazz = http://jazz-wr04.ice.infomaniak.ch/jazz-wr04-128.mp3 "Jazz Yeah !" + + + + + +# ======================================================== +# WARNING: WE DO NOT SUGGEST YOU MODIFY THE FOLLOWING +# PARTS, EXCEPT YOU KNOW WHAT YOU ARE DOING. +# ======================================================== +[commands] +# This is a list of characters the bot recognizes as command prefix. +command_symbol = !:! +# This option split username, in case you use such kind of mumo plugins https://wiki.mumble.info/wiki/Mumo#Set_Status split_username_at_space = False -play_file = file +play_file = file, f +play_file_match = filematch, fm play_url = url play_radio = radio play_playlist = playlist @@ -65,17 +117,22 @@ rb_query = rbquery rb_play = rbplay help = help +pause = pause +play = p, play stop = stop -list = list +remove = rm +clear = clear skip = skip -current_music = np -volume = v +current_music = np, now +volume = volume kill = kill stop_and_getout = oust joinme = joinme queue = queue repeat = repeat +random = random update = update +list_file = listfile user_ban = userban user_unban = userunban @@ -84,72 +141,91 @@ url_unban = urlunban ducking = duck ducking_threshold = duckthres +ducking_volume = duckv -#command to reload the ban list -reload = reload - -[radio] -ponyville = http://192.99.131.205:8000/stream.mp3 "Here a command of !radio comment" -luna = http://radio.ponyvillelive.com:8002/stream "calm and orchestra music" -radiobrony = http://62.210.138.34:8000/live "Borny music of a friend" -celestiaradio = http://celestia.aiverse.org:8000/mp3_256 -jazz = http://jazz-wr04.ice.infomaniak.ch/jazz-wr04-128.mp3 "Jazz Yeah !" +drop_database = dropdatabase [strings] -current_volume = volume : %d%% -change_volume = volume : %d%% by %s -bad_command = Incorrect command -not_admin = You are not an admin ! -not_playing = No music right now -bad_file = Bad file requested +current_volume = Current volume: {volume} +current_ducking_volume = Volume on ducking: {volume} by {user} +change_volume = Volume set to {volume} by {user} +change_ducking_volume = Volume on ducking set to {volume} by {user} +bad_command = {command}: command not found +bad_parameter = {command}: invalid parameter +error_executing_command = {command}: Command failed with error: {error}. +not_admin = You are not an admin! +not_playing = Nothing is playing right now no_file = File not found +wrong_pattern = Invalid regex: {error} +file_added = Added: {item} +multiple_file_added = Multiple files added: bad_url = Bad URL requested -preconfigurated_radio = Preconfigurated Radio available -unable_download = Error while downloading the music... +preconfigurated_radio = Preconfigurated Radio available: +unable_download = Error while downloading music... +which_command = Do you mean
{commands} multiple_matches = Track not found! Possible candidates: -queue_contents = The next items in the queue are: -queue_empty = No more music in the playlist! -now_playing = Now playing %s
%s -not_in_my_channel = You're not in my channel, command refused ! +queue_contents = Items on the playlist: +queue_empty = Playlist is empty! +now_playing = Now playing {item}
{thumb} +not_in_my_channel = You're not in my channel, command refused! pm_not_allowed = Private message aren't allowed. -too_long = This music is too long, skipping ! -download_in_progress = Download of %s in progress -no_possible = it's not possible to do that -removing_item = Removing entry %s from queue -user_ban = You are ban, not allowed to do that ! -url_ban = This url isn't allowed ! -rb_query_result = This is the result of your query, send !rbplay 'ID' to play a station -rb_query_empty = You have to add a query text to search for a matching radio stations. -rb_play_empty = Please enter a station ID from rbquery. Example: !rbplay 96748 +too_long = This music is too long, skip! +download_in_progress = Download of {item} in progress +removing_item = Removed entry {item} from playlist +user_ban = You are banned, not allowed to do that! +url_ban = This url is banned! +rb_query_result = This is the result of your query, send !rbplay 'ID' to play a station: +rb_play_empty = Please specify a radio station ID! +paused = Music paused. +stopped = Music stopped. +cleared = Playlist emptied. +database_dropped = Database dropped. All records have gone. +new_version_found =

Update Available!

New version of botamusique is available, send !update to update! +start_updating = Start updating... -help = Command available: -
!file [path] -
!url [url] - youtube or soundcloud -
!playlist [url] [offset] - youtube or soundcloud playlist (the offset is the track number the bot will start to play - 1 by default) -
!radio [url] - url of a stream -
!rbquery - Search http://www.radio-browser.info for a radio station -
!rbplay - Play a radio station from !rbquery search results (eg. !rbplay 96746) -
!list - display list of available tracks -
!queue - display items in queue -
!np - display the current music -
!skip - jump to the next music of the playlist (of remove the X items if you add a number) -
!stop - stop and clear the playlist -
!oust - stop + Go to default channel -
!v - get or change the volume (in %) -
!joinme - join your own channel -
!duck [on/off] - enable or disable ducking function -
!duckthres - set the threshold of volume to activate ducking (3000 by default) +help =

Commands

+ Control + + Playlist + + Other + -admin_help = Admin command: -
!kill (kill the bot) -
!update (update the bot) -
!userban [user] (ban a user) -
!userunban [user] (unban a user) -
!urlban [url] (ban an url) -
!urlunban [url] (unban an url) -
!reload (reload the ban config) +admin_help =

Admin command

+ -[debug] -ffmpeg = False -mumbleConnection = False diff --git a/configuration.example.ini b/configuration.example.ini new file mode 100644 index 0000000..863ee4d --- /dev/null +++ b/configuration.example.ini @@ -0,0 +1,126 @@ +# ======================================================== +# botamusique Example Configuration File +# Version 6 +# ======================================================== +# Rename this file into configuration.ini after editing. +# Uncomment lines you needed, and carefully follow the +# instructions. +# ======================================================== + +# [server] section tells the bot how to connect to your murmur server. +[server] +host = 127.0.0.1 +port = 64738 +#password = +#channel = +#tokens = token1,token2 +#certificate = + +# [bot] section stores some basic settings of the behavior of the bot. +[bot] +# 'username' is the user name of the bot. +# 'comment' is the comment displayed by the bot. +#username = botamusique +#comment = Hi, I'm here to play radio, local music or youtube/soundcloud music. Have fun! + +# 'volume' is default volume from 0 to 1. +#volume = 0.1 + +# 'admin': Users allowed to kill the bot, or ban URLs. Separated by ';' +#admin = User1;User2; + +# 'music_folder': Folder that stores your local songs. +#music_folder = music_folder/ + +# 'tmp_folder': Folder that stores the downloaded music. +# 'tmp_folder_max_size': in MB, 0 for no cache, -1 for unlimited size +# 'ignored_folders', 'ignored_files': files and folders that would be ignored during scanning. +#tmp_folder = /tmp/ +#tmp_folder_max_size = 10 +#ignored_folders = tmp +#ignored_files = Thumbs.db + +# 'auto_check_update': check for updates every time the bot starts +#auto_check_update = True +#pip3_path = venv/bin/pip + +# 'logfile': write logs into this file. +#logfile = + +#announce_current_music = True +#allow_other_channel_message = False +#allow_private_message = True + +# 'save_playlist': If save_playlist is set True, the bot will save current +# playlist before quitting and reload it the next time it start. +#save_playlist = True + +# 'max_track_playlist': Maximum track played when a playlist is added. +#max_track_playlist = 20 + +# 'max_track_duration': Maximum music duration (minutes) +#max_track_duration = 60 + +# 'ducking': If ducking is enabled, the bot will automatically attenuate its +# volume when someone is talking. +#ducking = False +#ducking_volume = 0.05 +#ducking_threshold = 3000 + +# [webinterface] stores settings related to the web interface. +[webinterface] +# 'enable': Set 'enabled' to True if you'd like to use the web interface to manage +# your playlist, upload files, etc. +# The web interface is disable by default for security and performance reason. +#enabled = False +#listening_addr = 127.0.0.1 +#listening_port = 8181 +#is_web_proxified = True + +# 'required_auth': Set this to True to enable password protection for the web interface. +#require_auth = False +#user = +#password = + +# [debug] stores some debug settings. +[debug] +# 'ffmpeg': Set ffmpeg to True if you want to display DEBUG level log of ffmpeg. +#ffmpeg = False +#mumbleConnection = False + +# [radio] is a list of default radio stations. +[radio] +#ponyville = http://192.99.131.205:8000/stream.mp3 "Here a command of !radio comment" +#luna = http://radio.ponyvillelive.com:8002/stream "calm and orchestra music" +#radiobrony = http://62.210.138.34:8000/live "Borny music of a friend" +#celestiaradio = http://celestia.aiverse.org:8000/mp3_256 +#jazz = http://jazz-wr04.ice.infomaniak.ch/jazz-wr04-128.mp3 "Jazz Yeah !" + + +# [commands] is settings related to user command sent via mumble message. +[commands] +# 'command_symbol' is a list of characters the bot recognizes as command prefix. +#command_symbol = !:! +# 'split_username_at_space': This option split username, in case you use such kind of mumo plugins https://wiki.mumble.info/wiki/Mumo#Set_Status +#split_username_at_space = False + + +# You may also customize commands recognized by the bot. For a full list of commands, +# see configuration.default.ini. Copy options you want to edit into this file. +#play_file = file, f +#play_file_match = filematch, fm + +# [strings] is used to compose what the bot says. You can customize them to fit in +# the style of your channel, or translate into your own language. +# For a full list of strings, please see configuration.default.ini. +# Copy options you want to edit into this file. +# Note: please keep those {placeholder} of each string in your new string. +[strings] +# Some examples are: +#current_volume = Current volume: {volume} +#current_volume = 当前音量为{volume} +#current_volume = よく聞いてね!今の音量は{volume}!
ちゃんと覚える:大音量で耳が悪くなる! +# +#bad_command = {command}: command not found +#bad_command = {command}: 未知命令,键入'!help'以获取可用命令列表 +#bad_command = {command}がなに?食べれる?おいしいでしか? diff --git a/constants.py b/constants.py new file mode 100644 index 0000000..b04a5f8 --- /dev/null +++ b/constants.py @@ -0,0 +1,32 @@ +import variables as var + +def strings(option, *argv, **kwargs): + string = "" + try: + string = var.config.get("strings", option) + except KeyError as e: + raise KeyError("Missed strings in configuration file: '{string}'. ".format(string=option) + + "Please restore you configuration file back to default if necessary.") + if argv or kwargs: + try: + formatted = string.format(*argv, **kwargs) + return formatted + except KeyError as e: + raise KeyError( + "Missed placeholder {{{placeholder}}} in string '{string}'. ".format(placeholder=str(e).strip("'"), string=option) + + "Please restore you configuration file back to default if necessary.") + except TypeError as e: + raise KeyError( + "Missed placeholder in string '{string}'. ".format(string=option) + + "Please restore you configuration file back to default if necessary.") + else: + return string + +def commands(command): + string = "" + try: + string = var.config.get("commands", command) + return string + except KeyError as e: + raise KeyError("Missed command in configuration file: '{string}'. ".format(string=command) + + "Please restore you configuration file back to default if necessary.") diff --git a/database.py b/database.py new file mode 100644 index 0000000..0ba16cd --- /dev/null +++ b/database.py @@ -0,0 +1,93 @@ +import sqlite3 + +class DatabaseError(Exception): + pass + +class Database: + def __init__(self, db_path): + self.db_path = db_path + + # connect + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + # check if table exists, or create one + tables = cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='botamusique';").fetchall() + if len(tables) == 0: + cursor.execute("CREATE TABLE botamusique (section text, option text, value text, UNIQUE(section, option))") + conn.commit() + + conn.close() + + def get(self, section, option, **kwargs): + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + result = cursor.execute("SELECT value FROM botamusique WHERE section=? AND option=?", (section, option)).fetchall() + conn.close() + + if len(result) > 0: + return result[0][0] + else: + if 'fallback' in kwargs: + return kwargs['fallback'] + else: + raise DatabaseError("Item not found") + + def getboolean(self, section, option, **kwargs): + return bool(int(self.get(section, option, **kwargs))) + + def getfloat(self, section, option, **kwargs): + return float(self.get(section, option, **kwargs)) + + def getint(self, section, option, **kwargs): + return int(self.get(section, option, **kwargs)) + + def set(self, section, option, value): + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + cursor.execute(''' + INSERT OR REPLACE INTO botamusique (section, option, value) + VALUES (?, ?, ?) + ''', (section, option, value)) + conn.commit() + conn.close() + + def has_option(self, section, option): + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + result = cursor.execute("SELECT value FROM botamusique WHERE section=? AND option=?", (section, option)).fetchall() + conn.close() + if len(result) > 0: + return True + else: + return False + + def remove_option(self, section, option): + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + cursor.execute("DELETE FROM botamusique WHERE section=? AND option=?", (section, option)) + conn.commit() + conn.close() + + def remove_section(self, section): + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + cursor.execute("DELETE FROM botamusique WHERE section=?", (section, )) + conn.commit() + conn.close() + + def items(self, section): + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + results = cursor.execute("SELECT option, value FROM botamusique WHERE section=?", (section, )).fetchall() + conn.close() + + return map(lambda v: (v[0], v[1]), results) + + def drop_table(self): + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + cursor.execute("DROP TABLE botamusique") + conn.close() + + diff --git a/interface.py b/interface.py index 1b9d22c..ee439ad 100644 --- a/interface.py +++ b/interface.py @@ -14,6 +14,7 @@ import errno import media import logging import time +import constants class ReverseProxied(object): @@ -105,41 +106,39 @@ def index(): music_library=music_library, os=os, playlist=var.playlist, - user=var.user + user=var.user, + paused=var.botamusique.is_pause ) @web.route("/playlist", methods=['GET']) @requires_auth def playlist(): if var.playlist.length() == 0: - return jsonify([render_template('playlist.html', + return jsonify({'items': [render_template('playlist.html', m=False, index=-1 )] - ) + }) - data = [] + items = [] for index, item in enumerate(var.playlist.playlist): - data.append(render_template('playlist.html', + items.append(render_template('playlist.html', index=index, m=item, playlist=var.playlist ) ) - return jsonify(data) + return jsonify({ 'items': items }) @web.route("/post", methods=['POST']) @requires_auth def post(): folder_path = var.music_folder - files = util.get_recursive_filelist_sorted(var.music_folder) - music_library = util.Dir(folder_path) - for file in files: - music_library.add_file(file) if request.method == 'POST': - logging.debug("Post request: "+ str(request.form)) + if request.form: + logging.debug("Post request: "+ str(request.form)) if 'add_file_bottom' in request.form and ".." not in request.form['add_file_bottom']: path = var.config.get('bot', 'music_folder') + request.form['add_file_bottom'] if os.path.isfile(path): @@ -147,8 +146,8 @@ def post(): 'path' : request.form['add_file_bottom'], 'title' : '', 'user' : 'Web'} - var.playlist.append(var.botamusique.get_music_tag_info(item, path)) - logging.info('web: add to playlist(bottom): ' + item['path']) + item = var.playlist.append(util.get_music_tag_info(item)) + logging.info('web: add to playlist(bottom): ' + util.format_debug_song_string(item)) elif 'add_file_next' in request.form and ".." not in request.form['add_file_next']: path = var.config.get('bot', 'music_folder') + request.form['add_file_next'] @@ -157,11 +156,11 @@ def post(): 'path' : request.form['add_file_next'], 'title' : '', 'user' : 'Web'} - var.playlist.insert( + item = var.playlist.insert( var.playlist.current_index + 1, - var.botamusique.get_music_tag_info(item, var.config.get('bot', 'music_folder') + item['path']) + item ) - logging.info('web: add to playlist(next): ' + item['path']) + logging.info('web: add to playlist(next): ' + util.format_debug_song_string(item)) elif ('add_folder' in request.form and ".." not in request.form['add_folder']) or ('add_folder_recursively' in request.form and ".." not in request.form['add_folder_recursively']): try: @@ -175,50 +174,63 @@ def post(): print('folder:', folder) if os.path.isdir(var.config.get('bot', 'music_folder') + folder): + + files = util.get_recursive_filelist_sorted(var.music_folder) + music_library = util.Dir(folder_path) + for file in files: + music_library.add_file(file) + if 'add_folder_recursively' in request.form: files = music_library.get_files_recursively(folder) else: files = music_library.get_files(folder) - files = list(map(lambda file: var.botamusique.get_music_tag_info({'type':'file','path': os.path.join(folder, file), 'user':'Web'}, \ - var.config.get('bot', 'music_folder') + os.path.join(folder, file)), files)) + files = list(map(lambda file: + {'type':'file', + 'path': os.path.join(folder, file), + 'user':'Web'}, files)) + + files = var.playlist.extend(files) + + for file in files: + logging.info("web: add to playlist: %s" % util.format_debug_song_string(file)) - logging.info("web: add to playlist: " + " ,".join([file['path'] for file in files])) - var.playlist.extend(files) elif 'add_url' in request.form: - var.playlist.append({'type':'url', + music = {'type':'url', 'url': request.form['add_url'], 'user': 'Web', - 'ready': 'validation'}) - logging.info("web: add to playlist: " + request.form['add_url']) - media.url.get_url_info() + 'ready': 'validation'} + media.url.get_url_info(music) + music = var.playlist.append(music) + logging.info("web: add to playlist: " + util.format_debug_song_string(music)) var.playlist.playlist[-1]['ready'] = "no" elif 'add_radio' in request.form: - var.playlist.append({'type': 'radio', + music = var.playlist.append({'type': 'radio', 'path': request.form['add_radio'], 'user': "Web"}) - logging.info("web: add to playlist: " + request.form['add_radio']) + logging.info("web: add to playlist: " + util.format_debug_song_string(music)) elif 'delete_music' in request.form: music = var.playlist.playlist[int(request.form['delete_music'])] - logging.info("web: delete from playlist: " + str(music['path'] if 'path' in music else music['url'])) + logging.info("web: delete from playlist: " + util.format_debug_song_string(music)) - if len(var.playlist.playlist) >= int(request.form['delete_music']): - if var.playlist.current_index == int(request.form['delete_music']): - var.botamusique.pause() + if var.playlist.length() >= int(request.form['delete_music']): + if int(request.form['delete_music']) == var.playlist.current_index: var.playlist.remove(int(request.form['delete_music'])) - var.botamusique.launch_music() + var.botamusique.stop() + var.botamusique.launch_music(int(request.form['delete_music'])) else: var.playlist.remove(int(request.form['delete_music'])) + elif 'play_music' in request.form: music = var.playlist.playlist[int(request.form['play_music'])] - logging.info("web: jump to: " + str(music['path'] if 'path' in music else music['url'])) + logging.info("web: jump to: " + util.format_debug_song_string(music)) if len(var.playlist.playlist) >= int(request.form['play_music']): - var.botamusique.pause() + var.botamusique.stop() var.botamusique.launch_music(int(request.form['play_music'])) elif 'delete_music_file' in request.form and ".." not in request.form['delete_music_file']: @@ -237,11 +249,17 @@ def post(): elif 'action' in request.form: action = request.form['action'] if action == "randomize": - var.playlist.randomize() - elif action == "stop": - var.botamusique.pause() - elif action == "clear": var.botamusique.stop() + var.playlist.randomize() + var.botamusique.resume() + elif action == "stop": + var.botamusique.stop() + elif action == "pause": + var.botamusique.pause() + elif action == "resume": + var.botamusique.resume() + elif action == "clear": + var.botamusique.clear() elif action == "volume_up": if var.botamusique.volume_set + 0.03 < 1.0: var.botamusique.volume_set = var.botamusique.volume_set + 0.03 @@ -255,7 +273,10 @@ def post(): var.botamusique.volume_set = 0 logging.info("web: volume up to %d" % (var.botamusique.volume_set * 100)) - return jsonify({'ver': var.playlist.version}) + if(var.playlist.length() > 0): + return jsonify({'ver': var.playlist.version, 'empty': False, 'play': not var.botamusique.is_pause}) + else: + return jsonify({'ver': var.playlist.version, 'empty': True, 'play': False}) @web.route('/upload', methods=["POST"]) def upload(): diff --git a/media/playlist.py b/media/playlist.py index 58aa1c9..0a284f0 100644 --- a/media/playlist.py +++ b/media/playlist.py @@ -1,6 +1,8 @@ import youtube_dl import variables as var +import util import random +import json class PlayList: playlist = [] @@ -9,25 +11,35 @@ class PlayList: def append(self, item): self.version += 1 + item = util.get_music_tag_info(item) self.playlist.append(item) + return item + def insert(self, index, item): self.version += 1 if index == -1: index = self.current_index + item = util.get_music_tag_info(item) self.playlist.insert(index, item) if index <= self.current_index: self.current_index += 1 + return item + def length(self): return len(self.playlist) def extend(self, items): self.version += 1 + items = list(map( + lambda item: util.get_music_tag_info(item), + items)) self.playlist.extend(items) + return items def next(self): self.version += 1 @@ -51,10 +63,14 @@ class PlayList: if index == -1: index = self.current_index + + removed = self.playlist[index] del self.playlist[index] - if self.current_index <= index: - self.next() + if self.current_index > index: + self.current_index -= 1 + + return removed def current_item(self): return self.playlist[self.current_index] @@ -81,12 +97,12 @@ class PlayList: def randomize(self): # current_index will lose track after shuffling, thus we take current music out before shuffling - current = self.current_item() - del self.playlist[self.current_index] + #current = self.current_item() + #del self.playlist[self.current_index] random.shuffle(self.playlist) - self.playlist.insert(0, current) + #self.playlist.insert(0, current) self.current_index = 0 self.version += 1 @@ -95,8 +111,25 @@ class PlayList: self.playlist = [] self.current_index = 0 + def save(self): + var.db.remove_section("playlist_item") + var.db.set("playlist", "current_index", self.current_index) + for index, item in enumerate(self.playlist): + var.db.set("playlist_item", str(index), json.dumps(item)) + + def load(self): + current_index = var.db.getint("playlist", "current_index", fallback=-1) + if current_index == -1: + return + + items = list(var.db.items("playlist_item")) + items.sort(key=lambda v: int(v[0])) + self.playlist = list(map(lambda v: json.loads(v[1]), items)) + self.current_index = current_index + def get_playlist_info(url, start_index=0, user=""): + items = [] ydl_opts = { 'extract_flat': 'in_playlist' } @@ -104,6 +137,16 @@ def get_playlist_info(url, start_index=0, user=""): for i in range(2): try: info = ydl.extract_info(url, download=False) + # # if url is not a playlist but a video + # if 'entries' not in info and 'webpage_url' in info: + # music = {'type': 'url', + # 'title': info['title'], + # 'url': info['webpage_url'], + # 'user': user, + # 'ready': 'validation'} + # items.append(music) + # return items + playlist_title = info['title'] for j in range(start_index, min(len(info['entries']), start_index + var.config.getint('bot', 'max_track_playlist'))): # Unknow String if No title into the json @@ -111,7 +154,6 @@ def get_playlist_info(url, start_index=0, user=""): # Add youtube url if the url in the json isn't a full url url = info['entries'][j]['url'] if info['entries'][j]['url'][0:4] == 'http' else "https://www.youtube.com/watch?v=" + info['entries'][j]['url'] - # append the music to a list of futur music to play music = {'type': 'url', 'title': title, 'url': url, @@ -120,32 +162,30 @@ def get_playlist_info(url, start_index=0, user=""): 'playlist_title': playlist_title, 'playlist_url': url, 'ready': 'validation'} - var.playlist.append(music) - except youtube_dl.utils.DownloadError: + items.append(music) + except: pass - else: - return True - return False + return items -def get_music_info(index=0): - ydl_opts = { - 'playlist_items': str(index) - } - with youtube_dl.YoutubeDL(ydl_opts) as ydl: - for i in range(2): - try: - info = ydl.extract_info(var.playlist.playlist[index]['url'], download=False) - # Check if the Duration is longer than the config - if var.playlist[index]['current_index'] == index: - var.playlist[index]['current_duration'] = info['entries'][0]['duration'] / 60 - var.playlist[index]['current_title'] = info['entries'][0]['title'] - # Check if the Duration of the next music is longer than the config (async download) - elif var.playlist[index]['current_index'] == index - 1: - var.playlist[index]['next_duration'] = info['entries'][0]['duration'] / 60 - var.playlist[index]['next_title'] = info['entries'][0]['title'] - except youtube_dl.utils.DownloadError: - pass - else: - return True - return False +# def get_music_info(index=0): +# ydl_opts = { +# 'playlist_items': str(index) +# } +# with youtube_dl.YoutubeDL(ydl_opts) as ydl: +# for i in range(2): +# try: +# info = ydl.extract_info(var.playlist.playlist[index]['url'], download=False) +# # Check if the Duration is longer than the config +# if var.playlist[index]['current_index'] == index: +# var.playlist[index]['current_duration'] = info['entries'][0]['duration'] / 60 +# var.playlist[index]['current_title'] = info['entries'][0]['title'] +# # Check if the Duration of the next music is longer than the config (async download) +# elif var.playlist[index]['current_index'] == index - 1: +# var.playlist[index]['next_duration'] = info['entries'][0]['duration'] / 60 +# var.playlist[index]['next_title'] = info['entries'][0]['title'] +# except youtube_dl.utils.DownloadError: +# pass +# else: +# return True +# return False diff --git a/media/radio.py b/media/radio.py index db9ad8a..cc7bf1b 100644 --- a/media/radio.py +++ b/media/radio.py @@ -68,6 +68,6 @@ def get_radio_title(url): title = m.group(1) if title: return title.decode() - except (urllib.error.URLError, urllib.error.HTTPError): + except (urllib.error.URLError, urllib.error.HTTPError, http.client.BadStatusLine): pass - return 'Unable to get the music title' + return 'Unknown title' diff --git a/mumbleBot.py b/mumbleBot.py index 599910b..66528c4 100644 --- a/mumbleBot.py +++ b/mumbleBot.py @@ -11,6 +11,7 @@ import configparser import audioop import subprocess as sp import argparse +import os import os.path import pymumble.pymumble_py3 as pymumble import interface @@ -18,12 +19,12 @@ import variables as var import hashlib import youtube_dl import logging +import traceback + import util -import base64 -from PIL import Image -from io import BytesIO -import mutagen -import re +import command +import constants +from database import Database import media.url import media.file import media.playlist @@ -62,33 +63,23 @@ type : file user """ -version = 5 - - class MumbleBot: + version = 5 + def __init__(self, args): + logging.info("bot: botamusique version %d, starting..." % self.version) signal.signal(signal.SIGINT, self.ctrl_caught) + self.cmd_handle = {} self.volume_set = var.config.getfloat('bot', 'volume') - if db.has_option('bot', 'volume'): + if var.db.has_option('bot', 'volume'): self.volume_set = var.db.getfloat('bot', 'volume') self.volume = self.volume_set - - root = logging.getLogger() - formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') - root.setLevel(logging.INFO) - - logfile = var.config.get('bot', 'logfile') - - handler = None - if logfile: - handler = logging.FileHandler(logfile) + if args.channel: + self.channel = args.channel else: - handler = logging.StreamHandler(sys.stdout) - - handler.setFormatter(formatter) - root.addHandler(handler) + self.channel = var.config.get("server", "channel", fallback=None) if args.verbose: root.setLevel(logging.DEBUG) @@ -106,18 +97,25 @@ class MumbleBot: self.exit = False self.nb_exit = 0 self.thread = None + self.thread_stderr = None self.is_playing = False self.is_pause = False + self.playhead = -1 + self.song_start_at = -1 if var.config.getboolean("webinterface", "enabled"): wi_addr = var.config.get("webinterface", "listening_addr") wi_port = var.config.getint("webinterface", "listening_port") interface.init_proxy() tt = threading.Thread( - target=start_web_interface, args=(wi_addr, wi_port)) + target=start_web_interface, name="WebThread", args=(wi_addr, wi_port)) tt.daemon = True tt.start() + if var.config.getboolean("bot", "auto_check_update"): + th = threading.Thread(target=self.check_update, name="UpdateThread") + th.daemon = True + th.start() if args.host: host = args.host @@ -171,10 +169,15 @@ class MumbleBot: self.is_ducking = False self.on_ducking = False self.ducking_release = time.time() - if var.config.getboolean("bot", "ducking"): + + + if not var.db.has_option("bot", "ducking") and var.config.getboolean("bot", "ducking", fallback=False)\ + or var.config.getboolean("bot", "ducking"): self.is_ducking = True self.ducking_volume = var.config.getfloat("bot", "ducking_volume", fallback=0.05) + self.ducking_volume = var.db.getfloat("bot", "ducking_volume", fallback=self.ducking_volume) self.ducking_threshold = var.config.getfloat("bot", "ducking_threshold", fallback=5000) + self.ducking_threshold = var.db.getfloat("bot", "ducking_threshold", fallback=self.ducking_threshold) self.mumble.callbacks.set_callback(pymumble.constants.PYMUMBLE_CLBK_SOUNDRECEIVED, self.ducking_sound_received) self.mumble.set_receive_sound(True) @@ -183,22 +186,39 @@ class MumbleBot: logging.info( "\nSIGINT caught, quitting, {} more to kill".format(2 - self.nb_exit)) self.exit = True - self.stop() + self.pause() if self.nb_exit > 1: logging.info("Forced Quit") sys.exit(0) self.nb_exit += 1 + def check_update(self): + logging.debug("update: checking for updates...") + new_version = util.new_release_version() + if new_version > self.version: + logging.info("update: new version %d found, current installed version %d." % (new_version, self.version)) + self.send_msg(constants.strings('new_version_found')) + else: + logging.debug("update: no new version found.") + + def register_command(self, cmd, handle): + cmds = cmd.split(",") + for command in cmds: + command = command.strip() + if command: + self.cmd_handle[command] = handle + logging.debug("bot: command added: " + command) + # All text send to the chat is analysed by this function def message_received(self, text): message = text.message.strip() user = self.mumble.users[text.actor]['name'] - if var.config.getboolean('command', 'split_username_at_space'): + if var.config.getboolean('commands', 'split_username_at_space'): # in can you use https://github.com/Natenom/mumblemoderator-module-collection/tree/master/os-suffixes , you want to split the username user = user.split()[0] - if message[0] == var.config.get('command', 'command_symbol'): + if message[0] in var.config.get('commands', 'command_symbol'): # remove the symbol from the message message = message[1:].split(' ', 1) @@ -208,451 +228,69 @@ class MumbleBot: parameter = '' if len(message) > 1: parameter = message[1] - else: return logging.info('bot: received command ' + command + ' - ' + parameter + ' by ' + user) - if command == var.config.get('command', 'joinme'): - self.mumble.users.myself.move_in( - self.mumble.users[text.actor]['channel_id'], token=parameter) - return - # Anti stupid guy function if not self.is_admin(user) and not var.config.getboolean('bot', 'allow_other_channel_message') and self.mumble.users[text.actor]['channel_id'] != self.mumble.users.myself['channel_id']: self.mumble.users[text.actor].send_text_message( - var.config.get('strings', 'not_in_my_channel')) + constants.strings('not_in_my_channel')) return if not self.is_admin(user) and not var.config.getboolean('bot', 'allow_private_message') and text.session: self.mumble.users[text.actor].send_text_message( - var.config.get('strings', 'pm_not_allowed')) + constants.strings('pm_not_allowed')) return - ### - # Admin command - ### for i in var.db.items("user_ban"): if user.lower() == i[0]: self.mumble.users[text.actor].send_text_message( - var.config.get('strings', 'user_ban')) + constants.strings('user_ban')) return - if command == var.config.get('command', 'user_ban'): - if self.is_admin(user): - if parameter: - self.mumble.users[text.actor].send_text_message( - util.user_ban(parameter)) - else: - self.mumble.users[text.actor].send_text_message( - util.get_user_ban()) - else: - self.mumble.users[text.actor].send_text_message( - var.config.get('strings', 'not_admin')) - return - - elif command == var.config.get('command', 'user_unban'): - if self.is_admin(user): - if parameter: - self.mumble.users[text.actor].send_text_message( - util.user_unban(parameter)) - else: - self.mumble.users[text.actor].send_text_message( - var.config.get('strings', 'not_admin')) - return - - elif command == var.config.get('command', 'url_ban'): - if self.is_admin(user): - if parameter: - self.mumble.users[text.actor].send_text_message( - util.url_ban(self.get_url_from_input(parameter))) - else: - self.mumble.users[text.actor].send_text_message( - util.get_url_ban()) - else: - self.mumble.users[text.actor].send_text_message( - var.config.get('strings', 'not_admin')) - return - - elif command == var.config.get('command', 'url_unban'): - if self.is_admin(user): - if parameter: - self.mumble.users[text.actor].send_text_message( - util.url_unban(self.get_url_from_input(parameter))) - else: - self.mumble.users[text.actor].send_text_message( - var.config.get('strings', 'not_admin')) - return - if parameter: for i in var.db.items("url_ban"): - if self.get_url_from_input(parameter.lower()) == i[0]: + if util.get_url_from_input(parameter.lower()) == i[0]: self.mumble.users[text.actor].send_text_message( - var.config.get('strings', 'url_ban')) + constants.strings('url_ban')) return - ### - # everyday commands - ### - if command == var.config.get('command', 'play_file') and parameter: - music_folder = var.config.get('bot', 'music_folder') - # sanitize "../" and so on - path = os.path.abspath(os.path.join(music_folder, parameter)) - if path.startswith(music_folder): - if os.path.isfile(path): - filename = path.replace(music_folder, '') - music = {'type': 'file', - 'path': filename, - 'user': user} - logging.info("bot: add to playlist: " + filename) - var.playlist.append(music) + + command_exc = "" + try: + if command in self.cmd_handle: + command_exc = command + self.cmd_handle[command](self, user, text, command, parameter) + else: + # try partial match + cmds = self.cmd_handle.keys() + matches = [] + for cmd in cmds: + if cmd.startswith(command): + matches.append(cmd) + + if len(matches) == 1: + logging.info("bot: {:s} matches {:s}".format(command, matches[0])) + command_exc = matches[0] + self.cmd_handle[matches[0]](self, user, text, command, parameter) + elif len(matches) > 1: + self.mumble.users[text.actor].send_text_message( + constants.strings('which_command', commands="
".join(matches))) else: - # try to do a partial match - matches = [file for file in util.get_recursive_filelist_sorted( - music_folder) if parameter.lower() in file.lower()] - if len(matches) == 0: - self.send_msg(var.config.get( - 'strings', 'no_file'), text) - elif len(matches) == 1: - music = {'type': 'file', - 'path': matches[0], - 'user': user} - logging.info("bot: add to playlist: " + matches[0]) - var.playlist.append(music) - else: - msg = var.config.get( - 'strings', 'multiple_matches') + '
' - msg += '
'.join(matches) - self.send_msg(msg, text) - else: - self.send_msg(var.config.get('strings', 'bad_file'), text) - self.async_download_next() + self.mumble.users[text.actor].send_text_message( + constants.strings('bad_command', command=command)) + except: + error_traceback = traceback.format_exc() + error = error_traceback.rstrip().split("\n")[-1] + logging.error("bot: command %s failed with error %s:\n" % (command_exc, error_traceback)) + self.send_msg(constants.strings('error_executing_command', command=command_exc, error=error), text) - elif command == var.config.get('command', 'play_url') and parameter: - music = {'type': 'url', - # grab the real URL - 'url': self.get_url_from_input(parameter), - 'user': user, - 'ready': 'validation'} - - if media.url.get_url_info(music): - if music['duration'] > var.config.getint('bot', 'max_track_duration'): - self.send_msg(var.config.get( - 'strings', 'too_long'), text) - else: - for i in var.db.options("url_ban"): - if music['url'] == i: - self.mumble.users[text.actor].send_text_message( - var.config.get('strings', 'url_ban')) - return - music['ready'] = "no" - var.playlist.append(music) - logging.info("bot: add to playlist: " + music['url']) - self.async_download_next() - else: - self.send_msg(var.config.get( - 'strings', 'unable_download'), text) - - - elif command == var.config.get('command', 'play_playlist') and parameter: - offset = 1 # if you want to start the playlist at a specific index - try: - offset = int(parameter.split(" ")[-1]) - except ValueError: - pass - if media.playlist.get_playlist_info(url=self.get_url_from_input(parameter), start_index=offset, user=user): - self.async_download_next() - - elif command == var.config.get('command', 'play_radio'): - if not parameter: - all_radio = var.config.items('radio') - msg = var.config.get( - 'strings', 'preconfigurated_radio') + " :" - for i in all_radio: - comment = "" - if len(i[1].split(maxsplit=1)) == 2: - comment = " - " + i[1].split(maxsplit=1)[1] - msg += "
" + i[0] + comment - self.send_msg(msg, text) - else: - if var.config.has_option('radio', parameter): - parameter = var.config.get('radio', parameter) - parameter = parameter.split()[0] - url = self.get_url_from_input(parameter) - if url: - music = {'type': 'radio', - 'url': url, - 'user': user} - var.playlist.append(music) - logging.info("bot: add to playlist: " + music['url']) - self.async_download_next() - else: - self.send_msg(var.config.get('strings', 'bad_url')) - # query http://www.radio-browser.info API for a radio station - elif command == var.config.get('command', 'rb_query'): - logging.info('bot: Querying radio stations') - if not parameter: - logging.debug('rbquery without parameter') - msg = var.config.get('strings', 'rb_query_empty') - self.send_msg(msg, text) - else: - logging.debug('bot: Found query parameter: ' + parameter) - # self.send_msg('Searching for stations - this may take some seconds...', text) - rb_stations = radiobrowser.getstations_byname(parameter) - msg = var.config.get('strings', 'rb_query_result') + " :" - msg += '\n' - if not rb_stations: - logging.debug('bot: No matches found for rbquery ' + parameter) - self.send_msg('Radio-Browser found no matches for ' + parameter, text) - else: - for s in rb_stations: - stationid = s['id'] - stationname = s['stationname'] - country = s['country'] - codec = s['codec'] - bitrate = s['bitrate'] - genre = s['genre'] - # msg += f'' - msg += '' % (stationid, stationname, genre, codec, bitrate, country) - msg += '
!rbplay IDStation NameGenreCodec/BitrateCountry
{stationid}{stationname}{genre}{codec}/{bitrate}{country}
%s%s%s%s/%s%s
' - # Full message as html table - if len(msg) <= 5000: - self.send_msg(msg, text) - # Shorten message if message too long (stage I) - else: - logging.debug('Result too long stage I') - msg = var.config.get('strings', 'rb_query_result') + " :" + ' (shortened L1)' - msg += '\n' - for s in rb_stations: - stationid = s['id'] - stationname = s['stationname'] - # msg += f'' - msg += '' % (stationid, stationname) - msg += '
!rbplay IDStation Name
{stationid}{stationname}
%s%s
' - if len(msg) <= 5000: - self.send_msg(msg, text) - # Shorten message if message too long (stage II) - else: - logging.debug('Result too long stage II') - msg = var.config.get('strings', 'rb_query_result') + " :" + ' (shortened L2)' - msg += '!rbplay ID - Station Name' - for s in rb_stations: - stationid = s['id'] - stationname = s['stationname'][:12] - # msg += f'{stationid} - {stationname}' - msg += '%s - %s' % (stationid, stationname) - if len(msg) <= 5000: - self.send_msg(msg, text) - # Message still too long - else: - self.send_msg('Query result too long to post (> 5000 characters), please try another query.', text) - # Play a secific station (by id) from http://www.radio-browser.info API - elif command == var.config.get('command', 'rb_play'): - logging.debug('bot: Play a station by ID') - if not parameter: - logging.debug('rbplay without parameter') - msg = var.config.get('strings', 'rb_play_empty') - self.send_msg(msg, text) - else: - logging.debug('bot: Retreiving url for station ID ' + parameter) - rstation = radiobrowser.getstationname_byid(parameter) - stationname = rstation[0]['name'] - country = rstation[0]['country'] - codec = rstation[0]['codec'] - bitrate = rstation[0]['bitrate'] - genre = rstation[0]['tags'] - homepage = rstation[0]['homepage'] - msg = 'Radio station added to playlist:' - # msg += '' + \ - # f'
IDStation NameGenreCodec/BitrateCountryHomepage
{parameter}{stationname}{genre}{codec}/{bitrate}{country}{homepage}
' - msg += '' + \ - '
IDStation NameGenreCodec/BitrateCountryHomepage
%s%s%s%s/%s%s%s
' \ - % (parameter, stationname, genre, codec, bitrate, country, homepage) - logging.debug('bot: Added station to playlist %s' % stationname) - self.send_msg(msg, text) - url = radiobrowser.geturl_byid(parameter) - if url != "-1": - logging.info('bot: Found url: ' + url) - music = {'type': 'radio', - 'title': stationname, - 'artist': homepage, - 'url': url, - 'user': user} - var.playlist.append(music) - logging.info("bot: add to playlist: " + music['url']) - self.async_download_next() - else: - logging.info('bot: No playable url found.') - msg += "No playable url found for this station, please try another station." - self.send_msg(msg, text) - - elif command == var.config.get('command', 'help'): - self.send_msg(var.config.get('strings', 'help'), text) - if self.is_admin(user): - self.send_msg(var.config.get( - 'strings', 'admin_help'), text) - - elif command == var.config.get('command', 'stop'): - self.stop() - - elif command == var.config.get('command', 'kill'): - if self.is_admin(user): - self.stop() - self.exit = True - else: - self.mumble.users[text.actor].send_text_message( - var.config.get('strings', 'not_admin')) - - elif command == var.config.get('command', 'update'): - if self.is_admin(user): - self.mumble.users[text.actor].send_text_message( - "Starting the update") - # Need to be improved - msg = util.update(version) - self.mumble.users[text.actor].send_text_message(msg) - else: - self.mumble.users[text.actor].send_text_message( - var.config.get('strings', 'not_admin')) - - elif command == var.config.get('command', 'stop_and_getout'): - self.stop() - if self.channel: - self.mumble.channels.find_by_name(self.channel).move_in() - - elif command == var.config.get('command', 'volume'): - # The volume is a percentage - if parameter is not None and parameter.isdigit() and 0 <= int(parameter) <= 100: - self.volume_set = float(float(parameter) / 100) - self.send_msg(var.config.get('strings', 'change_volume') % ( - int(self.volume_set * 100), self.mumble.users[text.actor]['name']), text) - var.db.set('bot', 'volume', str(self.volume_set)) - logging.info('bot: volume set to %d' % (self.volume_set * 100)) - else: - self.send_msg(var.config.get( - 'strings', 'current_volume') % int(self.volume_set * 100), text) - - elif command == var.config.get('command', 'ducking'): - if parameter == "" or parameter == "on": - self.is_ducking = True - self.ducking_volume = var.config.getfloat("bot", "ducking_volume", fallback=0.05) - self.ducking_threshold = var.config.getint("bot", "ducking_threshold", fallback=5000) - self.mumble.callbacks.set_callback(pymumble.constants.PYMUMBLE_CLBK_SOUNDRECEIVED, - self.ducking_sound_received) - self.mumble.set_receive_sound(True) - logging.info('bot: ducking is on') - msg = "Ducking on." - self.send_msg(msg, text) - elif parameter == "off": - self.is_ducking = False - self.mumble.set_receive_sound(False) - msg = "Ducking off." - logging.info('bot: ducking is off') - self.send_msg(msg, text) - - elif command == var.config.get('command', 'ducking_threshold'): - if parameter is not None and parameter.isdigit(): - self.ducking_threshold = int(parameter) - msg = "Ducking threshold set to %d." % self.ducking_threshold - self.send_msg(msg, text) - - elif command == var.config.get('command', 'current_music'): - if len(var.playlist.playlist) > 0: - current_music = var.playlist.current_item() - source = current_music["type"] - if source == "radio": - reply = "[radio] {title} on {url} by {user}".format( - title=media.radio.get_radio_title( - current_music["url"]), - url=current_music["title"], - user=current_music["user"] - ) - elif source == "url" and 'from_playlist' in current_music: - reply = "[playlist] {title} (from the playlist {playlist} by {user}".format( - title=current_music["title"], - url=current_music["playlist_url"], - playlist=current_music["playlist_title"], - user=current_music["user"] - ) - elif source == "url": - reply = "[url] {title} ({url}) by {user}".format( - title=current_music["title"], - url=current_music["url"], - user=current_music["user"] - ) - elif source == "file": - thumbnail_html = '' - reply = "[file] {title} by {user}
{thumb}".format( - title=current_music['artist'] + ' - ' + current_music['title'], - user=current_music["user"], - thumb=thumbnail_html - ) - else: - reply = "ERROR" - logging.error(current_music) - else: - reply = var.config.get('strings', 'not_playing') - - self.send_msg(reply, text) - - elif command == var.config.get('command', 'skip'): - # Allow to remove specific music into the queue with a number - if parameter is not None and parameter.isdigit() and int(parameter) > 0: - if int(parameter) < len(var.playlist.playlist): - removed = var.playlist.jump(int(parameter)) - - # the Title isn't here if the music wasn't downloaded - self.send_msg(var.config.get('strings', 'removing_item') % ( - removed['title'] if 'title' in removed else removed['url']), text) - else: - self.send_msg(var.config.get( - 'strings', 'no_possible'), text) - elif self.next(): # Is no number send, just skip the current music - self.launch_music() - self.async_download_next() - else: - self.send_msg(var.config.get( - 'strings', 'queue_empty'), text) - self.stop() - - elif command == var.config.get('command', 'list'): - folder_path = var.config.get('bot', 'music_folder') - - files = util.get_recursive_filelist_sorted(folder_path) - if files: - self.send_msg('
'.join(files), text) - else: - self.send_msg(var.config.get('strings', 'no_file'), text) - - elif command == var.config.get('command', 'queue'): - if len(var.playlist.playlist) <= 1: - msg = var.config.get('strings', 'queue_empty') - else: - msg = var.config.get( - 'strings', 'queue_contents') + '
' - i = 1 - for value in var.playlist.playlist: - msg += '[{}] ({}) {}
'.format(i, value['type'], value['title'] if 'title' in value else value['url']) - i += 1 - - self.send_msg(msg, text) - - elif command == var.config.get('command', 'repeat'): - var.playlist.append(var.playlist.current_item()) - music = var.playlist.current_item() - if music['type'] == 'file': - logging.info("bot: add to playlist: " + music['path']) - else: - logging.info("bot: add to playlist: " + music['url']) - - else: - self.mumble.users[text.actor].send_text_message( - var.config.get('strings', 'bad_command')) @staticmethod def is_admin(user): - list_admin = var.config.get('bot', 'admin').split(';') + list_admin = var.config.get('bot', 'admin').rstrip().split(';') if user in list_admin: return True else: @@ -674,7 +312,7 @@ class MumbleBot: else: music = var.playlist.jump(index) - logging.info("bot: play music " + str(music['path'] if 'path' in music else music['url'])) + logging.info("bot: play music " + util.format_debug_song_string(music)) if music["type"] == "url": # Delete older music is the tmp folder is too big media.system.clear_tmp_folder(var.config.get( @@ -683,51 +321,19 @@ class MumbleBot: # Check if the music is ready to be played if music["ready"] == "downloading": return - elif music["ready"] != "yes": - logging.info("Current music wasn't ready, Downloading...") - self.download_music() - if music == False: - var.playlist.remove() + elif music["ready"] != "yes" or not os.path.exists(music['path']): + logging.info("bot: current music isn't ready, downloading...") + downloaded_music = self.download_music() + if not downloaded_music: + logging.info("bot: removing music from the playlist: %s" % util.format_debug_song_string(music)) + var.playlist.remove(index) return uri = music['path'] - if self.update_music_tag_info(): - music = var.playlist.current_item() - - thumbnail_html = '' - if 'thumbnail' in music: - thumbnail_html = '' - display = '' - if 'artist' in music: - display = music['artist'] + ' - ' - if 'title' in music: - display += music['title'] - - if var.config.getboolean('bot', 'announce_current_music'): - self.send_msg(var.config.get( - 'strings', 'now_playing') % (display, thumbnail_html)) elif music["type"] == "file": uri = var.config.get('bot', 'music_folder') + \ var.playlist.current_item()["path"] - if self.update_music_tag_info(uri): - music = var.playlist.current_item() - - thumbnail_html = '' - if 'thumbnail' in music: - thumbnail_html = '' - display = '' - if 'artist' in music: - display = music['artist'] + ' - ' - if 'title' in music: - display += music['title'] - - if var.config.getboolean('bot', 'announce_current_music'): - self.send_msg(var.config.get( - 'strings', 'now_playing') % (display, thumbnail_html)) - elif music["type"] == "radio": uri = music["url"] if 'title' not in music: @@ -735,9 +341,8 @@ class MumbleBot: title = media.radio.get_radio_server_description(uri) music["title"] = title - if var.config.getboolean('bot', 'announce_current_music'): - self.send_msg(var.config.get('strings', 'now_playing') % - (music["title"], "URL: " + uri)) + if var.config.getboolean('bot', 'announce_current_music'): + self.send_msg(util.format_current_playing()) if var.config.getboolean('debug', 'ffmpeg'): ffmpeg_debug = "debug" @@ -747,50 +352,63 @@ class MumbleBot: command = ("ffmpeg", '-v', ffmpeg_debug, '-nostdin', '-i', uri, '-ac', '1', '-f', 's16le', '-ar', '48000', '-') logging.info("bot: execute ffmpeg command: " + " ".join(command)) + # The ffmpeg process is a thread - self.thread = sp.Popen(command, stdout=sp.PIPE, bufsize=480) + # prepare pipe for catching stderr of ffmpeg + pipe_rd, pipe_wd = os.pipe() + util.pipe_no_wait(pipe_rd) # Let the pipe work in non-blocking mode + self.thread_stderr = os.fdopen(pipe_rd) + self.thread = sp.Popen(command, stdout=sp.PIPE, stderr=pipe_wd, bufsize=480) self.is_playing = True self.is_pause = False + self.song_start_at = -1 + self.playhead = 0 self.last_volume_cycle_time = time.time() def download_music(self, index=-1): if index == -1: index = var.playlist.current_index - music = var.playlist.playlist[index] - if music['type'] == 'url' and music['ready'] == "validation": - music = media.url.get_url_info(music) - if music: - if music['duration'] > var.config.getint('bot', 'max_track_duration'): - # Check the length, useful in case of playlist, it wasn't checked before) - logging.info( - "the music " + music["url"] + " has a duration of " + music['duration'] + "s -- too long") - self.send_msg(var.config.get('strings', 'too_long')) - return False - else: - music['ready'] = "no" - else: - logging.error("Error while fetching info from the URL") - self.send_msg(var.config.get('strings', 'unable_download')) - return False - if music['type'] == 'url' and music['ready'] == "no": + if music['type'] != 'url': + # then no need to download + return music + + url = music['url'] + + url_hash = hashlib.md5(url.encode()).hexdigest() + + path = var.config.get('bot', 'tmp_folder') + url_hash + ".%(ext)s" + mp3 = path.replace(".%(ext)s", ".mp3") + music['path'] = mp3 + + # Download only if music is not existed + if not os.path.isfile(mp3): + if music['ready'] == "validation": + logging.info("bot: verifying the duration of url (%s) %s " % (music['title'], url)) + + if music: + if 'duration' not in music: + music = media.url.get_url_info(music) + + if music['duration'] > var.config.getint('bot', 'max_track_duration'): + # Check the length, useful in case of playlist, it wasn't checked before) + logging.info( + "the music " + music["url"] + " has a duration of " + music['duration'] + "s -- too long") + self.send_msg(constants.strings('too_long')) + return False + else: + music['ready'] = "no" + else: + logging.error("bot: error while fetching info from the URL") + self.send_msg(constants.strings('unable_download')) + return False + # download the music music['ready'] = "downloading" - url = music['url'] - url_hash = hashlib.md5(url.encode()).hexdigest() - logging.info("bot: Download url:" + url) - logging.debug(music) - - path = var.config.get('bot', 'tmp_folder') + url_hash + ".%(ext)s" - mp3 = path.replace(".%(ext)s", ".mp3") - music['path'] = mp3 - - # if os.path.isfile(mp3): - # audio = EasyID3(mp3) - # var.playlist[index]['title'] = audio["title"][0] + logging.info("bot: downloading url (%s) %s " % (music['title'], url)) ydl_opts = "" ydl_opts = { @@ -805,11 +423,8 @@ class MumbleBot: 'preferredquality': '192'}, {'key': 'FFmpegMetadata'}] } - self.send_msg(var.config.get( - 'strings', "download_in_progress") % music['title']) + self.send_msg(constants.strings('download_in_progress', music['title'])) - logging.info("Information before start downloading: " + - str(music['title'])) with youtube_dl.YoutubeDL(ydl_opts) as ydl: for i in range(2): # Always try 2 times try: @@ -820,113 +435,78 @@ class MumbleBot: pass else: break - var.playlist.playlist[index] = music - - def update_music_tag_info(self, uri=""): - music = var.playlist.current_item() - if not music['type'] == 'file' and not music['type'] == 'url': - return False - - # get the Path - if uri == "": - if 'path' in music: - uri = music['path'] - else: - return False - - if os.path.isfile(uri): - music = self.get_music_tag_info(music, uri) - var.playlist.update(music) - return True else: - return False + logging.info("bot: music file existed, skip downloading " + mp3) + music['ready'] = "yes" - def get_music_tag_info(self, music, uri=""): - if not uri: - uri = music['path'] - - if os.path.isfile(uri): - match = re.search("(.+)\.(.+)", uri) - if match is None: - return music - - file_no_ext = match[1] - ext = match[2] - - try: - im = None - path_thumbnail = file_no_ext + "jpg" - if os.path.isfile(path_thumbnail): - im = Image.open(path_thumbnail) - - if ext == "mp3": - # title: TIT2 - # artist: TPE1, TPE2 - # album: TALB - # cover artwork: APIC: - tags = mutagen.File(uri) - if 'TIT2' in tags: - music['title'] = tags['TIT2'].text[0] - if 'TPE1' in tags: # artist - music['artist'] = tags['TPE1'].text[0] - - print(music) - - if im is None: - if "APIC:" in tags: - im = Image.open(BytesIO(tags["APIC:"].data)) - - elif ext == "m4a" or ext == "m4b" or ext == "mp4" or ext == "m4p": - # title: ©nam (\xa9nam) - # artist: ©ART - # album: ©alb - # cover artwork: covr - tags = mutagen.File(uri) - if '©nam' in tags: - music['title'] = tags['©nam'][0] - if '©ART' in tags: # artist - music['artist'] = tags['©ART'][0] - - if im is None: - if "covr" in tags: - im = Image.open(BytesIO(tags["covr"][0])) - - if im: - im.thumbnail((100, 100), Image.ANTIALIAS) - buffer = BytesIO() - im = im.convert('RGB') - im.save(buffer, format="JPEG") - music['thumbnail'] = base64.b64encode(buffer.getvalue()).decode('utf-8') - - return music - except: - pass - - # if nothing found - match = re.search("([^\.]+)\.?.*", os.path.basename(uri)) - music['title'] = match[1] + music = util.get_music_tag_info(music, music['path']) + var.playlist.update(music, index) return music + def resume(self): + music = var.playlist.current_item() + + if var.config.getboolean('debug', 'ffmpeg'): + ffmpeg_debug = "debug" + else: + ffmpeg_debug = "warning" + + if music["type"] != "radio": + logging.info("bot: resume music at %.2f seconds" % self.playhead) + + uri = "" + if music["type"] == "url": + uri = music['path'] + + elif music["type"] == "file": + uri = var.config.get('bot', 'music_folder') + \ + var.playlist.current_item()["path"] + + command = ("ffmpeg", '-v', ffmpeg_debug, '-nostdin', '-ss', "%f" % self.playhead, '-i', + uri, '-ac', '1', '-f', 's16le', '-ar', '48000', '-') + + else: + logging.info("bot: resume radio") + uri = music["url"] + command = ("ffmpeg", '-v', ffmpeg_debug, '-nostdin', '-i', + uri, '-ac', '1', '-f', 's16le', '-ar', '48000', '-') + + if var.config.getboolean('bot', 'announce_current_music'): + self.send_msg(util.format_current_playing()) + + logging.info("bot: execute ffmpeg command: " + " ".join(command)) + # The ffmpeg process is a thread + # prepare pipe for catching stderr of ffmpeg + pipe_rd, pipe_wd = os.pipe() + util.pipe_no_wait(pipe_rd) # Let the pipe work in non-blocking mode + self.thread_stderr = os.fdopen(pipe_rd) + self.thread = sp.Popen(command, stdout=sp.PIPE, stderr=pipe_wd, bufsize=480) + self.is_playing = True + self.is_pause = False + self.song_start_at = -1 + self.last_volume_cycle_time = time.time() + def async_download_next(self): # Function start if the next music isn't ready # Do nothing in case the next music is already downloaded logging.info("bot: Async download next asked ") - if len(var.playlist.playlist) > 1 and var.playlist.next_item()['type'] == 'url' \ - and var.playlist.next_item()['ready'] in ["no", "validation"]: + if var.playlist.length() > 1 and var.playlist.next_item()['type'] == 'url' \ + and (var.playlist.next_item()['ready'] in ["no", "validation"]): th = threading.Thread( - target=self.download_music, kwargs={'index': var.playlist.next_index()}) + target=self.download_music, name="DownloadThread", args=(var.playlist.next_index(),)) else: return - logging.info("bot: Start downloading next in thread") + logging.info("bot: start downloading item in thread: " + util.format_debug_song_string(var.playlist.next_item())) th.daemon = True th.start() def volume_cycle(self): delta = time.time() - self.last_volume_cycle_time - if self.ducking_release < time.time(): + if self.on_ducking and self.ducking_release < time.time(): + self._clear_pymumble_soundqueue() self.on_ducking = False if delta > 0.001: @@ -945,18 +525,6 @@ class MumbleBot: self.ducking_release = time.time() + 1 # ducking release after 1s - @staticmethod - # Parse the html from the message to get the URL - def get_url_from_input(string): - if string.startswith('http'): - return string - p = re.compile('href="(.+?)"', re.IGNORECASE) - res = re.search(p, string) - if res: - return res.group(1) - else: - return False - # Main loop of the Bot def loop(self): raw_music = "" @@ -967,7 +535,20 @@ class MumbleBot: time.sleep(0.01) if self.thread: # I get raw from ffmpeg thread + # move playhead forward + if self.song_start_at == -1: + self.song_start_at = time.time() - self.playhead + self.playhead = time.time() - self.song_start_at + raw_music = self.thread.stdout.read(480) + + try: + stderr_msg = self.thread_stderr.readline() + if stderr_msg: + logging.debug("ffmpeg: " + stderr_msg.strip("\n")) + except: + pass + if raw_music: # Adjust the volume and send it to mumble self.volume_cycle() @@ -986,7 +567,7 @@ class MumbleBot: self.next() if not self.is_pause and len(var.playlist.playlist) > 0: if var.playlist.current_item()['type'] in ['radio', 'file'] \ - or (var.playlist.current_item()['type'] == 'url' and var.playlist.current_item()['ready'] not in ['validation', 'downloading']): + or (var.playlist.current_item()['type'] == 'url' and var.playlist.current_item()['ready'] not in ['downloading']): # Check if the music can be start before launch the music self.launch_music() self.async_download_next() @@ -997,10 +578,11 @@ class MumbleBot: time.sleep(0.5) if self.exit: - # The db is not fixed config like url/user ban and volume - util.write_db() + if var.config.getboolean('debug', 'save_playlist', fallback=True): + logging.info("bot: save playlist into database") + var.playlist.save() - def stop(self): + def clear(self): # Kill the ffmpeg thread and empty the playlist if self.thread: self.thread.kill() @@ -1009,6 +591,18 @@ class MumbleBot: self.is_playing = False logging.info("bot: music stopped. playlist trashed.") + def stop(self): + # Kill the ffmpeg thread + if self.thread: + self.thread.kill() + self.thread = None + self.is_playing = False + self.is_pause = True + self.song_start_at = -1 + self.playhead = 0 + self.next() + logging.info("bot: music stopped.") + def pause(self): # Kill the ffmpeg thread if self.thread: @@ -1016,7 +610,8 @@ class MumbleBot: self.thread = None self.is_playing = False self.is_pause = True - logging.info("bot: music paused.") + self.song_start_at = -1 + logging.info("bot: music paused at %.2f seconds." % self.playhead) def set_comment(self): self.mumble.users.myself.comment(var.config.get('bot', 'comment')) @@ -1030,6 +625,15 @@ class MumbleBot: else: self.mumble.users[text.actor].send_text_message(msg) + # TODO: this is a temporary workaround for issue #44 of pymumble. + def _clear_pymumble_soundqueue(self): + for id, user in self.mumble.users.items(): + user.sound.lock.acquire() + user.sound.queue.clear() + user.sound.lock.release() + logging.debug("bot: pymumble soundqueue cleared.") + + def start_web_interface(addr, port): logging.info('Starting web interface on {}:{}'.format(addr, port)) @@ -1044,7 +648,7 @@ if __name__ == '__main__': parser.add_argument("--config", dest='config', type=str, default='configuration.ini', help='Load configuration from this file. Default: configuration.ini') parser.add_argument("--db", dest='db', type=str, - default='db.ini', help='database file. Default db.ini') + default='database.db', help='database file. Default: database.db') parser.add_argument("-q", "--quiet", dest="quiet", action="store_true", help="Only Error logs") @@ -1068,21 +672,10 @@ if __name__ == '__main__': type=str, default=None, help="Certificate file") args = parser.parse_args() + var.dbfile = args.db config = configparser.ConfigParser(interpolation=None, allow_no_value=True) - parsed_configs = config.read( - ['configuration.default.ini', args.config], encoding='utf-8') - - db = configparser.ConfigParser( - interpolation=None, allow_no_value=True, delimiters='²') - db.read(var.dbfile, encoding='utf-8') - - if 'url_ban' not in db.sections(): - db.add_section('url_ban') - if 'bot' not in db.sections(): - db.add_section('bot') - if 'user_ban' not in db.sections(): - db.add_section('user_ban') + parsed_configs = config.read(['configuration.default.ini', args.config], encoding='utf-8') if len(parsed_configs) == 0: logging.error('Could not read configuration from file \"{}\"'.format( @@ -1090,6 +683,30 @@ if __name__ == '__main__': sys.exit() var.config = config - var.db = db + var.db = Database(var.dbfile) + + # Setup logger + root = logging.getLogger() + formatter = logging.Formatter('[%(asctime)s %(levelname)s %(threadName)s] %(message)s', "%b %d %H:%M:%S") + root.setLevel(logging.INFO) + + logfile = var.config.get('bot', 'logfile') + + handler = None + if logfile: + handler = logging.FileHandler(logfile) + else: + handler = logging.StreamHandler(sys.stdout) + + handler.setFormatter(formatter) + root.addHandler(handler) + var.botamusique = MumbleBot(args) + command.register_all_commands(var.botamusique) + + if var.config.getboolean('debug', 'save_playlist', fallback=True): + logging.info("bot: load playlist from previous session") + var.playlist.load() + + # Start the main loop. var.botamusique.loop() diff --git a/templates/index.html b/templates/index.html index b064158..8035077 100644 --- a/templates/index.html +++ b/templates/index.html @@ -37,10 +37,10 @@ -
- - -
+
@@ -81,10 +81,10 @@ -
- - -
+
{% endfor %} @@ -124,8 +124,19 @@ onclick="request('/post', {action : 'randomize'})"> - + + + + @@ -208,7 +219,7 @@
-
+
{% endfor %} - -
+
@@ -239,7 +249,7 @@
-
+
@@ -247,13 +257,12 @@
Add URL
-
- -
- - -
-
+ +
+ + +
@@ -263,13 +272,12 @@
Add Radio
-
- -
- - -
-
+ +
+ + +
@@ -294,27 +302,35 @@ var playlist_ver = 0; - function request(url, _data){ + function request(url, _data, refresh=false){ $.ajax({ type: 'POST', url: '/post', data : _data, statusCode : { 200 : function(data) { - if (data.ver > playlist_ver) { + if (data.ver !== playlist_ver) { updatePlaylist(); playlist_ver = data.ver; } + updateControls(data.empty, data.play); } } }); + if(refresh){ + location.reload() + } } function displayPlaylist(data){ + // console.info(data); $("#playlist-table tr").remove(); - $.each(data, function(index, item){ + + var items = data.items; + $.each(items, function(index, item){ $("#playlist-table").append(item); - }) + }); + } function updatePlaylist(){ @@ -327,6 +343,23 @@ }); } + function updateControls(empty, play){ + if(empty){ + $("#play-btn").prop("disabled", true); + $("#pause-btn").prop("disabled", true); + $("#stop-btn").prop("disabled", true); + }else{ + if(play){ + $("#play-btn").prop("disabled", true); + $("#pause-btn").prop("disabled", false); + $("#stop-btn").prop("disabled", false); + }else{ + $("#play-btn").prop("disabled", false); + $("#pause-btn").prop("disabled", true); + $("#stop-btn").prop("disabled", true); + } + } + } // Check the version of playlist to see if update is needed. setInterval(function(){ @@ -335,10 +368,11 @@ url : '/post', statusCode : { 200 : function(data){ - if(data.ver > playlist_ver){ + if(data.ver !== playlist_ver){ updatePlaylist(); playlist_ver = data.ver; } + updateControls(data.empty, data.play); } } }); diff --git a/templates/playlist.html b/templates/playlist.html index 463f592..ba6f8bf 100644 --- a/templates/playlist.html +++ b/templates/playlist.html @@ -41,7 +41,7 @@
- diff --git a/util.py b/util.py index 2ac09a2..b0bf32e 100644 --- a/util.py +++ b/util.py @@ -1,15 +1,26 @@ #!/usr/bin/python3 +# coding=utf-8 import hashlib import magic import os +import sys import variables as var +import constants import zipfile import urllib.request +import mutagen +import re import subprocess as sp import logging import youtube_dl from importlib import reload +from PIL import Image +from io import BytesIO +from sys import platform +import base64 +import media +import media.radio def get_recursive_filelist_sorted(path): filelist = [] @@ -35,6 +46,156 @@ def get_recursive_filelist_sorted(path): return filelist +def get_music_tag_info(music, uri = ""): + + if "path" in music: + if not uri: + uri = var.config.get('bot', 'music_folder') + music["path"] + + if os.path.isfile(uri): + match = re.search("(.+)\.(.+)", uri) + if match is None: + return music + + file_no_ext = match[1] + ext = match[2] + + try: + im = None + path_thumbnail = file_no_ext + ".jpg" + if os.path.isfile(path_thumbnail): + im = Image.open(path_thumbnail) + + if ext == "mp3": + # title: TIT2 + # artist: TPE1, TPE2 + # album: TALB + # cover artwork: APIC: + tags = mutagen.File(uri) + if 'TIT2' in tags: + music['title'] = tags['TIT2'].text[0] + if 'TPE1' in tags: # artist + music['artist'] = tags['TPE1'].text[0] + + if im is None: + if "APIC:" in tags: + im = Image.open(BytesIO(tags["APIC:"].data)) + + elif ext == "m4a" or ext == "m4b" or ext == "mp4" or ext == "m4p": + # title: ©nam (\xa9nam) + # artist: ©ART + # album: ©alb + # cover artwork: covr + tags = mutagen.File(uri) + if '©nam' in tags: + music['title'] = tags['©nam'][0] + if '©ART' in tags: # artist + music['artist'] = tags['©ART'][0] + + if im is None: + if "covr" in tags: + im = Image.open(BytesIO(tags["covr"][0])) + + if im: + im.thumbnail((100, 100), Image.ANTIALIAS) + buffer = BytesIO() + im = im.convert('RGB') + im.save(buffer, format="JPEG") + music['thumbnail'] = base64.b64encode(buffer.getvalue()).decode('utf-8') + except: + pass + else: + uri = music['url'] + + # if nothing found + if 'title' not in music: + match = re.search("([^\.]+)\.?.*", os.path.basename(uri)) + music['title'] = match[1] + + return music + +def format_song_string(music): + display = '' + source = music["type"] + title = music["title"] if "title" in music else "Unknown title" + artist = music["artist"] if "artist" in music else "Unknown artist" + + if source == "radio": + display = "[radio] {title} from {url} by {user}".format( + title=media.radio.get_radio_title(music["url"]), + url=music["url"], + user=music["user"] + ) + elif source == "url" and 'from_playlist' in music: + display = "[url] {title} (from playlist {playlist}) by {user}".format( + title=title, + url=music["playlist_url"], + playlist=music["playlist_title"], + user=music["user"] + ) + elif source == "url": + display = "[url] {title} by {user}".format( + title=title, + url=music["url"], + user=music["user"] + ) + elif source == "file": + display = "[file] {artist} - {title} by {user}".format( + title=title, + artist=artist, + user=music["user"] + ) + + return display + +def format_debug_song_string(music): + display = '' + source = music["type"] + title = music["title"] if "title" in music else "??" + artist = music["artist"] if "artist" in music else "??" + + if source == "radio": + display = "[radio] {url} by {user}".format( + url=music["url"], + user=music["user"] + ) + elif source == "url" and 'from_playlist' in music: + display = "[url] {title} ({url}) from playlist {playlist} by {user}".format( + title=title, + url=music["url"], + playlist=music["playlist_title"], + user=music["user"] + ) + elif source == "url": + display = "[url] {title} ({url}) by {user}".format( + title=title, + url=music["url"], + user=music["user"] + ) + elif source == "file": + display = "[file] {artist} - {title} ({path}) by {user}".format( + title=title, + artist=artist, + path=music["path"], + user=music["user"] + ) + + return display + +def format_current_playing(): + music = var.playlist.current_item() + display = format_song_string(music) + + thumbnail_html = '' + if 'thumbnail' in music: + thumbnail_html = '' + + display = (constants.strings('now_playing', item=display, thumb=thumbnail_html)) + + return display + + # - zips all files of the given zippath (must be a directory) # - returns the absolute path of the created zip file # - zip file will be in the applications tmp folder (according to configuration) @@ -70,52 +231,47 @@ def zipdir(zippath, zipname_prefix=None): return zipname -def write_db(): - with open(var.dbfile, 'w') as f: - var.db.write(f) - - def get_user_ban(): res = "List of ban hash" for i in var.db.items("user_ban"): res += "
" + i[0] return res +def new_release_version(): + v = int(urllib.request.urlopen(urllib.request.Request("https://azlux.fr/botamusique/version")).read()) + return v def update(version): - v = int(urllib.request.urlopen(urllib.request.Request("https://azlux.fr/botamusique/version")).read()) + v = new_release_version() if v > version: - logging.info('New version, starting update') + logging.info('update: new version, start updating...') tp = sp.check_output(['/usr/bin/env', 'bash', 'update.sh']).decode() logging.debug(tp) - logging.info('Update pip librairies dependancies') + logging.info('update: update pip librairies dependancies') tp = sp.check_output([var.config.get('bot', 'pip3_path'), 'install', '--upgrade', '-r', 'requirements.txt']).decode() - msg = "New version installed" + msg = "New version installed, please restart the bot." else: - logging.info('Starting update youtube-dl via pip3') + logging.info('update: starting update youtube-dl via pip3') tp = sp.check_output([var.config.get('bot', 'pip3_path'), 'install', '--upgrade', 'youtube-dl']).decode() msg = "" if "Requirement already up-to-date" in tp: msg += "Youtube-dl is up-to-date" else: - msg += "Update done : " + tp.split('Successfully installed')[1] + msg += "Update done: " + tp.split('Successfully installed')[1] reload(youtube_dl) msg += "
Youtube-dl reloaded" return msg - def user_ban(user): var.db.set("user_ban", user, None) res = "User " + user + " banned" - write_db() return res def user_unban(user): var.db.remove_option("user_ban", user) res = "Done" - write_db() return res @@ -129,16 +285,53 @@ def get_url_ban(): def url_ban(url): var.db.set("url_ban", url, None) res = "url " + url + " banned" - write_db() return res def url_unban(url): var.db.remove_option("url_ban", url) res = "Done" - write_db() return res +def pipe_no_wait(pipefd): + ''' Used to fetch the STDERR of ffmpeg. pipefd is the file descriptor returned from os.pipe()''' + if platform == "linux" or platform == "linux2" or platform == "darwin": + import fcntl + import os + try: + fl = fcntl.fcntl(pipefd, fcntl.F_GETFL) + fcntl.fcntl(pipefd, fcntl.F_SETFL, fl | os.O_NONBLOCK) + except: + print(sys.exc_info()[1]) + return False + else: + return True + + elif platform == "win32": + # https://stackoverflow.com/questions/34504970/non-blocking-read-on-os-pipe-on-windows + import msvcrt + import os + + from ctypes import windll, byref, wintypes, GetLastError, WinError + from ctypes.wintypes import HANDLE, DWORD, POINTER, BOOL + + LPDWORD = POINTER(DWORD) + PIPE_NOWAIT = wintypes.DWORD(0x00000001) + ERROR_NO_DATA = 232 + + SetNamedPipeHandleState = windll.kernel32.SetNamedPipeHandleState + SetNamedPipeHandleState.argtypes = [HANDLE, LPDWORD, LPDWORD, LPDWORD] + SetNamedPipeHandleState.restype = BOOL + + h = msvcrt.get_osfhandle(pipefd) + + res = windll.kernel32.SetNamedPipeHandleState(h, byref(PIPE_NOWAIT), None, None) + if res == 0: + print(WinError()) + return False + return True + + class Dir(object): def __init__(self, path): @@ -225,3 +418,16 @@ class Dir(object): val.render_text(ident + 1) for file in self.files: print('{}{}'.format(' ' * (ident + 1) * 4, file)) + + +# Parse the html from the message to get the URL + +def get_url_from_input(string): + if string.startswith('http'): + return string + p = re.compile('href="(.+?)"', re.IGNORECASE) + res = re.search(p, string) + if res: + return res.group(1) + else: + return False \ No newline at end of file