aboutsummaryrefslogtreecommitdiff
path: root/cogs/player.py
diff options
context:
space:
mode:
Diffstat (limited to 'cogs/player.py')
-rwxr-xr-xcogs/player.py719
1 files changed, 389 insertions, 330 deletions
diff --git a/cogs/player.py b/cogs/player.py
index 605d5ef..f21992d 100755
--- a/cogs/player.py
+++ b/cogs/player.py
@@ -31,90 +31,103 @@ from discord.ext import commands, tasks
from youtube_dl import YoutubeDL
ytdlopts = {
- 'format': 'bestaudio/best',
- 'outtmpl': 'downloads/%(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' # ipv6 addresses cause issues sometimes
+ "format": "bestaudio/best",
+ "outtmpl": "downloads/%(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", # ipv6 addresses cause issues sometimes
}
ffmpegopts = {
- 'before_options': '-nostdin',
- 'options': '-vn -reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5'
+ "before_options": "-nostdin",
+ "options": "-vn -reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5",
}
ytdl = YoutubeDL(ytdlopts)
class VoiceConnectionError(commands.CommandError):
- """Custom Exception class for connection errors."""
+ """Custom Exception class for connection errors."""
class InvalidVoiceChannel(VoiceConnectionError):
- """Exception for cases of invalid Voice Channels."""
+ """Exception for cases of invalid Voice Channels."""
class YTDLSource(discord.PCMVolumeTransformer):
+ def __init__(self, source, *, data, requester):
+ super().__init__(source)
+ self.requester = requester
- def __init__(self, source, *, data, requester):
- super().__init__(source)
- self.requester = requester
+ self.title = data.get("title")
+ self.web_url = data.get("webpage_url")
- self.title = data.get('title')
- self.web_url = data.get('webpage_url')
+ # YTDL info dicts (data) have other useful information you might want
+ # https://github.com/rg3/youtube-dl/blob/master/README.md
- # YTDL info dicts (data) have other useful information you might want
- # https://github.com/rg3/youtube-dl/blob/master/README.md
-
- def __getitem__(self, item: str):
- """Allows us to access attributes similar to a dict.
+ def __getitem__(self, item: str):
+ """Allows us to access attributes similar to a dict.
This is only useful when you are NOT downloading.
"""
- return self.__getattribute__(item)
-
- @classmethod
- async def create_source(cls, ctx, search: str, *, loop, download=True):
- loop = loop or asyncio.get_event_loop()
-
- to_run = partial(ytdl.extract_info, url=search, download=download)
- data = await loop.run_in_executor(None, to_run)
-
- if 'entries' in data:
- # take first item from a playlist
- data = data['entries'][0]
-
- await ctx.send(f'```ini\n[Added {data["title"]} to the Queue.]\n```', delete_after=15)
-
- if download:
- source = ytdl.prepare_filename(data)
- else:
- return {'webpage_url': data['webpage_url'], 'requester': ctx.author, 'title': data['title']}
-
- return cls(discord.FFmpegPCMAudio(source, **ffmpegopts), data=data, requester=ctx.author)
-
- @classmethod
- async def regather_stream(cls, data, *, loop):
- """Used for preparing a stream, instead of downloading.
+ return self.__getattribute__(item)
+
+ @classmethod
+ async def create_source(cls, ctx, search: str, *, loop, download=True):
+ loop = loop or asyncio.get_event_loop()
+
+ to_run = partial(ytdl.extract_info, url=search, download=download)
+ data = await loop.run_in_executor(None, to_run)
+
+ if "entries" in data:
+ # take first item from a playlist
+ data = data["entries"][0]
+
+ await ctx.send(
+ f'```ini\n[Added {data["title"]} to the Queue.]\n```', delete_after=15
+ )
+
+ if download:
+ source = ytdl.prepare_filename(data)
+ else:
+ return {
+ "webpage_url": data["webpage_url"],
+ "requester": ctx.author,
+ "title": data["title"],
+ }
+
+ return cls(
+ discord.FFmpegPCMAudio(source, **ffmpegopts),
+ data=data,
+ requester=ctx.author,
+ )
+
+ @classmethod
+ async def regather_stream(cls, data, *, loop):
+ """Used for preparing a stream, instead of downloading.
Since Youtube Streaming links expire."""
- loop = loop or asyncio.get_event_loop()
- requester = data['requester']
+ loop = loop or asyncio.get_event_loop()
+ requester = data["requester"]
- to_run = partial(ytdl.extract_info, url=data['webpage_url'])
- data = await loop.run_in_executor(None, to_run)
+ to_run = partial(ytdl.extract_info, url=data["webpage_url"])
+ data = await loop.run_in_executor(None, to_run)
- return cls(discord.FFmpegPCMAudio(data['url'], **ffmpegopts), data=data, requester=requester)
+ return cls(
+ discord.FFmpegPCMAudio(data["url"], **ffmpegopts),
+ data=data,
+ requester=requester,
+ )
class MusicPlayer:
- """A class which is assigned to each guild using the bot for Music.
+ """A class which is assigned to each guild using the bot for Music.
This class implements a queue and loop, which allows for different guilds to listen to different playlists
simultaneously.
@@ -122,134 +135,159 @@ class MusicPlayer:
When the bot disconnects from the Voice it's instance will be destroyed.
"""
- __slots__ = ('bot', '_guild', '_channel', '_cog', 'queue', 'next', 'current', 'np', 'volume')
-
- def __init__(self, ctx):
- self.bot = ctx.bot
- self._guild = ctx.guild
- self._channel = ctx.channel
- self._cog = ctx.cog
-
- self.queue = asyncio.Queue()
- self.next = asyncio.Event()
-
- self.np = None # Now playing message
- self.volume = .5
- self.current = None
-
- ctx.bot.loop.create_task(self.player_loop())
-
- async def player_loop(self):
- """Our main player loop."""
- await self.bot.wait_until_ready()
-
- while not self.bot.is_closed():
- self.next.clear()
-
- try:
- # Wait for the next song. If we timeout cancel the player and disconnect...
- async with timeout(300): # 5 minutes...
- source = await self.queue.get()
- except asyncio.TimeoutError:
- if self in self._cog.players.values():
- return self.destroy(self._guild)
- return
-
- if not isinstance(source, YTDLSource):
- # Source was probably a stream (not downloaded)
- # So we should regather to prevent stream expiration
- try:
- source = await YTDLSource.regather_stream(source, loop=self.bot.loop)
- except Exception as e:
- await self._channel.send(f'There was an error processing your song.\n'
- f'```css\n[{e}]\n```')
- continue
-
- source.volume = self.volume
- self.current = source
-
- self._guild.voice_client.play(source, after=lambda _: self.bot.loop.call_soon_threadsafe(self.next.set))
- self.np = await self._channel.send(f'**Now Playing:** `{source.title}` requested by '
- f'`{source.requester}`')
- await self.next.wait()
-
- # Make sure the FFmpeg process is cleaned up.
- source.cleanup()
- self.current = None
-
- try:
- # We are no longer playing this song...
- await self.np.delete()
- except discord.HTTPException:
- pass
-
- def destroy(self, guild):
- """Disconnect and cleanup the player."""
- return self.bot.loop.create_task(self._cog.cleanup(guild))
+ __slots__ = (
+ "bot",
+ "_guild",
+ "_channel",
+ "_cog",
+ "queue",
+ "next",
+ "current",
+ "np",
+ "volume",
+ )
+
+ def __init__(self, ctx):
+ self.bot = ctx.bot
+ self._guild = ctx.guild
+ self._channel = ctx.channel
+ self._cog = ctx.cog
+
+ self.queue = asyncio.Queue()
+ self.next = asyncio.Event()
+
+ self.np = None # Now playing message
+ self.volume = 0.5
+ self.current = None
+
+ ctx.bot.loop.create_task(self.player_loop())
+
+ async def player_loop(self):
+ """Our main player loop."""
+ await self.bot.wait_until_ready()
+
+ while not self.bot.is_closed():
+ self.next.clear()
+
+ try:
+ # Wait for the next song. If we timeout cancel the player and disconnect...
+ async with timeout(300): # 5 minutes...
+ source = await self.queue.get()
+ except asyncio.TimeoutError:
+ if self in self._cog.players.values():
+ return self.destroy(self._guild)
+ return
+
+ if not isinstance(source, YTDLSource):
+ # Source was probably a stream (not downloaded)
+ # So we should regather to prevent stream expiration
+ try:
+ source = await YTDLSource.regather_stream(
+ source, loop=self.bot.loop
+ )
+ except Exception as e:
+ await self._channel.send(
+ f"There was an error processing your song.\n"
+ f"```css\n[{e}]\n```"
+ )
+ continue
+
+ source.volume = self.volume
+ self.current = source
+
+ self._guild.voice_client.play(
+ source,
+ after=lambda _: self.bot.loop.call_soon_threadsafe(self.next.set),
+ )
+ self.np = await self._channel.send(
+ f"**Now Playing:** `{source.title}` requested by "
+ f"`{source.requester}`"
+ )
+ await self.next.wait()
+
+ # Make sure the FFmpeg process is cleaned up.
+ source.cleanup()
+ self.current = None
+
+ try:
+ # We are no longer playing this song...
+ await self.np.delete()
+ except discord.HTTPException:
+ pass
+
+ def destroy(self, guild):
+ """Disconnect and cleanup the player."""
+ return self.bot.loop.create_task(self._cog.cleanup(guild))
class Player(commands.Cog):
- """Music related commands."""
-
- __slots__ = ('bot', 'players')
-
- def __init__(self, bot):
- self.bot = bot
- #self.removedownloads.start()
- self.players = {}
-
- async def cleanup(self, guild):
- try:
- await guild.voice_client.disconnect()
- except AttributeError:
- pass
-
- try:
- for entry in self.players[guild.id].queue._queue:
- if isinstance(entry, YTDLSource):
- entry.cleanup()
- self.players[guild.id].queue._queue.clear()
- except KeyError:
- pass
-
- try:
- del self.players[guild.id]
- except KeyError:
- pass
-
- async def __local_check(self, ctx):
- """A local check which applies to all commands in this cog."""
- if not ctx.guild:
- raise commands.NoPrivateMessage
- return True
-
- async def __error(self, ctx, error):
- """A local error handler for all errors arising from commands in this cog."""
- if isinstance(error, commands.NoPrivateMessage):
- try:
- return await ctx.send('This command can not be used in Private Messages.')
- except discord.HTTPException:
- pass
- elif isinstance(error, InvalidVoiceChannel):
- await ctx.send('Error connecting to Voice Channel. '
- 'Please make sure you are in a valid channel or provide me with one')
-
- print('Ignoring exception in command {}:'.format(ctx.command), file=sys.stderr)
- traceback.print_exception(type(error), error, error.__traceback__, file=sys.stderr)
-
- def get_player(self, ctx):
- """Retrieve the guild player, or generate one."""
- try:
- player = self.players[ctx.guild.id]
- except KeyError:
- player = MusicPlayer(ctx)
- self.players[ctx.guild.id] = player
-
- return player
-
- @commands.command(name='connect', aliases=['join'])
- async def connect_(self, ctx, *, channel: discord.VoiceChannel=None):
- """Connect to voice.
+ """Music related commands."""
+
+ __slots__ = ("bot", "players")
+
+ def __init__(self, bot):
+ self.bot = bot
+ # self.removedownloads.start()
+ self.players = {}
+
+ async def cleanup(self, guild):
+ try:
+ await guild.voice_client.disconnect()
+ except AttributeError:
+ pass
+
+ try:
+ for entry in self.players[guild.id].queue._queue:
+ if isinstance(entry, YTDLSource):
+ entry.cleanup()
+ self.players[guild.id].queue._queue.clear()
+ except KeyError:
+ pass
+
+ try:
+ del self.players[guild.id]
+ except KeyError:
+ pass
+
+ async def __local_check(self, ctx):
+ """A local check which applies to all commands in this cog."""
+ if not ctx.guild:
+ raise commands.NoPrivateMessage
+ return True
+
+ async def __error(self, ctx, error):
+ """A local error handler for all errors arising from commands in this cog."""
+ if isinstance(error, commands.NoPrivateMessage):
+ try:
+ return await ctx.send(
+ "This command can not be used in Private Messages."
+ )
+ except discord.HTTPException:
+ pass
+ elif isinstance(error, InvalidVoiceChannel):
+ await ctx.send(
+ "Error connecting to Voice Channel. "
+ "Please make sure you are in a valid channel or provide me with one"
+ )
+
+ print("Ignoring exception in command {}:".format(ctx.command), file=sys.stderr)
+ traceback.print_exception(
+ type(error), error, error.__traceback__, file=sys.stderr
+ )
+
+ def get_player(self, ctx):
+ """Retrieve the guild player, or generate one."""
+ try:
+ player = self.players[ctx.guild.id]
+ except KeyError:
+ player = MusicPlayer(ctx)
+ self.players[ctx.guild.id] = player
+
+ return player
+
+ @commands.command(name="connect", aliases=["join"])
+ async def connect_(self, ctx, *, channel: discord.VoiceChannel = None):
+ """Connect to voice.
Parameters
------------
@@ -259,32 +297,36 @@ class Player(commands.Cog):
This command also handles moving the bot to different channels.
"""
- if not channel:
- try:
- channel = ctx.author.voice.channel
- except AttributeError:
- raise InvalidVoiceChannel('No channel to join. Please either specify a valid channel or join one.')
-
- vc = ctx.voice_client
-
- if vc:
- if vc.channel.id == channel.id:
- return
- try:
- await vc.move_to(channel)
- except asyncio.TimeoutError:
- raise VoiceConnectionError(f'Moving to channel: <{channel}> timed out.')
- else:
- try:
- await channel.connect()
- except asyncio.TimeoutError:
- raise VoiceConnectionError(f'Connecting to channel: <{channel}> timed out.')
-
- await ctx.send(f'Connected to: **{channel}**', delete_after=20)
-
- @commands.command(name='play', aliases=['sing'])
- async def play_(self, ctx, *, search: str):
- """Request a song and add it to the queue.
+ if not channel:
+ try:
+ channel = ctx.author.voice.channel
+ except AttributeError:
+ raise InvalidVoiceChannel(
+ "No channel to join. Please either specify a valid channel or join one."
+ )
+
+ vc = ctx.voice_client
+
+ if vc:
+ if vc.channel.id == channel.id:
+ return
+ try:
+ await vc.move_to(channel)
+ except asyncio.TimeoutError:
+ raise VoiceConnectionError(f"Moving to channel: <{channel}> timed out.")
+ else:
+ try:
+ await channel.connect()
+ except asyncio.TimeoutError:
+ raise VoiceConnectionError(
+ f"Connecting to channel: <{channel}> timed out."
+ )
+
+ await ctx.send(f"Connected to: **{channel}**", delete_after=20)
+
+ @commands.command(name="play", aliases=["sing"])
+ async def play_(self, ctx, *, search: str):
+ """Request a song and add it to the queue.
This command attempts to join a valid voice channel if the bot is not already in one.
Uses YTDL to automatically search and retrieve a song.
@@ -294,149 +336,166 @@ class Player(commands.Cog):
search: str [Required]
The song to search and retrieve using YTDL. This could be a simple search, an ID or URL.
"""
- await ctx.trigger_typing()
-
- vc = ctx.voice_client
-
- if not vc:
- await ctx.invoke(self.connect_)
-
- player = self.get_player(ctx)
+ await ctx.trigger_typing()
- # If download is False, source will be a dict which will be used later to regather the stream.
- # If download is True, source will be a discord.FFmpegPCMAudio with a VolumeTransformer.
- source = await YTDLSource.create_source(ctx, search, loop=self.bot.loop)
+ vc = ctx.voice_client
+
+ if not vc:
+ await ctx.invoke(self.connect_)
- await player.queue.put(source)
-
- @commands.command(name='pause')
- async def pause_(self, ctx):
- """Pause the currently playing song."""
- vc = ctx.voice_client
-
- if not vc or not vc.is_playing():
- return await ctx.send('I am not currently playing anything!', delete_after=20)
- elif vc.is_paused():
- return
-
- vc.pause()
- await ctx.send(f'**`{ctx.author}`**: Paused the song!')
-
- @commands.command(name='resume')
- async def resume_(self, ctx):
- """Resume the currently paused song."""
- vc = ctx.voice_client
-
- if not vc or not vc.is_connected():
- return await ctx.send('I am not currently playing anything!', delete_after=20)
- elif not vc.is_paused():
- return
-
- vc.resume()
- await ctx.send(f'**`{ctx.author}`**: Resumed the song!')
-
- @commands.command(name='skip')
- async def skip_(self, ctx):
- """Skip the song."""
- vc = ctx.voice_client
-
- if not vc or not vc.is_connected():
- return await ctx.send('I am not currently playing anything!', delete_after=20)
-
- if vc.is_paused():
- pass
- elif not vc.is_playing():
- return
-
- vc.stop()
- await ctx.send(f'**`{ctx.author}`**: Skipped the song!')
-
- @commands.command(name='queue', aliases=['q', 'playlist'])
- async def queue_info(self, ctx):
- """Retrieve a basic queue of upcoming songs."""
- vc = ctx.voice_client
-
- if not vc or not vc.is_connected():
- return await ctx.send('I am not currently connected to voice!', delete_after=20)
-
- player = self.get_player(ctx)
- if player.queue.empty():
- return await ctx.send('There are currently no more queued songs.')
-
- # Grab up to 5 entries from the queue...
- upcoming = list(itertools.islice(player.queue._queue, 0, 5))
-
- fmt = '\n'.join(f'**`{_["title"]}`**' for _ in upcoming)
- embed = discord.Embed(title=f'Upcoming - Next {len(upcoming)}', description=fmt)
-
- await ctx.send(embed=embed)
-
- @commands.command(name='now_playing', aliases=['np', 'current', 'currentsong', 'playing'])
- async def now_playing_(self, ctx):
- """Display information about the currently playing song."""
- vc = ctx.voice_client
-
- if not vc or not vc.is_connected():
- return await ctx.send('I am not currently connected to voice!', delete_after=20)
-
- player = self.get_player(ctx)
- if not player.current:
- return await ctx.send('I am not currently playing anything!')
-
- try:
- # Remove our previous now_playing message.
- await player.np.delete()
- except discord.HTTPException:
- pass
-
- player.np = await ctx.send(f'**Now Playing:** `{vc.source.title}` '
- f'requested by `{vc.source.requester}`')
-
- @commands.command(name='volume', aliases=['vol'])
- async def change_volume(self, ctx, *, vol: float):
- """Change the player volume.
+ player = self.get_player(ctx)
+
+ # If download is False, source will be a dict which will be used later to regather the stream.
+ # If download is True, source will be a discord.FFmpegPCMAudio with a VolumeTransformer.
+ source = await YTDLSource.create_source(ctx, search, loop=self.bot.loop)
+
+ await player.queue.put(source)
+
+ @commands.command(name="pause")
+ async def pause_(self, ctx):
+ """Pause the currently playing song."""
+ vc = ctx.voice_client
+
+ if not vc or not vc.is_playing():
+ return await ctx.send(
+ "I am not currently playing anything!", delete_after=20
+ )
+ elif vc.is_paused():
+ return
+
+ vc.pause()
+ await ctx.send(f"**`{ctx.author}`**: Paused the song!")
+
+ @commands.command(name="resume")
+ async def resume_(self, ctx):
+ """Resume the currently paused song."""
+ vc = ctx.voice_client
+
+ if not vc or not vc.is_connected():
+ return await ctx.send(
+ "I am not currently playing anything!", delete_after=20
+ )
+ elif not vc.is_paused():
+ return
+
+ vc.resume()
+ await ctx.send(f"**`{ctx.author}`**: Resumed the song!")
+
+ @commands.command(name="skip")
+ async def skip_(self, ctx):
+ """Skip the song."""
+ vc = ctx.voice_client
+
+ if not vc or not vc.is_connected():
+ return await ctx.send(
+ "I am not currently playing anything!", delete_after=20
+ )
+
+ if vc.is_paused():
+ pass
+ elif not vc.is_playing():
+ return
+
+ vc.stop()
+ await ctx.send(f"**`{ctx.author}`**: Skipped the song!")
+
+ @commands.command(name="queue", aliases=["q", "playlist"])
+ async def queue_info(self, ctx):
+ """Retrieve a basic queue of upcoming songs."""
+ vc = ctx.voice_client
+
+ if not vc or not vc.is_connected():
+ return await ctx.send(
+ "I am not currently connected to voice!", delete_after=20
+ )
+
+ player = self.get_player(ctx)
+ if player.queue.empty():
+ return await ctx.send("There are currently no more queued songs.")
+
+ # Grab up to 5 entries from the queue...
+ upcoming = list(itertools.islice(player.queue._queue, 0, 5))
+
+ fmt = "\n".join(f'**`{_["title"]}`**' for _ in upcoming)
+ embed = discord.Embed(title=f"Upcoming - Next {len(upcoming)}", description=fmt)
+
+ await ctx.send(embed=embed)
+
+ @commands.command(
+ name="now_playing", aliases=["np", "current", "currentsong", "playing"]
+ )
+ async def now_playing_(self, ctx):
+ """Display information about the currently playing song."""
+ vc = ctx.voice_client
+
+ if not vc or not vc.is_connected():
+ return await ctx.send(
+ "I am not currently connected to voice!", delete_after=20
+ )
+
+ player = self.get_player(ctx)
+ if not player.current:
+ return await ctx.send("I am not currently playing anything!")
+
+ try:
+ # Remove our previous now_playing message.
+ await player.np.delete()
+ except discord.HTTPException:
+ pass
+
+ player.np = await ctx.send(
+ f"**Now Playing:** `{vc.source.title}` "
+ f"requested by `{vc.source.requester}`"
+ )
+
+ @commands.command(name="volume", aliases=["vol"])
+ async def change_volume(self, ctx, *, vol: float):
+ """Change the player volume.
Parameters
------------
volume: float or int [Required]
The volume to set the player to in percentage. This must be between 1 and 100.
"""
- vc = ctx.voice_client
+ vc = ctx.voice_client
- if not vc or not vc.is_connected():
- return await ctx.send('I am not currently connected to voice!', delete_after=20)
+ if not vc or not vc.is_connected():
+ return await ctx.send(
+ "I am not currently connected to voice!", delete_after=20
+ )
- if not 0 < vol < 101:
- return await ctx.send('Please enter a value between 1 and 100.')
+ if not 0 < vol < 101:
+ return await ctx.send("Please enter a value between 1 and 100.")
- player = self.get_player(ctx)
+ player = self.get_player(ctx)
- if vc.source:
- vc.source.volume = vol / 100
+ if vc.source:
+ vc.source.volume = vol / 100
- player.volume = vol / 100
- await ctx.send(f'**`{ctx.author}`**: Set the volume to **{vol}%**')
+ player.volume = vol / 100
+ await ctx.send(f"**`{ctx.author}`**: Set the volume to **{vol}%**")
- @commands.command(name='stop')
- async def stop_(self, ctx):
- """Stop the currently playing song and destroy the player.
+ @commands.command(name="stop")
+ async def stop_(self, ctx):
+ """Stop the currently playing song and destroy the player.
!Warning!
This will destroy the player assigned to your guild, also deleting any queued songs and settings.
"""
- vc = ctx.voice_client
+ vc = ctx.voice_client
- if not vc or not vc.is_connected():
- return await ctx.send('I am not currently playing anything!', delete_after=20)
+ if not vc or not vc.is_connected():
+ return await ctx.send(
+ "I am not currently playing anything!", delete_after=20
+ )
- await self.cleanup(ctx.guild)
-
- @tasks.loop(hours=10.0)
- async def removedownloads(self):
- for p in Path("./downloads/").glob("*"):
- p.unlink()
+ await self.cleanup(ctx.guild)
+ @tasks.loop(hours=10.0)
+ async def removedownloads(self):
+ for p in Path("./downloads/").glob("*"):
+ p.unlink()
-def setup(bot):
- bot.add_cog(Player(bot))
+def setup(bot):
+ bot.add_cog(Player(bot))