From 6ab8a7958a1d8c7a103cb431118081d9ccd133c1 Mon Sep 17 00:00:00 2001 From: Terry Geng Date: Thu, 5 Mar 2020 16:28:08 +0800 Subject: [PATCH] REFACTOR: ITEM REVOLUTION #91 --- command.py | 146 ++++++++---------- configuration.default.ini | 14 +- interface.py | 127 +++++++--------- media/file.py | 158 +++++++++++++++++++ media/item.py | 98 ++++++++++++ media/playlist.py | 303 ++++++++++++++++++++++++++++++++----- media/radio.py | 64 +++++++- media/url.py | 227 ++++++++++++++++++++++++--- media/url_from_playlist.py | 99 ++++++++++++ mumbleBot.py | 269 ++++++-------------------------- playlist.py | 244 ----------------------------- playlist.txt | 24 --- templates/playlist.html | 28 ++-- util.py | 175 --------------------- variables.py | 2 +- 15 files changed, 1079 insertions(+), 899 deletions(-) create mode 100644 media/item.py create mode 100644 media/url_from_playlist.py delete mode 100644 playlist.py delete mode 100644 playlist.txt diff --git a/command.py b/command.py index a073df5..d0fa110 100644 --- a/command.py +++ b/command.py @@ -5,15 +5,16 @@ 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 +from media.playlist import PlaylistItemWrapper +from media.file import FileItem +from media.url_from_playlist import URLFromPlaylistItem, get_playlist_info +from media.url import URLItem +from media.radio import RadioItem log = logging.getLogger("bot") @@ -57,8 +58,8 @@ def register_all_commands(bot): # Just for debug use bot.register_command('rtrms', cmd_real_time_rms) - # bot.register_command('loop', cmd_loop_state) - # bot.register_command('item', cmd_item) + bot.register_command('loop', cmd_loop_state) + bot.register_command('item', cmd_item) def send_multi_lines(bot, lines, text): global log @@ -138,16 +139,16 @@ def cmd_play(bot, user, text, command, parameter): if var.playlist.length() > 0: if parameter: - if parameter.isdigit() and int(parameter) > 0 and int(parameter) <= len(var.playlist): + if parameter.isdigit() and 0 <= int(parameter) <= len(var.playlist): + var.playlist.point_to(int(parameter) - 1) bot.interrupt_playing() - bot.launch_music(int(parameter) - 1) else: bot.send_msg(constants.strings('invalid_index', index=parameter), text) elif bot.is_pause: bot.resume() else: - bot.send_msg(util.format_current_playing(), text) + bot.send_msg(var.playlist.current_item().format_current_playing(), text) else: bot.is_pause = False bot.send_msg(constants.strings('queue_empty'), text) @@ -168,12 +169,11 @@ def cmd_play_file(bot, user, text, command, parameter): files = util.get_recursive_file_list_sorted(var.music_folder) if int(parameter) < len(files): filename = files[int(parameter)].replace(var.music_folder, '') - music = {'type': 'file', - 'path': filename, - 'user': user} - music = var.playlist.append(music) - log.info("cmd: add to playlist: " + util.format_debug_song_string(music)) - bot.send_msg(constants.strings('file_added', item=util.format_song_string(music)), text) + music_wrapper = PlaylistItemWrapper(FileItem(bot, filename), user) + var.playlist.append(music_wrapper) + music = music_wrapper.item + log.info("cmd: add to playlist: " + music.format_debug_string()) + bot.send_msg(constants.strings('file_added', item=music.format_song_string(user)), text) # if parameter is {path} else: @@ -184,12 +184,11 @@ def cmd_play_file(bot, user, text, command, parameter): return if os.path.isfile(path): - music = {'type': 'file', - 'path': parameter, - 'user': user} - music = var.playlist.append(music) - log.info("cmd: add to playlist: " + util.format_debug_song_string(music)) - bot.send_msg(constants.strings('file_added', item=util.format_song_string(music)), text) + music_wrapper = PlaylistItemWrapper(FileItem(bot, parameter), user) + var.playlist.append(music_wrapper) + music = music_wrapper.item + log.info("cmd: add to playlist: " + music.format_debug_string()) + bot.send_msg(constants.strings('file_added', item=music.format_song_string(user)), text) return # if parameter is {folder} @@ -211,12 +210,11 @@ def cmd_play_file(bot, user, text, command, parameter): for file in files: count += 1 - music = {'type': 'file', - 'path': file, - 'user': user} - music = var.playlist.append(music) - log.info("cmd: add to playlist: " + util.format_debug_song_string(music)) - msgs.append("{} ({})".format(music['title'], music['path'])) + music_wrapper = PlaylistItemWrapper(FileItem(bot, file), user) + var.playlist.append(music_wrapper) + music = music_wrapper.item + log.info("cmd: add to playlist: " + music.format_debug_string()) + msgs.append("{} ({})".format(music.title, music.path)) if count != 0: send_multi_lines(bot, msgs, text) @@ -230,12 +228,12 @@ def cmd_play_file(bot, user, text, command, parameter): 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} - music = var.playlist.append(music) - log.info("cmd: add to playlist: " + util.format_debug_song_string(music)) - bot.send_msg(constants.strings('file_added', item=util.format_song_string(music)), text) + file = matches[0][1] + music_wrapper = PlaylistItemWrapper(FileItem(bot, file), user) + var.playlist.append(music_wrapper) + music = music_wrapper.item + log.info("cmd: add to playlist: " + music.format_debug_string()) + bot.send_msg(constants.strings('file_added', item=music.format_song_string(user)), text) else: msgs = [ constants.strings('multiple_matches')] for match in matches: @@ -256,13 +254,11 @@ def cmd_play_file_match(bot, user, text, command, parameter): match = re.search(parameter, file) if match: count += 1 - music = {'type': 'file', - 'path': file, - 'user': user} - music = var.playlist.append(music) - log.info("cmd: add to playlist: " + util.format_debug_song_string(music)) - - msgs.append("{} ({})".format(music['title'], music['path'])) + music_wrapper = PlaylistItemWrapper(FileItem(bot, file), user) + var.playlist.append(music_wrapper) + music = music_wrapper.item + log.info("cmd: add to playlist: " + music.format_debug_string()) + msgs.append("{} ({})".format(music.title, music.path)) if count != 0: send_multi_lines(bot, msgs, text) @@ -279,22 +275,15 @@ def cmd_play_file_match(bot, user, text, command, parameter): def cmd_play_url(bot, user, text, command, parameter): global log - music = {'type': 'url', - # grab the real URL - 'url': util.get_url_from_input(parameter), - 'user': user, - 'ready': 'validation'} + url = util.get_url_from_input(parameter) + music_wrapper = PlaylistItemWrapper(URLItem(bot, url), user) + var.playlist.append(music_wrapper) - music = bot.validate_music(music) - if music: - music = var.playlist.append(music) - log.info("cmd: add to playlist: " + util.format_debug_song_string(music)) - bot.send_msg(constants.strings('file_added', item=util.format_song_string(music)), text) - if var.playlist.length() == 2: - # If I am the second item on the playlist. (I am the next one!) - bot.async_download_next() - else: - bot.send_msg(constants.strings('unable_download'), text) + log.info("cmd: add to playlist: " + music_wrapper.format_debug_string()) + bot.send_msg(constants.strings('file_added', item=music_wrapper.format_song_string()), text) + if var.playlist.length() == 2: + # If I am the second item on the playlist. (I am the next one!) + bot.async_download_next() def cmd_play_playlist(bot, user, text, command, parameter): @@ -308,11 +297,11 @@ def cmd_play_playlist(bot, user, text, command, parameter): url = util.get_url_from_input(parameter) log.debug("cmd: fetching media info from playlist url %s" % url) - items = media.playlist.get_playlist_info(url=url, start_index=offset, user=user) + items = get_playlist_info(bot, url=url, start_index=offset, user=user) if len(items) > 0: var.playlist.extend(items) for music in items: - log.info("cmd: add to playlist: " + util.format_debug_song_string(music)) + log.info("cmd: add to playlist: " + music.format_debug_string()) else: bot.send_msg(constants.strings("playlist_fetching_failed"), text) @@ -335,16 +324,10 @@ def cmd_play_radio(bot, user, text, command, parameter): parameter = parameter.split()[0] url = util.get_url_from_input(parameter) if url: - music = {'type': 'radio', - 'url': url, - 'user': user} + music_wrapper = PlaylistItemWrapper(RadioItem(bot, url), user) - log.info("bot: fetching radio server description") - music["name"] = media.radio.get_radio_server_description(url) - - var.playlist.append(music) - log.info("cmd: add to playlist: " + util.format_debug_song_string(music)) - bot.async_download_next() + var.playlist.append(music_wrapper) + log.info("cmd: add to playlist: " + music_wrapper.format_debug_string()) else: bot.send_msg(constants.strings('bad_url')) @@ -440,13 +423,9 @@ def cmd_rb_play(bot, user, text, command, parameter): url = radiobrowser.geturl_byid(parameter) if url != "-1": log.info('cmd: Found url: ' + url) - music = {'type': 'radio', - 'name': stationname, - 'artist': homepage, - 'url': url, - 'user': user} - var.playlist.append(music) - log.info("cmd: add to playlist: " + util.format_debug_song_string(music)) + music_wrapper = PlaylistItemWrapper(RadioItem(bot, url, stationname), user) + var.playlist.append(music_wrapper) + log.info("cmd: add to playlist: " + music_wrapper.format_debug_string()) bot.async_download_next() else: log.info('cmd: No playable url found.') @@ -637,7 +616,7 @@ def cmd_current_music(bot, user, text, command, parameter): reply = "" if var.playlist.length() > 0: - bot.send_msg(util.format_current_playing()) + bot.send_msg(var.playlist.current_item().format_current_playing()) else: reply = constants.strings('not_playing') bot.send_msg(reply, text) @@ -647,9 +626,7 @@ def cmd_skip(bot, user, text, command, parameter): global log if var.playlist.length() > 0: - bot.stop() - bot.launch_music() - bot.async_download_next() + bot.interrupt_playing() else: bot.send_msg(constants.strings('queue_empty'), text) @@ -668,10 +645,6 @@ def cmd_last(bot, user, text, command, parameter): def cmd_remove(bot, user, text, command, parameter): global log - if bot.download_in_progress: - bot.send_msg(constants.strings("cannot_change_when_download")) - return - # Allow to remove specific music into the queue with a number if parameter and parameter.isdigit() and int(parameter) > 0 \ and int(parameter) <= var.playlist.length(): @@ -695,11 +668,10 @@ def cmd_remove(bot, user, text, command, parameter): 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) + item=removed.format_song_string()), text) - log.info("cmd: delete from playlist: " + str(removed['path'] if 'path' in removed else removed['url'])) + log.info("cmd: delete from playlist: " + removed.format_debug_string()) else: bot.send_msg(constants.strings('bad_parameter', command=command)) @@ -772,9 +744,9 @@ def cmd_repeat(bot, user, text, command, parameter): var.playlist.current_index + 1, music ) - log.info("bot: add to playlist: " + util.format_debug_song_string(music)) + log.info("bot: add to playlist: " + music.format_debug_string) - bot.send_msg(constants.strings("repeat", song=util.format_song_string(music), n=str(repeat)), text) + bot.send_msg(constants.strings("repeat", song=music.format_song_string, n=str(repeat)), text) def cmd_mode(bot, user, text, command, parameter): global log @@ -811,4 +783,4 @@ def cmd_loop_state(bot, user, text, command, parameter): def cmd_item(bot, user, text, command, parameter): print(bot.wait_for_downloading) - print(var.playlist.current_item()) + print(var.playlist.current_item().item.to_dict()) diff --git a/configuration.default.ini b/configuration.default.ini index ef38a99..0ea29da 100644 --- a/configuration.default.ini +++ b/configuration.default.ini @@ -182,15 +182,19 @@ multiple_matches = Track not found! Possible candidates: queue_contents = Items on the playlist: queue_empty = Playlist is empty! invalid_index = Invalid index {index}. Use '!queue' to see your playlist. -now_playing_radio = Now Playing Radio:
{title} from {name} added by {user} -now_playing_file = Now Playing File:
{artist} - {title} added by {user} -now_playing_from_playlist = Now Playing URL:
{title} from playlist {playlist} added by {user} -now_playing_url = Now Playing URL:
{title} added by {user} +now_playing = Playing
{item} +radio = Radio +file = File +url_from_playlist = URL +url = URL +radio_item = {title} from {name} added by {user} +file_item = {artist} - {title} added by {user} +url_from_playlist_item = {title} from playlist {playlist} added by {user} +url_item = {title} added by {user} 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, skip! download_in_progress = Download of {item} in progress... -cannot_change_when_download = Downloading songs, please wait until the download completes. removing_item = Removed entry {item} from playlist. user_ban = You are banned, not allowed to do that! url_ban = This url is banned! diff --git a/interface.py b/interface.py index 576e801..bf4d235 100644 --- a/interface.py +++ b/interface.py @@ -12,7 +12,11 @@ import random from werkzeug.utils import secure_filename import errno import media -import media.radio +from media.playlist import PlaylistItemWrapper +from media.file import FileItem +from media.url_from_playlist import URLFromPlaylistItem, get_playlist_info +from media.url import URLItem +from media.radio import RadioItem import logging import time import constants @@ -58,6 +62,7 @@ class ReverseProxied(object): web = Flask(__name__) log = logging.getLogger("bot") +user = 'Remote Control' def init_proxy(): global web @@ -109,7 +114,7 @@ def index(): os=os, playlist=var.playlist, user=var.user, - paused=var.botamusique.is_pause + paused=var.bot.is_pause ) @web.route("/playlist", methods=['GET']) @@ -124,10 +129,10 @@ def playlist(): items = [] - for index, item in enumerate(var.playlist): + for index, item_wrapper in enumerate(var.playlist): items.append(render_template('playlist.html', index=index, - m=item, + m=item_wrapper.item, playlist=var.playlist ) ) @@ -138,7 +143,7 @@ def status(): if (var.playlist.length() > 0): return jsonify({'ver': var.playlist.version, 'empty': False, - 'play': not var.botamusique.is_pause, + 'play': not var.bot.is_pause, 'mode': var.playlist.mode}) else: return jsonify({'ver': var.playlist.version, @@ -159,25 +164,16 @@ def post(): if 'add_file_bottom' in request.form and ".." not in request.form['add_file_bottom']: path = var.music_folder + request.form['add_file_bottom'] if os.path.isfile(path): - item = {'type': 'file', - 'path' : request.form['add_file_bottom'], - 'title' : '', - 'user' : 'Remote Control'} - item = var.playlist.append(util.attach_music_tag_info(item)) - log.info('web: add to playlist(bottom): ' + util.format_debug_song_string(item)) + music_wrapper = PlaylistItemWrapper(FileItem(var.bot, request.form['add_file_bottom']), user) + var.playlist.append(music_wrapper) + log.info('web: add to playlist(bottom): ' + music_wrapper.format_debug_string()) elif 'add_file_next' in request.form and ".." not in request.form['add_file_next']: path = var.music_folder + request.form['add_file_next'] if os.path.isfile(path): - item = {'type': 'file', - 'path' : request.form['add_file_next'], - 'title' : '', - 'user' : 'Remote Control'} - item = var.playlist.insert( - var.playlist.current_index + 1, - item - ) - log.info('web: add to playlist(next): ' + util.format_debug_song_string(item)) + music_wrapper = PlaylistItemWrapper(FileItem(var.bot, request.form['add_file_next']), user) + var.playlist.insert(var.playlist.current_index + 1, music_wrapper) + log.info('web: add to playlist(next): ' + music_wrapper.format_debug_string()) 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: @@ -202,42 +198,35 @@ def post(): else: files = music_library.get_files(folder) - files = list(map(lambda file: - {'type':'file', - 'path': os.path.join(folder, file), - 'user':'Remote Control'}, files)) + music_wrappers = list(map( + lambda file: PlaylistItemWrapper(FileItem(var.bot, file), user), + files)) - files = var.playlist.extend(files) + var.playlist.extend(files) - for file in files: - log.info("web: add to playlist: %s" % util.format_debug_song_string(file)) + for music_wrapper in music_wrappers: + log.info('web: add to playlist: ' + music_wrapper.format_debug_string()) elif 'add_url' in request.form: - music = {'type':'url', - 'url': request.form['add_url'], - 'user': 'Remote Control', - 'ready': 'validation'} - music = var.botamusique.validate_music(music) - if music: - var.playlist.append(music) - log.info("web: add to playlist: " + util.format_debug_song_string(music)) - if var.playlist.length() == 2: - # If I am the second item on the playlist. (I am the next one!) - var.botamusique.async_download_next() + music_wrapper = PlaylistItemWrapper(URLItem(var.bot, request.form['add_url']), user) + var.playlist.append(music_wrapper) + + log.info("web: add to playlist: " + music_wrapper.format_debug_string()) + if var.playlist.length() == 2: + # If I am the second item on the playlist. (I am the next one!) + var.bot.async_download_next() elif 'add_radio' in request.form: url = request.form['add_radio'] - music = var.playlist.append({'type': 'radio', - 'url': url, - 'user': "Remote Control"}) - log.info("web: fetching radio server description") - music["name"] = media.radio.get_radio_server_description(url) - log.info("web: add to playlist: " + util.format_debug_song_string(music)) + music_wrapper = PlaylistItemWrapper(RadioItem(var.bot, url), user) + var.playlist.append(music_wrapper) + + log.info("cmd: add to playlist: " + music_wrapper.format_debug_string()) elif 'delete_music' in request.form: - music = var.playlist[int(request.form['delete_music'])] - log.info("web: delete from playlist: " + util.format_debug_song_string(music)) + music_wrapper = var.playlist[int(request.form['delete_music'])] + log.info("web: delete from playlist: " + music_wrapper.format_debug_string()) if var.playlist.length() >= int(request.form['delete_music']): index = int(request.form['delete_music']) @@ -246,26 +235,26 @@ def post(): var.playlist.remove(index) if index < len(var.playlist): - if not var.botamusique.is_pause: - var.botamusique.interrupt_playing() + if not var.bot.is_pause: + var.bot.interrupt_playing() var.playlist.current_index -= 1 # then the bot will move to next item else: # if item deleted is the last item of the queue var.playlist.current_index -= 1 - if not var.botamusique.is_pause: - var.botamusique.interrupt_playing() + if not var.bot.is_pause: + var.bot.interrupt_playing() else: var.playlist.remove(index) elif 'play_music' in request.form: - music = var.playlist[int(request.form['play_music'])] - log.info("web: jump to: " + util.format_debug_song_string(music)) + music_wrapper = var.playlist[int(request.form['play_music'])] + log.info("web: jump to: " + music_wrapper.format_debug_string()) if len(var.playlist) >= int(request.form['play_music']): - var.botamusique.interrupt_playing() - var.botamusique.launch_music(int(request.form['play_music'])) + var.playlist.point_to(int(request.form['play_music']) - 1) + var.bot.interrupt_playing() elif 'delete_music_file' in request.form and ".." not in request.form['delete_music_file']: path = var.music_folder + request.form['delete_music_file'] @@ -283,7 +272,7 @@ def post(): elif 'action' in request.form: action = request.form['action'] if action == "randomize": - var.botamusique.interrupt_playing() + var.bot.interrupt_playing() var.playlist.set_mode("random") var.db.set('playlist', 'playback_mode', "random") log.info("web: playback mode changed to random.") @@ -296,27 +285,27 @@ def post(): var.db.set('playlist', 'playback_mode', "repeat") log.info("web: playback mode changed to repeat.") elif action == "stop": - var.botamusique.stop() + var.bot.stop() elif action == "pause": - var.botamusique.pause() + var.bot.pause() elif action == "resume": - var.botamusique.resume() + var.bot.resume() elif action == "clear": - var.botamusique.clear() + var.bot.clear() elif action == "volume_up": - if var.botamusique.volume_set + 0.03 < 1.0: - var.botamusique.volume_set = var.botamusique.volume_set + 0.03 + if var.bot.volume_set + 0.03 < 1.0: + var.bot.volume_set = var.bot.volume_set + 0.03 else: - var.botamusique.volume_set = 1.0 - var.db.set('bot', 'volume', str(var.botamusique.volume_set)) - log.info("web: volume up to %d" % (var.botamusique.volume_set * 100)) + var.bot.volume_set = 1.0 + var.db.set('bot', 'volume', str(var.bot.volume_set)) + log.info("web: volume up to %d" % (var.bot.volume_set * 100)) elif action == "volume_down": - if var.botamusique.volume_set - 0.03 > 0: - var.botamusique.volume_set = var.botamusique.volume_set - 0.03 + if var.bot.volume_set - 0.03 > 0: + var.bot.volume_set = var.bot.volume_set - 0.03 else: - var.botamusique.volume_set = 0 - var.db.set('bot', 'volume', str(var.botamusique.volume_set)) - log.info("web: volume up to %d" % (var.botamusique.volume_set * 100)) + var.bot.volume_set = 0 + var.db.set('bot', 'volume', str(var.bot.volume_set)) + log.info("web: volume up to %d" % (var.bot.volume_set * 100)) return status() diff --git a/media/file.py b/media/file.py index e69de29..bcde63d 100644 --- a/media/file.py +++ b/media/file.py @@ -0,0 +1,158 @@ +import logging +import os +import re +from io import BytesIO +import base64 +import hashlib +import mutagen +from PIL import Image +import json + +import util +import variables as var +from media.item import BaseItem +import constants + +''' +type : file + id + path + title + artist + duration + thumbnail + user +''' + +class FileItem(BaseItem): + def __init__(self, bot, path, from_dict=None): + if not from_dict: + super().__init__(bot) + self.path = path + self.title = "" + self.artist = "??" + self.thumbnail = None + if self.path: + self.id = hashlib.md5(path.encode()).hexdigest() + if os.path.exists(self.uri()): + self._get_info_from_tag() + self.ready = "yes" + else: + super().__init__(bot, from_dict) + self.path = from_dict['path'] + self.title = from_dict['title'] + self.artist = from_dict['artist'] + self.thumbnail = from_dict['thumbnail'] + if not self.validate(): + self.ready = "failed" + + self.type = "file" + + def uri(self): + return var.music_folder + self.path + + def is_ready(self): + return True + + def validate(self): + if not os.path.exists(self.uri()): + self.log.info( + "file: music file missed for %s" % self.format_debug_string()) + self.send_client_message(constants.strings('file_missed', file=self.path)) + return False + + self.ready = "yes" + return True + + def _get_info_from_tag(self): + match = re.search("(.+)\.(.+)", self.uri()) + assert match is not None + + 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(self.uri()) + if 'TIT2' in tags: + self.title = tags['TIT2'].text[0] + if 'TPE1' in tags: # artist + self.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(self.uri()) + if '©nam' in tags: + self.title = tags['©nam'][0] + if '©ART' in tags: # artist + self.artist = tags['©ART'][0] + + if im is None: + if "covr" in tags: + im = Image.open(BytesIO(tags["covr"][0])) + + if im: + self.thumbnail = self._prepare_thumbnail(im) + except: + pass + + if not self.title: + self.title = os.path.basename(file_no_ext) + + def _prepare_thumbnail(self, im): + im.thumbnail((100, 100), Image.ANTIALIAS) + buffer = BytesIO() + im = im.convert('RGB') + im.save(buffer, format="JPEG") + return base64.b64encode(buffer.getvalue()).decode('utf-8') + + def to_dict(self): + dict = super().to_dict() + dict['type'] = 'file' + dict['path'] = self.path + dict['title'] = self.title + dict['artist'] = self.artist + dict['thumbnail'] = self.thumbnail + return dict + + def format_debug_string(self): + return "[file] {artist} - {title} ({path})".format( + title=self.title, + artist=self.artist, + path=self.path + ) + + def format_song_string(self, user): + return constants.strings("file_item", + title=self.title, + artist=self.artist, + user=user + ) + + def format_current_playing(self, user): + display = constants.strings("now_playing", item=self.format_song_string(user)) + if self.thumbnail: + thumbnail_html = '' + display += "
" + thumbnail_html + + return display + + def display_type(self): + return constants.strings("file") diff --git a/media/item.py b/media/item.py new file mode 100644 index 0000000..5e1b3b5 --- /dev/null +++ b/media/item.py @@ -0,0 +1,98 @@ +import logging +import threading +import os +import re +from io import BytesIO +import base64 +import hashlib +import mutagen +from PIL import Image + +import util +import variables as var + +""" +FORMAT OF A MUSIC INTO THE PLAYLIST +type : url + id + url + title + path + duration + artist + thumbnail + user + ready (validation, no, downloading, yes, failed) + from_playlist (yes,no) + playlist_title + playlist_url + +type : radio + id + url + name + current_title + user + +""" + +class BaseItem: + def __init__(self, bot, from_dict=None): + self.bot = bot + self.log = logging.getLogger("bot") + self.type = "base" + + if from_dict is None: + self.id = "" + self.ready = "pending" # pending - is_valid() -> validated - prepare() -> yes, failed + else: + self.id = from_dict['id'] + self.ready = from_dict['ready'] + + def is_ready(self): + return True if self.ready == "yes" else False + + def is_failed(self): + return True if self.ready == "failed" else False + + def validate(self): + return False + + def uri(self): + raise + + def async_prepare(self): + th = threading.Thread( + target=self.prepare, name="Prepare-" + self.id[:7]) + self.log.info( + "%s: start preparing item in thread: " % self.type + self.format_debug_string()) + th.daemon = True + th.start() + #self.download_threads.append(th) + return th + + def prepare(self): + return True + + def play(self): + pass + + def format_song_string(self, user): + return self.id + + def format_current_playing(self, user): + return self.id + + def format_debug_string(self): + return self.id + + def display_type(self): + return "" + + def send_client_message(self, msg): + self.bot.send_msg(msg) + + def to_dict(self): + return {"type" : "base", "id": self.id, "ready": self.ready} + + diff --git a/media/playlist.py b/media/playlist.py index 78cb870..d2d1790 100644 --- a/media/playlist.py +++ b/media/playlist.py @@ -1,44 +1,271 @@ -import youtube_dl +import json +import random +import hashlib +import threading +import logging + +import util import variables as var +from media.item import BaseItem +from media.file import FileItem +from media.url import URLItem -def get_playlist_info(url, start_index=0, user=""): - items = [] - ydl_opts = { - 'extract_flat': 'in_playlist' - } - with youtube_dl.YoutubeDL(ydl_opts) as ydl: - attempts = var.config.getint('bot', 'download_attempts', fallback=2) - for i in range(attempts): - 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 +class PlaylistItemWrapper: + def __init__(self, item, user): + self.item = item + self.user = user - 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 - title = info['entries'][j]['title'] if 'title' in info['entries'][j] else "Unknown Title" - # 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'] + def to_dict(self): + dict = self.item.to_dict() + dict['user'] = self.user + return dict - music = {'type': 'url', - 'title': title, - 'url': url, - 'user': user, - 'from_playlist': True, - 'playlist_title': playlist_title, - 'playlist_url': url, - 'ready': 'validation'} - items.append(music) - except: - pass + def format_current_playing(self): + return self.item.format_current_playing(self.user) - return items + def format_song_string(self): + return self.item.format_song_string(self.user) + + def format_debug_string(self): + return self.item.format_debug_string() + + +def dict_to_item(dict): + if dict['type'] == 'file': + return PlaylistItemWrapper(FileItem(var.bot, "", dict), dict['user']) + elif dict['type'] == 'url': + return PlaylistItemWrapper(URLItem(var.bot, "", dict), dict['user']) + + +class PlayList(list): + def __init__(self, *args): + super().__init__(*args) + self.current_index = -1 + self.version = 0 # increase by one after each change + self.mode = "one-shot" # "repeat", "random" + self.pending_items = [] + self.log = logging.getLogger("bot") + self.validating_thread_lock = threading.Lock() + + def is_empty(self): + return True if len(self) == 0 else False + + def set_mode(self, mode): + # modes are "one-shot", "repeat", "random" + self.mode = mode + + if mode == "random": + self.randomize() + + elif mode == "one-shot" and self.current_index > 0: + # remove items before current item + self.version += 1 + for i in range(self.current_index): + super().__delitem__(0) + self.current_index = 0 + + def append(self, item: PlaylistItemWrapper): + self.version += 1 + super().append(item) + self.pending_items.append(item) + self.start_async_validating() + + return item + + def insert(self, index, item): + self.version += 1 + + if index == -1: + index = self.current_index + + item = util.attach_music_tag_info(item) + super().insert(index, item) + + if index <= self.current_index: + self.current_index += 1 + + self.pending_items.append(item) + self.start_async_validating() + + return item + + def length(self): + return len(self) + + def extend(self, items): + self.version += 1 + items = list(map( + lambda item: item, + items)) + super().extend(items) + self.pending_items.extend(items) + self.start_async_validating() + return items + + def next(self): + if len(self) == 0: + return False + + self.version += 1 + #logging.debug("playlist: Next into the queue") + + if self.current_index < len(self) - 1: + if self.mode == "one-shot" and self.current_index != -1: + super().__delitem__(self.current_index) + else: + self.current_index += 1 + + return self[self.current_index] + else: + self.current_index = 0 + if self.mode == "one-shot": + self.clear() + return False + elif self.mode == "repeat": + return self[0] + elif self.mode == "random": + self.randomize() + return self[0] + else: + raise TypeError("Unknown playlist mode '%s'." % self.mode) + + def point_to(self, index): + if -1 <= index < len(self): + self.current_index = index + + def find(self, id): + for index, wrapper in enumerate(self): + if wrapper.item.id == id: + return index + return None + + def update(self, item, id): + self.version += 1 + index = self.find(id) + if index: + self[index] = item + return True + return False + + def __delitem__(self, key): + return self.remove(key) + + def remove(self, index=-1): + self.version += 1 + if index > len(self) - 1: + return False + + if index == -1: + index = self.current_index + + removed = self[index] + super().__delitem__(index) + + if self.current_index > index: + self.current_index -= 1 + + return removed + + def remove_by_id(self, id): + to_be_removed = [] + for index, item in enumerate(self): + if item.id == id: + to_be_removed.append(index) + + for index in to_be_removed: + self.remove(index) + + def current_item(self): + if len(self) == 0: + return False + + return self[self.current_index] + + def next_index(self): + if len(self) == 0 or (len(self) == 1 and self.mode == 'one_shot'): + return False + + if self.current_index < len(self) - 1: + return self.current_index + 1 + else: + return 0 + + def next_item(self): + if len(self) == 0 or (len(self) == 1 and self.mode == 'one_shot'): + return False + + return self[self.next_index()] + + def jump(self, index): + if self.mode == "one-shot": + for i in range(index): + super().__delitem__(0) + self.current_index = 0 + else: + self.current_index = index + + self.version += 1 + return self[self.current_index] + + def randomize(self): + # current_index will lose track after shuffling, thus we take current music out before shuffling + #current = self.current_item() + #del self[self.current_index] + + random.shuffle(self) + + #self.insert(0, current) + self.current_index = -1 + self.version += 1 + + def clear(self): + self.version += 1 + self.current_index = -1 + super().clear() + + def save(self): + var.db.remove_section("playlist_item") + var.db.set("playlist", "current_index", self.current_index) + + for index, music in enumerate(self): + var.db.set("playlist_item", str(index), json.dumps(music.to_dict())) + + 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.extend(list(map(lambda v: dict_to_item(json.loads(v[1])), items))) + + self.current_index = current_index + + def _debug_print(self): + print("===== Playlist(%d)=====" % self.current_index) + for index, item_wrapper in enumerate(self): + if index == self.current_index: + print("-> %d %s" % (index, item_wrapper.item.title)) + else: + print("%d %s" % (index, item_wrapper.item.title)) + print("===== End =====") + + def start_async_validating(self): + if not self.validating_thread_lock.locked(): + th = threading.Thread(target=self._check_valid, name="Validating") + th.daemon = True + th.start() + + def _check_valid(self): + self.log.debug("playlist: start validating...") + self.validating_thread_lock.acquire() + while len(self.pending_items) > 0: + item = self.pending_items.pop().item + self.log.debug("playlist: validating %s" % item.format_debug_string()) + if not item.validate() or item.ready == 'failed': + # TODO: logging + self.remove_by_id(item.id) + + self.log.debug("playlist: validating finished.") + self.validating_thread_lock.release() diff --git a/media/radio.py b/media/radio.py index c4fc648..ebc2a4c 100644 --- a/media/radio.py +++ b/media/radio.py @@ -1,16 +1,19 @@ import re import logging -import json -import http.client import struct import requests import traceback +import hashlib + +from media.item import BaseItem +import constants log = logging.getLogger("bot") def get_radio_server_description(url): global log + log.debug("radio: fetching radio server description") p = re.compile('(https?\:\/\/[^\/]*)', re.IGNORECASE) res = re.search(p, url) base_url = res.group(1) @@ -50,6 +53,9 @@ def get_radio_server_description(url): def get_radio_title(url): + global log + + log.debug("radio: fetching radio server description") try: r = requests.get(url, headers={'Icy-MetaData': '1'}, stream=True, timeout=5) icy_metaint_header = int(r.headers['icy-metaint']) @@ -67,3 +73,57 @@ def get_radio_title(url): except (requests.exceptions.ConnectionError, requests.exceptions.HTTPError) as e: pass return url + +class RadioItem(BaseItem): + def __init__(self, bot, url, name="", from_dict=None): + if from_dict is None: + super().__init__(bot) + self.url = url + if not name: + self.title = get_radio_server_description(self.url) # The title of the radio station + else: + self.title = name + self.id = hashlib.md5(url.encode()).hexdigest() + else: + super().__init__(bot, from_dict) + self.url = from_dict['url'] + self.title = from_dict['title'] + + self.type = "radio" + + def validate(self): + return True + + def is_ready(self): + return True + + def uri(self): + return self.url + + def to_dict(self): + dict = super().to_dict() + dict['url'] = self.url + dict['title'] = self.title + + def format_debug_string(self): + return "[radio] {name} ({url})".format( + name=self.title, + url=self.url + ) + + def format_song_string(self, user): + return constants.strings("radio_item", + url=self.url, + title=get_radio_title(self.url), # the title of current song + name=self.title, # the title of radio station + user=user + ) + + def format_current_playing(self, user): + return constants.strings("now_playing", item=self.format_song_string(user)) + + def display_type(self): + return constants.strings("radio") + + + diff --git a/media/url.py b/media/url.py index 830a313..8fcdbd6 100644 --- a/media/url.py +++ b/media/url.py @@ -1,22 +1,215 @@ +import threading +import logging +import os +import hashlib +import traceback +from PIL import Image import youtube_dl +import glob + +import constants +import media import variables as var +from media.file import FileItem +import media.system + +log = logging.getLogger("bot") -def get_url_info(music): - ydl_opts = { - 'noplaylist': True - } - music['duration'] = 0 - with youtube_dl.YoutubeDL(ydl_opts) as ydl: - for i in range(2): - try: - info = ydl.extract_info(music['url'], download=False) - music['duration'] = info['duration'] / 60 - music['title'] = info['title'] - except youtube_dl.utils.DownloadError: - pass - except KeyError: - return music +class URLItem(FileItem): + def __init__(self, bot, url, from_dict=None): + self.validating_lock = threading.Lock() + if from_dict is None: + self.url = url + self.title = '' + self.duration = 0 + self.ready = 'pending' + super().__init__(bot, "") + self.id = hashlib.md5(url.encode()).hexdigest() + path = var.tmp_folder + self.id + ".mp3" + + if os.path.isfile(path): + self.log.info("url: file existed for url %s " % self.url) + self.ready = 'yes' + self.path = path + self._get_info_from_tag() else: - return music - return False + # self._get_info_from_url() + pass + else: + super().__init__(bot, "", from_dict) + self.url = from_dict['url'] + self.duration = from_dict['duration'] + + self.downloading = False + self.type = "url" + + def uri(self): + return self.path + + def is_ready(self): + if self.downloading or self.ready != 'yes': + return False + if self.ready == 'yes' and not os.path.exists(self.path): + self.log.info( + "url: music file missed for %s" % self.format_debug_string()) + self.ready = 'validated' + return False + + return True + + def validate(self): + if self.ready in ['yes', 'validated']: + return True + + if os.path.exists(self.path): + self.ready = "yes" + return True + + # avoid multiple process validating in the meantime + self.validating_lock.acquire() + info = self._get_info_from_url() + self.validating_lock.release() + + if self.duration == 0 and not info: + return False + + if self.duration > var.config.getint('bot', 'max_track_duration') != 0: + # Check the length, useful in case of playlist, it wasn't checked before) + log.info( + "url: " + self.url + " has a duration of " + str(self.duration) + " min -- too long") + self.send_client_message(constants.strings('too_long')) + return False + else: + self.ready = "validated" + return True + + # Run in a other thread + def prepare(self): + if not self.downloading: + assert self.ready == 'validated' + return self._download() + else: + assert self.ready == 'yes' + return True + + def _get_info_from_url(self): + self.log.info("url: fetching metadata of url %s " % self.url) + ydl_opts = { + 'noplaylist': True + } + succeed = False + with youtube_dl.YoutubeDL(ydl_opts) as ydl: + attempts = var.config.getint('bot', 'download_attempts', fallback=2) + for i in range(attempts): + try: + info = ydl.extract_info(self.url, download=False) + self.duration = info['duration'] / 60 + self.title = info['title'] + succeed = True + return True + except youtube_dl.utils.DownloadError: + pass + + if not succeed: + self.ready = 'failed' + self.log.error("url: error while fetching info from the URL") + self.send_client_message(constants.strings('unable_download')) + return False + + def _download(self): + media.system.clear_tmp_folder(var.tmp_folder, var.config.getint('bot', 'tmp_folder_max_size')) + + self.downloading = True + base_path = var.tmp_folder + self.id + save_path = base_path + ".%(ext)s" + mp3_path = base_path + ".mp3" + + # Download only if music is not existed + self.ready = "preparing" + + self.log.info("bot: downloading url (%s) %s " % (self.title, self.url)) + ydl_opts = "" + + ydl_opts = { + 'format': 'bestaudio/best', + 'outtmpl': save_path, + 'noplaylist': True, + 'writethumbnail': True, + 'updatetime': False, + 'postprocessors': [{ + 'key': 'FFmpegExtractAudio', + 'preferredcodec': 'mp3', + 'preferredquality': '192'}, + {'key': 'FFmpegMetadata'}] + } + # TODO + self.send_client_message(constants.strings('download_in_progress', item=self.url)) + + with youtube_dl.YoutubeDL(ydl_opts) as ydl: + attempts = var.config.getint('bot', 'download_attempts', fallback=2) + download_succeed = False + for i in range(attempts): + self.log.info("bot: download attempts %d / %d" % (i+1, attempts)) + try: + info = ydl.extract_info(self.url) + download_succeed = True + break + except: + error_traceback = traceback.format_exc().split("During")[0] + error = error_traceback.rstrip().split("\n")[-1] + self.log.error("bot: download failed with error:\n %s" % error) + + if download_succeed: + self.path = mp3_path + self.ready = "yes" + self.log.info( + "bot: finished downloading url (%s) %s, saved to %s." % (self.title, self.url, self.path)) + self.downloading = False + return True + else: + for f in glob.glob(base_path + "*"): + os.remove(f) + self.send_client_message(constants.strings('unable_download')) + self.ready = "failed" + self.downloading = False + return False + + def _read_thumbnail_from_file(self, path_thumbnail): + if os.path.isfile(path_thumbnail): + im = Image.open(path_thumbnail) + self.thumbnail = self._prepare_thumbnail(im) + + def to_dict(self): + dict = super().to_dict() + dict['type'] = 'url' + dict['url'] = self.url + dict['duration'] = self.duration + + return dict + + + def format_debug_string(self): + return "[url] {title} ({url})".format( + title=self.title, + url=self.url + ) + + def format_song_string(self, user): + return constants.strings("url_item", + title=self.title, + url=self.url, + user=user) + + def format_current_playing(self, user): + display = constants.strings("now_playing", item=self.format_song_string(user)) + + if self.thumbnail: + thumbnail_html = '' + display += "
" + thumbnail_html + + return display + + def display_type(self): + return constants.strings("url") diff --git a/media/url_from_playlist.py b/media/url_from_playlist.py new file mode 100644 index 0000000..33b0cdd --- /dev/null +++ b/media/url_from_playlist.py @@ -0,0 +1,99 @@ +import youtube_dl +import constants +import media +import variables as var +from media.url import URLItem +from media.playlist import PlaylistItemWrapper + +def get_playlist_info(bot, url, start_index=0, user=""): + items = [] + ydl_opts = { + 'extract_flat': 'in_playlist' + } + with youtube_dl.YoutubeDL(ydl_opts) as ydl: + attempts = var.config.getint('bot', 'download_attempts', fallback=2) + for i in range(attempts): + 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 + title = info['entries'][j]['title'] if 'title' in info['entries'][j] else "Unknown Title" + # Add youtube url if the url in the json isn't a full url + item_url = info['entries'][j]['url'] if info['entries'][j]['url'][0:4] == 'http' \ + else "https://www.youtube.com/watch?v=" + info['entries'][j]['url'] + + music = PlaylistItemWrapper( + URLFromPlaylistItem( + bot, + item_url, + title, + url, + playlist_title + ), user) + + items.append(music) + except: + pass + + return items + +class URLFromPlaylistItem(URLItem): + def __init__(self, bot, url, title, playlist_url, playlist_title, from_dict=None): + if from_dict is None: + super().__init__(bot, url) + self.title = title + self.playlist_url = playlist_url + self.playlist_title = playlist_title + else: + super().__init__(bot, "", from_dict) + self.playlist_title = from_dict['playlist_title'] + self.playlist_url = from_dict['playlist_url'] + + self.type = "url_from_playlist" + + def to_dict(self): + dict = super().to_dict() + dict['playlist_url'] = self.playlist_url + dict['playlist_title'] = self.playlist_title + + return dict + + def format_debug_string(self): + return "[url] {title} ({url}) from playlist {playlist}".format( + title=self.title, + url=self.url, + playlist=self.playlist_title + ) + + def format_song_string(self, user): + return constants.strings("url_from_playlist_item", + title=self.title, + url=self.url, + playlist_url=self.playlist_url, + playlist=self.playlist_title, + user=user) + + def format_current_playing(self, user): + display = constants.strings("now_playing", item=self.format_song_string(user)) + + if self.thumbnail: + thumbnail_html = '' + display += "
" + thumbnail_html + + return display + + def display_type(self): + return constants.strings("url_from_playlist") diff --git a/mumbleBot.py b/mumbleBot.py index 54cdd19..67a326c 100644 --- a/mumbleBot.py +++ b/mumbleBot.py @@ -5,7 +5,6 @@ import threading import time import sys import math -import re import signal import configparser import audioop @@ -28,11 +27,9 @@ import constants from database import Database import media.url import media.file -import media.playlist import media.radio import media.system -from librb import radiobrowser -from playlist import PlayList +from media.playlist import PlayList class MumbleBot: @@ -71,6 +68,7 @@ class MumbleBot: self.thread = None self.thread_stderr = None self.is_pause = False + self.pause_at_id = "" self.playhead = -1 self.song_start_at = -1 #self.download_threads = [] @@ -221,7 +219,8 @@ class MumbleBot: self.log.info('bot: received command ' + command + ' - ' + parameter + ' by ' + user) # 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']: + 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( constants.strings('not_in_my_channel')) return @@ -294,56 +293,18 @@ class MumbleBot: # Launch and Download # ======================= - def launch_music(self, index=-1): - uri = "" - music = None + def launch_music(self): if var.playlist.is_empty(): return + assert self.wait_for_downloading == False - if index == -1: - music = var.playlist.current_item() - else: - music = var.playlist.jump(index) + music_wrapper = var.playlist.current_item() + uri = music_wrapper.item.uri() - self.wait_for_downloading = False - - self.log.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.tmp_folder, var.config.getint('bot', 'tmp_folder_max_size')) - - if music['ready'] == 'downloading': - self.wait_for_downloading = True - self.log.info("bot: current music isn't ready, other thread is downloading.") - return - - # Check if the music is ready to be played - if music["ready"] != "yes" or not os.path.exists(music['path']): - self.wait_for_downloading = True - self.log.info("bot: current music isn't ready, start downloading.") - self.async_download(index) - return - - if music['ready'] == 'failed': - self.log.info("bot: removing music from the playlist: %s" % util.format_debug_song_string(music)) - var.playlist.remove(index) - return - uri = music['path'] - - elif music["type"] == "file": - if not self.check_item_path_or_remove(): - return - uri = var.music_folder + var.playlist.current_item()["path"] - - elif music["type"] == "radio": - uri = music["url"] - if 'name' not in music: - self.log.info("bot: fetching radio server description") - title = media.radio.get_radio_server_description(uri) - music["name"] = title + self.log.info("bot: play music " + music_wrapper.item.format_debug_string()) if var.config.getboolean('bot', 'announce_current_music'): - self.send_msg(util.format_current_playing()) + self.send_msg(music_wrapper.format_current_playing()) if var.config.getboolean('debug', 'ffmpeg'): ffmpeg_debug = "debug" @@ -365,172 +326,22 @@ class MumbleBot: self.playhead = 0 self.last_volume_cycle_time = time.time() - def validate_music(self, music): - url = music['url'] - - url_hash = hashlib.md5(url.encode()).hexdigest() - - path = var.tmp_folder + url_hash + ".%(ext)s" - mp3 = path.replace(".%(ext)s", ".mp3") - music['path'] = mp3 - - # Download only if music is not existed - if os.path.isfile(mp3): - self.log.info("bot: file existed for url %s " % music['url']) - music['ready'] = 'yes' - return music - - music = media.url.get_url_info(music) - - self.log.info("bot: verifying the duration of url %s " % music['url']) - - 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) - self.log.info( - "the music " + music["url"] + " has a duration of " + str(music['duration']) + "s -- too long") - self.send_msg(constants.strings('too_long')) - return False - else: - music['ready'] = "no" - - return music - else: - self.log.error("bot: error while fetching info from the URL") - self.send_msg(constants.strings('unable_download')) - return False - - def download_music(self, index=-1): - if index == -1: - index = var.playlist.current_index - music = var.playlist[index] - - if music['type'] != 'url': - # then no need to download - return music - - self.download_in_progress = True - - url = music['url'] - - url_hash = hashlib.md5(url.encode()).hexdigest() - - path = var.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): - # download the music - music['ready'] = "downloading" - var.playlist.update(music, music['id']) - - self.log.info("bot: downloading url (%s) %s " % (music['title'], url)) - ydl_opts = "" - - ydl_opts = { - 'format': 'bestaudio/best', - 'outtmpl': path, - 'noplaylist': True, - 'writethumbnail': True, - 'updatetime': False, - 'postprocessors': [{ - 'key': 'FFmpegExtractAudio', - 'preferredcodec': 'mp3', - 'preferredquality': '192'}, - {'key': 'FFmpegMetadata'}] - } - self.send_msg(constants.strings('download_in_progress', item=music['title'])) - - with youtube_dl.YoutubeDL(ydl_opts) as ydl: - attempts = var.config.getint('bot', 'download_attempts', fallback=2) - download_succeed = False - for i in range(attempts): - self.log.info("bot: download attempts %d / %d" % (i+1, attempts)) - try: - ydl.extract_info(url) - download_succeed = True - break - except: - error_traceback = traceback.format_exc().split("During")[0] - error = error_traceback.rstrip().split("\n")[-1] - self.log.error("bot: download failed with error:\n %s" % error) - - if download_succeed: - music['ready'] = "yes" - self.log.info( - "bot: finished downloading url (%s) %s, saved to %s." % (music['title'], url, music['path'])) - else: - for f in [mp3, path.replace(".%(ext)s", ".jpg"), path.replace(".%(ext)s", ".m4a")]: - if os.path.exists(f): - os.remove(f) - self.send_msg(constants.strings('unable_download')) - music['ready'] = "failed" - else: - self.log.info("bot: music file existed, skip downloading " + mp3) - music['ready'] = "yes" - - music = util.attach_music_tag_info(music) - - var.playlist.update(music, music['id']) - self.download_in_progress = False - return music - def async_download_next(self): # Function start if the next music isn't ready # Do nothing in case the next music is already downloaded self.log.debug("bot: Async download next asked ") - if var.playlist.next_item() and var.playlist.next_item()['type'] == 'url': + while var.playlist.next_item() and var.playlist.next_item().item.type == 'url': # usually, all validation will be done when adding to the list. # however, for performance consideration, youtube playlist won't be validate when added. # the validation has to be done here. - while var.playlist.next_item() and var.playlist.next_item()['ready'] == "validation": - music = self.validate_music(var.playlist.next_item()) - if music: - var.playlist.update(music, music['id']) - break - else: - var.playlist.remove(var.playlist.next_index()) + next = var.playlist.next_item().item + if next.validate(): + if not next.is_ready(): + next.async_prepare() + break + else: + var.playlist.remove_by_id(next.id) - if var.playlist.next_item() and var.playlist.next_item()['ready'] == "no": - self.async_download(var.playlist.next_index()) - - def async_download(self, index): - th = threading.Thread( - target=self.download_music, name="DownloadThread-" + var.playlist[index]['id'][:5], args=(index,)) - self.log.info( - "bot: start downloading item in thread: " + util.format_debug_song_string(var.playlist[index])) - th.daemon = True - th.start() - #self.download_threads.append(th) - return th - - def check_item_path_or_remove(self, index = -1): - if index == -1: - index = var.playlist.current_index - music = var.playlist[index] - - if music['type'] == 'radio': - return True - - if not 'path' in music: - return False - else: - if music["type"] == "url": - uri = music['path'] - if not os.path.exists(uri): - music['ready'] = 'validation' - return False - - elif music["type"] == "file": - uri = var.music_folder + music["path"] - if not os.path.exists(uri): - self.log.info("bot: music file missed. removing music from the playlist: %s" % util.format_debug_song_string(music)) - self.send_msg(constants.strings('file_missed', file=music["path"])) - var.playlist.remove(index) - return False - - return True # ======================= # Loop @@ -577,17 +388,30 @@ class MumbleBot: # ffmpeg thread has gone. indicate that last song has finished. move to the next song. if not self.wait_for_downloading: if var.playlist.next(): - # if downloading in the other thread - self.launch_music() - self.async_download_next() + current = var.playlist.current_item().item + if current.validate(): + print("validate") + if current.is_ready(): + print("ready") + self.launch_music() + self.async_download_next() + else: + self.log.info("bot: current music isn't ready, start downloading.") + self.wait_for_downloading = True + current.async_prepare() + else: + var.playlist.remove_by_id(current.id) else: self._loop_status = 'Empty queue' else: - if var.playlist.current_item(): - if var.playlist.current_item()["ready"] != "downloading": + current = var.playlist.current_item().item + if current: + if current.is_ready(): self.wait_for_downloading = False self.launch_music() self.async_download_next() + elif current.is_failed(): + var.playlist.remove_by_id(current.id) else: self._loop_status = 'Wait for downloading' else: @@ -666,6 +490,7 @@ class MumbleBot: def pause(self): # Kill the ffmpeg thread if self.thread: + self.pause_at_id = var.playlist.current_item().item.id self.thread.kill() self.thread = None self.is_pause = True @@ -678,10 +503,10 @@ class MumbleBot: if var.playlist.current_index == -1: var.playlist.next() - music = var.playlist.current_item() + music_wrapper = var.playlist.current_item() - if music['type'] == 'radio' or self.playhead == 0 or not self.check_item_path_or_remove(): - self.launch_music() + if not music_wrapper or not music_wrapper.item.id == self.pause_at_id or not music_wrapper.item.is_ready(): + self.playhead = 0 return if var.config.getboolean('debug', 'ffmpeg'): @@ -691,12 +516,7 @@ class MumbleBot: self.log.info("bot: resume music at %.2f seconds" % self.playhead) - uri = "" - if music["type"] == "url": - uri = music['path'] - - elif music["type"] == "file": - uri = var.music_folder + var.playlist.current_item()["path"] + uri = music_wrapper.item.uri() command = ("ffmpeg", '-v', ffmpeg_debug, '-nostdin', '-ss', "%f" % self.playhead, '-i', uri, '-ac', '1', '-f', 's16le', '-ar', '48000', '-') @@ -713,6 +533,7 @@ class MumbleBot: self.thread_stderr = os.fdopen(pipe_rd) self.thread = sp.Popen(command, stdout=sp.PIPE, stderr=pipe_wd, bufsize=480) self.last_volume_cycle_time = time.time() + self.pause_at_id = "" # TODO: this is a temporary workaround for issue #44 of pymumble. @@ -808,8 +629,8 @@ if __name__ == '__main__': var.bot_logger = bot_logger var.playlist = PlayList() # playlist should be initialized after the database - var.botamusique = MumbleBot(args) - command.register_all_commands(var.botamusique) + var.bot = MumbleBot(args) + command.register_all_commands(var.bot) # load playlist if var.config.getboolean('bot', 'save_playlist', fallback=True): @@ -827,4 +648,4 @@ if __name__ == '__main__': var.playlist.set_mode(playback_mode) # Start the main loop. - var.botamusique.loop() + var.bot.loop() diff --git a/playlist.py b/playlist.py deleted file mode 100644 index 8986399..0000000 --- a/playlist.py +++ /dev/null @@ -1,244 +0,0 @@ -import json -import random -import hashlib - -import util -import variables as var - -""" -FORMAT OF A MUSIC INTO THE PLAYLIST -type : url - id - url - title - path - duration - artist - thumbnail - user - ready (validation, no, downloading, yes, failed) - from_playlist (yes,no) - playlist_title - playlist_url - -type : radio - id - url - name - current_title - user - -type : file - id - path - title - artist - duration - thumbnail - user -""" - - -class PlayList(list): - current_index = -1 - version = 0 # increase by one after each change - mode = "one-shot" # "repeat", "random" - - - def __init__(self, *args): - super().__init__(*args) - - def is_empty(self): - return True if len(self) == 0 else False - - def set_mode(self, mode): - # modes are "one-shot", "repeat", "random" - self.mode = mode - - if mode == "random": - self.randomize() - - elif mode == "one-shot" and self.current_index > 0: - # remove items before current item - self.version += 1 - for i in range(self.current_index): - super().__delitem__(0) - self.current_index = 0 - - def append(self, item): - self.version += 1 - item = util.attach_music_tag_info(item) - super().append(item) - - return item - - def insert(self, index, item): - self.version += 1 - - if index == -1: - index = self.current_index - - item = util.attach_music_tag_info(item) - super().insert(index, item) - - if index <= self.current_index: - self.current_index += 1 - - return item - - def length(self): - return len(self) - - def extend(self, items): - self.version += 1 - items = list(map( - lambda item: util.attach_music_tag_info(item), - items)) - super().extend(items) - return items - - def next(self): - if len(self) == 0: - return False - - self.version += 1 - #logging.debug("playlist: Next into the queue") - - if self.current_index < len(self) - 1: - if self.mode == "one-shot" and self.current_index != -1: - super().__delitem__(self.current_index) - else: - self.current_index += 1 - - return self[self.current_index] - else: - self.current_index = 0 - if self.mode == "one-shot": - self.clear() - return False - elif self.mode == "repeat": - return self[0] - elif self.mode == "random": - self.randomize() - return self[0] - else: - raise TypeError("Unknown playlist mode '%s'." % self.mode) - - def find(self, id): - for index, item in enumerate(self): - if item['id'] == id: - return index - return None - - def update(self, item, id): - self.version += 1 - index = self.find(id) - if index: - self[index] = item - return True - return False - - def __delitem__(self, key): - return self.remove(key) - - def remove(self, index=-1): - self.version += 1 - if index > len(self) - 1: - return False - - if index == -1: - index = self.current_index - - removed = self[index] - super().__delitem__(index) - - if self.current_index > index: - self.current_index -= 1 - - return removed - - def current_item(self): - if len(self) == 0: - return False - - return self[self.current_index] - - def current_item_downloading(self): - if len(self) == 0: - return False - - if self[self.current_index]['type'] == 'url' and self[self.current_index]['ready'] == 'downloading': - return True - return False - - def next_index(self): - if len(self) == 0 or (len(self) == 1 and self.mode == 'one_shot'): - return False - - if self.current_index < len(self) - 1: - return self.current_index + 1 - else: - return 0 - - def next_item(self): - if len(self) == 0 or (len(self) == 1 and self.mode == 'one_shot'): - return False - - return self[self.next_index()] - - def jump(self, index): - if self.mode == "one-shot": - for i in range(index): - super().__delitem__(0) - self.current_index = 0 - else: - self.current_index = index - - self.version += 1 - return self[self.current_index] - - def randomize(self): - # current_index will lose track after shuffling, thus we take current music out before shuffling - #current = self.current_item() - #del self[self.current_index] - - random.shuffle(self) - - #self.insert(0, current) - self.current_index = -1 - self.version += 1 - - def clear(self): - self.version += 1 - self.current_index = -1 - super().clear() - - def save(self): - var.db.remove_section("playlist_item") - var.db.set("playlist", "current_index", self.current_index) - - for index, music in enumerate(self): - if music['type'] == 'url' and music['ready'] == 'downloading': - music['ready'] = 'no' - - var.db.set("playlist_item", str(index), json.dumps(music)) - - 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.extend(list(map(lambda v: json.loads(v[1]), items))) - - self.current_index = current_index - - def _debug_print(self): - print("===== Playlist(%d) ====" % self.current_index) - for index, item in enumerate(self): - if index == self.current_index: - print("-> %d %s" % (index, item['title'])) - else: - print("%d %s" % (index, item['title'])) - print("===== End ====") \ No newline at end of file diff --git a/playlist.txt b/playlist.txt deleted file mode 100644 index dca9629..0000000 --- a/playlist.txt +++ /dev/null @@ -1,24 +0,0 @@ -type : url - url - title - path - duration - thundnail - user - ready (validation, no, downloading, yes) - from_playlist (yes,no) - playlist_title - playlist_url - -type : radio - url - name - current_title - user - -type : file - path - title - duration - user - diff --git a/templates/playlist.html b/templates/playlist.html index f0adb8c..b677ac9 100644 --- a/templates/playlist.html +++ b/templates/playlist.html @@ -11,32 +11,34 @@ {{ index + 1 }}
- {% if 'thumbnail' in m %} - + {% if m.type != 'radio' and m.thumbnail %} + {% else %} {% endif %}
- {% if 'title' in m and m['title'].strip() %} - {{ m['title']|truncate(45) }} - {% elif 'url' in m %} - {{ m['url']|truncate(45) }} + {% if m.title.strip() %} + {{ m.title|truncate(45) }} + {% elif m.url %} + {{ m.url|truncate(45) }} {% endif %} - {{ m['type'].capitalize() }} + {{ m.display_type() }}
- {% if 'artist' in m %} - {{ m['artist'] }} + {% if m.type == 'file' %} + {{ m.artist }} + {% elif m.type == 'url_from_playlist' %} + {{ m.playlist_title|truncate(50) }} {% else %} Unknown Artist {% endif %}
- {% if 'url' in m %} - {{ m['url']|truncate(50) }} - {% elif 'path' in m %} - {{ m['path']|truncate(50) }} + {% if m.type == 'url' or m.type == 'radio' or m.type == 'url_from_playlist' %} + {{ m.url|truncate(50) }} + {% elif m.type == 'file' %} + {{ m.path|truncate(50) }} {% endif %} diff --git a/util.py b/util.py index a988637..da1f7fa 100644 --- a/util.py +++ b/util.py @@ -62,181 +62,6 @@ def get_recursive_file_list_sorted(path): filelist.sort() return filelist - -def get_music_path(music): - uri = '' - if music["type"] == "url": - uri = music['path'] - elif music["type"] == "file": - uri = var.music_folder + music["path"] - elif music["type"] == "radio": - uri = music['url'] - - return uri - -def attach_item_id(item): - if item['type'] == 'url': - item['id'] = hashlib.md5(item['url'].encode()).hexdigest() - elif item['type'] == 'file': - item['id'] = hashlib.md5(item['path'].encode()).hexdigest() - elif item['type'] == 'radio': - item['id'] = hashlib.md5(item['url'].encode()).hexdigest() - return item - -def attach_music_tag_info(music): - music = attach_item_id(music) - - if "path" in music: - uri = get_music_path(music) - - 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 = constants.strings("now_playing_radio", - url=music["url"], - title=media.radio.get_radio_title(music["url"]), - name=music["name"], - user=music["user"] - ) - elif source == "url" and 'from_playlist' in music: - display = constants.strings("now_playing_from_playlist", - title=title, - url=music['url'], - playlist_url=music["playlist_url"], - playlist=music["playlist_title"], - user=music["user"] - ) - elif source == "url": - display = constants.strings("now_playing_url", - title=title, - url=music["url"], - user=music["user"] - ) - elif source == "file": - display = constants.strings("now_playing_file", - 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] {name} ({url}) by {user}".format( - name=music["name"], - 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) - - if 'thumbnail' in music: - thumbnail_html = '' - return display + "
" + 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) diff --git a/variables.py b/variables.py index 473d99c..188aeb9 100644 --- a/variables.py +++ b/variables.py @@ -1,4 +1,4 @@ -botamusique = None +bot = None playlist = None user = ""