remove old files/remove music
This commit is contained in:
parent
3f7eaaa248
commit
465c9fa316
979
cogs/music.py
979
cogs/music.py
|
@ -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("[0;31mERROR:[0m ", "")
|
||||
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("[0;31mERROR:[0m ", "")
|
||||
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))
|
356
cogs/playlist.py
356
cogs/playlist.py
|
@ -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))
|
|
@ -1,4 +0,0 @@
|
|||
from .downloader import Downloader
|
||||
from .exceptions import *
|
||||
from .playlist import Playlist
|
||||
from .source import *
|
|
@ -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))
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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)
|
|
@ -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)
|
Loading…
Reference in a new issue