diff --git a/cogs/cogs.py b/cogs/cogs.py index 0001530..2ea5566 100644 --- a/cogs/cogs.py +++ b/cogs/cogs.py @@ -9,14 +9,14 @@ class Cogs(commands.Cog): self.settings = self.bot.settings @commands.group(name='cogs', hidden=True) - @checks.is_owner() + @commands.is_owner() async def _cogs(self, ctx): if ctx.invoked_subcommand is None: await ctx.invoke(self.bot.get_command('help'), ctx.command.qualified_name) @_cogs.command() - @checks.is_owner() + @commands.is_owner() async def load(self, ctx, *, module): """Loads a module.""" try: @@ -26,7 +26,7 @@ class Cogs(commands.Cog): await ctx.send(f'```py\n{traceback.format_exc(e)}\n```') @_cogs.command() - @checks.is_owner() + @commands.is_owner() async def unload(self, ctx, *, module): """Unloads a module.""" try: @@ -36,7 +36,7 @@ class Cogs(commands.Cog): await ctx.send(f'```py\n{traceback.format_exc()}\n```') @_cogs.command(name='reload') - @checks.is_owner() + @commands.is_owner() async def _reload(self, ctx, *, module): """Reloads a module.""" try: @@ -47,7 +47,7 @@ class Cogs(commands.Cog): await ctx.send(f'```py\n{traceback.format_exc()}\n```') @_cogs.command(name='reloadall') - @checks.is_owner() + @commands.is_owner() async def _relaod_all(self, ctx): """Reloads all extensions""" try: @@ -61,7 +61,7 @@ class Cogs(commands.Cog): await ctx.send(f'```py\n{traceback.format_exc()}\n```') @_cogs.command(name='shutdown') - @checks.is_owner() + @commands.is_owner() async def _shutdown(self, ctx): """Logs out and stops.""" self.bot.lavalink.players.clear() diff --git a/cogs/misc.py b/cogs/misc.py index f641e27..d879f7c 100644 --- a/cogs/misc.py +++ b/cogs/misc.py @@ -31,7 +31,7 @@ class Misc(commands.Cog): await ctx.send(f'{days}d {hours}h {minutes}m {seconds}s') @commands.command(name='guilds') - @checks.is_owner() + @commands.is_owner() async def _guilds(self, ctx): guilds = f"{self.bot.user.name} is in:\n" for guild in self.bot.guilds: @@ -41,7 +41,7 @@ class Misc(commands.Cog): @commands.command() async def musicinfo(self, ctx): """ - Info om musikkspilleren + Info about the music player """ embed = discord.Embed(title='{music.title}', color=ctx.me.color) lavalink = self.bot.lavalink @@ -56,14 +56,14 @@ class Misc(commands.Cog): await ctx.send(embed=embed) @commands.command(name="reloadlocale") - @checks.is_owner() + @commands.is_owner() async def reload_locale(self, ctx): self.bot.localizer.index_localizations() self.bot.localizer.load_localizations() await ctx.send("Localizations reloaded.") @commands.command(name="reloadalias") - @checks.is_owner() + @commands.is_owner() async def reload_alias(self, ctx): self.bot.aliaser.index_localizations() self.bot.aliaser.load_localizations() @@ -72,7 +72,7 @@ class Misc(commands.Cog): @commands.command() async def info(self, ctx): """ - Info om Shite Music Bot + Info about the bot """ membercount = [] for guild in self.bot.guilds: diff --git a/cogs/music.py b/cogs/music.py index 556cb2f..4817d9d 100644 --- a/cogs/music.py +++ b/cogs/music.py @@ -20,6 +20,7 @@ from .utils import checks, RoxUtils, timeformatter from .utils.mixplayer import MixPlayer from .utils.paginator import QueuePaginator, TextPaginator, Scroller +from .utils.selector import Selector time_rx = re.compile('[0-9]+') url_rx = re.compile('https?:\\/\\/(?:www\\.)?.+') @@ -292,42 +293,91 @@ class Music(commands.Cog): await ctx.send(ctx.localizer.format_str("{shuffle}")) @commands.command(name='move') - async def _move(self, ctx, from_pos: int, to_pos: int): + async def _move(self, ctx): """ Moves a song in your queue. """ player = self.bot.lavalink.player_manager.get(ctx.guild.id) - user_queue = player.user_queue(ctx.author.id) - if not user_queue: - return await ctx.send(ctx.localizer.format_str("{my_queue}")) - - if not all(x in range(1, len(user_queue) + 1) for x in [from_pos, to_pos]): - return await ctx.send(ctx.localizer.format_str("{out_of_range}", _len=len(user_queue))) - - moved = player.move_user_track(ctx.author.id, from_pos - 1, to_pos - 1) - if moved is None: - return await ctx.send(ctx.localizer.format_str("{move.not_in_queue}")) - - msg = ctx.localizer.format_str("{move.moved_to}", _title=moved.title, _from=from_pos, _to=to_pos) - await ctx.send(msg) - - @commands.command(name='remove') - async def _remove(self, ctx, pos: int): - """ Removes an item from the player's queue with the given pos. """ - player = self.bot.lavalink.player_manager.get(ctx.guild.id) - if player.queue.empty: return - user_queue = player.user_queue(ctx.author.id) + user_queue = player.user_queue(ctx.author.id, dual=True) if not user_queue: + return await ctx.send(ctx.localizer.format_str("{my_queue}")) + # Setup for selector + identifiers = [] + for index, temp in enumerate(user_queue): + track, globpos = temp + identifiers.append(ctx.localizer.format_str("{queue.usertrack}", _index=index+1, + _globalindex=globpos+1, _title=track.title, + _uri=track.uri)) + + def ret_first_arg(*args): + return args[0] + + functions = [ret_first_arg]*len(user_queue) + arguments = [(i,) for i in range(len(user_queue))] + + # Set the different titles for the different selections + round_titles = ["{moved.choose_song}","{moved.choose_pos}"] + round_titles = [ctx.localizer.format_str(i) for i in round_titles] + + selector = Selector(ctx, identifiers, functions, arguments, num_selections=5, round_titles=round_titles, + color=ctx.me.color, title=ctx.localizer.format_str("{moved.choose}")) + + # Get the initial and final position + message, page, selections = await selector.start_scrolling() + pos_initial, pos_final = selections[0], selections[1] + + # Move the track + moved = player.move_user_track(ctx.author.id, pos_initial, pos_final) + + # Create a nice embed explaining what happened + song = f'**[{moved.title}]({moved.uri})**' + embed = discord.Embed(color=ctx.me.color, description=song, title='{moved.moved}') + thumbnail_url = await RoxUtils.ThumbNailer.identify(self, moved.identifier, moved.uri) + member = ctx.guild.get_member(moved.requester) + if thumbnail_url: + embed.set_thumbnail(url=thumbnail_url) + if member.nick: + member_identifier = member.nick + else: + member_identifier = member.name + + embed.add_field(name="{moved.local}", value=f"`{pos_initial + 1} → {pos_final + 1}`", inline=True) + embed.add_field(name="{moved.global}", value=f"`{player.queue._loc_to_glob(ctx.author.id, pos_initial) + 1}\ + → {player.queue._loc_to_glob(ctx.author.id, pos_final) + 1}`", inline=True) + embed.set_footer(text=f'{{requested_by}} {member_identifier}', icon_url=member.avatar_url) + embed = ctx.localizer.format_embed(embed) + + await message.edit(embed=embed) + await message.clear_reactions() + + @commands.command(name='remove') + async def _remove(self, ctx): + player = self.bot.lavalink.player_manager.get(ctx.guild.id) + + if player.queue.empty: return - if pos > len(user_queue) or pos < 1: - return await ctx.send(ctx.localizer.format_str("{out_of_range}", _len=len(user_queue))) + user_queue = player.user_queue(ctx.author.id, dual=True) + if not user_queue: + return await ctx.send(ctx.localizer.format_str("{my_queue}")) - removed = player.remove_user_track(ctx.author.id, pos - 1) + identifiers = [] + for index, temp in enumerate(user_queue): + track, globpos = temp + identifiers.append(ctx.localizer.format_str("{queue.usertrack}", _index=index+1, + _globalindex=globpos+1, _title=track.title, + _uri=track.uri)) - await ctx.send(ctx.localizer.format_str("{remove}", _title=removed.title)) + functions = [player.remove_user_track]*len(user_queue) + arguments = [(ctx.author.id, i) for i in range(len(user_queue))] + + selector = Selector(ctx, identifiers, functions, arguments, num_selections=5, color=0xFF0000, + title='Remove song plz') + message, page, removed = await selector.start_scrolling() + await message.edit(content=ctx.localizer.format_str("{remove}", _title=removed.title), embed=None) + await message.clear_reactions() @commands.command(name="DJremove") @checks.DJ_or() @@ -382,8 +432,7 @@ class Music(commands.Cog): await ctx.send(embed=embed) @commands.command(name='search') - async def _search(self, ctx, *, query): - """ Lists the first 10 search results from a given query. """ + async def _search(self, ctx, *, query): player = self.bot.lavalink.player_manager.get(ctx.guild.id) if not query.startswith('ytsearch:') and not query.startswith('scsearch:'): @@ -397,80 +446,32 @@ class Music(commands.Cog): return await ctx.send(embed=embed) tracks = results['tracks'] - result_count = min(len(tracks), 5) + result_count = min(len(tracks), 15) + + # Setup for the selector + identifiers = [] + functions = [self.enqueue]*result_count + arguments = [(ctx, tracks[i], discord.Embed(color=ctx.me.color)) for i in range(result_count)] - search_results = '' for index, track in enumerate(tracks, start=1): track_title = track['info']['title'] track_uri = track['info']['uri'] duration = timeformatter.format(int(track['info']['length'])) - search_results += f'`{index}.` [{track_title}]({track_uri}) `{duration}`\n' + identifiers.append(f'`{index}.` [{track_title}]({track_uri}) `{duration}`') if index == result_count: break - choices = [ - ('1\N{combining enclosing keycap}', 1), - ('2\N{combining enclosing keycap}', 2), - ('3\N{combining enclosing keycap}', 3), - ('4\N{combining enclosing keycap}', 4), - ('5\N{combining enclosing keycap}', 5), - ] - choice = None + search_selector = Selector(ctx, identifiers, functions, arguments, num_selections=5, + color=ctx.me.color, title=ctx.localizer.format_str('{results}')) + # Let the user scroll through results + message, current_page, result = await search_selector.start_scrolling() - def check(reaction, user): - if user is None or user.id != ctx.author.id: - return False + result = ctx.localizer.format_embed(result) + await message.edit(embed=result) + await message.clear_reactions() - if reaction.message.id != result_msg.id: - return False - - if reaction.emoji == '❌': - return True - - for (emoji, index) in choices[:result_count]: - if emoji == reaction.emoji: - nonlocal choice - choice = index - return True - return False - - embed.description = '{search}' - embed = ctx.localizer.format_embed(embed) - result_msg = await ctx.send(embed=embed) - - for emoji, index in choices[:result_count]: - await result_msg.add_reaction(emoji) - await result_msg.add_reaction('❌') - - embed.description = search_results - - embed.title = '{results}' - embed.color = ctx.me.color - embed = ctx.localizer.format_embed(embed) - - await result_msg.edit(embed=embed) - - try: - reaction, user = await self.bot.wait_for('reaction_add', timeout=15.0, check=check) - except asyncio.TimeoutError: - await result_msg.clear_reactions() - embed.title = '' - embed.description = '{time_expired}' - embed = ctx.localizer.format_embed(embed) - await result_msg.edit(embed=embed) - await asyncio.sleep(5) - await result_msg.delete() - else: - if choice is None: - await result_msg.delete() - else: - await result_msg.clear_reactions() - track = tracks[choice - 1] - await self.enqueue(ctx, track, embed) - embed = ctx.localizer.format_embed(embed) - await result_msg.edit(embed=embed) - if not player.is_playing: - await player.play() + if not player.is_playing: + await player.play() @commands.command(name='disconnect') @checks.DJ_or(alone=True) @@ -718,7 +719,7 @@ class Music(commands.Cog): # Create returns a player if one exists, otherwise creates. # Add commands that require joining voice to work. - should_connect = ctx.command.callback.__name__ in ('_play', '_find', '_search') + should_connect = ctx.command.callback.__name__ in ('_play', '_find', '_search', '_sk') # Add commands that don't require the you being in voice. without_connect = ctx.command.callback.__name__ in ('_queue', '_history', '_now', '_lyrics') @@ -793,6 +794,8 @@ class Music(commands.Cog): duration = timeformatter.format(int(track.duration)) embed.description = f'[{track.title}]({track.uri})\n**{duration}**' + return embed + def max_track_length(self, guild, player): is_dynamic = self.bot.settings.get(guild, 'duration.is_dynamic', 'default_duration_type') maxlength = self.bot.settings.get(guild, 'duration.max', None) diff --git a/cogs/musicevents.py b/cogs/musicevents.py index 5f5ecc9..a98bf1e 100644 --- a/cogs/musicevents.py +++ b/cogs/musicevents.py @@ -68,6 +68,7 @@ class MusicEvents(commands.Cog): player.update_listeners(member, after) await self.check_leave_voice(member.guild) + async def check_leave_voice(self, guild): """ Checks if the bot should leave the voice channel """ # TODO, disconnect timer? diff --git a/cogs/settings.py b/cogs/settings.py index c2be021..006b77b 100644 --- a/cogs/settings.py +++ b/cogs/settings.py @@ -184,7 +184,6 @@ class Settings(commands.Cog): embed.description = f'Static' await ctx.send(embed=embed) - @checks.is_admin() @commands.guild_only() @_set.command(name='current') async def current_settings(self, ctx): diff --git a/cogs/utils/checks.py b/cogs/utils/checks.py index 70506a5..e58bd27 100644 --- a/cogs/utils/checks.py +++ b/cogs/utils/checks.py @@ -28,22 +28,6 @@ def has_guild_permissions(*, check=all, **perms): return commands.check(pred) -def is_even(): - def predicate(ctx): - return ctx.message.author.id == 142212883512557569 - return commands.check(predicate) - - -def is_owner(): - def predicate(ctx): - is_owner = (ctx.message.author.id == 120970603556503552 or - ctx.message.author.id == 142212883512557569 or - ctx.message.author.id == 212635519706726410 or - ctx.message.author.id == 170506717140877312) - return is_owner - return commands.check(predicate) - - def is_admin(): async def pred(ctx): return await check_guild_permissions(ctx, {'administrator': True}) diff --git a/cogs/utils/selector.py b/cogs/utils/selector.py new file mode 100644 index 0000000..6c322ab --- /dev/null +++ b/cogs/utils/selector.py @@ -0,0 +1,190 @@ +import discord +import asyncio +import itertools +import inspect + +from .paginator import * + + +class Selector(TextPaginator): + def __init__(self, ctx, identifiers, functions, arguments, terminate_on_select=True, num_selections=3, + round_titles=[], max_size=2000, **embed_base): + """ + Selector, tool for making interactive menus. + :param ctx: discord.py context + :param identifiers: A list of strings, one for each menu item + :param functions: List of functions, one for each menu item. Gets run when the corresponding + menu item is selected + :param arguments: List of arguments to pass to the corresponding function in functions. + :param terminate_on_select: + :param num_selections: the number of selectable items per page + :param round_titles: A list of embed titles, updates each time a menu selection is made + :param max_size: The max amount of characters per page. + :param embed_base: Arguments that specify the look of the scroller embeds + """ + self.match = None + self.current_page = 0 + self.ctx = ctx + self.bot = ctx.bot + self.cmdmsg = ctx.message + self.message = ctx.message + self.channel = ctx.channel + self.author = ctx.author + + self.localizer = ctx.localizer + + self.num_selections = num_selections + self.terminate_on_select = terminate_on_select + self.round_titles = round_titles + + assert len(identifiers) == len(functions) == len(arguments) + super().__init__(max_size=max_size, max_lines=num_selections, **embed_base) + + self.selections = list(zip(identifiers, functions, arguments)) + + self.max_selections = min(len(identifiers), num_selections) + for i, selection in enumerate(self.selections): + self.add_line(f"{i%num_selections+1}\N{combining enclosing keycap} {selection[0]}") + self.close_page() + + if len(self.pages) > 1: + self.multipage = True + else: + self.multipage = False + + assert num_selections <= 10 + self.select_emojis = [f"{i+1}\N{combining enclosing keycap}" for i in range(num_selections)] + + self.control_emojis = [ + ('\N{BLACK LEFT-POINTING TRIANGLE}', self.previous_page), + ('\N{BLACK RIGHT-POINTING TRIANGLE}', self.next_page), + ('❌', self.stop_scrolling), + ] + + self.timeout = 30 + self.args = None + self.control_input = False + self.scrolling = True + self.stopped = False + + self.add_page_indicator(self.localizer, "{queue.pageindicator}") + if self.round_titles: + self.update_embed_title(self.round_titles.pop(0)) + + async def scroll(self, page): + if page < 0 or page >= len(self.pages): + return + self.current_page = page + await self.message.edit(embed=self.pages[page]) + + async def next_page(self): + await self.scroll(self.current_page + 1) + + async def previous_page(self): + await self.scroll(self.current_page - 1) + + async def stop_scrolling(self): + self.scrolling = False + await self.message.clear_reactions() + + def update_embed_title(self, updated_title): + for page in self.pages: + page.title = updated_title + + def _react_check(self, reaction, user): + if user is None or user.id != self.author.id: + return False + + if reaction.message.id != self.message.id: + return False + + for i, emoji in enumerate(self.select_emojis): + if emoji == reaction.emoji: + choice = i + self.current_page * self.num_selections + if choice >= len(self.selections): + return True + self.match = self.selections[choice][1] + self.args = self.selections[choice][2] + return True + + for (emoji, func) in self.control_emojis: + if reaction.emoji == emoji: + self.match = func + self.control_input = True + return True + return False + + async def send(self): + # No embeds to scroll through + if not self.pages: + return + + self.message = await self.channel.send(embed=self.pages[0]) + + for reaction in self.select_emojis[:self.max_selections]: + if not self.stopped: + await self.message.add_reaction(reaction) + + if self.multipage: + for reaction in list(zip(*self.control_emojis))[0]: + if not self.stopped: + await self.message.add_reaction(reaction) + else: + if not self.stopped: + await self.message.add_reaction('❌') + + async def start_scrolling(self): + result = None + if not self.scrolling: + await self.send() + else: + self.bot.loop.create_task(self.send()) + + current_round = 0 + results = [] + while self.scrolling: + try: + reaction, user = await self.bot.wait_for('reaction_add', check=self._react_check, timeout=self.timeout) + except asyncio.TimeoutError: + self.scrolling = False + try: + await self.message.clear_reactions() + except: + pass + finally: + break + + try: + await self.message.remove_reaction(reaction, user) + except: + pass + if self.match: + if inspect.iscoroutinefunction(self.match): + if self.args: + result = await self.match(*self.args) + else: + result = await self.match() + else: + if self.args: + result = self.match(*self.args) + else: + result = self.match() + else: + continue + + if self.terminate_on_select and not self.control_input: + current_round += 1 + results.append(result) + if not self.round_titles: + self.stopped = True + await self.message.clear_reactions() + break + if self.round_titles: + self.update_embed_title(self.round_titles.pop(0)) + await self.scroll(self.current_page) + self.args = None + self.control_input = False + if len(results) == 1: + return self.message, self.pages[self.current_page], results[0] + else: + return self.message, self.pages[self.current_page], results diff --git a/localization/en_en/music.yaml b/localization/en_en/music.yaml index 8c87378..2afafbb 100644 --- a/localization/en_en/music.yaml +++ b/localization/en_en/music.yaml @@ -32,9 +32,12 @@ resume: paused: "⏯ | Paused" shuffle: Your queue has been shuffled out_of_range: Positions must be between 1 and {_len} -move: - not_in_queue: Check that the positions exist in your queue - moved_to: "{_title} moved from position {_from} to {_to} in your queue" +moved: + choose_song: "**Select the song to move**" + choose_pos: "**Select where to move it**" + moved: "**Song moved**" + local: "User queue:" + global: "Server queue:" remove: "**{_title}** removed." dj_removed: "**{_title}** queued by {_user} removed." dj_remove_user: Removed all songs queued by <@{_id}>.