diff --git a/cogs/music.py b/cogs/music.py deleted file mode 100644 index 6b4d4e8..0000000 --- a/cogs/music.py +++ /dev/null @@ -1,979 +0,0 @@ -from .voice_utilities import * -from discord import PCMVolumeTransformer - -import discord -from discord.ext import commands - -from . import utils - -import math -import asyncio -import time -import re -import logging -import random -from collections import deque - -log = logging.getLogger() - -if not discord.opus.is_loaded(): - discord.opus.load_opus('/usr/lib64/libopus.so.0') - - -class VoiceState: - def __init__(self, guild, bot, user_queue=False, volume=None): - self.guild = guild - self.songs = Playlist(bot) - self.djs = deque() - self.dj = None - self.current = None - self.required_skips = 0 - self.skip_votes = set() - self.user_queue = user_queue - self.loop = bot.loop - self._volume = volume or .5 - self.live = False - - @property - def volume(self): - return self._volume - - @volume.setter - def volume(self, v): - self._volume = v - if self.voice and self.voice.source: - self.voice.source.volume = v - - @property - def voice(self): - return self.guild.voice_client - - @property - def playing(self): - if self.voice is None: - return False - else: - return self.voice.is_playing() or self.voice.is_paused() - - def switch_queue_type(self): - self.songs.clear() - self.djs.clear() - self.dj = None - self.user_queue = not self.user_queue - self.skip() - - def get_dj(self, member): - for x in self.djs: - if x.member.id == member.id: - return x - - def skip(self): - self.skip_votes.clear() - if self.voice: - self.voice.stop() - - def after(self, _=None): - if self.user_queue: - self.djs.append(self.dj) - fut = asyncio.run_coroutine_threadsafe(self.play_next_song(), self.loop) - fut.result() - - async def play_next_song(self): - if self.playing or not self.voice: - return - - self.skip_votes.clear() - try: - await self.next_song() - except ExtractionError: - # For now lets just silently continue in the queue - # Implementation to the music notifications channel will change what we do here - return await self.play_next_song() - - if self.playing or not self.voice: - return - if self.current: - # Transform our source into a volume source - source = PCMVolumeTransformer(self.current, volume=self.volume) - self.voice.play(source, after=self.after) - self.current.start_time = time.time() - # We handle users who join a user queue without songs (either at all, or ready) elsewhere - # So if self.current is None here, there are a few reasons: - # User queue, last song failed to download - # Either queue, all songs/dj's have gone been gone through - # The first one sucks, but there's not much we can do about it here, blame youtube - # Second one just means we're done and don't want to do anything - # So in either case....we simply do nothing here, and just the playing end - - async def next_song(self): - if not self.user_queue: - self.current = await self.songs.next_entry() - else: - try: - dj = self.djs.popleft() - except IndexError: - self.dj = None - self.current = None - else: - song = await dj.next_entry() - # Add an extra check here in case in the very short period of time possible, someone has queued a - # song while we are downloading the next...which caused 2 play calls to be done - # The 2nd may be called while the first has already started playing...this check is for that 2nd one - # If this rare case does happen, we want to insert this dj back into the deque at the front - # Also rotate their songs back, since it shouldn't have been retrieved - if self.playing: - self.djs.insert(0, dj) - dj.entries.rotate() - return - - if song is None: - return await self.next_song() - else: - song.requester = dj.member - self.dj = dj - self.current = song - - -class Music: - """Voice related commands. - Works in multiple servers at once. - """ - - def __init__(self, bot): - self.bot = bot - self.voice_states = {} - down = Downloader(download_folder='audio_tmp') - self.downloader = down - self.bot.downloader = down - - def __unload(self): - for state in self.voice_states.values(): - try: - if state.voice: - state.voice.stop() - self.bot.loop.create_task(state.voice.disconnect()) - except: - pass - - async def queue_embed_task(self, state, channel, author): - index = 0 - message = None - fmt = None - possible_reactions = ['\u27A1', '\u2B05', '\u2b06', '\u2b07', '\u274c', '\u23ea', '\u23e9'] - - # Our check to ensure the only one who reacts is the bot - def check(react, u): - if message is None: - return False - elif react.message.id != message.id: - return False - elif react.emoji not in possible_reactions: - return False - else: - return u.id == author.id - - while True: - # Get the current queue (It might change while we're doing this) - # So do this in the while loop - if state.user_queue: - queue = state.djs - else: - queue = state.songs.entries - count = len(queue) - # This means the last song was removed - if count == 0: - await channel.send("Nothing currently in the queue") - break - # Get the current entry - entry = queue[index] - dj = None - if state.user_queue: - dj = entry - entry = entry.peek() - # Get the entry's embed - embed = entry.embed - - # Set the embed's title to indicate the amount of things in the queue - count = len(queue) - embed.title = "Current Queue [{}/{}]".format(index + 1, count) - # Now we need to send the embed, so check if the message is already set - # If not, then we need to send a new one (i.e. this is the first time called) - if message: - await message.edit(content=fmt, embed=embed) - # There's only one reaction we want to make sure we remove in the circumstances - # If the member doesn't have kick_members permissions, and isn't the requester - # Then they can't remove the song, otherwise they can - if not author.guild_permissions.mute_members and author.id != entry.requester.id: - try: - await message.remove_reaction('\u274c', channel.server.me) - except: - pass - elif not author.guild_permissions.mute_members and author.id == entry.requester.id: - try: - await message.add_reaction('\u274c') - except: - pass - else: - message = await channel.send(embed=embed) - await message.add_reaction('\N{BLACK LEFT-POINTING DOUBLE TRIANGLE}') - await message.add_reaction('\N{LEFTWARDS BLACK ARROW}') - await message.add_reaction('\N{BLACK RIGHTWARDS ARROW}') - await message.add_reaction('\N{BLACK RIGHT-POINTING DOUBLE TRIANGLE}') - # The moderation tools that can be used - if author.guild_permissions.mute_members: - await message.add_reaction('\N{DOWNWARDS BLACK ARROW}') - await message.add_reaction('\N{UPWARDS BLACK ARROW}') - await message.add_reaction('\N{CROSS MARK}') - elif author == entry.requester: - await message.add_reaction('\N{CROSS MARK}') - # Reset the fmt message - fmt = "\u200B" - # Now we wait for the next reaction - try: - reaction, user = await self.bot.wait_for('reaction_add', check=check, timeout=180) - except asyncio.TimeoutError: - break - # Now we can prepare for the next embed to be sent - # If right is clicked - if '\u27A1' in reaction.emoji: - index += 1 - if index >= count: - index = 0 - # If left is clicked - elif '\u2B05' in reaction.emoji: - index -= 1 - if index < 0: - index = count - 1 - # If up is clicked - elif '\u2b06' in reaction.emoji: - # A second check just to make sure, as well as ensuring index is higher than 0 - if author.guild_permissions.mute_members and index > 0: - if dj and dj != queue[index]: - fmt = "`Error: Position of this entry has changed, cannot complete your action`" - elif not dj and entry != queue[index]: - fmt = "`Error: Position of this entry has changed, cannot complete your action`" - else: - # Remove the current entry - del queue[index] - # Add it one position higher - if state.user_queue: - queue.insert(index - 1, dj) - else: - queue.insert(index - 1, entry) - # Lets move the index to look at the new place of the entry - index -= 1 - # If down is clicked - elif '\u2b07' in reaction.emoji: - # A second check just to make sure, as well as ensuring index is lower than last - if author.guild_permissions.mute_members and index < (count - 1): - if dj and dj != queue[index]: - fmt = "`Error: Position of this entry has changed, cannot complete your action`" - elif not dj and entry != queue[index]: - fmt = "`Error: Position of this entry has changed, cannot complete your action`" - else: - # Remove the current entry - del queue[index] - # Add it one position lower - if state.user_queue: - queue.insert(index + 1, dj) - else: - queue.insert(index + 1, entry) - # Lets move the index to look at the new place of the entry - index += 1 - # If x is clicked - elif '\u274c' in reaction.emoji: - # A second check just to make sure - if author.guild_permissions.mute_members or author == entry.requester: - if dj and dj != queue[index]: - fmt = "`Error: Position of this entry has changed, cannot complete your action`" - elif not dj and entry != queue[index]: - fmt = "`Error: Position of this entry has changed, cannot complete your action`" - else: - # Simply remove the entry in place - del queue[index] - # This is the only check we need to make, to ensure index is now not more than last - new_count = count - 1 - if index >= new_count: - index = new_count - 1 - # If first is clicked - elif '\u23ea': - index = 0 - # If last is clicked - elif '\u23e9': - index = count - 1 - try: - await message.remove_reaction(reaction.emoji, user) - except discord.Forbidden: - pass - await message.delete() - - # noinspection PyUnusedLocal - async def on_voice_state_update(self, _, __, after): - if after is None or after.channel is None: - return - state = self.voice_states.get(after.channel.guild.id) - if state is None or state.voice is None or state.voice.channel is None: - return - voice_channel = state.voice.channel - num_members = len(voice_channel.members) - state.required_skips = math.ceil((num_members + 1) / 3) - - async def add_entry(self, song, ctx): - state = self.voice_states[ctx.message.guild.id] - entry = await state.songs.add_entry(song) - if not state.playing: - self.bot.loop.create_task(state.play_next_song()) - entry.requester = ctx.message.author - return entry - - async def import_playlist(self, url, ctx): - state = self.voice_states[ctx.message.guild.id] - try: - msg = await ctx.send("Looking up {}\nThis may take a while...".format(url)) - except: - msg = None - - num_songs = None - successful = 0 - failed = 0 - fmt = "Downloading {} songs\n{} successful\n{} failed" - if msg: - # Go through each song in the playlist - async for success in state.songs.import_from(url, ctx.message.author): - # If this hasn't been set yet, the first yield is the number of songs - # Otherwise just add one based on successful or not - if not num_songs: - num_songs = success - elif success: - # If we're not playing yet and this is the first successful one we found - if not state.playing and successful == 0: - await state.play_next_song() - successful += 1 - else: - failed += 1 - try: - await msg.edit(content=fmt.format(num_songs, successful, failed)) - except: - pass - else: - await state.songs.import_from(url) - - async def join_channel(self, channel, text_channel): - state = self.voice_states.get(channel.guild.id) - log.info("Joining channel {} in guild {}".format(channel.id, channel.guild.id)) - - # Send a message letting the channel know we are attempting to join - try: - msg = await text_channel.send("Trying to join channel {}...".format(channel.name)) - except discord.Forbidden: - msg = None - - try: - # If we're already connected, try moving to the channel - if state and state.voice and state.voice.channel: - await state.voice.move_to(channel) - # Otherwise, try connecting - else: - await channel.connect(reconnect=False) - - # If we have connnected, create our voice state - queue_type = self.bot.db.load('server_settings', key=channel.guild.id, pluck='queue_type') - volume = self.bot.db.load('server_settings', key=channel.guild.id, pluck='volume') - user_queue = queue_type == "user" - self.voice_states[channel.guild.id] = VoiceState(channel.guild, self.bot, user_queue=user_queue, - volume=volume) - - # If we can send messages, edit it to let the channel know we have succesfully joined - if msg: - try: - await msg.edit(content="Ready to play audio in channel {}".format(channel.name)) - except discord.NotFound: - pass - return True - # If we time out trying to join, just let them know and return False - except (asyncio.TimeoutError, OSError): - if msg: - try: - await msg.edit(content="Sorry, but I couldn't connect right now! Please try again later") - except discord.NotFound: - pass - return False - # Theoretically this should never happen, however in rare cirumstances it does - # This error arises when we are already in a channel and don't use "move" - # We already checked if that existed above though, so this means the voice connection got stuck somewhere - except discord.ClientException: - if channel.guild.voice_client: - # Force a disconnection - await channel.guild.voice_client.disconnect(force=True) - # Log this so we can track it - log.warning( - "Force cleared voice connection on guild {} after being stuck " - "between connected/not connected".format(channel.guild.id)) - # Let them know what happened - await text_channel.send("Sorry but I couldn't connect...try again?") - return False - - @commands.command() - @commands.guild_only() - @utils.custom_perms(send_messages=True) - @utils.check_restricted() - async def progress(self, ctx): - """Provides the progress of the current song""" - - # Make sure we're playing first - state = self.voice_states.get(ctx.message.guild.id) - if state is None or not state.playing or state.current is None: - await ctx.send('Not playing anything.') - else: - progress = state.current.progress - length = state.current.length - # Another check, just to make sure; this may happen for a very brief amount of time - # Between when the song was requested, and still downloading to play - if not progress or not length: - await ctx.send('Not playing anything.') - return - - # Otherwise just format this nicely - progress = divmod(round(progress, 0), 60) - length = divmod(round(length, 0), 60) - fmt = "Current song progress: {0[0]}m {0[1]}s/{1[0]}m {1[1]}s".format(progress, length) - await ctx.send(fmt) - - @commands.command(aliases=['summon']) - @commands.guild_only() - @utils.custom_perms(send_messages=True) - @utils.check_restricted() - async def join(self, ctx, *, channel: discord.VoiceChannel = None): - """Joins a voice channel. Provide the name of a voice channel after the command, and - I will attempt to join this channel. Otherwise, I will join the channel you are in. - - EXAMPLE: !join Music - RESULT: I have joined the music channel""" - if channel is None: - if ctx.message.author.voice is None or ctx.message.author.voice.channel is None: - await ctx.send("You need to either be in a voice channel, or provide the name of a voice channel!") - return False - channel = ctx.message.author.voice.channel - - perms = channel.permissions_for(ctx.message.guild.me) - - if not perms.connect or not perms.speak or not perms.use_voice_activation: - await ctx.send("I do not have correct permissions in {}! Please turn on `connect`, `speak`, and `use " - "voice activation`".format(channel.name)) - return False - - return await self.join_channel(channel, ctx.channel) - - @commands.command(name='import') - @commands.guild_only() - @utils.custom_perms(send_messages=True) - @utils.check_restricted() - async def _import(self, ctx, *, song: str): - """Imports a song into the current voice queue""" - # If we don't have a voice state yet, create one - if not self.bot.db.load('server_settings', key=ctx.message.guild.id, pluck='playlists_allowed'): - await ctx.send("You cannot import playlists at this time; the {}allowplaylists command can be used to " - "change this setting".format(ctx.prefix)) - return - if ctx.message.guild.id not in self.voice_states: - if not await ctx.invoke(self.join): - return - state = self.voice_states.get(ctx.message.guild.id) - # If this is a user queue, this is the wrong command - if state.user_queue: - await ctx.send("The current queue type is the DJ queue. " - "Use the command {}dj to join this queue".format(ctx.prefix)) - return - # Ensure the user is in the voice channel - try: - if ctx.message.author.voice.channel != ctx.message.guild.me.voice.channel: - await ctx.send("You need to be in the channel to use this command!") - return - except AttributeError: - await ctx.send("You need to be in the channel to use this command!") - return - - song = re.sub('[<>\[\]]', '', song) - # Check if we've got the list variable in the URL, if so lets just use this - playlist_id = re.search(r'list=(.+)', song) - if playlist_id: - song = playlist_id.group(1) - try: - await self.import_playlist(song, ctx) - except WrongEntryTypeError: - await ctx.send("This URL is not a playlist! If you want to play this song just use `play`") - except ExtractionError: - await ctx.send("Failed to download {}! If this is not a playlist, use the `play` command".format(song)) - - @commands.command() - @commands.guild_only() - @utils.custom_perms(send_messages=True) - @utils.check_restricted() - async def play(self, ctx, *, song: str): - """Plays a song. - If there is a song currently in the queue, then it is - queued until the next song is done playing. - This command automatically searches as well from YouTube. - The list of supported sites can be found here: - https://rg3.github.io/youtube-dl/supportedsites.html - """ - # If we don't have a voice state yet, create one - if ctx.message.guild.id not in self.voice_states: - if not await ctx.invoke(self.join): - return - - state = self.voice_states.get(ctx.message.guild.id) - # If this is a user queue, this is the wrong command - if state and state.user_queue: - await ctx.send("The current queue type is the DJ queue. " - "Use the command {}dj to join this queue".format(ctx.prefix)) - return - # If the state is live, songs can't play at the same time - if state and state.live: - await ctx.send("Currently playing a live stream! The stream needs to stop before a song can be played") - return - - # Ensure the user is in the voice channel - try: - if ctx.message.author.voice.channel != ctx.message.guild.me.voice.channel: - await ctx.send("You need to be in the channel to use this command!") - return - except AttributeError: - await ctx.send("You need to be in the channel to use this command!") - return - - song = re.sub('[<>\[\]]', '', song) - if len(song) == 11: - # Youtube-dl will attempt to use results with a length of 11 as a video ID - # If this is a search, this causes it to break - # Youtube will still succeed if this *is* an ID provided, if there's a . after - song += "." - - try: - msg = await ctx.send("Looking up {}...".format(song)) - except: - msg = None - - try: - entry = await self.add_entry(song, ctx) - # This error only happens if Discord has derped, and the voice state didn't get created succesfully - except KeyError: - await ctx.send("Sorry, but I failed to connect! Please try again!") - except LiveStreamError as e: - await ctx.send(str(e)) - except WrongEntryTypeError: - await ctx.send("Please use the {}import command to import a playlist.".format(ctx.prefix)) - except ExtractionError as e: - error = e.message.split('\n') - if len(error) >= 3: - # The first entry is the "We couldn't download" printed by the exception - # The 2nd is the new line - # We want youtube_dl's error message, but just the first part, the actual "error" - error = error[2] - # This is colour formatting for the console...it's just going to show up as text on discord - error = error.replace("ERROR: ", "") - else: - # This happens when the download just returns `None` - error = error[0] - await ctx.send(error) - else: - try: - if entry is None: - await ctx.send("Sorry but I couldn't download/find {}".format(song)) - else: - embed = entry.embed - embed.title = "Enqueued song!" - try: - await msg.edit(content=None, embed=embed) - except: - await ctx.send(embed=embed) - except discord.Forbidden: - pass - - @commands.command() - @commands.guild_only() - @utils.custom_perms(mute_members=True) - @utils.check_restricted() - async def volume(self, ctx, value: int = None): - """Sets the volume of the currently playing song.""" - - state = self.voice_states.get(ctx.message.guild.id) - if value: - value /= 100 - if state is None or state.voice is None: - await ctx.send("I need to be in a channel before my volume can be set") - elif value is None: - await ctx.send('Current volume is {:.0%}'.format(state.volume)) - elif value > 1.0: - await ctx.send("Sorry but the max volume is 100%") - else: - state.volume = value - entry = {'server_id': str(ctx.message.guild.id), 'volume': value} - self.bot.db.save('server_settings', entry) - await ctx.send('Set the volume to {:.0%}'.format(state.volume)) - - @commands.command() - @commands.guild_only() - @utils.custom_perms(mute_members=True) - @utils.check_restricted() - async def pause(self, ctx): - """Pauses the currently played song.""" - state = self.voice_states.get(ctx.message.guild.id) - if state and state.voice and state.voice.is_connected(): - state.voice.pause() - - @commands.command() - @commands.guild_only() - @utils.custom_perms(mute_members=True) - @utils.check_restricted() - async def resume(self, ctx): - """Resumes the currently played song.""" - state = self.voice_states.get(ctx.message.guild.id) - if state and state.voice and state.voice.is_connected(): - state.voice.resume() - - @commands.command() - @commands.guild_only() - @utils.custom_perms(mute_members=True) - @utils.check_restricted() - async def stop(self, ctx): - """Stops playing audio and leaves the voice channel. - This also clears the queue. - """ - state = self.voice_states.get(ctx.message.guild.id) - voice = ctx.message.guild.voice_client - - # If we have a state, clear the songs, dj's, then skip the current song - if state: - state.songs.clear() - state.djs.clear() - state.skip() - try: - del self.voice_states[ctx.message.guild.id] - except KeyError: - pass - - # If we have a voice connection (separate from state...just in case....) - # Then stop playing, and disconnect - if voice: - voice.stop() - await voice.disconnect(force=True) - - # Temporarily disabling what's after this - - # So discord has a weird case where the connection can be interrupted, and an auto-reconnect is attempted - # Auto-reconnects aren't handled for bot accounts, and this causes the bot to appear to be in the channel while it's actually not - # Since this means there is no connection (checked by voice being None) there's nothing to disconnect from - # So our workaround here is try to connect (this may timeout, and force it to now be visually disconnected) then disconnect - - #else: - # if ctx.message.guild.me.voice: - # channel = ctx.message.guild.me.voice.channel - # else: - # # Get a list of all channels that we can connect to - # channels = [c for c in ctx.message.guild.voice_channels if c.permissions_for(ctx.message.guild.me).connect] - # if len(channels) > 0: - # channel = channels[0] - # # If we can't connect to any channels but we're stuck in a voice channel what is this server doing .-. - # # Don't handle this, just return - # else: - # return - - # Now simply connect then disconnect - # try: - # await channel.connect() - # except asyncio.TimeoutError: - # pass - # else: - # # Refresh the guild info, as want whatever the new VoiceClient is - # guild = self.bot.get_guild(ctx.message.guild.id) - # if guild.voice_client: - # await guild.voice_client.disconnect(force=True) - - @commands.command() - @commands.guild_only() - @utils.custom_perms(send_messages=True) - @utils.check_restricted() - async def eta(self, ctx): - """Provides an ETA on when your next song will play""" - state = self.voice_states.get(ctx.message.guild.id) - author = ctx.message.author - - if state is None or not state.playing: - await ctx.send('Not playing any music right now...') - return - - if state.user_queue: - queue = [x.peek() for x in state.djs if x.peek()] - else: - queue = state.songs.entries - if len(queue) == 0: - await ctx.send("Nothing currently in the queue") - return - - # Start off by adding the remaining length of the current song - count = state.current.remaining or 0 - 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 - for song in queue: - if song.requester == author: - found = True - break - count += song.length - - if not found: - await ctx.send("You are not in the queue!") - return - await ctx.send("ETA till your next play is: {0[0]}m {0[1]}s".format(divmod(round(count, 0), 60))) - - @commands.command() - @commands.guild_only() - @utils.custom_perms(send_messages=True) - @utils.check_restricted() - async def queue(self, ctx): - """Provides a printout of the songs that are in the queue""" - state = self.voice_states.get(ctx.message.guild.id) - if state is None: - await ctx.send("Nothing currently in the queue") - return - - if state.user_queue: - _queue = [x.peek() for x in state.djs if x.peek()] - else: - _queue = state.songs.entries - if len(_queue) == 0: - await ctx.send("Nothing currently in the queue") - else: - self.bot.loop.create_task(self.queue_embed_task(state, ctx.message.channel, ctx.message.author)) - - @commands.command() - @commands.guild_only() - @utils.custom_perms(send_messages=True) - @utils.check_restricted() - async def queuelength(self, ctx): - """Prints the length of the queue""" - state = self.voice_states.get(ctx.message.guild.id) - if state is None: - await ctx.send("Nothing currently in the queue") - return - - if state.user_queue: - _queue = [x.peek() for x in state.djs if x.peek()] - else: - _queue = state.songs.entries - if len(_queue) == 0: - await ctx.send("Nothing currently in the queue") - await ctx.send("There are a total of {} songs in the queue".format(len(_queue))) - - @commands.command() - @commands.guild_only() - @utils.custom_perms(send_messages=True) - @utils.check_restricted() - async def skip(self, ctx): - """Vote to skip a song. The song requester can automatically skip. - approximately 1/3 of the members in the voice channel - are required to vote to skip for the song to be skipped. - """ - - state = self.voice_states.get(ctx.message.guild.id) - if state is None or not state.playing: - await ctx.send('Not playing any music right now...') - return - # Ensure the user is in our channel - try: - if ctx.message.author.voice.channel != ctx.message.guild.me.voice.channel: - await ctx.send("You need to be in the channel to use this command!") - except AttributeError: - await ctx.send("You need to be in the channel to use this command!") - - # Check if the person requesting a skip is the requester of the song, if so automatically skip - voter = ctx.message.author - if hasattr(state.current, 'requester') and voter == state.current.requester: - await ctx.send('Requester requested skipping song...') - state.skip() - # Otherwise check if the voter has already voted - elif voter.id not in state.skip_votes: - state.skip_votes.add(voter.id) - total_votes = len(state.skip_votes) - - # Now check how many votes have been made, if 3 then go ahead and skip, otherwise add to the list of votes - if total_votes >= state.required_skips: - await ctx.send('Skip vote passed, skipping song...') - state.skip() - else: - await ctx.send('Skip vote added, currently at [{}/{}]'.format(total_votes, state.required_skips)) - else: - await ctx.send('You have already voted to skip this song.') - - @commands.command() - @commands.guild_only() - @utils.custom_perms(mute_members=True) - @utils.check_restricted() - async def modskip(self, ctx): - """Forces a song skip, can only be used by a moderator""" - state = self.voice_states.get(ctx.message.guild.id) - if state is None: - await ctx.send('Not playing any music right now...') - return - - state.skip() - await ctx.send('Song has just been skipped.') - - @commands.command() - @commands.guild_only() - @utils.custom_perms(send_messages=True) - @utils.check_restricted() - async def playing(self, ctx): - """Shows info about the currently played song.""" - - state = self.voice_states.get(ctx.message.guild.id) - if state is None or not state.playing or not state.current: - await ctx.send('Not playing anything.') - else: - # Create the embed object we'll use - embed = discord.Embed() - # Fill in the simple things - embed.add_field(name='Title', value=state.current.title, inline=False) - if state.current.requester: - embed.add_field(name='Requester', value=state.current.requester.display_name, inline=False) - # Get the amount of current skips, and display how many have been skipped/how many required - skip_count = len(state.skip_votes) - embed.add_field(name='Skip Count', value='{}/{}'.format(skip_count, state.required_skips), inline=False) - # Get the current progress and display this - length = state.current.length - progress = state.current.progress - if length and progress: - progress = divmod(round(progress, 0), 60) - length = divmod(round(length, 0), 60) - fmt = "{0[0]}m {0[1]}s/{1[0]}m {1[1]}s".format(progress, length) - embed.add_field(name='Progress', value=fmt, inline=False) - # And send the embed - await ctx.send(embed=embed) - - @commands.command() - @commands.guild_only() - @utils.custom_perms(send_messages=True) - @utils.check_restricted() - async def dj(self, ctx): - """Attempts to join the current DJ queue - - EXAMPLE: !dj - RESULT: You are 7th on the waitlist for the queue""" - if ctx.message.guild.id not in self.voice_states: - if not await ctx.invoke(self.join): - return - - state = self.voice_states.get(ctx.message.guild.id) - if state and not state.user_queue: - await ctx.send("The current queue type is the song queue. " - "Use the command {}play to add a song to the queue".format(ctx.prefix)) - return - # If the state is live, songs can't play at the same time - if state and state.live: - await ctx.send("Currently playing a live stream! The stream needs to stop before a song can be played") - return - - if state.get_dj(ctx.message.author): - await ctx.send("You are already in the DJ queue!") - else: - new_dj = self.bot.get_cog('DJEvents').djs[ctx.message.author.id] - if not new_dj.peek(): - await ctx.send("You currently have nothing in your playlist! This can happen for two reasons:\n" - "1) You actually have nothing in your active playlist\n" - "2) You just joined the voice channel and your playlist is still being downloaded\n\n" - "If the first one is true, then you need to manage your playlist to have an active " - "playlist with songs in it. " - "Otherwise, you will need to wait while your songs are being downloaded before you can " - "join") - else: - state.djs.append(new_dj) - try: - await ctx.send("You have joined the DJ queue; there are currently {} people ahead of you".format( - state.djs.index(new_dj))) - except discord.Forbidden: - pass - - if not state.playing: - await state.play_next_song() - - @commands.command() - @commands.guild_only() - @utils.custom_perms(mute_members=True) - @utils.check_restricted() - async def shuffle(self, ctx): - """Shuffles the current playlist, be it users or songs - - EXAMPLE: !shuffle - RESULT: The queue is shuffled""" - state = self.voice_states.get(ctx.message.guild.id) - if state: - if state.user_queue: - random.SystemRandom().shuffle(state.djs) - else: - state.songs.shuffle() - await ctx.send("The queue has been shuffled!") - else: - await ctx.send("There needs to be a queue before I can shuffle it!") - - @commands.command() - @commands.guild_only() - @utils.custom_perms(mute_members=True) - @utils.check_restricted() - async def stream(self, ctx, *, url): - """Plays a livestream - - EXAMPLE: !stream live_stream_url - RESULT: A livestream starts playing""" - # If we don't have a voice state yet, create one - if ctx.message.guild.id not in self.voice_states: - if not await ctx.invoke(self.join): - return - - state = self.voice_states.get(ctx.message.guild.id) - - # If we have a state, clear the songs, dj's, then skip the current song - if state and state.voice: - state.songs.clear() - state.djs.clear() - state.skip() - - # Now we can start the livestream - # Create the source used - source = YoutubeDLLiveStreamSource(self.bot, url) - # Download the info - try: - await source.get_ready() - except ExtractionError as e: - error = e.message.split('\n') - if len(error) >= 3: - # The first entry is the "We couldn't download" printed by the exception - # The 2nd is the new line - # We want youtube_dl's error message, but just the first part, the actual "error" - error = error[2] - # This is colour formatting for the console...it's just going to show up as text on discord - error = error.replace("ERROR: ", "") - else: - # This happens when the download just returns `None` - error = error[0] - await ctx.send(error) - return - - # They may have removed the bot from the channel during this time, so lets check again - if state.voice is None: - await ctx.send("Failed to join the channel...") - return - - # Set the current song as the livestream - state.current = source - # Use the volume transformer - source = PCMVolumeTransformer(source, volume=state.volume) - # Then play the livestream - state.voice.play(source) - state.live = True - else: - await ctx.send("Failed to join the channel...") - - -def setup(bot): - bot.add_cog(Music(bot)) diff --git a/cogs/playlist.py b/cogs/playlist.py deleted file mode 100644 index c5d3726..0000000 --- a/cogs/playlist.py +++ /dev/null @@ -1,356 +0,0 @@ -import discord -import asyncio -from discord.ext import commands - -from . import utils - - -class Playlist: - """Used to manage user playlists""" - - def __init__(self, bot): - self.bot = bot - - async def get_response(self, ctx, question): - # Save our simple variables - channel = ctx.message.channel - author = ctx.message.author - - # Create our check function used to ensure the author and channel are the only possible message we get - check = lambda m: m.author == author and m.channel == channel - - try: - # Ask our question, wait 60 seconds for a response - my_msg = await ctx.send(question) - response = await self.bot.wait_for('message', check=check, timeout=60) - except asyncio.TimeoutError: - # If we timeout, let them know and return None - await ctx.send("You took too long. I'm impatient, don't make me wait") - return None - else: - # If succesful try to delete the message we sent, and the response - try: - await my_msg.delete() - await response.delete() - except (discord.Forbidden, discord.NotFound): - pass - - # For our case here, everything needs to be lowered and stripped, so just do this now - return response.content - - async def get_info(self, song_url): - try: - # Just download the information - info = await self.bot.downloader.extract_info(self.bot.loop, song_url, download=False) - except Exception as e: - # If we fail, it's possibly due to an incorrect detection as a URL instead of a search - if "gaierror" in str(e) or "unknown url type" in str(e): - # So just force a search - song_url = "ytsearch:" + song_url - info = await self.bot.downloader.extract_info(self.bot.loop, song_url, download=False) - else: - # Otherwise if we fail, we just want to return None - return None - - # If we detected a search, get the first entry in the results - if info.get('_type', None) == 'playlist': - if info.get('extractor') == 'youtube:search': - if len(info['entries']) == 0: - return None - else: - info = info['entries'][0] - song_url = info['webpage_url'] - - # If we are successful, create the entry we'll need to add to the playlist database, and return it - if info: - return { - 'title': info.get('title', 'Untitled'), - 'url': song_url - } - else: - return None - - async def add_to_playlist(self, author, playlist, url): - # Simply get the database entry for this user's playlist - key = str(author.id) - playlist = playlist.lower().strip() - playlists = self.bot.db.load('user_playlists', key=key, pluck='playlists') or [] - - entry = await self.get_info(url) - - # Search through, find the name that matches the playlist - if entry: - for pl in playlists: - if pl['name'] == playlist: - # If we find it, add the song entry to the songs - pl['songs'].append(entry) - # Create the json needed to save to the database, and save - update = { - 'member_id': key, - 'playlists': playlists - } - self.bot.db.save('user_playlists', update) - await self.update_dj_for_member(author) - return True - - async def rename_playlist(self, author, old_name, new_name): - # Simply get the database entry for this user's playlist - key = str(author.id) - old_name = old_name.lower().strip() - new_name = new_name.lower().strip() - playlists = self.bot.db.load('user_playlists', key=key, pluck='playlists') or [] - - # Find the playlist that matches the old name - for pl in playlists: - if pl['name'] == old_name: - # Once found, change the name, update the json, save - pl['name'] = new_name - update = { - 'member_id': key, - 'playlists': playlists - } - self.bot.db.save('user_playlists', update) - await self.update_dj_for_member(author) - return True - - async def remove_from_playlist(self, author, playlist, index): - # Simply get the database entry for this user's playlist - key = str(author.id) - playlist = playlist.lower().strip() - playlists = self.bot.db.load('user_playlists', key=key, pluck='playlists') or [] - - # Loop through till we find the playlist that matches - for pl in playlists: - if pl['name'] == playlist: - song = pl['songs'][index] - # Once found, remove the matching song, update json, save - pl['songs'].remove(song) - update = { - 'member_id': key, - 'playlists': playlists - } - self.bot.db.save('user_playlists', update) - await self.update_dj_for_member(author) - return song - - async def update_dj_for_member(self, member): - music = self.bot.get_cog('Music') - if music: - for state in music.voice_states.values(): - dj = state.get_dj(member) - if dj: - # We want to add a slight delay to this, because our database method launches a task to update - # Before we update what is live, we need the information saved in (at least the cache) the database - await asyncio.sleep(2) - self.bot.loop.create_task(dj.resolve_playlist()) - - @commands.command() - @utils.custom_perms(send_messages=True) - @utils.check_restricted() - async def playlists(self, ctx): - """Displays the playlists you have - - EXAMPLE: !playlists - RESULT: All your playlists""" - # Get all the author's playlists - playlists = self.bot.db.load('user_playlists', key=ctx.message.author.id, pluck='playlists') - if playlists: - # Create the entries for our paginator detailing the name of the playlist, and the number of songs in it - entries = [ - "{} ({} songs)".format(x['name'], len(x['songs'])) if not x.get('active') - else "{} ({} songs) - Active playlist".format(x['name'], len(x['songs'])) - for x in playlists - ] - - try: - # And paginate - pages = utils.Pages(self.bot, message=ctx.message, entries=entries) - await pages.paginate() - except utils.CannotPaginate as e: - await ctx.send(str(e)) - else: - await ctx.send("You do not have any playlists") - - @commands.group(invoke_without_command=True) - @utils.custom_perms(send_messages=True) - @utils.check_restricted() - async def playlist(self, ctx, *, playlist_name): - """Used to view your playlists - - EXAMPLE: !playlist Playlist 2 - RESULT: Displays the songs in your playlist called "Playlist 2" """ - playlist_name = playlist_name.lower().strip() - - playlists = self.bot.db.load('user_playlists', key=ctx.message.author.id, pluck='playlists') - try: - # Get the playlist if the name matches - playlist = [x for x in playlists if playlist_name == x['name']][0] - # Create the entries for our paginator just based on the title of the songs in the playlist - entries = ["{}".format(x['title']) for x in playlist['songs']] - # Paginate - pages = utils.Pages(self.bot, message=ctx.message, entries=entries) - await pages.paginate() - except (IndexError, TypeError, KeyError): - await ctx.send("You do not have a playlist named {}!".format(playlist_name)) - except utils.CannotPaginate as e: - await ctx.send(str(e)) - - @playlist.command(name='create') - @utils.custom_perms(send_messages=True) - @utils.check_restricted() - async def _pl_create(self, ctx, *, name): - """Used to create a new playlist - - EXAMPLE: !playlist create Playlist - RESULT: A new playlist called Playlist""" - key = str(ctx.message.author.id) - playlists = self.bot.db.load('user_playlists', key=key, pluck='playlists') or [] - - # Create the new playlist entry - entry = { - 'name': name.lower().strip(), - 'songs': [] - } - - # Check to make sure that there isn't a playlist with the same name - names = [x['name'] for x in playlists] - if name in names: - await ctx.send('You already have a playlist called {}'.format(name)) - # Otherwise add this new playlist, and save - else: - # This is here to set the first playlist we create as the active one. - # If someone has a playlist already, we don't want to change which is the active one - # If they don't have any, then we want to set our first one as the active one - entry['active'] = len(playlists) == 0 - playlists.append(entry) - update = { - 'member_id': key, - 'playlists': playlists - } - self.bot.db.save('user_playlists', update) - await ctx.send("You have just created a new playlist called {}".format(name)) - - @playlist.command(name='edit') - @utils.custom_perms(send_messages=True) - @utils.check_restricted() - async def _pl_edit(self, ctx): - """A command used to edit a current playlist - The available ways to edit a playlist are to rename, add a song, remove a song, or delete the playlist - - EXAMPLE: !playlist edit - RESULT: A followalong asking for what you need""" - # Load the playlists for the author - author = ctx.message.author - key = str(author.id) - playlists = self.bot.db.load('user_playlists', key=key, pluck='playlists') or [] - # Also create a list of the names for easy comparision - names = [x['name'] for x in playlists] - - if not playlists: - await ctx.send("You have no playlists to edit!") - return - - # Show the playlists we have, and ask which to choose from - await ctx.invoke(self.playlists) - question = "Please provide what playlist you would like to edit, the playlists you have available are above." - playlist = await self.get_response(ctx, question) - if not playlist: - return - playlist = playlist.lower().strip() - if playlist not in names: - await ctx.send("You do not have a playlist named {}!".format(playlist)) - return - - q1 = "How would you like to edit {}? Choices are `add`, `remove`, " \ - "`rename`, `delete`, `list`, or `activate`.\n" \ - "**add** - Adds a song to this playlist\n" \ - "**remove** - Removes a song from this playlist\n" \ - "**rename** - Changes the name of this playlist\n" \ - "**list** - Lists the songs in this playlist\n" \ - "**delete** - Deletes this playlist\n" \ - "**activate** - Sets this as the active playlist\n\n" \ - "Type **quit** to stop editing this playlist".format(playlist) - - # Lets create a list of the messages we'll delete after - delete_msgs = [] - - # We want to loop this in order to continue editing, till the user is done - while True: - response = await self.get_response(ctx, q1) - - if not response: - break - - response = response.lower().strip() - - if 'add' in response: - # Ask the user what song to add, get the response, add it - question = "What is the song you would like to add to {}?".format(playlist) - response = await self.get_response(ctx, question) - # If we didn't get a response, just continue with the loop, we have no need to say anything - # The "error" message is sent with our `get_response` helper method - if response: - await ctx.message.channel.trigger_typing() - if await self.add_to_playlist(author, playlist, response): - delete_msgs.append(await ctx.send("Successfully added song {} to playlist {}".format(response, - playlist))) - else: - delete_msgs.append(await ctx.send("Failed to lookup {}".format(response))) - elif 'remove' in response: - await ctx.invoke(self.playlist, playlist_name=playlist) - question = "Please provide just the number of the song you want to delete" - try: - response = await self.get_response(ctx, question) - if response: - num = int(response.lower().strip()) - 1 - song = await self.remove_from_playlist(ctx.author, playlist, num) - await ctx.send("Successfully removed {} from {}".format(song['title'], playlist)) - except (ValueError, IndexError): - delete_msgs.append(await ctx.send("Please provide just the number of the song you want to delete " - "next time!")) - elif 'delete' in response: - playlists = [x for x in playlists if x['name'] != playlist] - entry = { - 'member_id': str(key), - 'playlists': playlists - } - self.bot.db.save('user_playlists', entry) - delete_msgs.append(await ctx.send("Successfully deleted playlist {}".format(playlist))) - await ctx.send("Finished editing {}".format(playlist)) - break - elif 'rename' in response: - question = "What would you like to rename the playlist {} to?".format(playlist) - new_name = await self.get_response(ctx, question) - if new_name: - await self.rename_playlist(ctx.message.author, playlist, new_name) - new_name = new_name.lower().strip() - playlist = new_name - delete_msgs.append(await ctx.send("Successfully renamed {} to {}!".format(playlist, new_name))) - elif 'list' in response: - await ctx.invoke(self.playlist, playlist_name=playlist) - elif 'activate' in response: - for x in playlists: - x['active'] = x['name'] == playlist - - entry = { - 'member_id': str(key), - 'playlists': playlists - } - self.bot.db.save('user_playlists', entry) - # Now we have edited the user's actual playlist...but we need to - delete_msgs.append(await ctx.send("{} is now your active playlist".format(playlist))) - elif 'quit' in response: - await ctx.send("Finished editing {}".format(playlist)) - break - else: - delete_msgs.append(await ctx.send("That is not a valid option!")) - - if not isinstance(ctx.message.channel, discord.DMChannel): - if len(delete_msgs) == 1: - await delete_msgs[0].delete() - elif len(delete_msgs) > 1: - await ctx.message.channel.delete_messages(delete_msgs) - - -def setup(bot): - bot.add_cog(Playlist(bot)) diff --git a/cogs/voice_utilities/__init__.py b/cogs/voice_utilities/__init__.py deleted file mode 100644 index 368aceb..0000000 --- a/cogs/voice_utilities/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .downloader import Downloader -from .exceptions import * -from .playlist import Playlist -from .source import * diff --git a/cogs/voice_utilities/downloader.py b/cogs/voice_utilities/downloader.py deleted file mode 100644 index e7c23a6..0000000 --- a/cogs/voice_utilities/downloader.py +++ /dev/null @@ -1,92 +0,0 @@ -import os -import asyncio -import functools -import youtube_dl -import discord - -from .. import utils - -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': 'ytsearch', - 'proxy': utils.ytdl_proxy, - '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 deleted file mode 100644 index c0b1053..0000000 --- a/cogs/voice_utilities/entry.py +++ /dev/null @@ -1,279 +0,0 @@ -import asyncio -import json -import os -import traceback -import time -import aiohttp - -from discord import Embed -from hashlib import md5 -from .exceptions import ExtractionError - - -async def get_header(url, headerfield=None, *, timeout=5): - with aiohttp.Timeout(timeout): - async with aiohttp.ClientSession().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, thumbnail, duration=0, expected_filename=None, **meta): - super().__init__() - - self.playlist = playlist - self.url = url - self.title = title - self.duration = duration - self.thumbnail = thumbnail - self.expected_filename = expected_filename - self.meta = meta - 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 hasattr(self, 'start_time') and 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) - # 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'].guild.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': - 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)] - ) - - lsize = os.path.getsize(lfile) - - if lsize != rsize: - await self._really_download(hash=True) - else: - self.filename = lfile - - else: - 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) - - elif expected_fname_noex in flistdir: - self.filename = os.path.join(self.download_folder, ldir[flistdir.index(expected_fname_noex)]) - - 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): - - try: - result = await self.playlist.downloader.extract_info(self.playlist.loop, self.url, download=True) - except Exception as e: - raise ExtractionError(e) - - 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) - - def to_embed(self): - """Returns an embed that can be used to display information about this particular song""" - # Create the embed object we'll use - embed = Embed() - # Fill in the simple things - embed.add_field(name='Title', value=self.title, inline=False) - embed.add_field(name='Requester', value=self.requester.display_name, inline=False) - if self.thumbnail: - embed.set_thumbnail(url=self.thumbnail) - # Get the current length of the song and display this - if self.length: - length = divmod(round(self.length, 0), 60) - fmt = "{0[0]}m {0[1]}s".format(length) - embed.add_field(name='Duration', value=fmt, inline=False) - # And return the embed we created - return embed diff --git a/cogs/voice_utilities/event_emitter.py b/cogs/voice_utilities/event_emitter.py deleted file mode 100644 index f360704..0000000 --- a/cogs/voice_utilities/event_emitter.py +++ /dev/null @@ -1,38 +0,0 @@ -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 deleted file mode 100644 index 2cf82ac..0000000 --- a/cogs/voice_utilities/exceptions.py +++ /dev/null @@ -1,102 +0,0 @@ -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 - -# Live stream's cannot be downloaded -class LiveStreamError(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 deleted file mode 100644 index 95622fa..0000000 --- a/cogs/voice_utilities/playlist.py +++ /dev/null @@ -1,187 +0,0 @@ -import datetime -import traceback -import asyncio -from collections import deque -from itertools import islice -from random import shuffle - -from .source import YoutubeDLSource -from .entry import URLPlaylistEntry, get_header -from .exceptions import ExtractionError, WrongEntryTypeError, LiveStreamError -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() - - def __iter__(self): - return iter(self.entries) - - def shuffle(self): - shuffle(self.entries) - - def clear(self): - self.entries.clear() - - @property - def count(self): - if self.entries: - return len(self.entries) - else: - return 0 - - async def add_entry(self, song_url, **meta): - """Adds a song to this playlist""" - entry = YoutubeDLSource(self, song_url) - await entry.prepare() - self.entries.append(entry) - return entry - - async def import_from(self, playlist_url, requester): - """ - Imports the songs from `playlist_url` and queues them to be played. - - Returns a list of `entries` that have been enqueued. - """ - - 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) - - if info.get('playlist') is None and 'playlist' not in info.get('extractor', ''): - raise WrongEntryTypeError('This is not a playlist!', False, playlist_url) - - # Once again, the generic extractor fucks things up. - if info.get('extractor', None) == 'generic': - url_field = 'url' - else: - url_field = 'webpage_url' - - yield len(info['entries']) - - for items in info['entries']: - if items: - entry = YoutubeDLSource(self, items[url_field]) - try: - await entry.prepare() - except: - yield False - else: - entry.requester = requester - self.entries.append(entry) - yield True - else: - yield False - - 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 - else: - baditems += 1 - - 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 - else: - baditems += 1 - - return gooditems - - def _add_entry(self, entry): - self.entries.append(entry) - - async def next_entry(self): - """Get the next song in the playlist; this class will wait until the next song is ready""" - entry = self.peek() - - # While we have an entry available - while entry: - # Check if we are ready or if we've errored, either way we'll pop it from the deque - if entry.ready or entry.error: - return self.entries.popleft() - # Otherwise, wait a second and check again - else: - await asyncio.sleep(1) - # "Refresh" the next entry, in case someone cleared the next song in the queue - entry = self.peek() - - # If we've reached here, we have no entries - return None - - def peek(self): - """ - Returns the next entry that should be scheduled to be played. - """ - if self.entries: - return self.entries[0] - - def count_for_user(self, user): - return sum(1 for e in self.entries if e.meta.get('author', None) == user) diff --git a/cogs/voice_utilities/source.py b/cogs/voice_utilities/source.py deleted file mode 100644 index 6edebe9..0000000 --- a/cogs/voice_utilities/source.py +++ /dev/null @@ -1,171 +0,0 @@ -import discord -import time -import asyncio - -from .exceptions import ExtractionError, WrongEntryTypeError, LiveStreamError -from .entry import get_header - - -class YoutubeDLSource(discord.FFmpegPCMAudio): - def __init__(self, playlist, url): - self._process = None - self.playlist = playlist - self.loop = playlist.loop - self.downloader = playlist.downloader - self.url = url - self.info = None - self.ready = False - self.error = False - - async def get_info(self): - try: - # First attempt to gather the information - info = await self.downloader.extract_info(self.loop, self.url, download=False) - except Exception as e: - raise ExtractionError('Could not extract information from {}\n\n{}'.format(self.url, e)) - - # Check if a playlist was provided - if info.get('_type', None) == 'playlist': - # It is possible that the 'playlist' is the search - if info.get('extractor') == 'youtube:search': - # If so, and we have no entries, then nothing with this search was found - if len(info['entries']) == 0: - raise ExtractionError('Could not extract information from %s' % self.url) - # Otherwise get the first result - else: - info = info['entries'][0] - # If this isn't a search, then it is a playlist, this can't be done - else: - 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 - headers = await get_header(info['url']) - content_type = headers.get('Content-Type') - except Exception as 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, self.url)) - if headers.get('ice-audio-info'): - raise LiveStreamError("Cannot download from a livestream") - - if info.get('is_live', False): - raise LiveStreamError("Cannot download from a livestream") - - # Set our info - self.info = info - - async def prepare(self): - await self.get_info() - asyncio.run_coroutine_threadsafe(self.download(), self.loop) - return self.info - - async def download(self): - try: - result = await self.downloader.extract_info(self.loop, self.url, download=True) - except Exception as e: - self.error = True - raise ExtractionError(e) - if result: - self.ready = True - opts = { - 'before_options': '-nostdin', - 'options': '-vn -b:a 128k -v fatal' - } - super().__init__(self.downloader.ytdl.prepare_filename(self.info), **opts) - - @property - def title(self): - return self.info.get('title', 'Untitled') - - @property - def thumbnail(self): - return self.info.get('thumbnail', None) - - @property - def length(self): - return self.info.get('duration') or 0 - - @property - def progress(self): - if hasattr(self, 'start_time') and 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 - - @property - def embed(self): - """A property that returns an embed that can be used to display information about this particular song""" - # Create the embed object we'll use - embed = discord.Embed() - # Fill in the simple things - embed.add_field(name='Title', value=self.title, inline=False) - embed.add_field(name='Requester', value=self.requester.display_name, inline=False) - embed.add_field(name='Place in Queue', value=str(self.playlist.count)) - if self.thumbnail: - embed.set_thumbnail(url=self.thumbnail) - # Get the current length of the song and display this - if self.length: - length = divmod(round(self.length, 0), 60) - fmt = "{0[0]}m {0[1]}s".format(length) - embed.add_field(name='Duration', value=fmt, inline=False) - # And return the embed we created - return embed - -class YoutubeDLLiveStreamSource(discord.FFmpegPCMAudio): - def __init__(self, bot, url): - self._process = None - self.downloader = bot.downloader - self.loop = bot.loop - self.url = url - - @property - def title(self): - return self.info.get('title', 'Untitled') - - @property - def thumbnail(self): - return self.info.get('thumbnail', None) - - @property - def embed(self): - """A property that returns an embed that can be used to display information about this particular song""" - # Create the embed object we'll use - embed = discord.Embed() - # Fill in the simple things - embed.add_field(name='Title', value=self.title, inline=False) - embed.add_field(name='Requester', value=self.requester.display_name, inline=False) - if self.thumbnail: - embed.set_thumbnail(url=self.thumbnail) - # And return the embed we created - return embed - - async def get_ready(self): - try: - # First attempt to gather the information - info = await self.downloader.extract_info(self.loop, self.url, download=False) - except Exception as e: - raise ExtractionError('Could not extract information from {}\n\n{}'.format(self.url, e)) - - if not info.get('is_live', False): - raise WrongEntryTypeError("This is not a livestream!", True, info.get('webpage_url', None) or info.get('url', None)) - - # Set our info - self.info = info - opts = { - 'before_options': '-nostdin', - 'options': '-vn -b:a 128k -v fatal' - } - super().__init__(info.get('manifest_url'), **opts) diff --git a/riot.txt b/riot.txt deleted file mode 100644 index a45e313..0000000 --- a/riot.txt +++ /dev/null @@ -1 +0,0 @@ -fe04e897-b34e-4bf5-b79e-ccd5664bbd54