1
0
Fork 0
mirror of synced 2024-05-20 20:42:27 +12:00

Finish music implementation on rewrite

This commit is contained in:
phxntxm 2017-04-19 22:18:05 -05:00
parent 60f2b0e2e5
commit b59b57f716

View file

@ -1,4 +1,5 @@
from .voice_utilities import *
from discord import FFmpegPCMAudio, PCMVolumeTransformer
import discord
from discord.ext import commands
@ -6,113 +7,56 @@ from discord.ext import commands
from . import utils
import math
import time
import asyncio
import re
import os
import glob
import socket
import inspect
import time
if not discord.opus.is_loaded():
discord.opus.load_opus('/usr/lib64/libopus.so.0')
class VoiceState:
def __init__(self, bot, download):
self.current = None
self.voice = None
self.bot = bot
self.play_next_song = asyncio.Event()
# This is the queue that holds all VoiceEntry's
def __init__(self, guild, bot):
self.guild = guild
self.songs = Playlist(bot)
self.current = None
self.required_skips = 0
# a set of user_ids that voted
self.skip_votes = set()
# Our actual task that handles the queue system
self.audio_player = self.bot.loop.create_task(self.audio_player_task())
self.opts = {
'default_search': 'auto',
'quiet': True
}
self.volume = 50
self.downloader = download
self.file_names = []
def is_playing(self):
# If our VoiceClient or current VoiceEntry do not exist, then we are not playing a song
if self.voice is None or self.current is None:
return False
# If they do exist, check if the current player has finished
player = self.current.player
try:
return not player.is_done()
except AttributeError:
return False
self.audio_player = bot.loop.create_task(self.audio_player_task())
@property
def player(self):
return self.current.player
def voice(self):
return self.guild.voice_client
@property
def playing(self):
return self.voice.is_playing() or self.voice.is_paused()
def skip(self):
# Make sure we clear the votes, before stopping the player
# When the player is stopped, our toggle_next method is called, so the next song can be played
self.skip_votes.clear()
if self.is_playing():
self.player.stop()
if self.playing:
self.voice.stop()
def toggle_next(self):
# Set the Event so that the next song in the queue can be played
self.bot.loop.call_soon_threadsafe(self.play_next_song.set)
def after(self):
self.current = None
async def audio_player_task(self):
while True:
# At the start of our task, clear the Event, so we can wait till it is next set
self.play_next_song.clear()
# Clear the votes skip that were for the last song
self.skip_votes.clear()
# Now wait for the next song in the queue
if self.playing or self.songs.peek() is None:
await asyncio.sleep(1)
continue
self.current = await self.songs.get_next_entry()
# Make sure we find a song
while self.current is None:
self.clear_audio_files()
await asyncio.sleep(1)
self.current = await self.songs.get_next_entry()
# At this point we're sure we have a song, however it needs to be downloaded
while not getattr(self.current, 'filename'):
print("Downloading...")
await asyncio.sleep(1)
# Now add this file to our list of filenames, so that it can be deleted later
if self.current.filename not in self.file_names:
self.file_names.append(self.current.filename)
# Create the player object
self.current.player = self.voice.create_ffmpeg_player(
source = FFmpegPCMAudio(
self.current.filename,
before_options="-nostdin",
options="-vn -b:a 128k",
after=self.toggle_next
before_options='-nostdin',
options='-vn -b:a 128k'
)
# Now we can start actually playing the song
self.current.player.start()
self.current.player.volume = self.volume / 100
# Save the variable for when our time for this song has started
source = PCMVolumeTransformer(source)
self.voice.play(source, after=self.after)
self.current.start_time = time.time()
# Wait till the Event has been set, before doing our task again
await self.play_next_song.wait()
def clear_audio_files(self):
"""Deletes all the audio files this guild has created"""
for f in self.file_names:
os.remove(f)
self.file_names = []
class Music:
"""Voice related commands.
@ -125,67 +69,6 @@ class Music:
down = Downloader(download_folder='audio_tmp')
self.downloader = down
self.bot.downloader = down
self.clear_audio_tmp()
def clear_audio_tmp(self):
files = glob.glob('audio_tmp/*')
for f in files:
os.remove(f)
def get_voice_state(self, server):
state = self.voice_states.get(server.id)
# Internally handle creating a voice state if there isn't a current state
# This can be used for example, in case something is skipped when not being connected
# We create the voice state when checked
# This only creates the state, we are still not playing anything, which can then be handled separately
if state is None:
state = VoiceState(self.bot, self.downloader)
self.voice_states[server.id] = state
return state
async def create_voice_client(self, channel):
"""Creates a voice client and saves it"""
# First join the channel and get the VoiceClient that we'll use to save per server
await self.remove_voice_client(channel.guild)
server = channel.guild
state = self.get_voice_state(server)
voice = self.bot.voice_client_in(server)
# Attempt 3 times
for i in range(3):
try:
if voice is None:
state.voice = await self.bot.join_voice_channel(channel)
if state.voice:
return True
elif voice.channel == channel:
state.voice = voice
return True
else:
# This shouldn't theoretically ever happen yet it does. Thanks Discord
await voice.disconnect()
state.voice = await self.bot.join_voice_channel(channel)
if state.voice:
return True
except (discord.ClientException, socket.gaierror, ConnectionResetError):
continue
return False
async def remove_voice_client(self, server):
"""Removes any voice clients from a server
This is sometimes needed, due to the unreliability of Discord's voice connection
We do not want to end up with a voice client stuck somewhere, so this cancels any found for a server"""
state = self.get_voice_state(server)
voice = self.bot.voice_client_in(server)
if voice:
await voice.disconnect()
if state.voice:
await state.voice.disconnect()
def __unload(self):
# If this is unloaded, cancel all players and disconnect from all channels
@ -197,22 +80,23 @@ class Music:
except:
pass
async def on_voice_state_update(self, before, after):
state = self.get_voice_state(after.guild)
if state.voice is None:
return
voice_channel = state.voice.channel
num_members = len(voice_channel.voice_members)
state.required_skips = math.ceil((num_members + 1) / 3)
async def queue_embed_task(self, state, channel, author):
index = 0
message = None
fmt = None
# Our check to ensure the only one who reacts is the bot
def check(reaction, user):
return user == author
possible_reactions = ['\u27A1', '\u2B05', '\u2b06', '\u2b07', '\u274c']
# 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
@ -220,7 +104,7 @@ class Music:
count = len(queue)
# This means the last song was removed
if count == 0:
await self.bot.send_message(channel, "Nothing currently in the queue")
await channel.send("Nothing currently in the queue")
break
# Get the current entry
entry = queue[index]
@ -228,43 +112,42 @@ class Music:
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)
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:
message = await self.bot.edit_message(message, fmt, embed=embed)
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 != entry.requester:
if not author.guild_permissions.kick_members and author.id != entry.requester.id:
try:
await self.bot.remove_reaction(message, '\u274c', channel.guild.me)
await message.remove_reaction('\u274c', channel.server.me)
except:
pass
elif not author.guild_permissions.kick_members and author == entry.requester:
elif not author.guild_permissions.kick_members and author.id == entry.requester.id:
try:
await self.bot.add_reaction(message, '\u274c')
await message.add_reaction('\u274c')
except:
pass
else:
message = await self.bot.send_message(channel, embed=embed)
await self.bot.add_reaction(message, '\N{LEFTWARDS BLACK ARROW}')
await self.bot.add_reaction(message, '\N{BLACK RIGHTWARDS ARROW}')
message = await channel.send(embed=embed)
await message.add_reaction('\N{LEFTWARDS BLACK ARROW}')
await message.add_reaction('\N{BLACK RIGHTWARDS ARROW}')
# The moderation tools that can be used
if author.guild_permissions.kick_members:
await self.bot.add_reaction(message, '\N{DOWNWARDS BLACK ARROW}')
await self.bot.add_reaction(message, '\N{UPWARDS BLACK ARROW}')
await self.bot.add_reaction(message, '\N{CROSS MARK}')
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 self.bot.add_reaction(message, '\N{CROSS MARK}')
await message.add_reaction('\N{CROSS MARK}')
# Reset the fmt message
fmt = None
fmt = "\u200B"
# Now we wait for the next reaction
res = await self.bot.wait_for_reaction(possible_reactions, message=message, check=check, timeout=180)
if res is None:
try:
reaction, user = await self.bot.wait_for('reaction_add', check=check, timeout=180)
except asyncio.TimeoutError:
break
else:
reaction, user = res
# Now we can prepare for the next embed to be sent
# If right is clicked
if '\u27A1' in reaction.emoji:
@ -316,14 +199,56 @@ class Music:
if index >= new_count:
index = new_count - 1
try:
await self.bot.remove_reaction(message, reaction.emoji, user)
await message.remove_reaction(reaction.emoji, user)
except discord.Forbidden:
pass
await self.bot.delete_message(message)
await message.delete()
async def on_voice_state_update(self, member, before, 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:
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):
requester = ctx.message.author
state = self.voice_states[ctx.message.guild.id]
try:
entry, _ = await state.songs.add_entry(song, requester)
except WrongEntryTypeError:
info = await self.downloader.extract_info(self.bot.loop, song, download=False, process=True)
try:
songs = info.get('entries', [])[:3]
if len(songs) > 1:
# TODO: Figures out why youtube_dl is only returning one result here
fmt = "Founds multiple possibilities, which one do you want to add?\n\n"
for i, entry in enumerate(songs):
title = entry['title']
fmt += "**{})** {}".format(i + 1, title)
await ctx.send(fmt)
def check(m):
if m.channel != ctx.message.channel and m.author != ctx.message.author:
return False
return m.content.strip() in ['1', '2', '3']
msg = await self.bot.wait_for('message', check=check, timeout=60)
song = songs[int(msg.content) - 1]['webpage_url']
else:
song = songs[0]['webpage_url']
except IndexError:
return None
entry, _ = await state.songs.add_entry(song, requester)
return entry
@commands.command(pass_context=True)
@commands.check(utils.is_owner)
async def vdebug(self, ctx, *, code : str):
async def vdebug(self, ctx, *, code: str):
"""Evaluates code."""
code = code.strip('` ')
python = '```py\n{}\n```'
@ -334,6 +259,7 @@ class Music:
'ctx': ctx,
'message': ctx.message,
'server': ctx.message.guild,
'guild': ctx.message.guild,
'channel': ctx.message.channel,
'author': ctx.message.author
}
@ -357,8 +283,8 @@ class Music:
"""Provides the progress of the current song"""
# Make sure we're playing first
state = self.get_voice_state(ctx.message.guild)
if not state.is_playing():
state = self.voice_states.get(ctx.message.guild.id)
if state is None or not state.playing:
await ctx.send('Not playing anything.')
else:
progress = state.current.progress
@ -375,56 +301,29 @@ class Music:
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
self.voice_states[ctx.message.guild.id] = VoiceState(ctx.message.guild, self.bot)
await channel.connect()
await ctx.send("Joined {} and ready to play".format(channel.name))
return True
@commands.command()
@commands.guild_only()
@utils.custom_perms(send_messages=True)
async def join(self, *, channel: discord.TextChannel):
"""Joins a voice channel."""
try:
await self.create_voice_client(channel)
# Check if the channel given was an actual voice channel
except discord.InvalidArgument:
await ctx.send('This is not a voice channel...')
except (asyncio.TimeoutError, discord.ConnectionClosed):
await ctx.send("I failed to connect! This usually happens if I don't have permission to join the"
" channel, but can sometimes be caused by your server region being far away."
" Otherwise this is an issue on Discord's end, causing the connect to timeout!")
await self.remove_voice_client(channel.guild)
else:
await ctx.send('Ready to play audio in ' + channel.name)
@commands.command(pass_context=True)
@commands.guild_only()
@utils.custom_perms(send_messages=True)
async def summon(self, ctx):
"""Summons the bot to join your voice channel."""
# This method will be invoked by other commands, so we should return True or False instead of just returning
# First check if the author is even in a voice_channel
summoned_channel = ctx.message.author.voice_channel
if summoned_channel is None:
await ctx.send('You are not in a voice channel.')
return False
# Then simply create a voice client
try:
success = await self.create_voice_client(summoned_channel)
except (asyncio.TimeoutError, discord.ConnectionClosed):
await ctx.send("I failed to connect! This usually happens if I don't have permission to join the"
" channel, but can sometimes be caused by your server region being far away."
" Otherwise this is an issue on Discord's end, causing the connect to timeout!")
await self.remove_voice_client(summoned_channel.guild)
return False
if success:
try:
await ctx.send('Ready to play audio in ' + summoned_channel.name)
except discord.Forbidden:
pass
return success
@commands.command(pass_context=True)
@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
@ -433,80 +332,19 @@ class Music:
The list of supported sites can be found here:
https://rg3.github.io/youtube-dl/supportedsites.html
"""
state = self.get_voice_state(ctx.message.guild)
# First check if we are connected to a voice channel at all, if not summon to the channel the author is in
# Since summon utils if the author is in a channel, we don't need to handle that here, just return if it failed
if state.voice is None:
success = await ctx.invoke(self.summon)
if not success:
if ctx.message.guild.id not in self.voice_states:
if not await ctx.invoke(self.join):
return
# If the queue is full, we ain't adding anything to it
if state.songs.full:
await ctx.send("The queue is currently full! You'll need to wait to add a new song")
return
author_channel = ctx.message.author.voice.voice_channel
my_channel = ctx.message.guild.me.voice.voice_channel
if my_channel is None:
# If we're here this means that after 3 attempts...4 different "failsafes"...
# Discord has returned saying the connection was successful, and returned a None connection
await ctx.send("I failed to connect to the channel! Please try again soon")
return
# To try to avoid some abuse, ensure the requester is actually in our channel
if my_channel != author_channel:
await ctx.send("You are not currently in the channel; please join before trying to request a song.")
return
# Set the number of required skips to start
num_members = len(my_channel.voice_members)
state.required_skips = math.ceil((num_members + 1) / 3)
# Create the player, and check if this was successful
# Here all we want is to get the information of the player
song = re.sub('[<>\[\]]', '', song)
try:
_entry, position = await state.songs.add_entry(song, ctx.message.author)
except WrongEntryTypeError:
# This means that a song was attempted to be searched, instead of a link provided
try:
info = await self.downloader.extract_info(self.bot.loop, song, download=False, process=True)
song = info.get('entries', [])[0]['webpage_url']
except IndexError:
await self.bot.send_message(ctx.message.channel, "No results found for {}!".format(song))
return
except ExtractionError as e:
# This gets the youtube_dl error, instead of our error raised
error = str(e).split("\n\n")[1]
# Youtube has a "fancy" colour error message it prints to the console
# Obviously this doesn't work in Discord, so just remove this
error = " ".join(error.split()[1:])
await self.bot.send_message(ctx.message.channel, error)
return
try:
_entry, position = await state.songs.add_entry(song, ctx.message.author)
except WrongEntryTypeError:
# This is either a playlist, or something not supported
fmt = "Sorry but I couldn't download that! Either you provided a playlist, a streamed link, or " \
"a page that is not supported to download."
await self.bot.send_message(ctx.message.channel, fmt)
return
except ExtractionError as e:
# This gets the youtube_dl error, instead of our error raised
error = str(e).split("\n\n")[1]
# Youtube has a "fancy" colour error message it prints to the console
# Obviously this doesn't work in Discord, so just remove this
error = " ".join(error.split()[1:])
# Make sure we are not over our 2000 message limit length (there are some youtube errors that are)
if len(error) >= 2000:
error = "{}...".format(error[:1996])
await self.bot.send_message(ctx.message.channel, error)
return
await ctx.send('Enqueued ' + str(_entry))
entry = await self.add_entry(song, ctx)
except asyncio.TimeoutError:
await ctx.send("You took too long!")
else:
if entry is None:
await ctx.send("Sorry but I couldn't download/find {}".format(song))
else:
await ctx.send("Enqueued {}".format(entry))
@commands.command(pass_context=True)
@commands.guild_only()
@ -514,37 +352,36 @@ class Music:
async def volume(self, ctx, value: int = None):
"""Sets the volume of the currently playing song."""
state = self.get_voice_state(ctx.message.guild)
if value is None:
volume = state.volume
state = self.voice_states.get(ctx.message.guild.id)
source = state.voice.source
if value is None or not state.playing:
volume = source.volume
await ctx.send("Current volume is {}".format(volume))
return
if value > 200:
await ctx.send("Sorry but the max volume is 200")
return
state.volume = value
if state.is_playing():
player = state.player
player.volume = value / 100
await ctx.send('Set the volume to {:.0%}'.format(player.volume))
if state.playing:
source.volume = value / 100
await ctx.send('Set the volume to {:.0%}'.format(source.volume))
@commands.command(pass_context=True)
@commands.guild_only()
@utils.custom_perms(kick_members=True)
async def pause(self, ctx):
"""Pauses the currently played song."""
state = self.get_voice_state(ctx.message.guild)
if state.is_playing():
state.player.pause()
state = self.voice_states.get(ctx.message.guild.id)
if state and state.playing:
state.voice.pause()
@commands.command(pass_context=True)
@commands.guild_only()
@utils.custom_perms(kick_members=True)
async def resume(self, ctx):
"""Resumes the currently played song."""
state = self.get_voice_state(ctx.message.guild)
if state.is_playing():
state.player.resume()
state = self.voice_states.get(ctx.message.guild.id)
if state.voice.is_connected():
state.voice.resume()
@commands.command(pass_context=True)
@commands.guild_only()
@ -553,24 +390,19 @@ class Music:
"""Stops playing audio and leaves the voice channel.
This also clears the queue.
"""
server = ctx.message.guild
state = self.get_voice_state(server)
state = self.voice_states.get(ctx.message.guild.id)
# Stop playing whatever song is playing.
if state.is_playing():
state.player.stop()
if state.voice.is_connected():
state.voice.stop()
state.songs.clear()
# This will stop cancel the audio event we're using to loop through the queue
# 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
try:
state.audio_player.cancel()
state.clear_audio_files()
await self.remove_voice_client(ctx.message.guild)
del self.voice_states[server.id]
except:
pass
state.audio_player.cancel()
await state.voice.disconnect()
del self.voice_states[ctx.message.guild.id]
@commands.command(pass_context=True)
@commands.guild_only()
@ -579,10 +411,10 @@ class Music:
"""Provides an ETA on when your next song will play"""
# Note: There is no way to tell how long a song has been playing, or how long there is left on a song
# That is why this is called an "ETA"
state = self.get_voice_state(ctx.message.guild)
state = self.voice_states.get(ctx.message.guild.id)
author = ctx.message.author
if not state.is_playing():
if state is None or not state.playing:
await ctx.send('Not playing any music right now...')
return
@ -602,12 +434,6 @@ class Music:
break
count += song.duration
# This is checking if nothing from the queue has been added to the total
# If it has not, then we have not looped through the queue at all
# Since the queue was already checked to have more than one song in it, this means the author is next
if count == state.current.duration:
await ctx.send("You are next in the queue!")
return
if not found:
await ctx.send("You are not in the queue!")
return
@ -618,14 +444,13 @@ class Music:
@utils.custom_perms(send_messages=True)
async def queue(self, ctx):
"""Provides a printout of the songs that are in the queue"""
state = self.get_voice_state(ctx.message.guild)
if not state.is_playing():
await ctx.send('Not playing any music right now...')
state = self.voice_states.get(ctx.message.guild.id)
if state is None:
await ctx.send("Nothing currently in the queue")
return
# Asyncio provides no non-private way to access the queue, so we have to use _queue
queue = state.songs.entries
if len(queue) == 0:
_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))
@ -636,7 +461,7 @@ class Music:
async def queuelength(self, ctx):
"""Prints the length of the queue"""
await ctx.send("There are a total of {} songs in the queue"
.format(len(self.get_voice_state(ctx.message.guild).songs.entries)))
.format(len(self.voice_states.get(ctx.message.guild.id).songs.entries)))
@commands.command(pass_context=True)
@commands.guild_only()
@ -647,8 +472,8 @@ class Music:
are required to vote to skip for the song to be skipped.
"""
state = self.get_voice_state(ctx.message.guild)
if not state.is_playing():
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
@ -676,8 +501,8 @@ class Music:
@utils.custom_perms(kick_members=True)
async def modskip(self, ctx):
"""Forces a song skip, can only be used by a moderator"""
state = self.get_voice_state(ctx.message.guild)
if not state.is_playing():
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
@ -690,8 +515,8 @@ class Music:
async def playing(self, ctx):
"""Shows info about the currently played song."""
state = self.get_voice_state(ctx.message.guild)
if not state.is_playing():
state = self.voice_states.get(ctx.message.guild.id)
if state is None or not state.playing:
await ctx.send('Not playing anything.')
else:
# Create the embed object we'll use
@ -708,7 +533,7 @@ class Music:
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)
embed.add_field(name='Progress', value=fmt, inline=False)
# And send the embed
await ctx.send(embed=embed)