diff --git a/cogs/playlist.py b/cogs/music.py similarity index 63% rename from cogs/playlist.py rename to cogs/music.py index aa82292..90dc54a 100644 --- a/cogs/playlist.py +++ b/cogs/music.py @@ -1,4 +1,5 @@ -from .utils import checks +from .utils import * +from .voice_utilities import * import discord from discord.ext import commands @@ -14,78 +15,14 @@ import re if not discord.opus.is_loaded(): discord.opus.load_opus('/usr/lib64/libopus.so.0') - -class VoicePlayer: - # This does not need to match up too closely to the StreamPlayer that is "technically" used here - # This is more of a placeholder, just to keep the information that will be requested - # Before the video is actually downloaded, which happens in our audio player task - # For example, is_done() will not exist on this object, which could be called later - # However, it should not ever be, as we overwrite this object with the StreamPlayer in our audio task - def __init__(self, song, **kwargs): - self.url = song - self.views = kwargs.get('view_count') - self.is_live = bool(kwargs.get('is_live')) - self.likes = kwargs.get('likes') - self.dislikes = kwargs.get('dislikes') - self.duration = kwargs.get('duration') - self.uploader = kwargs.get('uploader') - if 'twitch' in song: - self.title = kwargs.get('description') - self.description = None - else: - self.title = kwargs.get('title') - self.description = kwargs.get('description') - - date = kwargs.get('upload_date') - if date: - try: - date = datetime.datetime.strptime(date, '%Y%M%d').date() - except ValueError: - date = None - - self.upload_date = date - - -class VoiceEntry: - def __init__(self, message, player): - self.requester = message.author - self.channel = message.channel - self.player = player - self.start_time = None - - @property - def length(self): - if self.player.duration: - return self.player.duration - - @property - def progress(self): - if self.start_time: - return round(time.time() - self.start_time) - - @property - def remaining(self): - length = self.length - progress = self.progress - if length and progress: - return length - progress - - def __str__(self): - fmt = '*{0.title}* uploaded by {0.uploader} and requested by {1.display_name}' - duration = self.length - if duration: - fmt += ' [length: {0[0]}m {0[1]}s]'.format(divmod(round(duration, 0), 60)) - return fmt.format(self.player, self.requester) - - class VoiceState: - def __init__(self, bot): + def __init__(self, bot, downloader): self.current = None self.voice = None self.bot = bot self.play_next_song = asyncio.Event() # This is the queue that holds all VoiceEntry's - self.songs = asyncio.Queue(maxsize=10) + self.songs = Playlist(bot) self.required_skips = 0 # a set of user_ids that voted self.skip_votes = set() @@ -96,6 +33,7 @@ class VoiceState: 'quiet': True } self.volume = 50 + self.downloader = downloader def is_playing(self): # If our VoiceClient or current VoiceEntry do not exist, then we are not playing a song @@ -130,20 +68,27 @@ class VoiceState: self.play_next_song.clear() # Clear the votes skip that were for the last song self.skip_votes.clear() - # Set current to none while we are waiting for the next song in the queue - # If we don't do this and we hit the end of the queue - # our current song will remain the song that just finished - self.current = None # Now wait for the next song in the queue - self.current = await self.songs.get() - # Tell the channel that requested the new song that we are now playing - try: - await self.bot.send_message(self.current.channel, 'Now playing ' + str(self.current)) - except discord.Forbidden: - pass - # Create the player object; this automatically creates the ffmpeg player - self.current.player = await self.voice.create_ytdl_player(self.current.player.url, ytdl_options=self.opts, - after=self.toggle_next) + self.current = await self.songs.get_next_entry() + + # Make sure we find a song + while (self.current is None): + await asyncio.sleep(1) + self.current = await self.songs.get_next_entry() + + # At this point we're sure we have a song, however it needs to be downloaded + while(not getattr(self.current, 'filename')): + print("Downloading...") + await asyncio.sleep(1) + + # Create the player object + self.current.player = self.voice.create_ffmpeg_player( + self.current.filename, + before_options="-nostdin", + options="-vn -b:a 128k", + after=self.toggle_next + ) + # Now we can start actually playing the song self.current.player.start() self.current.player.volume = self.volume / 100 @@ -163,15 +108,9 @@ class Music: def __init__(self, bot): self.bot = bot self.voice_states = {} - self.opts = { - 'format': 'webm[abr>0]/bestaudio/best', - 'prefer_ffmpeg': True, - 'default_search': 'auto', - 'quiet': True - } - # We want to create our own YoutubeDL object to avoid downloading a video when first searching it - # We will download the actual video, in our audio_player_task, for which we can just use create_ytdl_player - self.ytdl = youtube_dl.YoutubeDL(self.opts) + down = Downloader(download_folder='audio_tmp') + self.downloader = down + self.bot.downloader = down def get_voice_state(self, server): state = self.voice_states.get(server.id) @@ -181,22 +120,28 @@ class Music: # We create the voice state when checked # This only creates the state, we are still not playing anything, which can then be handled separately if state is None: - state = VoiceState(self.bot) + state = VoiceState(self.bot, self.downloader) self.voice_states[server.id] = state return state async def create_voice_client(self, channel): # First join the channel and get the VoiceClient that we'll use to save per server - try: - voice = await self.bot.join_voice_channel(channel) - except asyncio.TimeoutError: - await self.bot.say( - "Sorry, I couldn't connect! This can sometimes be caused by the server region you are in. " - "You can either try again, or try to change the server's region and see if that fixes the issue") - return - state = self.get_voice_state(channel.server) - state.voice = voice + server = channel.server + state = self.get_voice_state(server) + voice = self.bot.voice_client_in(server) + + if voice is None: + state.voice = await self.bot.join_voice_channel(channel) + return True + elif voice.channel == channel: + state.voice = voice + return True + else: + await voice.disconnect() + state.voice = await self.bot.join_voice_channel(channel) + return True + def __unload(self): # If this is unloaded, cancel all players and disconnect from all channels @@ -251,18 +196,6 @@ class Music: # Check if the channel given was an actual voice channel except discord.InvalidArgument: await self.bot.say('This is not a voice channel...') - # Check if we failed to join a channel, which means we are already in a channel. - # move_channel needs to be used if we are already in a channel - except discord.ClientException: - state = self.get_voice_state(ctx.message.server) - if state.voice is None: - voice_channel = self.bot.voice_client_in(ctx.message.server) - if voice_channel is not None: - await voice_channel.disconnect() - await self.bot.say("Sorry but I failed to connect! Please try again") - else: - await state.voice.move_to(channel) - await self.bot.say('Ready to play audio in ' + channel.name) else: await self.bot.say('Ready to play audio in ' + channel.name) @@ -277,43 +210,15 @@ class Music: await self.bot.say('You are not in a voice channel.') return False - # Check if we're in a channel already, if we are then we just need to move channels - # Otherwse, we need to create an actual voice state - state = self.get_voice_state(ctx.message.server) - # Discord's voice connecting is not very reliable, so we need to implement - # a couple different workarounds here in case something goes wrong + # Then simply create a voice client + success = await self.create_voice_client(summoned_channel) - # First check if we have a voice connection saved - if state.voice is not None: - # Check if our saved voice connection doesn't actually exist - if self.bot.voice_client_in(ctx.message.server) is None: - await state.voice.disconnect() - await self.bot.say("I had an issue connecting to the channel, please try again") - return False - # If it does exist, then we are in a voice channel already, and need to move to the new channel - else: - await state.voice.move_to(summoned_channel) - # Otherwise, our connection is not detected by this cog - else: - # Check if there is actually a voice connection though - voice_channel = self.bot.voice_client_in(ctx.message.server) - if voice_channel is not None: - await voice_channel.disconnect() - await self.bot.say("I had an issue connecting to the channel, please try again") - return False - # In this case, nothing has gone wrong, and we aren't in a channel, so we can join it - else: - try: - state.voice = await self.bot.join_voice_channel(summoned_channel) - # Weird timeout error usually caused by the region someone is in - except (asyncio.TimeoutError, discord.ConnectionClosed, ConnectionResetError): - await self.bot.say( - "Sorry, I couldn't connect! This can sometimes be caused by the server region you are in. " - "You can either try again, or try to change the server's" - " region and see if that fixes the issue") - return False - # Return true if nothing has failed, so that we can invoke this, and ensure we succeeded - return True + if success: + try: + await self.bot.say('Ready to play audio in ' + summoned_channel.name) + except discord.Forbidden: + pass + return success @commands.command(pass_context=True, no_pm=True) @checks.custom_perms(send_messages=True) @@ -336,7 +241,7 @@ class Music: return # If the queue is full, we ain't adding anything to it - if state.songs.full(): + if state.songs.full: await self.bot.say("The queue is currently full! You'll need to wait to add a new song") return @@ -350,35 +255,19 @@ class Music: # Create the player, and check if this was successful # Here all we want is to get the information of the player - try: - song = re.sub('[<>\[\]]', '', song) - func = functools.partial(self.ytdl.extract_info, song, download=False) - info = await self.bot.loop.run_in_executor(None, func) - if "entries" in info: - info = info['entries'][0] - player = VoicePlayer(song, **info) - # player = await state.voice.create_ytdl_player(song, ytdl_options=state.opts, after=state.toggle_next) - except youtube_dl.DownloadError: - fmt = "Sorry, either I had an issue downloading that video, or that's not a supported URL!" - await self.bot.send_message(ctx.message.channel, fmt) - return - except IndexError: - fmt = "Sorry, but there's no result with that search time! Try something else" - await self.bot.send_message(ctx.message.channel, fmt) - return - except ValueError: - fmt = "Brackets are my enemy; please remove them or else!\n" \ - "(Youtube_dl errors when brackets are used, try running this again without the brackets)" - await self.bot.send_message(ctx.message.channel, fmt) - return + song = re.sub('[<>\[\]]', '', song) - # Now we can create a VoiceEntry and queue it - entry = VoiceEntry(ctx.message, player) - await state.songs.put(entry) try: - await self.bot.say('Enqueued ' + str(entry)) - except discord.Forbidden: - pass + _entry = await state.songs.add_entry(song, ctx.message.author) + except WrongEntryTypeError: + # This means that a song was attempted to be searched, instead of a link provided + info = await self.downloader.extract_info(self.bot.loop, song, download=False, process=True) + + song = info.get('entries', [])[0]['webpage_url'] + _entry = await state.songs.add_entry(song, ctx.message.author) + if 'ytsearch' in info.get('url', ''): + print(info) + await self.bot.say('Enqueued ' + str(_entry)) @commands.command(pass_context=True, no_pm=True) @checks.custom_perms(kick_members=True) @@ -450,13 +339,14 @@ class Music: if not state.is_playing(): await self.bot.say('Not playing any music right now...') return - queue = state.songs._queue + + queue = state.songs.entries if len(queue) == 0: await self.bot.say("Nothing currently in the queue") return - # Start off by adding the length of the current song - count = state.current.player.duration + # Start off by adding the remaining length of the current song + count = state.current.remaining found = False # Loop through the songs in the queue, until the author is found as the requester # The found bool is used to see if we actually found the author, or we just looped through the whole queue @@ -464,12 +354,12 @@ class Music: if song.requester == author: found = True break - count += song.player.duration + count += song.duration # This is checking if nothing from the queue has been added to the total # If it has not, then we have not looped through the queue at all # Since the queue was already checked to have more than one song in it, this means the author is next - if count == state.current.player.duration: + if count == state.current.duration: await self.bot.say("You are next in the queue!") return if not found: @@ -487,7 +377,7 @@ class Music: return # Asyncio provides no non-private way to access the queue, so we have to use _queue - queue = state.songs._queue + queue = state.songs.entries if len(queue) == 0: fmt = "Nothing currently in the queue" else: @@ -499,7 +389,7 @@ class Music: async def queuelength(self, ctx): """Prints the length of the queue""" await self.bot.say("There are a total of {} songs in the queue" - .format(str(self.get_voice_state(ctx.message.server).songs.qsize()))) + .format(len(self.get_voice_state(ctx.message.server).songs.entries))) @commands.command(pass_context=True, no_pm=True) @checks.custom_perms(send_messages=True) diff --git a/cogs/voice_utilities/__init__.py b/cogs/voice_utilities/__init__.py new file mode 100644 index 0000000..3161697 --- /dev/null +++ b/cogs/voice_utilities/__init__.py @@ -0,0 +1,3 @@ +from .downloader import Downloader +from .playlist import Playlist +from .exceptions import * diff --git a/cogs/voice_utilities/downloader.py b/cogs/voice_utilities/downloader.py new file mode 100644 index 0000000..504d7c3 --- /dev/null +++ b/cogs/voice_utilities/downloader.py @@ -0,0 +1,85 @@ +import os +import asyncio +import functools +import youtube_dl + +from concurrent.futures import ThreadPoolExecutor + +ytdl_format_options = { + 'format': 'bestaudio/best', + 'extractaudio': True, + 'audioformat': 'mp3', + 'outtmpl': '%(extractor)s-%(id)s-%(title)s.%(ext)s', + 'restrictfilenames': True, + 'noplaylist': True, + 'nocheckcertificate': True, + 'ignoreerrors': False, + 'logtostderr': False, + 'quiet': True, + 'no_warnings': True, + 'default_search': 'auto', + 'source_address': '0.0.0.0' +} + +# Fuck your useless bugreports message that gets two link embeds and confuses users +youtube_dl.utils.bug_reports_message = lambda: '' + +''' + Alright, here's the problem. To catch youtube-dl errors for their useful information, I have to + catch the exceptions with `ignoreerrors` off. To not break when ytdl hits a dumb video + (rental videos, etc), I have to have `ignoreerrors` on. I can change these whenever, but with async + that's bad. So I need multiple ytdl objects. + +''' + +class Downloader: + def __init__(self, download_folder=None): + self.thread_pool = ThreadPoolExecutor(max_workers=2) + self.unsafe_ytdl = youtube_dl.YoutubeDL(ytdl_format_options) + self.safe_ytdl = youtube_dl.YoutubeDL(ytdl_format_options) + self.safe_ytdl.params['ignoreerrors'] = True + self.download_folder = download_folder + + if download_folder: + otmpl = self.unsafe_ytdl.params['outtmpl'] + self.unsafe_ytdl.params['outtmpl'] = os.path.join(download_folder, otmpl) + # print("setting template to " + os.path.join(download_folder, otmpl)) + + otmpl = self.safe_ytdl.params['outtmpl'] + self.safe_ytdl.params['outtmpl'] = os.path.join(download_folder, otmpl) + + + @property + def ytdl(self): + return self.safe_ytdl + + async def extract_info(self, loop, *args, on_error=None, retry_on_error=False, **kwargs): + """ + Runs ytdl.extract_info within the threadpool. Returns a future that will fire when it's done. + If `on_error` is passed and an exception is raised, the exception will be caught and passed to + on_error as an argument. + """ + if callable(on_error): + try: + return await loop.run_in_executor(self.thread_pool, functools.partial(self.unsafe_ytdl.extract_info, *args, **kwargs)) + + except Exception as e: + + # (youtube_dl.utils.ExtractorError, youtube_dl.utils.DownloadError) + # I hope I don't have to deal with ContentTooShortError's + if asyncio.iscoroutinefunction(on_error): + asyncio.ensure_future(on_error(e), loop=loop) + + elif asyncio.iscoroutine(on_error): + asyncio.ensure_future(on_error, loop=loop) + + else: + loop.call_soon_threadsafe(on_error, e) + + if retry_on_error: + return await self.safe_extract_info(loop, *args, **kwargs) + else: + return await loop.run_in_executor(self.thread_pool, functools.partial(self.unsafe_ytdl.extract_info, *args, **kwargs)) + + async def safe_extract_info(self, loop, *args, **kwargs): + return await loop.run_in_executor(self.thread_pool, functools.partial(self.safe_ytdl.extract_info, *args, **kwargs)) diff --git a/cogs/voice_utilities/entry.py b/cogs/voice_utilities/entry.py new file mode 100644 index 0000000..300481c --- /dev/null +++ b/cogs/voice_utilities/entry.py @@ -0,0 +1,274 @@ +import asyncio +import json +import os +import traceback +import time + +from hashlib import md5 +from .exceptions import ExtractionError + +async def get_header(session, url, headerfield=None, *, timeout=5): + with aiohttp.Timeout(timeout): + async with session.head(url) as response: + if headerfield: + return response.headers.get(headerfield) + else: + return response.headers + +def md5sum(filename, limit=0): + fhash = md5() + with open(filename, "rb") as f: + for chunk in iter(lambda: f.read(8192), b""): + fhash.update(chunk) + return fhash.hexdigest()[-limit:] + +class BasePlaylistEntry: + def __init__(self): + self.filename = None + self._is_downloading = False + self._waiting_futures = [] + + @property + def is_downloaded(self): + if self._is_downloading: + return False + + return bool(self.filename) + + @classmethod + def from_json(cls, playlist, jsonstring): + raise NotImplementedError + + def to_json(self): + raise NotImplementedError + + async def _download(self): + raise NotImplementedError + + def get_ready_future(self): + """ + Returns a future that will fire when the song is ready to be played. The future will either fire with the result (being the entry) or an exception + as to why the song download failed. + """ + future = asyncio.Future() + if self.is_downloaded: + # In the event that we're downloaded, we're already ready for playback. + future.set_result(self) + + else: + # If we request a ready future, let's ensure that it'll actually resolve at one point. + asyncio.ensure_future(self._download()) + self._waiting_futures.append(future) + + return future + + def _for_each_future(self, cb): + """ + Calls `cb` for each future that is not cancelled. Absorbs and logs any errors that may have occurred. + """ + futures = self._waiting_futures + self._waiting_futures = [] + + for future in futures: + if future.cancelled(): + continue + + try: + cb(future) + + except: + traceback.print_exc() + + def __eq__(self, other): + return self is other + + def __hash__(self): + return id(self) + + +class URLPlaylistEntry(BasePlaylistEntry): + def __init__(self, playlist, url, title, requester, duration=0, expected_filename=None, **meta): + super().__init__() + + self.playlist = playlist + self.url = url + self.title = title + self.duration = duration + self.expected_filename = expected_filename + self.meta = meta + self.requester = requester + self.download_folder = self.playlist.downloader.download_folder + + def __str__(self): + fmt = '*{0}* requested by **{1.display_name}**' + if self.duration: + fmt += ' [length: {0[0]}m {0[1]}s]'.format(divmod(round(self.duration, 0), 60)) + return fmt.format(self.title, self.requester) + + @property + def length(self): + if self.duration: + return self.duration + + @property + def progress(self): + if self.start_time: + return round(time.time() - self.start_time) + + @property + def remaining(self): + length = self.length + progress = self.progress + if length and progress: + return length - progress + + @classmethod + def from_json(cls, playlist, jsonstring): + data = json.loads(jsonstring) + print(data) + # TODO: version check + url = data['url'] + title = data['title'] + duration = data['duration'] + downloaded = data['downloaded'] + filename = data['filename'] if downloaded else None + meta = {} + + # TODO: Better [name] fallbacks + if 'channel' in data['meta']: + ch = playlist.bot.get_channel(data['meta']['channel']['id']) + meta['channel'] = ch or data['meta']['channel']['name'] + + if 'author' in data['meta']: + meta['author'] = meta['channel'].server.get_member(data['meta']['author']['id']) + + return cls(playlist, url, title, duration, filename, **meta) + + def to_json(self): + data = { + 'version': 1, + 'type': self.__class__.__name__, + 'url': self.url, + 'title': self.title, + 'duration': self.duration, + 'downloaded': self.is_downloaded, + 'filename': self.filename, + 'meta': { + i: { + 'type': self.meta[i].__class__.__name__, + 'id': self.meta[i].id, + 'name': self.meta[i].name + } for i in self.meta + } + # Actually I think I can just getattr instead, getattr(discord, type) + } + return json.dumps(data, indent=2) + + # noinspection PyTypeChecker + async def _download(self): + if self._is_downloading: + return + + self._is_downloading = True + try: + # Ensure the folder that we're going to move into exists. + if not os.path.exists(self.download_folder): + os.makedirs(self.download_folder) + + # self.expected_filename: audio_cache\youtube-9R8aSKwTEMg-NOMA_-_Brain_Power.m4a + extractor = os.path.basename(self.expected_filename).split('-')[0] + + # the generic extractor requires special handling + if extractor == 'generic': + # print("Handling generic") + flistdir = [f.rsplit('-', 1)[0] for f in os.listdir(self.download_folder)] + expected_fname_noex, fname_ex = os.path.basename(self.expected_filename).rsplit('.', 1) + + if expected_fname_noex in flistdir: + try: + rsize = int(await get_header(self.playlist.bot.aiosession, self.url, 'CONTENT-LENGTH')) + except: + rsize = 0 + + lfile = os.path.join( + self.download_folder, + os.listdir(self.download_folder)[flistdir.index(expected_fname_noex)] + ) + + # print("Resolved %s to %s" % (self.expected_filename, lfile)) + lsize = os.path.getsize(lfile) + # print("Remote size: %s Local size: %s" % (rsize, lsize)) + + if lsize != rsize: + await self._really_download(hash=True) + else: + # print("[Download] Cached:", self.url) + self.filename = lfile + + else: + # print("File not found in cache (%s)" % expected_fname_noex) + await self._really_download(hash=True) + + else: + ldir = os.listdir(self.download_folder) + flistdir = [f.rsplit('.', 1)[0] for f in ldir] + expected_fname_base = os.path.basename(self.expected_filename) + expected_fname_noex = expected_fname_base.rsplit('.', 1)[0] + + # idk wtf this is but its probably legacy code + # or i have youtube to blame for changing shit again + + if expected_fname_base in ldir: + self.filename = os.path.join(self.download_folder, expected_fname_base) + print("[Download] Cached:", self.url) + + elif expected_fname_noex in flistdir: + print("[Download] Cached (different extension):", self.url) + self.filename = os.path.join(self.download_folder, ldir[flistdir.index(expected_fname_noex)]) + print("Expected %s, got %s" % ( + self.expected_filename.rsplit('.', 1)[-1], + self.filename.rsplit('.', 1)[-1] + )) + + else: + await self._really_download() + + # Trigger ready callbacks. + self._for_each_future(lambda future: future.set_result(self)) + + except Exception as e: + traceback.print_exc() + self._for_each_future(lambda future: future.set_exception(e)) + + finally: + self._is_downloading = False + + # noinspection PyShadowingBuiltins + async def _really_download(self, *, hash=False): + print("[Download] Started:", self.url) + + try: + result = await self.playlist.downloader.extract_info(self.playlist.loop, self.url, download=True) + except Exception as e: + raise ExtractionError(e) + + print("[Download] Complete:", self.url) + + if result is None: + raise ExtractionError("ytdl broke and hell if I know why") + # What the fuck do I do now? + + self.filename = unhashed_fname = self.playlist.downloader.ytdl.prepare_filename(result) + + if hash: + # insert the 8 last characters of the file hash to the file name to ensure uniqueness + self.filename = md5sum(unhashed_fname, 8).join('-.').join(unhashed_fname.rsplit('.', 1)) + + if os.path.isfile(self.filename): + # Oh bother it was actually there. + os.unlink(unhashed_fname) + else: + # Move the temporary file to it's final location. + os.rename(unhashed_fname, self.filename) + + diff --git a/cogs/voice_utilities/event_emitter.py b/cogs/voice_utilities/event_emitter.py new file mode 100644 index 0000000..f360704 --- /dev/null +++ b/cogs/voice_utilities/event_emitter.py @@ -0,0 +1,38 @@ +import asyncio +import traceback +import collections + + +class EventEmitter: + def __init__(self): + self._events = collections.defaultdict(list) + self.loop = asyncio.get_event_loop() + + def emit(self, event, *args, **kwargs): + if event not in self._events: + return + + for cb in self._events[event]: + # noinspection PyBroadException + try: + if asyncio.iscoroutinefunction(cb): + asyncio.ensure_future(cb(*args, **kwargs), loop=self.loop) + else: + cb(*args, **kwargs) + + except: + traceback.print_exc() + + def on(self, event, cb): + self._events[event].append(cb) + return self + + def off(self, event, cb): + self._events[event].remove(cb) + + if not self._events[event]: + del self._events[event] + + return self + + # TODO: add .once diff --git a/cogs/voice_utilities/exceptions.py b/cogs/voice_utilities/exceptions.py new file mode 100644 index 0000000..282a516 --- /dev/null +++ b/cogs/voice_utilities/exceptions.py @@ -0,0 +1,88 @@ +import shutil +import textwrap + +# Base class for exceptions +class MusicbotException(Exception): + def __init__(self, message, *, expire_in=0): + self._message = message + self.expire_in = expire_in + + @property + def message(self): + return self._message + + @property + def message_no_format(self): + return self._message + +# Something went wrong during the processing of a command +class CommandError(MusicbotException): + pass + +# Something went wrong during the processing of a song/ytdl stuff +class ExtractionError(MusicbotException): + pass + +# The no processing entry type failed and an entry was a playlist/vice versa +class WrongEntryTypeError(ExtractionError): + def __init__(self, message, is_playlist, use_url): + super().__init__(message) + self.is_playlist = is_playlist + self.use_url = use_url + +# The user doesn't have permission to use a command +class PermissionsError(CommandError): + @property + def message(self): + return "You don't have permission to use that command.\nReason: " + self._message + +# Error with pretty formatting for hand-holding users through various errors +class HelpfulError(MusicbotException): + def __init__(self, issue, solution, *, preface="An error has occured:\n", expire_in=0): + self.issue = issue + self.solution = solution + self.preface = preface + self.expire_in = expire_in + + @property + def message(self): + return ("\n{}\n{}\n{}\n").format( + self.preface, + self._pretty_wrap(self.issue, " Problem: "), + self._pretty_wrap(self.solution, " Solution: ")) + + @property + def message_no_format(self): + return "\n{}\n{}\n{}\n".format( + self.preface, + self._pretty_wrap(self.issue, " Problem: ", width=None), + self._pretty_wrap(self.solution, " Solution: ", width=None)) + + @staticmethod + def _pretty_wrap(text, pretext, *, width=-1): + if width is None: + return pretext + text + elif width == -1: + width = shutil.get_terminal_size().columns + + l1, *lx = textwrap.wrap(text, width=width - 1 - len(pretext)) + + lx = [((' ' * len(pretext)) + l).rstrip().ljust(width) for l in lx] + l1 = (pretext + l1).ljust(width) + + return ''.join([l1, *lx]) + +class HelpfulWarning(HelpfulError): + pass + +# Base class for control signals +class Signal(Exception): + pass + +# signal to restart the bot +class RestartSignal(Signal): + pass + +# signal to end the bot "gracefully" +class TerminateSignal(Signal): + pass diff --git a/cogs/voice_utilities/playlist.py b/cogs/voice_utilities/playlist.py new file mode 100644 index 0000000..0c23b35 --- /dev/null +++ b/cogs/voice_utilities/playlist.py @@ -0,0 +1,280 @@ +import datetime +import traceback +from collections import deque +from itertools import islice +from random import shuffle + +from .entry import URLPlaylistEntry +from .exceptions import ExtractionError, WrongEntryTypeError +from .event_emitter import EventEmitter + + +class Playlist(EventEmitter): + """ + A playlist is manages the list of songs that will be played. + """ + + def __init__(self, bot): + super().__init__() + self.bot = bot + self.loop = bot.loop + self.downloader = bot.downloader + self.entries = deque() + self.max_songs = 10 + + def __iter__(self): + return iter(self.entries) + + def shuffle(self): + shuffle(self.entries) + + def clear(self): + self.entries.clear() + + @property + def full(self): + return self.count >= self.max_songs + + @property + def count(self): + if self.entries: + return len(self.entries) + else: + return 0 + + async def add_entry(self, song_url, requester, **meta): + """ + Validates and adds a song_url to be played. This does not start the download of the song. + + Returns the entry & the position it is in the queue. + + :param song_url: The song url to add to the playlist. + :param meta: Any additional metadata to add to the playlist entry. + """ + + try: + info = await self.downloader.extract_info(self.loop, song_url, download=False) + except Exception as e: + raise ExtractionError('Could not extract information from {}\n\n{}'.format(song_url, e)) + + if not info: + raise ExtractionError('Could not extract information from %s' % song_url) + + # TODO: Sort out what happens next when this happens + if info.get('_type', None) == 'playlist': + raise WrongEntryTypeError("This is a playlist.", True, info.get('webpage_url', None) or info.get('url', None)) + + if info['extractor'] in ['generic', 'Dropbox']: + try: + # unfortunately this is literally broken + # https://github.com/KeepSafe/aiohttp/issues/758 + # https://github.com/KeepSafe/aiohttp/issues/852 + content_type = await get_header(self.bot.aiosession, info['url'], 'CONTENT-TYPE') + print("Got content type", content_type) + + except Exception as e: + print("[Warning] Failed to get content type for url %s (%s)" % (song_url, e)) + content_type = None + + if content_type: + if content_type.startswith(('application/', 'image/')): + if '/ogg' not in content_type: # How does a server say `application/ogg` what the actual fuck + raise ExtractionError("Invalid content type \"%s\" for url %s" % (content_type, song_url)) + + elif not content_type.startswith(('audio/', 'video/')): + print("[Warning] Questionable content type \"%s\" for url %s" % (content_type, song_url)) + + entry = URLPlaylistEntry( + self, + song_url, + info.get('title', 'Untitled'), + requester, + info.get('duration', 0) or 0, + self.downloader.ytdl.prepare_filename(info), + **meta + ) + self._add_entry(entry) + return entry, len(self.entries) + + async def import_from(self, playlist_url, **meta): + """ + Imports the songs from `playlist_url` and queues them to be played. + + Returns a list of `entries` that have been enqueued. + + :param playlist_url: The playlist url to be cut into individual urls and added to the playlist + :param meta: Any additional metadata to add to the playlist entry + """ + position = len(self.entries) + 1 + entry_list = [] + + try: + info = await self.downloader.safe_extract_info(self.loop, playlist_url, download=False) + except Exception as e: + raise ExtractionError('Could not extract information from {}\n\n{}'.format(playlist_url, e)) + + if not info: + raise ExtractionError('Could not extract information from %s' % playlist_url) + + # Once again, the generic extractor fucks things up. + if info.get('extractor', None) == 'generic': + url_field = 'url' + else: + url_field = 'webpage_url' + + baditems = 0 + for items in info['entries']: + if items: + try: + entry = URLPlaylistEntry( + self, + items[url_field], + items.get('title', 'Untitled'), + items.get('duration', 0) or 0, + self.downloader.ytdl.prepare_filename(items), + **meta + ) + + self._add_entry(entry) + entry_list.append(entry) + except: + baditems += 1 + # Once I know more about what's happening here I can add a proper message + traceback.print_exc() + print(items) + print("Could not add item") + else: + baditems += 1 + + if baditems: + print("Skipped %s bad entries" % baditems) + + return entry_list, position + + async def async_process_youtube_playlist(self, playlist_url, **meta): + """ + Processes youtube playlists links from `playlist_url` in a questionable, async fashion. + + :param playlist_url: The playlist url to be cut into individual urls and added to the playlist + :param meta: Any additional metadata to add to the playlist entry + """ + + try: + info = await self.downloader.safe_extract_info(self.loop, playlist_url, download=False, process=False) + except Exception as e: + raise ExtractionError('Could not extract information from {}\n\n{}'.format(playlist_url, e)) + + if not info: + raise ExtractionError('Could not extract information from %s' % playlist_url) + + gooditems = [] + baditems = 0 + for entry_data in info['entries']: + if entry_data: + baseurl = info['webpage_url'].split('playlist?list=')[0] + song_url = baseurl + 'watch?v=%s' % entry_data['id'] + + try: + entry, elen = await self.add_entry(song_url, **meta) + gooditems.append(entry) + except ExtractionError: + baditems += 1 + except Exception as e: + baditems += 1 + print("There was an error adding the song {}: {}: {}\n".format( + entry_data['id'], e.__class__.__name__, e)) + else: + baditems += 1 + + if baditems: + print("Skipped %s bad entries" % baditems) + + return gooditems + + async def async_process_sc_bc_playlist(self, playlist_url, **meta): + """ + Processes soundcloud set and bancdamp album links from `playlist_url` in a questionable, async fashion. + + :param playlist_url: The playlist url to be cut into individual urls and added to the playlist + :param meta: Any additional metadata to add to the playlist entry + """ + + try: + info = await self.downloader.safe_extract_info(self.loop, playlist_url, download=False, process=False) + except Exception as e: + raise ExtractionError('Could not extract information from {}\n\n{}'.format(playlist_url, e)) + + if not info: + raise ExtractionError('Could not extract information from %s' % playlist_url) + + gooditems = [] + baditems = 0 + for entry_data in info['entries']: + if entry_data: + song_url = entry_data['url'] + + try: + entry, elen = await self.add_entry(song_url, **meta) + gooditems.append(entry) + except ExtractionError: + baditems += 1 + except Exception as e: + baditems += 1 + print("There was an error adding the song {}: {}: {}\n".format( + entry_data['id'], e.__class__.__name__, e)) + else: + baditems += 1 + + if baditems: + print("Skipped %s bad entries" % baditems) + + return gooditems + + def _add_entry(self, entry): + self.entries.append(entry) + self.emit('entry-added', playlist=self, entry=entry) + + if self.peek() is entry: + entry.get_ready_future() + + async def get_next_entry(self, predownload_next=True): + """ + A coroutine which will return the next song or None if no songs left to play. + + Additionally, if predownload_next is set to True, it will attempt to download the next + song to be played - so that it's ready by the time we get to it. + """ + if not self.entries: + return None + + entry = self.entries.popleft() + + if predownload_next: + next_entry = self.peek() + if next_entry: + next_entry.get_ready_future() + + return await entry.get_ready_future() + + def peek(self): + """ + Returns the next entry that should be scheduled to be played. + """ + if self.entries: + return self.entries[0] + + async def estimate_time_until(self, position, player): + """ + (very) Roughly estimates the time till the queue will 'position' + """ + estimated_time = sum([e.duration for e in islice(self.entries, position - 1)]) + + # When the player plays a song, it eats the first playlist item, so we just have to add the time back + if not player.is_stopped and player.current_entry: + estimated_time += player.current_entry.duration - player.progress + + return datetime.timedelta(seconds=estimated_time) + + def count_for_user(self, user): + return sum(1 for e in self.entries if e.meta.get('author', None) == user) +