From 59276ce2a505c1ce6534d1c50645cda4c6cab2de Mon Sep 17 00:00:00 2001 From: aikaterna <20862007+aikaterna@users.noreply.github.com> Date: Mon, 16 Apr 2018 15:42:32 -0700 Subject: [PATCH] [V3 Audio] External lavalink server settings, playlist saving & recall, bugfixes (#1528) * Add settings for external lavalink servers * Add external lavalink server to settings display * Add simple error handling * Remove future permissions conflict Assuming base Red permissions will be accessed with the 'p' command in the future * Update prev to set requester to user instead of id * Various fixes Jukebox: Plebs not in a channel will not have the song cost deducted before the warning to join a channel first. DJ role: Finer grained permissions for plebs that are alone: they can use prev (if shuffle is off), skip, pause, resume * Update permissions * Add track number to footer when queueing * Add basic playlist saving More subcommands to come later * Check if user is not in voice * Restrict playlist deletion to author and mods * Add playlist info command --- redbot/cogs/audio/audio.py | 334 +++++++++++++++++++++++++++++++++---- 1 file changed, 299 insertions(+), 35 deletions(-) diff --git a/redbot/cogs/audio/audio.py b/redbot/cogs/audio/audio.py index f398e9584..99895e970 100644 --- a/redbot/cogs/audio/audio.py +++ b/redbot/cogs/audio/audio.py @@ -9,7 +9,7 @@ from redbot.core import Config, checks, bank from .manager import shutdown_lavalink_server -__version__ = "0.0.4" +__version__ = "0.0.5" __author__ = ["aikaterna", "billy/bollo/ati"] @@ -33,6 +33,7 @@ class Audio: "dj_role": None, "jukebox": False, "jukebox_price": 0, + "playlists": {}, "notify": False, "repeat": False, "shuffle": False, @@ -116,6 +117,17 @@ class Audio: await self.bot.change_presence(activity=discord.Activity(name='music in {} servers'.format(playing_servers), type=discord.ActivityType.playing)) + if event_type == lavalink.LavalinkEvents.TRACK_EXCEPTION: + message_channel = player.fetch('channel') + if message_channel: + message_channel = self.bot.get_channel(message_channel) + embed = discord.Embed(colour=message_channel.guild.me.top_role.colour, title='Track Error', + description='{}\n**[{}]({})**'.format(extra, player.current.title, + player.current.uri)) + embed.set_footer(text='Skipping...') + await message_channel.send(embed=embed) + await player.skip() + @commands.group() @commands.guild_only() async def audioset(self, ctx): @@ -186,11 +198,12 @@ class Audio: async def settings(self, ctx): """Show the current settings.""" data = await self.config.guild(ctx.guild).all() + global_data = await self.config.all() dj_role_obj = discord.utils.get(ctx.guild.roles, id=data['dj_role']) dj_enabled = data['dj_enabled'] jukebox = data['jukebox'] jukebox_price = data['jukebox_price'] - status = await self.config.status() + vote_percent = data['vote_percent'] msg = ('```ini\n' '----Server Settings----\n') @@ -202,12 +215,13 @@ class Audio: msg += ('Repeat: [{repeat}]\n' 'Shuffle: [{shuffle}]\n' 'Song notify msgs: [{notify}]\n' - 'Songs as status: [{0}]\n'.format(status, **data)) + 'Songs as status: [{status}]\n'.format(**global_data, **data)) if vote_percent > 0: msg += ('Vote skip: [{vote_enabled}]\n' 'Skip percentage: [{vote_percent}%]\n').format(**data) msg += ('---Lavalink Settings---\n' - 'Cog version: {}\n```'.format(__version__)) + 'Cog version: [{}]\n' + 'External server: [{use_external_lavalink}]```').format(__version__, **global_data) embed = discord.Embed(colour=ctx.guild.me.top_role.colour, description=msg) return await ctx.send(embed=embed) @@ -291,7 +305,7 @@ class Audio: if dj_enabled: if not await self._can_instaskip(ctx, ctx.author): return await self._embed_msg(ctx, 'You need the DJ role to disconnect.') - if not await self._can_instaskip(ctx, ctx.author): + if not await self._can_instaskip(ctx, ctx.author) and not await self._is_alone(ctx, ctx.author): return await self._embed_msg(ctx, 'There are other people listening to music.') else: await lavalink.get_player(ctx.guild.id).stop() @@ -337,7 +351,7 @@ class Audio: dj_enabled = await self.config.guild(ctx.guild).dj_enabled() vote_enabled = await self.config.guild(ctx.guild).vote_enabled() if dj_enabled or vote_enabled: - if not await self._can_instaskip(ctx, ctx.author): + if not await self._can_instaskip(ctx, ctx.author) and not await self._is_alone(ctx, ctx.author): return if player.current: @@ -376,11 +390,11 @@ class Audio: await self._can_instaskip(ctx, ctx.author)): return await self._embed_msg(ctx, 'You must be in the voice channel to pause the music.') if dj_enabled: - if not await self._can_instaskip(ctx, ctx.author): + if not await self._can_instaskip(ctx, ctx.author) and not await self._is_alone(ctx, ctx.author): return await self._embed_msg(ctx, 'You need the DJ role to pause songs.') command = ctx.invoked_with - if player.current and not player.paused and command == 'pause': + if player.current and not player.paused and command != 'resume': await player.pause() embed = discord.Embed( colour=ctx.guild.me.top_role.colour, title='Track Paused', @@ -391,7 +405,7 @@ class Audio: ) return await ctx.send(embed=embed) - if player.paused and command == 'resume': + if player.paused and command != 'pause': await player.pause(False) embed = discord.Embed( colour=ctx.guild.me.top_role.colour, @@ -449,7 +463,7 @@ class Audio: description=queue_user_list) await ctx.send(embed=embed) - @commands.command(aliases=['p']) + @commands.command() async def play(self, ctx, *, query): """Play a URL or search for a song.""" dj_enabled = await self.config.guild(ctx.guild).dj_enabled() @@ -465,8 +479,6 @@ class Audio: if dj_enabled: if not await self._can_instaskip(ctx, ctx.author): return await self._embed_msg(ctx, 'You need the DJ role to queue songs.') - if not await self._currency_check(ctx, jukebox_price): - return player = lavalink.get_player(ctx.guild.id) player.store('channel', ctx.channel.id) player.store('guild', ctx.guild.id) @@ -474,6 +486,8 @@ class Audio: if ((not ctx.author.voice or ctx.author.voice.channel != player.channel) and not await self._can_instaskip(ctx, ctx.author)): return await self._embed_msg(ctx, 'You must be in the voice channel to use the play command.') + if not await self._currency_check(ctx, jukebox_price): + return query = query.strip('<>') if not query.startswith('http'): @@ -485,6 +499,7 @@ class Audio: queue_duration = await self._queue_duration(ctx) queue_total_duration = lavalink.utils.format_time(queue_duration) + before_queue_length = len(player.queue) + 1 if 'list' in query and 'ytsearch:' not in query: for track in tracks: @@ -492,7 +507,7 @@ class Audio: embed = discord.Embed(colour=ctx.guild.me.top_role.colour, title='Playlist Enqueued', description='Added {} tracks to the queue.'.format(len(tracks))) if not shuffle and queue_duration > 0: - embed.set_footer(text='{} until start of playlist playback'.format(queue_total_duration)) + embed.set_footer(text='{} until start of playlist playback: starts at #{} in queue'.format(queue_total_duration, before_queue_length)) if not player.current: await player.play() else: @@ -501,11 +516,181 @@ class Audio: embed = discord.Embed(colour=ctx.guild.me.top_role.colour, title='Track Enqueued', description='**[{}]({})**'.format(single_track.title, single_track.uri)) if not shuffle and queue_duration > 0: - embed.set_footer(text='{} until track playback'.format(queue_total_duration)) + embed.set_footer(text='{} until track playback: #{} in queue'.format(queue_total_duration, before_queue_length)) if not player.current: await player.play() await ctx.send(embed=embed) + @commands.group() + @commands.guild_only() + async def playlist(self, ctx): + """Playlist configuration options.""" + if ctx.invoked_subcommand is None: + await ctx.send_help() + + @playlist.command(name='delete') + async def _playlist_delete(self, ctx, playlist_name): + """Delete a saved playlist.""" + async with self.config.guild(ctx.guild).playlists() as playlists: + try: + if playlists[playlist_name]['author'] != ctx.author.id and not await self._can_instaskip(ctx, ctx.author): + return await self._embed_msg(ctx, 'You are not the author of that playlist.') + del playlists[playlist_name] + except KeyError: + return await self._embed_msg(ctx, 'No playlist with that name.') + await self._embed_msg(ctx, '{} playlist removed.'.format(playlist_name)) + + @playlist.command(name='info') + async def _playlist_info(self, ctx, playlist_name): + """Retrieve information from a saved playlist.""" + playlists = await self.config.guild(ctx.guild).playlists.get_raw() + try: + author_id = playlists[playlist_name]['author'] + except KeyError: + return await self._embed_msg(ctx, 'No playlist with that name.') + author_obj = self.bot.get_user(author_id) + playlist_url = playlists[playlist_name]['playlist_url'] + try: + track_len = len(playlists[playlist_name]['tracks']) + except TypeError: + track_len = 1 + if playlist_url is None: + playlist_url = '**Not generated from a URL.**' + else: + playlist_url = 'URL: <{}>'.format(playlist_url) + embed = discord.Embed(colour=ctx.guild.me.top_role.colour, title='Playlist info for {}:'.format(playlist_name), + description='Author: **{}**\n{}'.format(author_obj, + playlist_url)) + if track_len > 1: + embed.set_footer(text='{} tracks'.format(track_len)) + if track_len == 1: + embed.set_footer(text='{} track'.format(track_len)) + await ctx.send(embed=embed) + + @playlist.command(name='list') + async def _playlist_list(self, ctx): + """List saved playlists.""" + playlists = await self.config.guild(ctx.guild).playlists.get_raw() + playlist_list = [] + for playlist_name in playlists: + playlist_list.append(playlist_name) + abc_names = sorted(playlist_list, key=str.lower) + all_playlists = ', '.join(abc_names) + embed = discord.Embed(colour=ctx.guild.me.top_role.colour, title='Playlists for {}:'.format(ctx.guild.name), + description=all_playlists) + await ctx.send(embed=embed) + + @playlist.command(name='queue') + async def _playlist_queue(self, ctx, playlist_name=None): + """Save the queue to a playlist.""" + dj_enabled = await self.config.guild(ctx.guild).dj_enabled() + if dj_enabled: + if not await self._can_instaskip(ctx, ctx.author): + return await self._embed_msg(ctx, 'You need the DJ role to save playlists.') + async with self.config.guild(ctx.guild).playlists() as playlists: + if playlist_name in playlists: + return await self._embed_msg(ctx, 'Playlist name already exists, try again with a different name.') + if not self._player_check(ctx): + return await self._embed_msg(ctx, 'Nothing playing.') + player = lavalink.get_player(ctx.guild.id) + tracklist = [] + np_song = self._track_creator(ctx, player, 'np', None) + tracklist.append(np_song) + for track in player.queue: + queue_idx = player.queue.index(track) + track_obj = self._track_creator(ctx, player, queue_idx, None) + tracklist.append(track_obj) + if not playlist_name: + await self._embed_msg(ctx, 'Please enter a name for this playlist.') + def check(m): + return m.author == ctx.author + try: + playlist_name_msg = await ctx.bot.wait_for('message', timeout=15.0, check=check) + playlist_name = str(playlist_name_msg.content) + if len(playlist_name) > 20: + return await self._embed_msg(ctx, 'Try the command again with a shorter name.') + if playlist_name in playlists: + return await self._embed_msg(ctx, 'Playlist name already exists, try again with a different name.') + except asyncio.TimeoutError: + return await self._embed_msg(ctx, 'No playlist name entered, try again later.') + + playlist_list = self._to_json(ctx, None, tracklist, playlist_name) + async with self.config.guild(ctx.guild).playlists() as playlists: + playlists[playlist_name] = playlist_list + await self._embed_msg(ctx, 'Playlist {} saved from current queue: {} tracks added.'.format(playlist_name, len(tracklist))) + + @playlist.command(name='save') + async def _playlist_save(self, ctx, playlist_name, playlist_url): + """Save a playlist from a url.""" + if not await self._playlist_check(ctx): + return + player = lavalink.get_player(ctx.guild.id) + tracks = await player.get_tracks(playlist_url) + if not tracks: + return await self._embed_msg(ctx, 'Nothing found.') + tracklist = [] + for track in tracks: + track_obj = self._track_creator(ctx, player, None, track) + tracklist.append(track_obj) + playlist_list = self._to_json(ctx, playlist_url, tracklist, playlist_name) + + async with self.config.guild(ctx.guild).playlists() as playlists: + playlists[playlist_name] = playlist_list + return await self._embed_msg(ctx, 'Playlist {} saved: {} tracks added.'.format(playlist_name, len(tracks))) + + @playlist.command(name='start') + async def _playlist_start(self, ctx, playlist_name=None): + """Load a playlist into the queue.""" + if not await self._playlist_check(ctx): + return + playlists = await self.config.guild(ctx.guild).playlists.get_raw() + try: + author_id = playlists[playlist_name]["author"] + except KeyError: + return await self._embed_msg(ctx, 'That playlist doesn\'t exist.') + author_obj = self.bot.get_user(author_id) + track_count = 0 + try: + playlist_len = len(playlists[playlist_name]["tracks"]) + player = lavalink.get_player(ctx.guild.id) + for track in playlists[playlist_name]["tracks"]: + player.add(author_obj, lavalink.rest_api.Track(data=track)) + track_count = track_count + 1 + embed = discord.Embed(colour=ctx.guild.me.top_role.colour, title='Playlist Enqueued', + description='Added {} tracks to the queue.'.format(track_count)) + await ctx.send(embed=embed) + if not player.current: + await player.play() + except TypeError: + await ctx.invoke(self.play, query=playlists[playlist_name]["playlist_url"]) + + async def _playlist_check(self, ctx): + dj_enabled = await self.config.guild(ctx.guild).dj_enabled() + jukebox_price = await self.config.guild(ctx.guild).jukebox_price() + if dj_enabled: + if not await self._can_instaskip(ctx, ctx.author): + await self._embed_msg(ctx, 'You need the DJ role to use playlists.') + return False + if not self._player_check(ctx): + try: + await lavalink.connect(ctx.author.voice.channel) + player = lavalink.get_player(ctx.guild.id) + player.store('connect', datetime.datetime.utcnow()) + except AttributeError: + await self._embed_msg(ctx, 'Connect to a voice channel first.') + return False + player = lavalink.get_player(ctx.guild.id) + player.store('channel', ctx.channel.id) + player.store('guild', ctx.guild.id) + if ((not ctx.author.voice or ctx.author.voice.channel != player.channel) and not + await self._can_instaskip(ctx, ctx.author)): + await self._embed_msg(ctx, 'You must be in the voice channel to use the playlist command.') + return False + if not await self._currency_check(ctx, jukebox_price): + return False + await self._data_check(ctx) + return True + @commands.command() async def prev(self, ctx): """Skips to the start of the previously played track.""" @@ -515,7 +700,7 @@ class Audio: player = lavalink.get_player(ctx.guild.id) shuffle = await self.config.guild(ctx.guild).shuffle() if dj_enabled: - if not await self._can_instaskip(ctx, ctx.author): + if not await self._can_instaskip(ctx, ctx.author) and not await self._is_alone(ctx, ctx.author): return await self._embed_msg(ctx, 'You need the DJ role to skip songs.') if ((not ctx.author.voice or ctx.author.voice.channel != player.channel) and not await self._can_instaskip(ctx, ctx.author)): @@ -526,7 +711,7 @@ class Audio: return await self._embed_msg(ctx, 'No previous track.') else: last_track = await player.get_tracks(player.fetch('prev_song')) - player.add(player.fetch('prev_requester').id, last_track[0]) + player.add(player.fetch('prev_requester'), last_track[0]) queue_len = len(player.queue) bump_song = player.queue[-1] player.queue.insert(0, bump_song) @@ -557,7 +742,10 @@ class Audio: end = start + items_per_page queue_list = '' - arrow = await self._draw_time(ctx) + try: + arrow = await self._draw_time(ctx) + except AttributeError: + return await self._embed_msg(ctx, 'There\'s nothing in the queue.') pos = lavalink.utils.format_time(player.position) if player.current.is_stream: @@ -715,7 +903,7 @@ class Audio: queue_duration = await self._queue_duration(ctx) queue_total_duration = lavalink.utils.format_time(queue_duration) if not shuffle and queue_duration > 0: - songembed.set_footer(text='{} until start of search playback'.format(queue_total_duration)) + songembed.set_footer(text='{} until start of search playback: starts at #{} in queue'.format(queue_total_duration, (len(player.queue) + 1))) for track in tracks: player.add(ctx.author, track) if not player.current: @@ -735,7 +923,7 @@ class Audio: queue_duration = await self._queue_duration(ctx) queue_total_duration = lavalink.utils.format_time(queue_duration) if not shuffle and queue_duration > 0: - embed.set_footer(text='{} until track playback'.format(queue_total_duration)) + embed.set_footer(text='{} until track playback: #{} in queue'.format(queue_total_duration, (len(player.queue) + 1))) player.add(ctx.author, search_choice) if not player.current: await player.play() @@ -752,7 +940,7 @@ class Audio: await self._can_instaskip(ctx, ctx.author)): return await self._embed_msg(ctx, 'You must be in the voice channel to use seek.') if dj_enabled: - if not await self._can_instaskip(ctx, ctx.author): + if not await self._can_instaskip(ctx, ctx.author) and not await self._is_alone(ctx, ctx.author): return await self._embed_msg(ctx, 'You need the DJ role to use seek.') if player.current: if player.current.is_stream: @@ -798,7 +986,7 @@ class Audio: dj_enabled = await self.config.guild(ctx.guild).dj_enabled() vote_enabled = await self.config.guild(ctx.guild).vote_enabled() if dj_enabled and not vote_enabled and not await self._can_instaskip(ctx, ctx.author): - if not await self._can_instaskip(ctx, ctx.author): + if not await self._is_alone(ctx, ctx.author): return await self._embed_msg(ctx, 'You need the DJ role to skip songs.') if vote_enabled: if not await self._can_instaskip(ctx, ctx.author): @@ -846,18 +1034,26 @@ class Audio: is_admin = discord.utils.get(ctx.guild.get_member(member.id).roles, id=admin_role) is not None is_mod = discord.utils.get(ctx.guild.get_member(member.id).roles, id=mod_role) is not None is_bot = member.bot is True + + return is_active_dj or is_owner or is_server_owner or is_coowner or is_admin or is_mod or is_bot + + async def _is_alone(self, ctx, member): try: - nonbots = sum(not m.bot for m in ctx.guild.get_member(member.id).voice.channel.members) + user_voice = ctx.guild.get_member(member.id).voice + bot_voice = ctx.guild.get_member(self.bot.user.id).voice + nonbots = sum(not m.bot for m in user_voice.channel.members) + if user_voice.channel != bot_voice.channel: + nonbots = nonbots + 1 except AttributeError: if ctx.guild.get_member(self.bot.user.id).voice is not None: nonbots = sum(not m.bot for m in ctx.guild.get_member(self.bot.user.id).voice.channel.members) if nonbots == 1: nonbots = 2 else: - nonbots = 2 + if ctx.guild.get_member(member.id).voice.channel.members == 1: + nonbots = 1 alone = nonbots <= 1 - - return is_active_dj or is_owner or is_server_owner or is_coowner or is_admin or is_mod or is_bot or alone + return alone async def _has_dj_role(self, ctx, member): dj_role_id = await self.config.guild(ctx.guild).dj_role() @@ -902,7 +1098,7 @@ class Audio: await self._can_instaskip(ctx, ctx.author)): return await self._embed_msg(ctx, 'You must be in the voice channel to stop the music.') if vote_enabled or vote_enabled and dj_enabled: - if not await self._can_instaskip(ctx, ctx.author): + if not await self._can_instaskip(ctx, ctx.author) and not await self._is_alone(ctx, ctx.author): return await self._embed_msg(ctx, 'There are other people listening - vote to skip instead.') if dj_enabled and not vote_enabled: if not await self._can_instaskip(ctx, ctx.author): @@ -956,29 +1152,73 @@ class Audio: if ctx.invoked_subcommand is None: await ctx.send_help() + @llsetup.command() + async def external(self, ctx): + """Toggles using external lavalink servers.""" + external = await self.config.use_external_lavalink() + await self.config.use_external_lavalink.set(not external) + if external: + await self.config.host.set('localhost') + await self.config.password.set('youshallnotpass') + await self.config.rest_port.set(2333) + await self.config.ws_port.set(2332) + embed = discord.Embed(colour=ctx.guild.me.top_role.colour, title='External lavalink server: {}.'.format(not external)) + embed.set_footer(text='Defaults reset.') + return await ctx.send(embed=embed) + else: + await self._embed_msg(ctx, 'External lavalink server: {}.'.format(not external)) + @llsetup.command() async def host(self, ctx, host): """Set the lavalink server host.""" await self.config.host.set(host) - await self._embed_msg(ctx, 'Host set to {}.'.format(host)) + if await self._check_external(): + embed = discord.Embed(colour=ctx.guild.me.top_role.colour, title='Host set to {}.'.format(host)) + embed.set_footer(text='External lavalink server set to True.') + await ctx.send(embed=embed) + else: + await self._embed_msg(ctx, 'Host set to {}.'.format(host)) @llsetup.command() - async def password(self, ctx, passw): + async def password(self, ctx, password): """Set the lavalink server password.""" - await self.config.passw.set(str(passw)) - await self._embed_msg(ctx, 'Server password set to {}.'.format(passw)) + await self.config.password.set(str(password)) + if await self._check_external(): + embed = discord.Embed(colour=ctx.guild.me.top_role.colour, title='Server password set to {}.'.format(password)) + embed.set_footer(text='External lavalink server set to True.') + await ctx.send(embed=embed) + else: + await self._embed_msg(ctx, 'Server password set to {}.'.format(password)) @llsetup.command() async def restport(self, ctx, rest_port): """Set the lavalink REST server port.""" - await self.config.rest_port.set(str(rest_port)) - await self._embed_msg(ctx, 'REST port set to {}.'.format(rest_port)) + await self.config.rest_port.set(rest_port) + if await self._check_external(): + embed = discord.Embed(colour=ctx.guild.me.top_role.colour, title='REST port set to {}.'.format(rest_port)) + embed.set_footer(text='External lavalink server set to True.') + await ctx.send(embed=embed) + else: + await self._embed_msg(ctx, 'REST port set to {}.'.format(rest_port)) @llsetup.command() - async def wsport(self, ctx, rest_port): + async def wsport(self, ctx, ws_port): """Set the lavalink websocket server port.""" - await self.config.ws_port.set(str(ws_port)) - await self._embed_msg(ctx, 'Websocket port set to {}.'.format(ws_port)) + await self.config.rest_port.set(ws_port) + if await self._check_external(): + embed = discord.Embed(colour=ctx.guild.me.top_role.colour, title='Websocket port set to {}.'.format(ws_port)) + embed.set_footer(text='External lavalink server set to True.') + await ctx.send(embed=embed) + else: + await self._embed_msg(ctx, 'Websocket port set to {}.'.format(ws_port)) + + async def _check_external(self): + external = await self.config.use_external_lavalink() + if not external: + await self.config.use_external_lavalink.set(True) + return True + else: + return False @staticmethod async def _clear_react(message): @@ -1090,6 +1330,30 @@ class Audio: except KeyError: return False + def _to_json(self, ctx, playlist_url, tracklist, playlist_name): + playlist = {"author": ctx.author.id, "playlist_url": playlist_url, "tracks": tracklist} + return playlist + + def _track_creator(self, ctx, player, position, other_track=None): + if position == 'np': + queued_track = player.current + elif position == None: + queued_track = other_track + else: + queued_track = player.queue[position] + track_keys = queued_track._info.keys() + track_values = queued_track._info.values() + track_id = queued_track.track_identifier + track_info = {} + for k, v in zip(track_keys, track_values): + track_info[k] = v + keys = ['track', 'info'] + values = [track_id, track_info] + track_obj = {} + for key, value in zip(keys, values): + track_obj[key] = value + return track_obj + async def on_voice_state_update(self, member, before, after): if after.channel != before.channel: try: