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