1
0
Fork 0
mirror of synced 2024-06-21 12:00:16 +12:00

Complete overhaul of the music searching/downloading

This commit is contained in:
Phxntxm 2016-12-21 17:06:32 -06:00
parent 1391bc051a
commit 431490fa32
7 changed files with 840 additions and 182 deletions

View file

@ -1,4 +1,5 @@
from .utils import checks
from .utils import *
from .voice_utilities import *
import discord
from discord.ext import commands
@ -14,78 +15,14 @@ import re
if not discord.opus.is_loaded():
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:
def __init__(self, bot):
def __init__(self, bot, downloader):
self.current = None
self.voice = None
self.bot = bot
self.play_next_song = asyncio.Event()
# This is the queue that holds all VoiceEntry's
self.songs = asyncio.Queue(maxsize=10)
self.songs = Playlist(bot)
self.required_skips = 0
# a set of user_ids that voted
self.skip_votes = set()
@ -96,6 +33,7 @@ class VoiceState:
'quiet': True
}
self.volume = 50
self.downloader = downloader
def is_playing(self):
# 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()
# Clear the votes skip that were for the last song
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
self.current = await self.songs.get()
# Tell the channel that requested the new song that we are now playing
try:
await self.bot.send_message(self.current.channel, 'Now playing ' + str(self.current))
except discord.Forbidden:
pass
# 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,
after=self.toggle_next)
self.current = await self.songs.get_next_entry()
# Make sure we find a song
while (self.current is None):
await asyncio.sleep(1)
self.current = await self.songs.get_next_entry()
# At this point we're sure we have a song, however it needs to be downloaded
while(not getattr(self.current, 'filename')):
print("Downloading...")
await asyncio.sleep(1)
# 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
self.current.player.start()
self.current.player.volume = self.volume / 100
@ -163,15 +108,9 @@ class Music:
def __init__(self, bot):
self.bot = bot
self.voice_states = {}
self.opts = {
'format': 'webm[abr>0]/bestaudio/best',
'prefer_ffmpeg': True,
'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)
down = Downloader(download_folder='audio_tmp')
self.downloader = down
self.bot.downloader = down
def get_voice_state(self, server):
state = self.voice_states.get(server.id)
@ -181,22 +120,28 @@ class Music:
# We create the voice state when checked
# This only creates the state, we are still not playing anything, which can then be handled separately
if state is None:
state = VoiceState(self.bot)
state = VoiceState(self.bot, self.downloader)
self.voice_states[server.id] = state
return state
async def create_voice_client(self, channel):
# First join the channel and get the VoiceClient that we'll use to save per server
try:
voice = await self.bot.join_voice_channel(channel)
except asyncio.TimeoutError:
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
state = self.get_voice_state(channel.server)
state.voice = voice
server = channel.server
state = self.get_voice_state(server)
voice = self.bot.voice_client_in(server)
if voice is None:
state.voice = await self.bot.join_voice_channel(channel)
return True
elif voice.channel == channel:
state.voice = voice
return True
else:
await voice.disconnect()
state.voice = await self.bot.join_voice_channel(channel)
return True
def __unload(self):
# 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
except discord.InvalidArgument:
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:
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.')
return False
# Check if we're in a channel already, if we are then we just need to move channels
# Otherwse, we need to create an actual voice state
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
# Then simply create a voice client
success = await self.create_voice_client(summoned_channel)
# First check if we have a voice connection saved
if state.voice is not None:
# Check if our saved voice connection doesn't actually exist
if self.bot.voice_client_in(ctx.message.server) is None:
await state.voice.disconnect()
await self.bot.say("I had an issue connecting to the channel, please try again")
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
if success:
try:
await self.bot.say('Ready to play audio in ' + summoned_channel.name)
except discord.Forbidden:
pass
return success
@commands.command(pass_context=True, no_pm=True)
@checks.custom_perms(send_messages=True)
@ -336,7 +241,7 @@ class Music:
return
# 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")
return
@ -350,35 +255,19 @@ class Music:
# Create the player, and check if this was successful
# Here all we want is to get the information of the player
try:
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
song = re.sub('[<>\[\]]', '', song)
# Now we can create a VoiceEntry and queue it
entry = VoiceEntry(ctx.message, player)
await state.songs.put(entry)
try:
await self.bot.say('Enqueued ' + str(entry))
except discord.Forbidden:
pass
_entry = await state.songs.add_entry(song, ctx.message.author)
except WrongEntryTypeError:
# This means that a song was attempted to be searched, instead of a link provided
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)
@checks.custom_perms(kick_members=True)
@ -450,13 +339,14 @@ class Music:
if not state.is_playing():
await self.bot.say('Not playing any music right now...')
return
queue = state.songs._queue
queue = state.songs.entries
if len(queue) == 0:
await self.bot.say("Nothing currently in the queue")
return
# Start off by adding the length of the current song
count = state.current.player.duration
# Start off by adding the remaining length of the current song
count = state.current.remaining
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
@ -464,12 +354,12 @@ class Music:
if song.requester == author:
found = True
break
count += song.player.duration
count += song.duration
# This is checking if nothing from the queue has been added to the total
# If it has not, then we have not looped through the queue at all
# Since the queue was already checked to have more than one song in it, this means the author is next
if count == state.current.player.duration:
if count == state.current.duration:
await self.bot.say("You are next in the queue!")
return
if not found:
@ -487,7 +377,7 @@ class Music:
return
# 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:
fmt = "Nothing currently in the queue"
else:
@ -499,7 +389,7 @@ class Music:
async def queuelength(self, ctx):
"""Prints the length of 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)
@checks.custom_perms(send_messages=True)

View file

@ -0,0 +1,3 @@
from .downloader import Downloader
from .playlist import Playlist
from .exceptions import *

View 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))

View 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)

View 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

View 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

View 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)