From 6b955b8f5f9e365dcd66bf60361fee09d5131757 Mon Sep 17 00:00:00 2001 From: Ev-1 <33662061+Ev-1@users.noreply.github.com> Date: Sat, 1 Oct 2022 15:19:06 +0200 Subject: [PATCH] Bug fixes for selector and scroller --- musicbot/cogs/helpformatter/help.py | 9 +- musicbot/cogs/music/__init__.py | 227 +++++++++++++-------- musicbot/utils/mixplayer/mixqueue.py | 5 +- musicbot/utils/mixplayer/player.py | 5 +- musicbot/utils/userinteraction/scroller.py | 55 +++-- musicbot/utils/userinteraction/selector.py | 38 ++-- 6 files changed, 206 insertions(+), 133 deletions(-) diff --git a/musicbot/cogs/helpformatter/help.py b/musicbot/cogs/helpformatter/help.py index d7a44d0..86cbf95 100644 --- a/musicbot/cogs/helpformatter/help.py +++ b/musicbot/cogs/helpformatter/help.py @@ -1,9 +1,7 @@ # Discord Packages from discord.ext import commands -from musicbot.utils.userinteraction.scroller import ScrollClearSettings - -from ...utils.userinteraction import Scroller +from ...utils.userinteraction.scroller import Scroller, ScrollClear from .helpformatter import coghelper, commandhelper, helper, prefix_cleaner @@ -34,6 +32,5 @@ class Help(commands.Cog): else: paginator = await coghelper(ctx, thing) - scroller = Scroller(ctx, paginator, - ScrollClearSettings.OnTimeout | ScrollClearSettings.OnInteractionExit) - await scroller.start_scrolling() + scroller = Scroller(ctx, paginator) + await scroller.start_scrolling(ScrollClear.OnTimeout | ScrollClear.OnInteractionExit) diff --git a/musicbot/cogs/music/__init__.py b/musicbot/cogs/music/__init__.py index 6879dd1..8fc5a1f 100644 --- a/musicbot/cogs/music/__init__.py +++ b/musicbot/cogs/music/__init__.py @@ -1,4 +1,5 @@ # Discord Packages +from os import remove import discord import lavalink import asyncio @@ -7,8 +8,9 @@ from discord.ext import commands, tasks import re from bs4 import BeautifulSoup +from musicbot.utils.mixplayer.player import MixPlayer -from musicbot.utils.userinteraction.scroller import ScrollClearSettings +from musicbot.utils.userinteraction.scroller import ScrollClear from ...utils import checks, thumbnailer, timeformatter from ...utils.userinteraction import QueuePaginator, Scroller, Selector, TextPaginator @@ -164,8 +166,7 @@ class Music(commands.Cog): if not player.is_playing: await player.play() - - + @commands.command(name='seek') @checks.dj_or(alone=True, track_requester=True) @require_voice_connection() @@ -229,33 +230,29 @@ class Music(commands.Cog): embed = discord.Embed(color=ctx.me.color, title='{stop}') await ctx.send(embed=ctx.localizer.format_embed(embed)) - - + @commands.command(name='now') @require_playing() async def _now(self, ctx): embed = self.get_current_song_embed(ctx, include_time=True) embed = ctx.localizer.format_embed(embed) await ctx.send(embed=embed) - - + @commands.command(name='queue') @require_queue(require_member_queue=True) async def _queue(self, ctx, *, member: discord.Member = None): """ Shows the player's queue. """ player = self.bot.lavalink.player_manager.get(ctx.guild.id) pagified_queue = QueuePaginator(ctx.localizer, player, color=ctx.me.color, member=member) - scroller = Scroller(ctx, pagified_queue, ScrollClearSettings.OnInteractionExit | ScrollClearSettings.OnTimeout) - await scroller.start_scrolling() - - + scroller = Scroller(ctx, pagified_queue) + await scroller.start_scrolling(ScrollClear.OnInteractionExit | ScrollClear.OnTimeout) + @commands.command(name='myqueue') @require_queue(require_author_queue=True) async def _myqueue(self, ctx): """ Shows your queue. """ await self._queue(ctx, member=ctx.author) - - + @commands.command(name='pause') @checks.dj_or(alone=True) @require_voice_connection() @@ -340,26 +337,55 @@ class Music(commands.Cog): @require_voice_connection() @require_queue(require_author_queue=True) async def _remove(self, ctx): - player = self.bot.lavalink.player_manager.get(ctx.guild.id) + player: MixPlayer = self.bot.lavalink.player_manager.get(ctx.guild.id) user_queue = player.user_queue(ctx.author.id, dual=True) - functions = [player.remove_user_track]*len(user_queue) - arguments = [(ctx.author.id, i) for i in range(len(user_queue))] - 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)) + tracks_to_remove = [] + async def update_remove_list(tracks_list, track): + if track in tracks_list: + tracks_list.remove(track) + else: + tracks_list.append(track) - selector = Selector(ctx, identifiers, functions, arguments, num_selections=5, color=0xFF0000, - title='Remove song plz') - message, _, removed = await selector.start_scrolling() - if removed: - await message.edit(content=ctx.localizer.format_str("{remove}", _title=removed.title), embed=None) - else: - await message.delete() + # The callback from the selector takes a discord interaction and + # the button class. We wrap the track remove callback to handle those + # arguments and update button color + def add_change_button_color(func, *args): + async def inner(_, button: SelectorButton): + if button.style == discord.ButtonStyle.red: + button.style = discord.ButtonStyle.gray + else: + button.style = discord.ButtonStyle.red + return await func(*args) + return inner + + selector_buttons = [] + for index, (track, _) in enumerate(user_queue, start=1): + selector_buttons.append( + SelectorItem(f'`{index}` [{track.title}]({track.uri})`', str(index), + add_change_button_color(update_remove_list, tracks_to_remove, track))) + + remove_selector = Selector2(ctx, selector_buttons, terminate_on_select=False, check_for_stop=True, + color=ctx.me.color, title='Select songs to remove') + await remove_selector.start_scrolling(ScrollClear.OnTimeout | ScrollClear.OnInteractionExit) + + # If any tracks were removed create a scroller for navigating them + if tracks_to_remove: + paginator = TextPaginator(color=ctx.me.color, title="Removed") + + # If the user spent a long time selecting songs the queue might have changed + user_queue = player.user_queue(ctx.author.id, dual=False) + track_indexes_to_remove = [i for i, track in enumerate(user_queue) if track in tracks_to_remove] + + # Sort in reverse order since songs shift index when deleting + for track in sorted(track_indexes_to_remove, reverse=True): + if removed_track := player.remove_user_track(ctx.author.id, track): + paginator.add_line(f"{removed_track.title}") + + paginator.close_page() + scroller = Scroller(ctx, paginator) + await scroller.start_scrolling(ScrollClear.OnInteractionExit | ScrollClear.OnTimeout) @commands.command(name="DJremove") @checks.dj_or() @@ -398,39 +424,6 @@ class Music(commands.Cog): await ctx.send(embed=embed) - @commands.command(name='search2') - @require_voice_connection(should_connect=True) - async def _search2(self, ctx, *, query): - player = self.bot.lavalink.player_manager.get(ctx.guild.id) - - if not query.startswith('ytsearch:') and not query.startswith('scsearch:'): - query = 'ytsearch:' + query - - results = await player.node.get_tracks(query) - - embed = discord.Embed(description='{nothing_found}', color=0x36393F) - if not results or not results['tracks']: - embed = ctx.localizer.format_embed(embed) - return await ctx.send(embed=embed) - - buttons = [] - for track in results['tracks']: - duration = timeformatter.format_ms(int(track.duration)) - interaction = SelectorItem(f'[{track.title}]({track.uri}) `{duration}`', - wrap_in_button_callback(self.enqueue, ctx, track, discord.Embed(color=ctx.me.color))) - buttons.append(interaction) - - search_selector = Selector2(ctx, buttons, color=ctx.me.color, title=ctx.localizer.format_str('{results}')) - message, callback_results = await search_selector.start_scrolling() - - song_embed, song_added = callback_results[0] - if song_added: - embed = ctx.localizer.format_embed(song_embed) - await message.edit(embed=embed, view=None) - - if not player.is_playing: - await player.play() - @commands.command(name='search') @require_voice_connection(should_connect=True) async def _search(self, ctx, *, query): @@ -446,34 +439,27 @@ class Music(commands.Cog): embed = ctx.localizer.format_embed(embed) return await ctx.send(embed=embed) - tracks = results['tracks'] - 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)] - - for index, track in enumerate(tracks, start=1): + buttons = [] + for i, track in enumerate(results['tracks'], start=1): duration = timeformatter.format_ms(int(track.duration)) - identifiers.append(f'`{index}.` [{track.title}]({track.uri}) `{duration}`') - if index == result_count: - break + interaction = SelectorItem(f'`{i}` [{track.title}]({track.uri}) `{duration}`', str(i), + wrap_in_button_callback( + self.enqueue, ctx, track, discord.Embed(color=ctx.me.color))) + buttons.append(interaction) + + search_selector = Selector2(ctx, buttons, color=ctx.me.color, title=ctx.localizer.format_str('{results}')) + message, callback_results = await search_selector.start_scrolling() - 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() + if len(callback_results) == 0: + return await message.delete() - if result: - (embed, added) = result - embed = ctx.localizer.format_embed(embed) - await message.edit(embed=embed) + song_embed, song_added = callback_results[0] + if song_added: + embed = ctx.localizer.format_embed(song_embed) + await message.edit(embed=embed, view=None) if not player.is_playing: await player.play() - else: - await message.delete() @commands.command(name='disconnect') @require_voice_connection() @@ -703,7 +689,78 @@ class Music(commands.Cog): await ctx.send(embed=page) else: paginator.add_page_indicator(ctx.localizer) - await Scroller(ctx, paginator).start_scrolling() + await Scroller(ctx, paginator).start_scrolling(ScrollClear.OnTimeout | ScrollClear.OnInteractionExit) + + @commands.command(name='scrub2') + @checks.dj_or(alone=True) + @require_voice_connection() + @require_playing(require_user_listening=True) + async def _scrub2(self, ctx): + """ Lists the first 10 search results from a given query. """ + player: MixPlayer = self.bot.lavalink.player_manager.get(ctx.guild.id) + + controls = '{scrub.controls}' + + scrubber = [ + ('\N{BLACK LEFT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}', player.seek, -1000), + ('\N{BLACK LEFT-POINTING DOUBLE TRIANGLE}', player.seek, -15), + ('\N{DOUBLE VERTICAL BAR}', player.set_pause, True), + ('\N{BLACK RIGHT-POINTING TRIANGLE}', player.set_pause, False), + ('\N{BLACK RIGHT-POINTING DOUBLE TRIANGLE}', player.seek, 15), + ('\N{BLACK RIGHT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}', player.seek, 1000), + ] + + selection = None + arg = None + + def check(reaction, user): + if user is None or user.id != ctx.author.id: + return False + + if reaction.message.id != msg.id: + return False + + for (emoji, func, _arg) in scrubber: + if emoji == reaction.emoji: + nonlocal selection + nonlocal arg + selection = func + arg = _arg + return True + return False + + embed = discord.Embed(description='{scrub.add}', color=ctx.me.color) + embed = ctx.localizer.format_embed(embed) + msg = await ctx.send(embed=embed) + + for (emoji, _, _) in scrubber: + await msg.add_reaction(emoji) + + embed.description = controls + embed = ctx.localizer.format_embed(embed) + await msg.edit(embed=embed) + scrubbing = True + while scrubbing: + try: + reaction, user = await self.bot.wait_for('reaction_add', timeout=15.0, check=check) + except asyncio.TimeoutError: + # PEP8EDIT scrolling = False + try: + await msg.delete() + await ctx.message.delete() + except (discord.Forbidden, discord.HTTPException): + pass + finally: + break + + try: + await msg.remove_reaction(reaction, user) + except discord.Forbidden: + pass + if selection is not None: + if not isinstance(arg, bool): + arg = player.position + arg * 1000 + await selection(arg) @commands.command(name='scrub') @checks.dj_or(alone=True) diff --git a/musicbot/utils/mixplayer/mixqueue.py b/musicbot/utils/mixplayer/mixqueue.py index bb64866..eb6e683 100644 --- a/musicbot/utils/mixplayer/mixqueue.py +++ b/musicbot/utils/mixplayer/mixqueue.py @@ -4,6 +4,7 @@ from lavalink import AudioTrack from collections import OrderedDict, deque from itertools import chain, cycle, islice from random import shuffle +from typing import Union, List def roundrobin(*iterables): @@ -22,7 +23,7 @@ def roundrobin(*iterables): class MixQueue: def __init__(self): - self.queues = OrderedDict() + self.queues: OrderedDict[int, List[AudioTrack]] = OrderedDict() self.priority_queue = [] self._history = deque(maxlen=11) # 10 + current self.looping = False @@ -131,7 +132,7 @@ class MixQueue: else: self.queues.pop(requester) - def remove_user_track(self, requester: int, pos: int): + def remove_user_track(self, requester: int, pos: int) -> Union[None, AudioTrack]: user_queue = self.queues.get(requester) if user_queue is not None: if pos < len(user_queue): diff --git a/musicbot/utils/mixplayer/player.py b/musicbot/utils/mixplayer/player.py index 8bf336a..3ab7e92 100644 --- a/musicbot/utils/mixplayer/player.py +++ b/musicbot/utils/mixplayer/player.py @@ -5,6 +5,7 @@ from lavalink import AudioTrack, DefaultPlayer, Node from lavalink.events import QueueEndEvent, TrackEndEvent, TrackExceptionEvent, TrackStartEvent, TrackStuckEvent import typing +from typing import Union from .mixqueue import MixQueue @@ -13,7 +14,7 @@ class MixPlayer(DefaultPlayer): def __init__(self, guild_id: int, node: Node): super().__init__(guild_id, node) - self.queue = MixQueue() + self.queue: MixQueue = MixQueue() self.listeners = set() self.voteables = {} @@ -35,7 +36,7 @@ class MixPlayer(DefaultPlayer): def remove_user_queue(self, requester: int): self.queue.remove_user_queue(requester) - def remove_user_track(self, requester: int, pos: int): + def remove_user_track(self, requester: int, pos: int) -> Union[None, AudioTrack]: """ Removes the song at from the queue of requester """ return self.queue.remove_user_track(requester, pos) diff --git a/musicbot/utils/userinteraction/scroller.py b/musicbot/utils/userinteraction/scroller.py index f1b509a..198befa 100644 --- a/musicbot/utils/userinteraction/scroller.py +++ b/musicbot/utils/userinteraction/scroller.py @@ -4,8 +4,9 @@ from enum import Flag, auto from lavalink.client import asyncio from .paginators import BasePaginator, CantScrollException +from typing import Union -class ScrollClearSettings(Flag): +class ScrollClear(Flag): OnTimeout = auto() OnInteractionExit = auto() @@ -23,14 +24,11 @@ class ScrollerNav(discord.ui.Select): class Scroller: - def __init__(self, ctx, paginator, clear_mode: ScrollClearSettings, timeout=20.0, - with_nav_bar: bool = True): + def __init__(self, ctx, paginator, timeout=20.0, check_for_stop: bool = False): if not isinstance(paginator, BasePaginator): raise TypeError('Paginator needs to be a subclass of BasePaginator.') - self.clear_mode = clear_mode - self.use_nav_bar = with_nav_bar self.paginator = paginator self.ctx = ctx @@ -40,21 +38,23 @@ class Scroller: self.bot = ctx.bot self.channel = ctx.channel - self.message = None + self.message: Union[discord.Message, None] = None # Initialize the view we'll be using for scrolling self.view = discord.ui.View(timeout=timeout) self.view.on_timeout = self.on_timeout self.is_scrolling_paginator = len(self.paginator.pages) > 1 + self.use_nav_bar = len(self.paginator.pages) > 3 self.scrolling_done = asyncio.Event() + stop_emoji = '✔️' if check_for_stop else '❌' self.interaction_mapping = [ ('\N{BLACK LEFT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}', self.first_page), ('\N{BLACK LEFT-POINTING TRIANGLE}', self.previous_page), ('\N{BLACK RIGHT-POINTING TRIANGLE}', self.next_page), ('\N{BLACK RIGHT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}', self.last_page), - ('❌', self.stop), + (stop_emoji, self.stop), ] bot_user = ctx.guild if ctx.guild.me is not None else ctx.bot.user @@ -66,22 +66,22 @@ class Scroller: if not self.permissions.send_messages: raise CantScrollException('Bot cannot send messages.') - async def start_scrolling(self) -> discord.Message: + async def start_scrolling(self, clear_mode: ScrollClear) -> discord.Message: + self.clear_mode = clear_mode self.current_page_number = 0 self.build_view() - - if not self.is_scrolling_paginator: - return await self.channel.send(embed=self.paginator.pages[0]) - self.message = await self.channel.send(embed=self.paginator.pages[0], view=self.view) await self.scrolling_done.wait() - return self.message def update_view(self, interaction: discord.Interaction): - self.navigator.placeholder = f"Page: {self.current_page_number + 1}/{len(self.paginator.pages)}" + if self.is_scrolling_paginator and self.use_nav_bar: + self.navigator.placeholder = f"Page: {self.current_page_number + 1}/{len(self.paginator.pages)}" def build_view(self): + if not self.is_scrolling_paginator: + return + for (reaction, callback) in self.interaction_mapping: self.view.add_item(item=ScrollerButton(callback=callback, label=reaction, row=3)) @@ -95,13 +95,19 @@ class Scroller: self.is_scrolling_paginator = False self.view.stop() self.view.clear_items() - if (user_stopped and self.clear_mode & ScrollClearSettings.OnInteractionExit or - not user_stopped and self.clear_mode & ScrollClearSettings.OnTimeout): - await self.message.delete() - self.message = None - await self.ctx.message.delete() + if (user_stopped and self.clear_mode & ScrollClear.OnInteractionExit or + not user_stopped and self.clear_mode & ScrollClear.OnTimeout): + if self.message: + await self.message.delete() + self.message = None + # If this fails the message is not accessable either way, which + # means it is probably deleted + try: + await self.ctx.message.delete() + except: + pass else: - await self.message.edit(embed=self.paginator.pages[self.current_page_number]) + await self.update_message() # Notify the start_scrolling function that we're done self.scrolling_done.set() @@ -117,9 +123,13 @@ class Scroller: if interaction.message: # Update the view before we edit the message self.update_view(interaction) - await interaction.message.edit(embed=self.paginator.pages[page], view=self.view) + await self.update_message() await interaction.response.defer() + async def update_message(self): + if self.message: + await self.message.edit(embed=self.paginator.pages[self.current_page_number], view=self.view) + async def first_page(self, interaction: discord.Interaction): await self._scroll(0, interaction) @@ -135,7 +145,8 @@ class Scroller: async def navigate(self, interaction: discord.Interaction): await self._scroll(int(self.navigator.values[0]), interaction) - async def stop_scrolling(self, _: discord.Interaction): + async def stop_scrolling(self, interaction: discord.Interaction): + await interaction.response.defer() await self.stop(user_stopped=True) async def on_timeout(self): diff --git a/musicbot/utils/userinteraction/selector.py b/musicbot/utils/userinteraction/selector.py index 468d468..487146b 100644 --- a/musicbot/utils/userinteraction/selector.py +++ b/musicbot/utils/userinteraction/selector.py @@ -6,8 +6,10 @@ import asyncio import inspect from typing import List, Callable, Coroutine +from discord.ext.commands import check + from .paginators import TextPaginator -from .scroller import ScrollClearSettings, Scroller +from .scroller import ScrollClear, Scroller class SelectorButton(discord.ui.Button): @@ -16,8 +18,8 @@ class SelectorButton(discord.ui.Button): super().__init__(label=label, style=style, **kwargs) # The callback we need to supply to disord.py takes only a discord.Interaction - # we want to have access to the button that caused that was clicked - # so we can update it, i.e. change color + # we want to have access to the button that was clicked (self) + # so we can update or modify it upon it being pressed i.e. change color. def add_button_to_callback(func): async def wrapped_callback(interaction: discord.Interaction): await func(interaction, self) @@ -27,13 +29,14 @@ class SelectorButton(discord.ui.Button): class SelectorItem: - def __init__(self, identifier, callback: Callable[[discord.Interaction, SelectorButton], Coroutine]): + def __init__(self, identifier, button_label, callback: Callable[[discord.Interaction, SelectorButton], Coroutine]): + self.button_label = button_label self.identifier = identifier self.callback = callback class Selector2(TextPaginator, Scroller): - def __init__(self, ctx, choices: List[SelectorItem], round_titles=None, + def __init__(self, ctx, choices: List[SelectorItem], round_titles=None, check_for_stop: bool = False, terminate_on_select: bool = True, max_size=2000, **embed_base): self.match = None if round_titles is None: @@ -42,16 +45,15 @@ class Selector2(TextPaginator, Scroller): self.terminate_on_select = terminate_on_select self.round_titles = round_titles - self.max_selections_per_page = 5 + self.selections_per_page = 5 self.selections = choices - self.max_selections = min(len(self.selections), self.max_selections_per_page) - TextPaginator.__init__(self, max_size=max_size, max_lines=self.max_selections_per_page, **embed_base) - for index, selection in enumerate(self.selections, start=1): - self.add_line(f"`{index}.` {selection.identifier}") + TextPaginator.__init__(self, max_size=max_size, max_lines=self.selections_per_page, **embed_base) + for selection in self.selections: + self.add_line(selection.identifier) self.close_page() - Scroller.__init__(self, ctx, self, ScrollClearSettings.OnTimeout) + Scroller.__init__(self, ctx, self, check_for_stop=check_for_stop) # Contains a list of all buttons that can be interacted with, both # currently visible and not. @@ -64,6 +66,8 @@ class Selector2(TextPaginator, Scroller): # after we have finished interacting with the scroller self.callback_results = [] + # Wrap the provided so that we can both update the view if needed + # or terminate the selection process upon interaction def with_update_view(func): async def wrapped_callback(interaction: discord.Interaction, button: SelectorButton): result = await func(interaction, button) @@ -72,10 +76,12 @@ class Selector2(TextPaginator, Scroller): await interaction.response.defer() if terminate_on_select: await self.stop(True) + else: + await self.update_message() return wrapped_callback for i, choice in enumerate(self.selections): - self.buttons.append(SelectorButton(label=i+1, callback=with_update_view(choice.callback), row=0)) + self.buttons.append(SelectorButton(label=choice.button_label, callback=with_update_view(choice.callback), row=0)) def build_view(self): super().build_view() @@ -91,15 +97,15 @@ class Selector2(TextPaginator, Scroller): self.visible_buttons = [] self.current_page_number - start = self.current_page_number * self.max_selections_per_page - end = min((self.current_page_number + 1) * self.max_selections_per_page, len(self.selections)) + start = self.current_page_number * self.selections_per_page + end = min((self.current_page_number + 1) * self.selections_per_page, len(self.selections)) for button in self.buttons[start:end]: self.view.add_item(item=button) self.visible_buttons.append(button) - async def start_scrolling(self): - message = await super().start_scrolling() + async def start_scrolling(self, clear_mode: ScrollClear=ScrollClear.OnTimeout): + message = await super().start_scrolling(clear_mode) return message, self.callback_results