from .voice_utilities import * from discord import FFmpegPCMAudio, PCMVolumeTransformer import discord from discord.ext import commands from . import utils import math import asyncio import time import re import logging 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): 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 = 50 @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.playing: 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): 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.current: source = FFmpegPCMAudio( self.current.filename, before_options='-nostdin', options='-vn -b:a 128k' ) source = PCMVolumeTransformer(source, volume=self.volume) self.voice.play(source, after=self.after) self.current.start_time = time.time() else: # If we're here what we can assume is the following took place: # 1) The queue type is `user` # 2) Someone joined for the first time, starting off the queue # 3) They don't have a song in their playlist ready yet # So what we'll do here is just call this again a few seconds later await asyncio.sleep(2) return await self.play_next_song() async def next_song(self): if not self.user_queue: self.current = await self.songs.get_next_entry() else: try: self.dj = self.djs.popleft() except IndexError: self.current = None else: self.current = await self.dj.get_next_entry() self.djs.rotate(-1) if self.current is None: self.djs.remove(self.dj) await self.next_song() else: self.current.requester = self.dj.member 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 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.to_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.kick_members and author.id != entry.requester.id: try: await message.remove_reaction('\u274c', channel.server.me) except: pass elif not author.guild_permissions.kick_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.kick_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.kick_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.kick_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.kick_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: await state.play_next_song() entry.requester = ctx.message.author return entry 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() # If we have connnected, create our voice state queue_type = self.bot.db.load('server_settings', key=channel.guild.id, pluck='queue_type') user_queue = queue_type == "user" self.voice_states[channel.guild.id] = VoiceState(channel.guild, self.bot, user_queue=user_queue) # If we can send messages, edit it to let the channel know we have succesfully joined if msg: await msg.edit(content="Ready to play audio in channel {}".format(channel.name)) return True # If we time out trying to join, just let them know and return False except asyncio.TimeoutError: if msg: await msg.edit(content="Sorry, but I couldn't connect right now! Please try again later") 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 channel.send("Sorry but I couldn't connect...try again?") return False @commands.command() @commands.guild_only() @utils.custom_perms(send_messages=True) 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) 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() @commands.guild_only() @utils.custom_perms(send_messages=True) 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 ctx.message.guild.id not in self.voice_states: if not await ctx.invoke(self.join): return if self.voice_states.get(ctx.message.guild.id).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 song = re.sub('[<>\[\]]', '', song) if len(song) == 11: # Youtube-dl will attempt to things with the 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: entry = await self.add_entry(song, ctx) except LiveStreamError as e: await ctx.send(str(e)) except WrongEntryTypeError: await ctx.send("Cannot enqueue playlists at this time.") 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.to_embed() embed.title = "Enqueued song!" await ctx.send(embed=embed) except discord.Forbidden: pass @commands.command() @commands.guild_only() @utils.custom_perms(kick_members=True) 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 await ctx.send('Set the volume to {:.0%}'.format(state.volume)) @commands.command() @commands.guild_only() @utils.custom_perms(kick_members=True) 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(kick_members=True) 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(kick_members=True) 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) # Stop playing whatever song is playing. if state and state.voice: state.voice.stop() state.songs.clear() # This will cancel the audio event we're using to loop through the queue # Then erase the voice_state entirely, and disconnect from the channel await state.voice.disconnect() try: del self.voice_states[ctx.message.guild.id] except KeyError: pass @commands.command() @commands.guild_only() @utils.custom_perms(send_messages=True) 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.duration 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) 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) 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) 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 or state.current is None: await ctx.send('Not playing any music right now...') return # Check if the person requesting a skip is the requester of the song, if so automatically skip voter = ctx.message.author if 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(kick_members=True) 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 or not state.playing: 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) 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) 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 if length: progress = state.current.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) 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 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 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] 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() def setup(bot): bot.add_cog(Music(bot))