diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3ab3efb..cb10b99 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,7 +2,7 @@ name: Test And Build BOT on: [push, pull_request] jobs: - flake8: + lint: runs-on: ubuntu-latest env: CheckFiles: "bot.py musicbot/" @@ -24,14 +24,27 @@ jobs: pip install isort isort --check --sp setup.cfg ${CheckFiles} - - name: Run tests + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - name: Set up Python 3.10 + uses: actions/setup-python@v4.2.0 + with: + python-version: 3.10.7 + + - name: Install dependencies run: | pip install pytest pip install -r requirements.txt + + - name: Run pytest + run: | pytest build-and-push: - needs: flake8 + needs: [lint, test] runs-on: ubuntu-latest if: github.event_name != 'pull_request' && contains(github.ref, 'actions') != 'true' env: diff --git a/bot.py b/bot.py index 70d1351..01d1c4e 100644 --- a/bot.py +++ b/bot.py @@ -1,5 +1,6 @@ # Discord Packages import discord +import lavalink from discord.ext import commands from discord.flags import MemberCacheFlags @@ -8,6 +9,7 @@ import os import time import traceback from argparse import ArgumentParser, RawTextHelpFormatter +from typing import Optional import aiohttp import yaml @@ -35,7 +37,7 @@ def _get_prefix(bot, message): return commands.when_mentioned_or(*prefixes)(bot, message) -class Bot(commands.Bot): +class MusicBot(commands.Bot): def __init__(self, datadir, debug: bool = False): intents = discord.Intents.all() super().__init__(command_prefix=_get_prefix, @@ -55,6 +57,7 @@ class Bot(commands.Bot): self.main_logger = logger self.logger = self.main_logger.bot_logger.getChild("Bot") self.logger.debug("Debug: %s" % debug) + self.lavalink: Optional[lavalink.Client] = None async def on_message(self, message): if message.author.bot: @@ -106,11 +109,11 @@ class Bot(commands.Bot): except Exception as e: tb = e.__traceback__ traceback.print_tb(tb) - self.logger.error(e) + self.logger.exception(e) def run_bot(datadir, debug: bool = False): - bot = Bot(datadir, debug=debug) + bot = MusicBot(datadir, debug=debug) bot.run() diff --git a/localization/en_en/commands.yaml b/localization/en_en/commands.yaml index 4253121..ec1d051 100644 --- a/localization/en_en/commands.yaml +++ b/localization/en_en/commands.yaml @@ -50,11 +50,11 @@ move: description: 'Moves a track in your queue.' remove: aliases: [rem] - args: '' + args: '' description: 'Removes a song from your queue.' DJremove: aliases: [DJremove, djremove] - args: '' + args: '' description: 'Removes a song from the global queue.' removeuser: aliases: [removeuser] diff --git a/localization/en_en/misc.yaml b/localization/en_en/misc.yaml index 221cfac..62869b1 100644 --- a/localization/en_en/misc.yaml +++ b/localization/en_en/misc.yaml @@ -1,12 +1,13 @@ bot: name: What? - infotext: Music bot written for use on /r/Norge's Discord Servers. It's open source! + infotext: + Music bot written for use on /r/Norge's Discord Servers. It's open source! You can find it [HERE]({global.vars.repo}) spectext: |- **Python:** [{_python_v}](https://www.python.org/) - **Discord.py:** [{_discord_v}](https://github.com/Rapptz/discord.py/tree/rewrite) - **Lavalink:** [{_lavalink_v}](https://github.com/Devoxin/Lavalink.py) - **ShiteMusicBot:** {_bot_v} + **Discord.py:** [{_discord_v}](https://github.com/Rapptz/discord.py/releases/tag/v{_discord_v}) + **Lavalink:** [{_lavalink_v}](https://github.com/Devoxin/Lavalink.py/releases/tag/{_lavalink_v}) + **ShiteMusicBot:** [{_bot_v}](https://github.com/r-Norge/ShiteMusicBot) stattext: |- **Guilds:** {_guilds} **Users:** {_members} diff --git a/localization/global/vars.yaml b/localization/global/vars.yaml index 38f94d8..e255353 100644 --- a/localization/global/vars.yaml +++ b/localization/global/vars.yaml @@ -1 +1 @@ -repo: "https://github.com/Ev-1/ShiteMusicBot" +repo: "https://github.com/r-Norge/ShiteMusicBot" diff --git a/localization/nb_no/commands.yaml b/localization/nb_no/commands.yaml index 2f75945..e9612f3 100644 --- a/localization/nb_no/commands.yaml +++ b/localization/nb_no/commands.yaml @@ -54,7 +54,7 @@ remove: description: 'Fjerner en sang fra personlige køen din.' DJremove: aliases: [DJfjern, djfjern] - args: '' + args: '' description: 'Fjerner en sang fra køen.' removeuser: aliases: [fjernbruker] @@ -78,6 +78,10 @@ boost: aliases: [bassforsterker, forsterkbass] args: '' description: 'Skrur av/på bassforsterkeren' +nightcore: + aliases: [natt, nattkjernemodus, natteravn] + args: + description: 'Skrur av/på nattkjernemodus' history: aliases: [historikk, avspillingshistorikk, h] description: 'Viser en liste over de 10 siste avspilte sangene.' @@ -88,6 +92,12 @@ lyrics: scrub: aliases: [kontroller] description: 'Gir deg en kontroller som du kan styre musikken med.' +reconnect: + aliases: [rc] + description: 'Kobler båtten av, så på' +forcedisconnect: + aliases: [fd] + description: 'Tvinger båtten til å koble av' forceplay: aliases: [tvingspill, ts] args: '' # This is just a cosmetic representation of the arguments. @@ -115,7 +125,7 @@ uptime: description: 'Viser hvor lenge båtten har kjørt.' ping: aliases: [ping] - description: 'Sjekker responstiden til båtten.' + description: 'Sjekker responstiden til båtten.' guilds: aliases: [tenarar] description: 'Sender en liste over tenarane som båtten er medlem av' @@ -126,6 +136,24 @@ reloadalias: aliases: [reloadalias] description: 'Laster inn aliaser og hjelp på nytt.' +# NodeManager +node: + aliases: [node] + description: 'Håndterer noder for serveren.' + sub_commands: + add: + aliases: [ny] + description: 'Legger til en ny node.' + list: + aliases: [liste, noder] + description: 'Viser en liste over konfigurerte noder.' + remove: + aliases: [fjern] + description: 'Fjerner noder.' + reload_file: + aliases: [last_fil] + description: 'Leser innstillingsfilen og legger til nye noder.' + # Settings settings: aliases: [innstillinger, oppsett] @@ -200,4 +228,5 @@ cogmanager: description: 'Laser inn alle cogs på nytt.' shutdown: aliases: [shutdown] - description: 'Stopper båtten.' \ No newline at end of file + description: 'Stopper båtten.' + diff --git a/localization/nb_no/misc.yaml b/localization/nb_no/misc.yaml index 73b0765..b0ab0e7 100644 --- a/localization/nb_no/misc.yaml +++ b/localization/nb_no/misc.yaml @@ -1,12 +1,13 @@ bot: name: hva? - infotext: Musikkbot skrevet for bruk på /r/Norge sine Discord Servere. Kildekoden + infotext: + Musikkbot skrevet for bruk på /r/Norge sine Discord Servere. Kildekoden er åpen! Den kan du finne [HER]({global.vars.repo}) spectext: |- **Python:** [{_python_v}](https://www.python.org/) - **Discord.py:** [{_discord_v}](https://github.com/Rapptz/discord.py/tree/rewrite) - **Lavalink:** [{_lavalink_v}](https://github.com/Devoxin/Lavalink.py) - **ShiteMusicBot:** {_bot_v} + **Discord.py:** [{_discord_v}](https://github.com/Rapptz/discord.py/releases/tag/v{_discord_v}) + **Lavalink:** [{_lavalink_v}](https://github.com/Devoxin/Lavalink.py/releases/tag/{_lavalink_v}) + **ShiteMusicBot:** [{_bot_v}](https://github.com/r-Norge/ShiteMusicBot) stattext: |- **Tenarar:** {_guilds} **Brukere:** {_members} diff --git a/localization/nb_no/music.yaml b/localization/nb_no/music.yaml index c888385..4dc2a6e 100644 --- a/localization/nb_no/music.yaml +++ b/localization/nb_no/music.yaml @@ -55,6 +55,9 @@ volume: boost: 'on': Bassforsterkeren er på 'off': Bassforsterkeren er av +nightcore: + 'on': Nattkjernemodus er på + 'off': Nattkjernemodus er av scrub: add: Legger til kontrollere controls: "{music_controls}" diff --git a/localization/nn_no/misc.yaml b/localization/nn_no/misc.yaml index 8d2d0f6..c72904a 100644 --- a/localization/nn_no/misc.yaml +++ b/localization/nn_no/misc.yaml @@ -1,11 +1,13 @@ bot: name: Kva? - infotext: Musikkbot skreve til bruk på /r/Norge sine Discord Serverar. Kjeldekoda + infotext: + Musikkbot skreve til bruk på /r/Norge sine Discord Serverar. Kjeldekoda er ope! Dei kan du finna [HER]({global.vars.repo}) spectext: |- **Python:** [{_python_v}](https://www.python.org/) - **Discord.py:** [{_discord_v}](https://github.com/Rapptz/discord.py/tree/rewrite) - **Lavalink:** [{_lavalink_v}](https://github.com/Devoxin/Lavalink.py) + **Discord.py:** [{_discord_v}](https://github.com/Rapptz/discord.py/releases/tag/v{_discord_v}) + **Lavalink:** [{_lavalink_v}](https://github.com/Devoxin/Lavalink.py/releases/tag/{_lavalink_v}) + **ShiteMusicBot:** [{_bot_v}](https://github.com/r-Norge/ShiteMusicBot) stattext: |- **Tenarar:** {_guilds} **Brukarar:** {_members} diff --git a/localization/we_eb/commands.yaml b/localization/we_eb/commands.yaml index 7007219..0cea22a 100644 --- a/localization/we_eb/commands.yaml +++ b/localization/we_eb/commands.yaml @@ -54,7 +54,7 @@ remove: description: 'Wemoves a sonyg fwom youw queue.' DJremove: aliases: [DJwemove, djwemove] - args: '' + args: '' description: 'Wemoves a sonyg fwom the gwobaw queue.' removeuser: aliases: [wemoveusew] diff --git a/localization/we_eb/misc.yaml b/localization/we_eb/misc.yaml index 21c787e..68eaa0c 100644 --- a/localization/we_eb/misc.yaml +++ b/localization/we_eb/misc.yaml @@ -1,11 +1,13 @@ bot: name: What? - infotext: Music bot wwitteny fow use ony /r/Norges Discord Sewvews. Its openy souwce! + infotext: + Music bot wwitteny fow use ony /r/Norges Discord Sewvews. Its openy souwce! You cany finyd it [HEWE]({global.vars.repo}) spectext: |- **Python:** [{_python_v}](https://www.python.org/) - **Discord.py:** [{_discord_v}](https://github.com/Rapptz/discord.py/tree/rewrite) - **Lavalink:** [{_lavalink_v}](https://github.com/Devoxin/Lavalink.py) + **Discord.py:** [{_discord_v}](https://github.com/Rapptz/discord.py/releases/tag/v{_discord_v}) + **Lavalink:** [{_lavalink_v}](https://github.com/Devoxin/Lavalink.py/releases/tag/{_lavalink_v}) + **ShiteMusicBot:** [{_bot_v}](https://github.com/r-Norge/ShiteMusicBot) stattext: |- **Guiwds:** {_guilds} **Usews:** {_members} diff --git a/musicbot/cogs/cogmanager.py b/musicbot/cogs/cogmanager.py index cd3e0fc..14825be 100644 --- a/musicbot/cogs/cogmanager.py +++ b/musicbot/cogs/cogmanager.py @@ -3,13 +3,15 @@ from discord.ext import commands import traceback +from bot import MusicBot + from ..utils.userinteraction import ClearOn, Scroller from .helpformatter import commandhelper class CogManager(commands.Cog): - def __init__(self, bot): - self.bot = bot + def __init__(self, bot: MusicBot): + self.bot: MusicBot = bot self.settings = self.bot.settings @commands.group(name='cogmanager', hidden=True) @@ -72,7 +74,8 @@ class CogManager(commands.Cog): @commands.is_owner() async def _shutdown(self, ctx): """Logs out and stops.""" - self.bot.lavalink.player_manager.players.clear() + if lavalink := self.bot.lavalink: + lavalink.player_manager.players.clear() await self.bot.close() diff --git a/musicbot/cogs/errors.py b/musicbot/cogs/errors.py index 54b06b6..4098d04 100644 --- a/musicbot/cogs/errors.py +++ b/musicbot/cogs/errors.py @@ -2,16 +2,18 @@ import discord from discord.ext import commands -import traceback +import sys + +from bot import MusicBot from ..utils.userinteraction import ClearOn, Scroller from .helpformatter import commandhelper -from .music.music_errors import WrongTextChannelError, WrongVoiceChannelError +from .music import music_errors class Errors(commands.Cog): - def __init__(self, bot): - self.bot = bot + def __init__(self, bot: MusicBot): + self.bot: MusicBot = bot self.logger = self.bot.main_logger.bot_logger.getChild("Errors") async def base_msg(self, ctx, state: int = 0xFCBA03): @@ -42,53 +44,61 @@ class Errors(commands.Cog): embed = ctx.localizer.format_embed(embed) return await ctx.send(embed=embed) + # User input errors + if isinstance(err, commands.UserInputError): + if self.bot.debug: + self.logger.debug("User input error") + self.logger.debug(err) + return + # Music related errors - if isinstance(err, commands.CommandInvokeError): - if (err.original == 'Join a voicechannel first.'): + if isinstance(err, music_errors.MusicError): + if isinstance(err, music_errors.UserNotConnectedError): return await send_error_embed('{errors.join_voice_first}') - elif (err.original == 'Not connected.'): + elif isinstance(err, music_errors.BotNotConnectedError): return await send_error_embed('{errors.not_connected}') - elif (err.original == 'I need the `CONNECT` and `SPEAK` permissions.'): + elif isinstance(err, music_errors.MissingPermissionsError): return await send_error_embed('{errors.need_permission}') - elif (err.original == 'Not playing'): - return await send_error_embed('{not_playing}') + elif isinstance(err, music_errors.PlayBackStatusError): + if isinstance(err, music_errors.RequirePlayingError): + return await send_error_embed('{not_playing}') + if isinstance(err, music_errors.RequireListeningError): + return await send_error_embed('{have_to_listen}') - elif (err.original == 'Not listening'): - return await send_error_embed('{have_to_listen}') + elif isinstance(err, music_errors.ChannelError): + if isinstance(err, music_errors.VoiceChannelFullError): + return await send_error_embed('{errors.full_channel}') - elif (err.original == 'The channel is currently full'): - return await send_error_embed('{errors.full_channel}') + elif isinstance(err, music_errors.UserInDifferentVoiceChannelError): + return await send_error_embed(err.channels[0].name, title='{errors.my_channel}') - if isinstance(err, WrongVoiceChannelError): - if (err.original.startswith("You need to be in my voice channel")): - return await send_error_embed(err.channels[0].name, title='{errors.my_channel}') + elif isinstance(err, music_errors.ConfiguredChannelsError): + if isinstance(err, music_errors.WrongVoiceChannelError): + changed = False + response = ctx.localizer.format_str('{settings_check.voicechannel}') + for channel_id in err.channels: + channel = ctx.guild.get_channel(channel_id) + if channel is not None: + response += f'{channel.name}, ' + changed = True + if changed: + return await send_error_embed(response[:-2], title='{errors.right_channel}') + else: + return await send_error_embed('{errors.right_channel}') - elif (err.original.startswith('You need to be in the right voice channel')): - changed = False - response = ctx.localizer.format_str('{settings_check.voicechannel}') - for channel_id in err.channels: - channel = ctx.guild.get_channel(channel_id) - if channel is not None: - response += f'{channel.name}, ' - changed = True - if changed: - return await send_error_embed(response[:-2], title='{errors.right_channel}') - else: - return await send_error_embed('{errors.right_channel}') - - if isinstance(err, WrongTextChannelError): - try: - await ctx.message.delete() - except Exception as e: - self.logger.debug("Error deleting message: %s\nTraceback: %s" % (e, err)) - response = ctx.localizer.format_str('{settings_check.textchannel}') - for channel_id in err.channels: - response += f'<#{channel_id}>, ' - msg = await send_error_embed(response[:-2], title='{errors.right_channel}') - return await msg.delete(delay=5) + if isinstance(err, music_errors.WrongTextChannelError): + try: + await ctx.message.delete() + except Exception as e: + self.logger.debug("Error deleting message: %s\nTraceback: %s" % (e, err)) + response = ctx.localizer.format_str('{settings_check.textchannel}') + for channel_id in err.channels: + response += f'<#{channel_id}>, ' + msg = await send_error_embed(response[:-2], title='{errors.right_channel}') + return await msg.delete(delay=5) if isinstance(err, commands.CheckFailure): pass @@ -103,16 +113,15 @@ class Errors(commands.Cog): else: # Log all exceptions if the bot is in debug mode if self.bot.debug: - tb = err.__traceback__ - traceback.print_tb(tb) - self.logger.debug("Error running command: %s\nTraceback: %s" % (ctx.command, err)) + self.logger.error("Got error in command %s" % ctx.command) + self.logger.exception(err.with_traceback(sys.exc_info()[2])) else: to_log = (RuntimeError, commands.CheckFailure, commands.CommandInvokeError, commands.NoPrivateMessage) if isinstance(err, to_log): - self.logger.debug("Error running command: %s\nTraceback: %s" % (ctx.command, err)) + self.logger.info("Error running command: %s\nTraceback: %s" % (ctx.command, err)) async def setup(bot): diff --git a/musicbot/cogs/helpformatter/help.py b/musicbot/cogs/helpformatter/help.py index 11f7c82..328cee7 100644 --- a/musicbot/cogs/helpformatter/help.py +++ b/musicbot/cogs/helpformatter/help.py @@ -1,14 +1,16 @@ # Discord Packages from discord.ext import commands +from bot import MusicBot + from ...utils.userinteraction.scroller import ClearOn, Scroller from .helpformatter import coghelper, commandhelper, helper, prefix_cleaner class Help(commands.Cog): """Help command""" - def __init__(self, bot): - self.bot = bot + def __init__(self, bot: MusicBot): + self.bot: MusicBot = bot @commands.command(hidden=True) async def help(self, ctx): # Takes no args because reasons(using the view directly) diff --git a/musicbot/cogs/misc.py b/musicbot/cogs/misc.py index b2f867b..7624f00 100644 --- a/musicbot/cogs/misc.py +++ b/musicbot/cogs/misc.py @@ -6,12 +6,14 @@ from lavalink import __version__ as LavalinkVersion import platform import time +from bot import MusicBot + from ..utils import bot_version class Misc(commands.Cog): - def __init__(self, bot): - self.bot = bot + def __init__(self, bot: MusicBot): + self.bot: MusicBot = bot def get_uptime(self): now = time.time() @@ -50,14 +52,14 @@ class Misc(commands.Cog): Info about the music player """ embed = discord.Embed(title='{music.title}', color=ctx.me.color) - lavalink = self.bot.lavalink listeners = 0 - for guild, player in lavalink.player_manager.players.items(): - listeners += len(player.listeners) + if lavalink := self.bot.lavalink: + for _, player in lavalink.player_manager.players.items(): + listeners += len(player.listeners) - embed.add_field(name='{music.players}', value=f'{len(lavalink.player_manager.players)}') - embed.add_field(name='{music.listeners}', value=f'{listeners}') + embed.add_field(name='{music.players}', value=f'{len(lavalink.player_manager.players)}') + embed.add_field(name='{music.listeners}', value=f'{listeners}') embed = ctx.localizer.format_embed(embed) await ctx.send(embed=embed) diff --git a/musicbot/cogs/music/__init__.py b/musicbot/cogs/music/__init__.py index 6fcb981..ef75fd4 100644 --- a/musicbot/cogs/music/__init__.py +++ b/musicbot/cogs/music/__init__.py @@ -1,5 +1,7 @@ # Discord Packages import discord +import lavalink +from discord import VoiceChannel from discord.ext import commands, tasks from lavalink.events import ( NodeChangedEvent, NodeConnectedEvent, NodeDisconnectedEvent, PlayerUpdateEvent, QueueEndEvent, TrackEndEvent, @@ -9,32 +11,40 @@ from lavalink.models import AudioTrack import asyncio import json import re -import urllib -from typing import List +import urllib.parse as urlparse +from typing import List, Optional, Tuple from bs4 import BeautifulSoup +from bot import MusicBot + # Bot Utilities from musicbot.utils.mixplayer.player import MixPlayer -from ...utils import checks, thumbnailer, timeformatter +from ...utils import checks, timeformatter +from ...utils.thumbnailer import Thumbnailer from ...utils.userinteraction.paginators import QueuePaginator, TextPaginator from ...utils.userinteraction.scroller import ClearOn, Scroller from ...utils.userinteraction.selector import SelectMode, Selector, SelectorButton, SelectorItem -from .decorators import BasicVoiceClient, require_playing, require_queue, require_voice_connection, voteable -from .music_errors import WrongTextChannelError +from .decorators import require_playing, require_queue, require_voice_connection, voteable +from .music_errors import MusicError, PlayerNotAvailableError, WrongTextChannelError +from .voice_client import BasicVoiceClient time_rx = re.compile('[0-9]+') url_rx = re.compile('https?:\\/\\/(?:www\\.)?.+') class Music(commands.Cog): - def __init__(self, bot): - self.bot = bot + def __init__(self, bot: MusicBot): + self.bot: MusicBot = bot self.logger = self.bot.main_logger.bot_logger.getChild("Music") - self.bot = bot + self.thumbnailer = Thumbnailer(bot=self.bot) + self.leave_timer.start() - self.bot.lavalink.add_event_hook(self.track_hook) + if self.bot.lavalink is None: + raise MusicError("Lavalink is not yet initialized") + self.lavalink: lavalink.Client = self.bot.lavalink + self.lavalink.add_event_hook(self.track_hook) async def cog_check(self, ctx): if not ctx.guild: @@ -47,30 +57,36 @@ class Music(commands.Cog): async def cog_before_invoke(self, ctx): """ Ensures a valid player exists whenever a command is run """ # Creates a new only if one doesn't exist, ensures a valid player for all checks. - self.bot.lavalink.player_manager.create(ctx.guild.id) + if ctx.guild: + self.lavalink.player_manager.create(ctx.guild.id) - async def enqueue(self, ctx, track, embed, silent=False, check_max_length=True) -> tuple[discord.Embed, bool]: - player = self.bot.lavalink.player_manager.get(ctx.guild.id) + def get_player(self, guild: discord.Guild) -> MixPlayer: + player: Optional[MixPlayer] = self.lavalink.player_manager.get(guild.id) + if player is None: + raise PlayerNotAvailableError(f"Tried to get player for guild {guild.id} but got None") + return player + + async def enqueue(self, ctx, track: AudioTrack, embed, standalone: bool = False, + check_max_length: bool = True) -> bool: + player = self.get_player(ctx.guild) + + track_duration_str = timeformatter.format_track_duration(track) # Only add tracks that don't exceed the max track length - if maxlength := self.max_track_length(ctx.guild, player): - if track.duration > maxlength and check_max_length: - if track.stream: - length = '{live}' - else: - length = timeformatter.format_ms(track.duration) - embed.description = ctx.localizer.format_str("{enqueue.toolong}", - _length=length, - _max=timeformatter.format_ms(maxlength)) - return embed, False + maxlength = self.max_track_length(ctx.guild, player) + if (track.stream or track.duration > maxlength) and check_max_length: + embed.description = ctx.localizer.format_str("{enqueue.toolong}", + _length=track_duration_str, + _max=timeformatter.format_ms(maxlength)) + return False - track.extra["thumbnail_url"] = await thumbnailer.ThumbNailer.identify(self, track.identifier, track.uri) + track.extra["thumbnail_url"] = await self.thumbnailer.identify(track.identifier, track.uri) track.requester = ctx.author.id # Add to player - track, pos_global, pos_local = player.add(requester=ctx.author.id, track=track) + track, pos_global, pos_local = player.add(requester=ctx.author, track=track) - if player.current is not None and not silent: + if player.current is not None and not standalone: if player.current.stream: until_play = '--:--' else: @@ -84,16 +100,11 @@ class Music(commands.Cog): if thumbnail_url := track.extra["thumbnail_url"]: embed.set_thumbnail(url=thumbnail_url) - if track.stream: - duration = '{live}' - else: - duration = timeformatter.format_ms(int(track.duration)) - embed.description = f'[{track.title}]({track.uri})\n**{duration}**' - - return embed, True + embed.description = f'[{track.title}]({track.uri})\n**{track_duration_str}**' + return True def get_current_song_embed(self, ctx, include_time=False): - player: MixPlayer = self.bot.lavalink.player_manager.get(ctx.guild.id) + player = self.get_player(ctx.guild) if not player.current: return @@ -101,10 +112,7 @@ class Music(commands.Cog): song = f'**[{player.current.title}]({player.current.uri})**' if include_time: position = timeformatter.format_ms(player.position) - if player.current.stream: - duration = '{live}' - else: - duration = timeformatter.format_ms(player.current.duration) + duration = timeformatter.format_track_duration(player.current) song += f'\n({position}/{duration})' embed = discord.Embed(color=ctx.me.color, description=song, title='{now}') @@ -118,30 +126,28 @@ class Music(commands.Cog): embed = ctx.localizer.format_embed(embed) return embed - def max_track_length(self, guild, player): - if maxlength := self.bot.settings.get(guild, 'duration.max', None): - is_dynamic = self.bot.settings.get(guild, 'duration.is_dynamic', 'default_duration_type') - listeners = max(1, len(player.listeners)) # Avoid division by 0 + def max_track_length(self, guild, player) -> float: + # Numeric constants + minimum_dynamic_length = 10 + minutes_to_milliseconds = 60 * 1000 + configured_max = self.bot.settings.get(guild, 'duration.max', float('inf')) + is_dynamic = self.bot.settings.get(guild, 'duration.is_dynamic', 'default_duration_type') + listeners = max(1, len(player.listeners)) # Avoid division by 0 - if maxlength > 10 and is_dynamic: - return max(maxlength*60*1000/listeners, 60*10*1000) - else: - return maxlength*60*1000 + if configured_max > minimum_dynamic_length and is_dynamic: + return minutes_to_milliseconds * max(configured_max/listeners, minimum_dynamic_length) else: - return None + return minutes_to_milliseconds * configured_max - @commands.command(name='play') - @require_voice_connection(should_connect=True) - async def _play(self, ctx, *, query: str): - """ Searches and plays a song from a given query. """ + async def _search_and_play_query(self, ctx, query: str, check_max_length: bool): self.logger.debug("Query: %s" % query) - player = self.bot.lavalink.player_manager.get(ctx.guild.id) + player = self.get_player(ctx.guild) query = query.strip('<>') if not url_rx.match(query): query = f'ytsearch:{query}' - results = await player.node.get_tracks(query) + results: lavalink.LoadResult = await player.node.get_tracks(query) if not results or not results.tracks: return await ctx.send(ctx.localizer.format_str("{nothing_found}")) @@ -149,17 +155,16 @@ class Music(commands.Cog): embed = discord.Embed(color=ctx.me.color) if results.load_type == 'PLAYLIST_LOADED': - numtracks = 0 - for track in results.tracks: - _, track_added = await self.enqueue(ctx, track, embed, silent=True) - if track_added: - numtracks += 1 - - embed.title = '{playlist_enqued}' - embed.description = f'{results.playlist_info.name} - {numtracks} {{tracks}}' + tracks = [track for track in results.tracks if + await self.enqueue(ctx, track, embed, True, check_max_length)] + if tracks: + embed.title = '{playlist_enqued}' + embed.description = f'{results.playlist_info.name} - {len(tracks)} {{tracks}}' + else: + # TODO: This really means no tracks passed the max_length requirement + return await ctx.send(ctx.localizer.format_str("{nothing_found}")) else: - track = results.tracks[0] - await self.enqueue(ctx, track, embed) + await self.enqueue(ctx, results.tracks[0], embed, check_max_length=check_max_length) embed = ctx.localizer.format_embed(embed) await ctx.send(embed=embed) @@ -167,19 +172,32 @@ class Music(commands.Cog): if not player.is_playing: await player.play() + @commands.command(name='play') + @require_voice_connection(should_connect=True) + async def _play(self, ctx, *, query: str): + """ Searches and plays a song from a given query. """ + await self._search_and_play_query(ctx, query, check_max_length=True) + + @commands.command(name='forceplay') + @require_voice_connection(should_connect=True) + @voteable(DJ_override=True, react_to_vote=True) + async def _forceplay(self, ctx, *, query: str): + """ Searches and plays a song from a given query, ignoring configured max duration. """ + await self._search_and_play_query(ctx, query, check_max_length=False) + @commands.command(name='seek') @checks.dj_or(alone=True, track_requester=True) @require_voice_connection() @require_playing(require_user_listening=True) async def _seek(self, ctx, *, time: str): """ Seeks to a given position in a track. """ - player = self.bot.lavalink.player_manager.get(ctx.guild.id) + player = self.get_player(ctx.guild) if seconds := time_rx.search(time): # Convert to milliseconds, include sign milliseconds = int(seconds.group())*1000 * (-1 if time.startswith('-1') else 1) track_time = player.position + milliseconds - await player.seek(track_time) + await player.seek(int(track_time)) msg = ctx.localizer.format_str("{seek.track_moved}", _position=timeformatter.format_ms(track_time)) await ctx.send(msg) else: @@ -191,7 +209,7 @@ class Music(commands.Cog): @voteable(requester_override=True, react_to_vote=True) async def _skip(self, ctx): """ Skips the current track. """ - player = self.bot.lavalink.player_manager.get(ctx.guild.id) + player = self.get_player(ctx.guild) await player.skip() embed = self.get_current_song_embed(ctx) @@ -202,7 +220,7 @@ class Music(commands.Cog): @require_playing(require_user_listening=True) async def _skip_to(self, ctx, pos: int = 1): """ Plays the queue from a specific point. Disregards tracks before the pos. """ - player = self.bot.lavalink.player_manager.get(ctx.guild.id) + player = self.get_player(ctx.guild) # TODO: Do all queue out of range messages the same way if pos < 1: @@ -212,8 +230,11 @@ class Music(commands.Cog): await player.skip(pos - 1) - msg = ctx.localizer.format_str("{skip_to.skipped_to}", _title=player.current.title, _pos=pos) - await ctx.send(msg) + if player.current: + msg = ctx.localizer.format_str("{skip_to.skipped_to}", _title=player.current.title, _pos=pos) + await ctx.send(msg) + else: + self.logger.error("Expected current track to be valid due to bounds checks") @commands.command(name='stop') @require_voice_connection() @@ -221,7 +242,7 @@ class Music(commands.Cog): @voteable(DJ_override=True, react_to_vote=True) async def _stop(self, ctx): """ Stops the player and clears its queue. """ - player = self.bot.lavalink.player_manager.get(ctx.guild.id) + player = self.get_player(ctx.guild) player.queue.clear() await player.stop() @@ -237,9 +258,9 @@ class Music(commands.Cog): @commands.command(name='queue') @require_queue(require_member_queue=True) - async def _queue(self, ctx, *, member: discord.Member = None): + async def _queue(self, ctx, *, member: Optional[discord.Member] = None): """ Shows the player's queue. """ - player = self.bot.lavalink.player_manager.get(ctx.guild.id) + player = self.get_player(ctx.guild) pagified_queue = QueuePaginator(ctx.localizer, player, color=ctx.me.color, member=member) scroller = Scroller(ctx, pagified_queue) await scroller.start_scrolling(ClearOn.ManualExit | ClearOn.Timeout) @@ -256,7 +277,7 @@ class Music(commands.Cog): @require_playing() async def _pause(self, ctx): """ Pauses/Resumes the current track. """ - player = self.bot.lavalink.player_manager.get(ctx.guild.id) + player = self.get_player(ctx.guild) await player.set_pause(not player.paused) if player.paused: await ctx.send(ctx.localizer.format_str("{resume.paused}")) @@ -268,8 +289,8 @@ class Music(commands.Cog): @require_queue(require_author_queue=True) async def _shuffle(self, ctx): """ Shuffles your queue. """ - player = self.bot.lavalink.player_manager.get(ctx.guild.id) - player.shuffle_user_queue(ctx.author.id) + player = self.get_player(ctx.guild) + player.shuffle_user_queue(ctx.author) await ctx.send(ctx.localizer.format_str("{shuffle}")) @commands.command(name='move') @@ -277,10 +298,10 @@ class Music(commands.Cog): @require_queue(require_author_queue=True) async def _move(self, ctx): """ Moves a song in your queue. """ - player: MixPlayer = self.bot.lavalink.player_manager.get(ctx.guild.id) + player = self.get_player(ctx.guild) # We create a new selector for each selection - def move_selector(ctx, queue: List[AudioTrack], title: str, first: bool): + def build_move_selector(ctx, queue: List[AudioTrack], title: str, first: bool): async def return_track(track): return track @@ -302,30 +323,30 @@ class Music(commands.Cog): page = 0 while True: # Prompt the user for which track to move - selector = move_selector(ctx, player.user_queue(ctx.author.id), "{moved.choose_pos}", True) - message, track_to_move = await selector.start_scrolling(ClearOn.Timeout, message, page) + selector = build_move_selector(ctx, player.user_queue(ctx.author), "{moved.choose_pos}", True) + message, timed_out, track_to_move = await selector.start_scrolling(ClearOn.Timeout, message, page) page = selector.page_number - if not track_to_move: + if not track_to_move or timed_out: break # Prompt the user for where to move it - selector = move_selector(ctx, player.user_queue(ctx.author.id), "{moved.choose_song}", False) - message, track_to_replace = await selector.start_scrolling(ClearOn.Timeout, message, page) + selector = build_move_selector(ctx, player.user_queue(ctx.author), "{moved.choose_song}", False) + message, timed_out, track_to_replace = await selector.start_scrolling(ClearOn.Timeout, message, page) page = selector.page_number - if not track_to_replace: + if not track_to_replace or timed_out: break # At this point the user has chosen two songs, we will move the first song to be in front of the second # one in the queue. Find the index of both songs in the current queue # Keep updating the user queue in case it changes while the user is selecting songs - user_queue = player.user_queue(ctx.author.id) + user_queue = player.user_queue(ctx.author) try: pos_initial = user_queue.index(track_to_move[0]) pos_final = user_queue.index(track_to_replace[0]) - player.move_user_track(ctx.author.id, pos_initial, pos_final) + player.move_user_track(ctx.author, pos_initial, pos_final) except ValueError: # Track not found in queue. Queue could have changed during selection # if a song was skipped or the current song finished @@ -334,17 +355,19 @@ class Music(commands.Cog): if message: await message.delete() - @commands.command(name='remove') - @require_voice_connection() - @require_queue(require_author_queue=True) - async def _remove(self, ctx): - player: MixPlayer = self.bot.lavalink.player_manager.get(ctx.guild.id) - - user_queue: List[AudioTrack] = player.user_queue(ctx.author.id) + async def _interactive_remove(self, ctx, queue: List[AudioTrack]): + """ + Helper function for creating an interactive selector over a given queue + in which will remove the selected tracks on exit. + """ + player = self.get_player(ctx.guild) tracks_to_remove = [] - async def update_remove_list(tracks_list, track): + async def update_remove_list(tracks_list, track: AudioTrack): + # It seems duplicate songs still don't satisfy equality + # which meas remove is sufficient to preserve order + # of similar items if track in tracks_list: tracks_list.remove(track) else: @@ -363,59 +386,55 @@ class Music(commands.Cog): return inner selector_buttons = [] - for index, track in enumerate(user_queue, start=1): + # Build each selection from the queue, a visible string and a callback. + for index, track in enumerate(queue, start=1): + requester = self.bot.get_user(track.requester) selector_buttons.append( - SelectorItem(f'`{index}` [{track.title}]({track.uri})', str(index), - add_change_button_color(update_remove_list, tracks_to_remove, track))) + SelectorItem(f'`{index}` [{track.title}]({track.uri}) - {requester.mention if requester else ""}', + str(index), add_change_button_color(update_remove_list, tracks_to_remove, track))) remove_selector = Selector(ctx, selector_buttons, select_mode=SelectMode.MultiSelect, use_tick_for_stop_emoji=True, color=ctx.me.color, title='Select songs to remove') - await remove_selector.start_scrolling(ClearOn.AnyExit) + _, timed_out, _ = await remove_selector.start_scrolling(ClearOn.AnyExit) # If any tracks were removed create a scroller for navigating them - if tracks_to_remove: + if tracks_to_remove and not timed_out: 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) - track_indexes_to_remove = [i for i, track in enumerate(user_queue) if track in tracks_to_remove] + tracks_removed: List[Tuple[int, str]] = [] + for track in tracks_to_remove: + if remove_result := player.remove_track(track): + (position, removed_track) = remove_result + if requester := self.bot.get_user(removed_track.requester): + tracks_removed.append((position, f"{removed_track.title} - {requester.mention}")) - # Sort in reverse order since songs shift index when deleting - tracks_removed = [] - for track in sorted(track_indexes_to_remove, reverse=True): - if removed_track := player.remove_user_track(ctx.author.id, track): - tracks_removed.append(f"{removed_track.title}") - - # We want to display the removed tracks in the original queue order, so reverse the list once again - for removed in reversed(tracks_removed): - paginator.add_line(removed) + # remove_track returns the global queue index of the track, + # so we display the removed tracks in "queue order" + for removed in sorted(tracks_removed, key=lambda x: x[0]): + paginator.add_line(removed[1]) paginator.close_page() scroller = Scroller(ctx, paginator) await scroller.start_scrolling(ClearOn.ManualExit | ClearOn.Timeout) + @commands.command(name='remove') + @require_voice_connection() + @require_queue(require_author_queue=True) + async def _remove(self, ctx): + """ Remove a song from your queue. """ + player = self.get_player(ctx.guild) + return await self._interactive_remove(ctx, player.user_queue(ctx.author)) + @commands.command(name="DJremove") @checks.dj_or() @require_voice_connection() @require_queue() - async def _djremove(self, ctx, pos: int, member: discord.Member = None): + async def _djremove(self, ctx, member: Optional[discord.Member] = None): """ Remove a song from either the global queue or a users queue""" - player = self.bot.lavalink.player_manager.get(ctx.guild.id) - + player = self.get_player(ctx.guild) # TODO: add way to allow "member" as regular arg in require_queue() instead of only kwargs - queue = player.user_queue(member.id) if member else player.queue - - # TODO: Do all queue out of range messages the same way - if pos <= len(queue) and pos >= 1: - if member is None: - removed = player.remove_global_track(pos - 1) - else: - removed = player.remove_user_track(member.id, pos - 1) - - requester = self.bot.get_user(removed.requester) - await ctx.send(ctx.localizer.format_str("{dj_removed}", _title=removed.title, _user=requester.display_name)) - else: - await ctx.send(ctx.localizer.format_str("{out_of_range}", _len=len(queue))) + queue = player.user_queue(member) if member else player.global_queue() + return await self._interactive_remove(ctx, queue) @commands.command(name='removeuser') @checks.dj_or(alone=True) @@ -423,8 +442,8 @@ class Music(commands.Cog): @require_queue(require_member_queue=True) async def _user_queue_remove(self, ctx, *, member: discord.Member): """ Remove a song from either the global queue or a users queue""" - player = self.bot.lavalink.player_manager.get(ctx.guild.id) - player.remove_user_queue(member.id) + player = self.get_player(ctx.guild) + player.remove_user_queue(member) embed = discord.Embed(description="{dj_remove_user}", color=ctx.me.color) embed = ctx.localizer.format_embed(embed, _id=member.id) @@ -434,49 +453,47 @@ class Music(commands.Cog): @commands.command(name='search') @require_voice_connection(should_connect=True) async def _search(self, ctx, *, query): - player = self.bot.lavalink.player_manager.get(ctx.guild.id) + player = self.get_player(ctx.guild) + self.logger.debug("Query: %s" % query) 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 = discord.Embed(description='{nothing_found}', color=0x36393F) embed = ctx.localizer.format_embed(embed) return await ctx.send(embed=embed) + # This is the base embed that will be modified by the selector + embed = discord.Embed(color=ctx.me.color) buttons = [] for i, track in enumerate(results['tracks'], start=1): duration = timeformatter.format_ms(int(track.duration)) 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))) + self.enqueue, ctx, track, embed)) buttons.append(interaction) search_selector = Selector(ctx, buttons, select_mode=SelectMode.SingleSelect, color=ctx.me.color, title=ctx.localizer.format_str('{results}')) - message, callback_results = await search_selector.start_scrolling() + message, timed_out, _ = await search_selector.start_scrolling(ClearOn.Timeout) - if len(callback_results) == 0: - if message: - await message.delete() + if timed_out: return - song_embed, song_added = callback_results[0] - if song_added: - embed = ctx.localizer.format_embed(song_embed) - await message.edit(embed=embed, view=None) + await message.edit(embed=ctx.localizer.format_embed(embed), view=None) - if not player.is_playing: - await player.play() + if not player.is_playing: + await player.play() @commands.command(name='disconnect') @require_voice_connection() @voteable(DJ_override=True, react_to_vote=True) async def _disconnect(self, ctx): """ Disconnects the player from the voice channel and clears its queue. """ - player = self.bot.lavalink.player_manager.get(ctx.guild.id) + player = self.get_player(ctx.guild) player.queue.clear() await player.stop() @@ -490,10 +507,10 @@ class Music(commands.Cog): @voteable(DJ_override=True, react_to_vote=True) async def _reconnect(self, ctx): """ Tries to disconnect then reconnect the player in case the bot gets stuck on a song """ - player = self.bot.lavalink.player_manager.get(ctx.guild.id) + player = self.get_player(ctx.guild) current_channel = player.channel_id - async def recon(): + async def inner_reconnect(): await player.stop() if ctx.voice_client: await ctx.voice_client.disconnect() @@ -504,18 +521,18 @@ class Music(commands.Cog): if player.current: track = player.current start_time = player.position - await recon() + await inner_reconnect() if track: - await player.play(track, start_time=start_time) + await player.play(track, start_time=int(start_time)) else: - await recon() + await inner_reconnect() @commands.command(name='volume') @checks.dj_or(alone=True, track_requester=True) @require_playing(require_user_listening=True) - async def _volume(self, ctx, volume: int = None): + async def _volume(self, ctx, volume: Optional[int] = None): """ Changes the player's volume. Must be between 0 and 1000. Error Handling for that is done by Lavalink. """ - player = self.bot.lavalink.player_manager.get(ctx.guild.id) + player = self.get_player(ctx.guild) if not volume: return await ctx.send(f'🔈 | {player.volume}%') @@ -531,53 +548,17 @@ class Music(commands.Cog): embed = ctx.localizer.format_embed(embed, _volume=player.volume) await ctx.send(embed=embed) - @commands.command(name='forceplay') - @require_voice_connection(should_connect=True) - @voteable(DJ_override=True, react_to_vote=True) - async def _forceplay(self, ctx, *, query: str): - """ Searches and plays a song from a given query. """ - player = self.bot.lavalink.player_manager.get(ctx.guild.id) - query = query.strip('<>') - - if not url_rx.match(query): - query = f'ytsearch:{query}' - - results = await player.node.get_tracks(query) - - if not results or not results.tracks: - return await ctx.send(ctx.localizer.format_str("{nothing_found}")) - - embed = discord.Embed(color=ctx.me.color) - - if results.load_type == 'PLAYLIST_LOADED': - numtracks = 0 - for track in results.tracks: - _, track_added = await self.enqueue(ctx, track, embed, silent=True, check_max_length=False) - if track_added: - numtracks += 1 - - embed.title = '{playlist_enqued}' - embed.description = f'{results.playlist_info.name} - {numtracks} {{tracks}}' - else: - track = results.tracks[0] - await self.enqueue(ctx, track, embed, check_max_length=False) - - embed = ctx.localizer.format_embed(embed) - await ctx.send(embed=embed) - - if not player.is_playing: - await player.play() - @commands.command(name='forcedisconnect') @checks.dj_or(alone=True) async def _forcedisconnect(self, ctx): """ Attempts to force disconnect the bot without checking if it is connected initially. """ try: - player = self.bot.lavalink.player_manager.get(ctx.guild.id) + player = self.get_player(ctx.guild) player.queue.clear() await player.stop() - except Exception: - self.logger.exception("Error forcedisconnecting") + except Exception as e: + self.logger.error("Error forcedisconnecting") + self.logger.exception(e) await ctx.voice_client.disconnect(force=True) embed = discord.Embed(description='{disconnect.disconnected}', color=ctx.me.color) embed = ctx.localizer.format_embed(embed) @@ -588,10 +569,11 @@ class Music(commands.Cog): @require_voice_connection() async def _normalize(self, ctx): """ Reset the equalizer and volume """ - player: MixPlayer = self.bot.lavalink.player_manager.get(ctx.guild.id) + player = self.get_player(ctx.guild) await player.set_volume(100) await player.bassboost(False) + await player.nightcoreify(False) embed = discord.Embed(description='{volume.reset}', color=ctx.me.color) embed = ctx.localizer.format_embed(embed) @@ -601,7 +583,7 @@ class Music(commands.Cog): @checks.dj_or(alone=True) async def _boost(self, ctx, boost: bool = False): """ Set the equalizer to bass boost the music """ - player = self.bot.lavalink.player_manager.get(ctx.guild.id) + player = self.get_player(ctx.guild) if boost is not None: await player.bassboost(boost) @@ -616,7 +598,7 @@ class Music(commands.Cog): @checks.dj_or(alone=True) async def _nightcore(self, ctx, boost: bool = False): """ Set a filter mimicking nightcore the music """ - player: MixPlayer = self.bot.lavalink.player_manager.get(ctx.guild.id) + player = self.get_player(ctx.guild) await player.nightcoreify(boost) @@ -628,7 +610,7 @@ class Music(commands.Cog): @commands.command(name='history') async def _history(self, ctx): """ Show the last 10 songs played """ - player = self.bot.lavalink.player_manager.get(ctx.guild.id) + player = self.get_player(ctx.guild) history = player.get_history() if not history: embed = discord.Embed(description='{history.empty}', color=ctx.me.color) @@ -645,8 +627,9 @@ class Music(commands.Cog): embed = discord.Embed(title=ctx.localizer.format_str('{history.title}'), color=ctx.me.color, description=description) - if thumbnail_url := player.current.extra["thumbnail_url"]: - embed.set_thumbnail(url=thumbnail_url) + if current := player.current: + if thumbnail_url := current.extra["thumbnail_url"]: + embed.set_thumbnail(url=thumbnail_url) await ctx.send(embed=embed) @commands.cooldown(1, 5, commands.BucketType.guild) @@ -654,7 +637,7 @@ class Music(commands.Cog): async def _lyrics(self, ctx, *query: str): # TODO: - player = self.bot.lavalink.player_manager.get(ctx.guild.id) + player = self.get_player(ctx.guild) genius_access_token = self.bot.APIkeys.get('genius', None) @@ -664,13 +647,13 @@ class Music(commands.Cog): excluded_words = ['music', 'video', 'version', 'original', 'lyrics', 'lyric', 'official', 'live', 'instrumental', 'audio', 'hd'] - query = ' '.join(query) + query_as_string = ' '.join(query) - if not query and player.is_playing: - query = player.current.title - query = re.sub(r'[-()_\[\]]', '', query) - filtered_words = [word for word in query.split() if word.lower() not in excluded_words] - query = ' '.join(filtered_words) + if not query_as_string and player.is_playing and player.current: + query_as_string = player.current.title + query_as_string = re.sub(r'[-()_\[\]]', '', query_as_string) + filtered_words = [word for word in query_as_string.split() if word.lower() not in excluded_words] + query_as_string = ' '.join(filtered_words) async def get_site_content(url): async with self.bot.session.get(url) as resp: @@ -681,8 +664,8 @@ class Music(commands.Cog): status_msg = await ctx.send(embed=embed) try: - url = 'https://api.genius.com/search?' + urllib.parse.urlencode( - {'access_token': genius_access_token, 'q': query}) + url = 'https://api.genius.com/search?' + urlparse.urlencode( + {'access_token': genius_access_token, 'q': query_as_string}) result = await get_site_content(url) response = json.loads(result) song_id = response['response']['hits'][0]['result']['id'] @@ -713,7 +696,7 @@ class Music(commands.Cog): await ctx.send(embed=page) else: paginator.add_page_indicator(ctx.localizer) - await Scroller(ctx, paginator).start_scrolling(ClearOn.AnyExi) + await Scroller(ctx, paginator).start_scrolling(ClearOn.AnyExit) @commands.command(name='scrub') @checks.dj_or(alone=True) @@ -721,7 +704,7 @@ class Music(commands.Cog): @require_playing(require_user_listening=True) async def _scrub(self, ctx): """ Shows a set of controls which can be used to skip forward or backwards in the song """ - player: MixPlayer = self.bot.lavalink.player_manager.get(ctx.guild.id) + player = self.get_player(ctx.guild) controls = '{scrub.controls}' def seek_callback(player: MixPlayer, seconds): @@ -753,7 +736,7 @@ class Music(commands.Cog): async def _loop(self, ctx): # This is done using subcommands to have separate vote counts for starting and stopping. if ctx.invoked_subcommand is None: - player = self.bot.lavalink.player_manager.get(ctx.guild.id) + player = self.get_player(ctx.guild) embed = discord.Embed(color=ctx.me.color, title='Loop status') if player.looping: @@ -767,7 +750,7 @@ class Music(commands.Cog): @voteable(DJ_override=True, react_to_vote=True) async def _loop_start(self, ctx): """ Set the equalizer to bass boost the music """ - player = self.bot.lavalink.player_manager.get(ctx.guild.id) + player = self.get_player(ctx.guild) player.enable_looping(True) embed = discord.Embed(color=ctx.me.color) embed.description = 'Looping turned on' @@ -779,7 +762,7 @@ class Music(commands.Cog): @voteable(DJ_override=True, react_to_vote=True) async def _loop_stop(self, ctx): """ Set the equalizer to bass boost the music """ - player = self.bot.lavalink.player_manager.get(ctx.guild.id) + player = self.get_player(ctx.guild) player.enable_looping(False) embed = discord.Embed(color=ctx.me.color) embed.description = 'Looping turned off' @@ -787,19 +770,21 @@ class Music(commands.Cog): await ctx.send(embed=embed) async def cog_unload(self): - self.bot.lavalink._event_hooks.clear() + self.lavalink._event_hooks.clear() async def track_hook(self, event): if isinstance(event, TrackEndEvent): - channel = self.bot.get_channel(event.player.fetch('channel')) - player = self.bot.lavalink.player_manager.get(channel.guild.id) - player.skip_voters.clear() + if channel := self.bot.get_channel(event.player.fetch('channel')): + if isinstance(channel, VoiceChannel): + player = self.get_player(channel.guild) + player.skip_voters.clear() if isinstance(event, TrackStartEvent): pass if isinstance(event, QueueEndEvent): - channel = self.bot.get_channel(event.player.fetch('channel')) - await self.check_leave_voice(channel.guild) + if channel := self.bot.get_channel(event.player.fetch('channel')): + if isinstance(channel, VoiceChannel): + await self.check_leave_voice(channel.guild) if isinstance(event, PlayerUpdateEvent): pass if isinstance(event, NodeDisconnectedEvent): @@ -812,44 +797,57 @@ class Music(commands.Cog): pass @commands.Cog.listener() - async def on_voice_state_update(self, member, before, after): + async def on_voice_state_update(self, member: discord.Member, _: discord.VoiceState, after: discord.VoiceState): """ Updates listeners when the bot or a user changes voice state """ + if self.bot.user is None: + return # Bot not logged in if member.id == self.bot.user.id and after.channel is not None: voice_channel = after.channel - player = self.bot.lavalink.player_manager.get(member.guild.id) + try: + player = self.get_player(member.guild) + except PlayerNotAvailableError: # This is expected if we have not created a player for the guild yet + return player.clear_listeners() for member in voice_channel.members: if not member.bot: player.update_listeners(member, member.voice) if not member.bot: - player = self.bot.lavalink.player_manager.get(member.guild.id) - if player is not None: - player.update_listeners(member, after) - await self.check_leave_voice(member.guild) + try: + player = self.get_player(member.guild) + except PlayerNotAvailableError: # This is expected if we have not created a player for the guild yet + return + player.update_listeners(member, after) + await self.check_leave_voice(member.guild) - async def check_leave_voice(self, guild): + async def check_leave_voice(self, guild: discord.Guild): """ Checks if the bot should leave the voice channel """ # TODO, disconnect timer? - player = self.bot.lavalink.player_manager.get(guild.id) + player = self.get_player(guild) if len(player.listeners) == 0 and player.is_connected: if player.queue.empty and player.current is None: await player.stop() - await guild.voice_client.disconnect() + if voice_client := guild.voice_client: + await voice_client.disconnect(force=False) async def leave_check(self): - for player_id in self.bot.lavalink.player_manager.players: - await self.check_leave_voice(self.bot.get_guild(player_id)) + for player_id in self.lavalink.player_manager.players: + if guild := self.bot.get_guild(player_id): + await self.check_leave_voice(guild) @tasks.loop(seconds=10.0) async def leave_timer(self): try: await self.leave_check() except Exception as err: - self.logger.debug("Error in leave_timer loop.\nTraceback: %s" % (err)) + self.logger.error("Error in leave_timer loop") + self.logger.exception(err) def wrap_in_button_callback(func, *args): + """ + Take a callback and convert it to the form required by a SelectorItem + """ async def inner(_interaction, _button): return await func(*args) return inner diff --git a/musicbot/cogs/music/decorators.py b/musicbot/cogs/music/decorators.py index b9d69ce..07217b1 100644 --- a/musicbot/cogs/music/decorators.py +++ b/musicbot/cogs/music/decorators.py @@ -1,46 +1,16 @@ # Discord Packages import discord -from discord.ext import commands import asyncio import functools import inspect import math +# Bot Utilities +from musicbot.utils.mixplayer.player import MixPlayer from ...utils.checks import is_dj -from .music_errors import WrongVoiceChannelError - - -class BasicVoiceClient(discord.VoiceClient): - def __init__(self, client: discord.Client, channel: discord.abc.Connectable): - self.client = client - self.channel = channel - if hasattr(self.client, 'lavalink'): - self.lavalink = self.client.lavalink - else: - raise Exception("client did not have defined lavalink before connect") - - async def on_voice_server_update(self, data): - await self.lavalink.voice_update_handler({'t': 'VOICE_SERVER_UPDATE', 'd': data}) - - async def on_voice_state_update(self, data): - await self.lavalink.voice_update_handler({'t': 'VOICE_STATE_UPDATE', 'd': data}) - - async def connect(self, *, timeout: float, reconnect: bool, self_deaf: bool = False, - self_mute: bool = False) -> None: - await self.channel.guild.change_voice_state(channel=self.channel, self_mute=self_mute, self_deaf=self_deaf) - - async def disconnect(self, *, force: bool = False) -> None: - player = self.lavalink.player_manager.get(self.channel.guild.id) - - # no need to disconnect if we are not connected - if not force and not player.is_connected: - return - - # None means disconnect - await self.channel.guild.change_voice_state(channel=None) - player.channel_id = None - self.cleanup() +from . import music_errors +from .voice_client import BasicVoiceClient def require_voice_connection(should_connect=False): @@ -52,37 +22,39 @@ def require_voice_connection(should_connect=False): @functools.wraps(func) async def ensure_voice_inner(self, ctx, *command_args, **kwargs): """ This check ensures that the bot and command author are in the same voicechannel. """ + player: MixPlayer = self.bot.lavalink.player_manager.get(ctx.guild.id) + if not player: + raise music_errors.MusicError("ensure voice could not get lavalink player") - player = self.bot.lavalink.player_manager.get(ctx.guild.id) if not ctx.author.voice or not ctx.author.voice.channel: - raise commands.CommandInvokeError('Join a voicechannel first.') + raise music_errors.UserNotConnectedError('Join a voicechannel first.') if not player.is_connected: if not should_connect: - raise commands.CommandInvokeError('Not connected.') + raise music_errors.BotNotConnectedError('Not connected.') user_voice_channel = ctx.author.voice.channel permissions = user_voice_channel.permissions_for(ctx.me) if not permissions.connect or not permissions.speak: # Check user limit too? - raise commands.CommandInvokeError('I need the `CONNECT` and `SPEAK` permissions.') + raise music_errors.MissingPermissionsError('I need the `CONNECT` and `SPEAK` permissions.') if (len(user_voice_channel.members) == user_voice_channel.user_limit) and not permissions.administrator: - raise commands.CommandInvokeError('The channel is currently full') + raise music_errors.VoiceChannelFullError('The channel is currently full', user_voice_channel) # Check against music channel restrictions in bot settings if voice_channels := self.bot.settings.get(ctx.guild, 'channels.music', []): if user_voice_channel.id not in voice_channels: - raise WrongVoiceChannelError( + raise music_errors.WrongVoiceChannelError( 'You need to be in the right voice channel', channels=voice_channels) player.store('channel', ctx.channel.id) await ctx.author.voice.channel.connect(cls=BasicVoiceClient) - elif int(player.channel_id) != ctx.author.voice.channel.id: + elif player.channel_id and int(player.channel_id) != ctx.author.voice.channel.id: bot_channel = self.bot.get_channel(int(player.channel_id)) - raise WrongVoiceChannelError( - 'You need to be in my voice channel', channels=[bot_channel]) + raise music_errors.UserInDifferentVoiceChannelError('You need to be in my voice channel', + channel=bot_channel) await func(self, ctx, *command_args, **kwargs) @@ -102,10 +74,10 @@ def require_playing(require_user_listening=False): if player := self.bot.lavalink.player_manager.get(ctx.guild.id): if not player.is_playing: - raise commands.CommandInvokeError('Not playing') + raise music_errors.RequirePlayingError('Not playing') if require_user_listening and (ctx.author not in player.listeners): - raise commands.CommandInvokeError('Not listening') + raise music_errors.RequireListeningError('Not listening') else: raise Exception('Player object does not yet exist') @@ -136,7 +108,7 @@ def require_queue(require_member_queue=False, require_author_queue=False): if require_member_queue: try: if member := kwargs['member']: # Ignore if member is None - user_queue = player.user_queue(member.id) + user_queue = player.user_queue(member) if not user_queue: embed = discord.Embed(description='{queue.user_empty}', color=ctx.me.color) embed = ctx.localizer.format_embed(embed, _user=member.display_name) @@ -145,7 +117,7 @@ def require_queue(require_member_queue=False, require_author_queue=False): raise Exception("require_member_error can only be used on commands with a member keyword argument") if require_author_queue: - user_queue = player.user_queue(ctx.author.id) + user_queue = player.user_queue(ctx.author) if not user_queue: embed = discord.Embed(description='{my_queue}', color=ctx.me.color) embed = ctx.localizer.format_embed(embed) diff --git a/musicbot/cogs/music/music_errors.py b/musicbot/cogs/music/music_errors.py index 24a2f41..4d68218 100644 --- a/musicbot/cogs/music/music_errors.py +++ b/musicbot/cogs/music/music_errors.py @@ -1,20 +1,77 @@ -# define Python user-defined exceptions # Discord Packages from discord.ext.commands import CommandError +from typing import Optional -class WrongChannelError(CommandError): - def __init__(self, e: str, channels=None): - self.original = e + +class MusicError(CommandError): + def __init__(self, message: str, inner_error: Optional[Exception] = None): + self.inner_error = inner_error + super().__init__(message) + + +class PlayerNotAvailableError(MusicError): + def __init__(self, message: str, inner_error: Optional[Exception] = None): + super().__init__(message, inner_error=inner_error) + + +class MissingPermissionsError(MusicError): + def __init__(self, message: str, inner_error: Optional[Exception] = None): + super().__init__(message, inner_error=inner_error) + + +class BotNotConnectedError(MusicError): + def __init__(self, message: str, inner_error: Optional[Exception] = None): + super().__init__(message, inner_error=inner_error) + + +class UserNotConnectedError(MusicError): + def __init__(self, message: str, inner_error: Optional[Exception] = None): + super().__init__(message, inner_error=inner_error) + + +class PlayBackStatusError(MusicError): + def __init__(self, message: str, inner_error: Optional[Exception] = None): + super().__init__(message, inner_error=inner_error) + + +class RequirePlayingError(PlayBackStatusError): + def __init__(self, message: str, inner_error: Optional[Exception] = None): + super().__init__(message, inner_error=inner_error) + + +class RequireListeningError(PlayBackStatusError): + def __init__(self, message: str, inner_error: Optional[Exception] = None): + super().__init__(message, inner_error=inner_error) + + +# Channel errors +class ChannelError(MusicError): + def __init__(self, message: str, channels, inner_error: Optional[Exception] = None): self.channels = channels - super().__init__() + super().__init__(message=message, inner_error=inner_error) -class WrongVoiceChannelError(WrongChannelError): - def __init__(self, e: str, channels=None): - super().__init__(e, channels=channels) +class VoiceChannelFullError(ChannelError): + def __init__(self, message: str, channel, inner_error: Optional[Exception] = None): + super().__init__(message, channels=[channel], inner_error=inner_error) -class WrongTextChannelError(WrongChannelError): - def __init__(self, e: str, channels=None): - super().__init__(e, channels=channels) +class UserInDifferentVoiceChannelError(ChannelError): + def __init__(self, message: str, channel, inner_error: Optional[Exception] = None): + super().__init__(message, channels=[channel], inner_error=inner_error) + + +class ConfiguredChannelsError(ChannelError): + def __init__(self, message: str, channels, inner_error: Optional[Exception] = None): + super().__init__(message, channels=channels, inner_error=inner_error) + + +class WrongVoiceChannelError(ConfiguredChannelsError): + def __init__(self, message: str, channels, inner_error: Optional[Exception] = None): + super().__init__(message, channels=channels, inner_error=inner_error) + + +class WrongTextChannelError(ConfiguredChannelsError): + def __init__(self, message: str, channels, inner_error: Optional[Exception] = None): + super().__init__(message, channels=channels, inner_error=inner_error) diff --git a/musicbot/cogs/music/voice_client.py b/musicbot/cogs/music/voice_client.py new file mode 100644 index 0000000..52f1855 --- /dev/null +++ b/musicbot/cogs/music/voice_client.py @@ -0,0 +1,41 @@ +# Discord Packages +import discord + +from bot import MusicBot +from musicbot.cogs.music.music_errors import MusicError + + +class BasicVoiceClient(discord.VoiceClient): + def __init__(self, client: MusicBot, channel: discord.VoiceChannel): + # Needs to be named client in order for base class to work + # in most cases lavalink handles disconnects, but if we force it then we'll get an error. + # during self.cleanup() + self.client = client + self.channel = channel + if self.client.lavalink: + self.lavalink = self.client.lavalink + else: + raise MusicError("client did not have defined lavalink before connect") + + async def on_voice_server_update(self, data): + await self.lavalink.voice_update_handler({'t': 'VOICE_SERVER_UPDATE', 'd': data}) + + async def on_voice_state_update(self, data): + await self.lavalink.voice_update_handler({'t': 'VOICE_STATE_UPDATE', 'd': data}) + + async def connect(self, *, timeout: float, reconnect: bool, self_deaf: bool = False, + self_mute: bool = False) -> None: + await self.channel.guild.change_voice_state(channel=self.channel, self_mute=self_mute, self_deaf=self_deaf) + + async def disconnect(self, *, force: bool = False) -> None: + player = self.lavalink.player_manager.get(self.channel.guild.id) + + if player: + # no need to disconnect if we are not connected + if not force and not player.is_connected: + return + + # None means disconnect + await self.channel.guild.change_voice_state(channel=None) + player.channel_id = None + self.cleanup() diff --git a/musicbot/cogs/nodemanager.py b/musicbot/cogs/nodemanager.py index 5e4c501..e8ba3d6 100644 --- a/musicbot/cogs/nodemanager.py +++ b/musicbot/cogs/nodemanager.py @@ -1,21 +1,27 @@ # Discord Packages import discord +import lavalink from discord.ext import commands -from lavalink import Client, Node import codecs +from typing import Optional import yaml +from bot import MusicBot +from musicbot.cogs.music.music_errors import MusicError + +# Bot Utilities +from musicbot.utils.settingsmanager import Settings from ..utils.mixplayer import MixPlayer from ..utils.userinteraction import ClearOn, Scroller from .helpformatter import commandhelper class NodeManager(commands.Cog): - def __init__(self, bot): - self.bot = bot - self.settings = self.bot.settings + def __init__(self, bot: MusicBot): + self.bot: MusicBot = bot + self.settings: Settings = self.bot.settings self.logger = self.bot.main_logger.bot_logger.getChild("NodeManager") async def load_music_cogs(self): @@ -23,8 +29,9 @@ class NodeManager(commands.Cog): 'musicbot.cogs.music', ] - if not hasattr(self.bot, 'lavalink'): - self.bot.lavalink = Client(self.bot.user.id, player=MixPlayer) + if self.bot.lavalink is None and self.bot.user: + self.bot.lavalink = lavalink.Client(self.bot.user.id, player=MixPlayer) + self.lavalink = self.bot.lavalink self.load_nodes_from_file() @@ -36,6 +43,9 @@ class NodeManager(commands.Cog): await self.bot.load_extension(extension) except Exception: self.logger.exception("Loading of extension %s failed" % extension) + else: + self.logger.error("Failed to initialize lavalink client") + raise MusicError("Failed to initialize lavalink client") def load_nodes_from_file(self): with codecs.open(f"{self.bot.datadir}/config.yaml", 'r', encoding='utf8') as f: @@ -44,19 +54,20 @@ class NodeManager(commands.Cog): name_cache = [] new_nodes = [] - for node in self.bot.lavalink.node_manager.nodes: + for node in self.lavalink.node_manager.nodes: name_cache.append(node.name) for node in conf['lavalink nodes'] + self.settings.get('lavalink', 'nodes', []): if node['name'] in name_cache: continue - name_cache.append(node['name']) - self.bot.lavalink.add_node(**node) + self.lavalink.add_node(**node) + self.logger.debug("Adding Lavalink node: %s on %s with the port %s in %s" % ( node['name'], node['host'], node['port'], node['region'],)) new_nodes.append({**node}) + name_cache.append(node['name']) return new_nodes async def _regioner(self, region): @@ -89,7 +100,7 @@ class NodeManager(commands.Cog): embed.add_field(name=f'{await self._regioner(n.region)} **Name:** {n.name}', value=f'**Host:** {n.host}\n **Port:** {n.port}') - if isinstance(node, Node): + if isinstance(node, lavalink.Node): embed.add_field(name=f'{await self._regioner(node.region)} **Name:** {node.name}', value=f'**Host:** {node.host}\n **Port:** {node.port}') @@ -127,24 +138,24 @@ class NodeManager(commands.Cog): @_node.command(name='add') @commands.is_owner() - async def _add(self, ctx, host, port, password, region, name=None): - if name in [n.name for n in self.bot.lavalink.node_manager.nodes]: + async def _add(self, ctx, host: str, port: int, password: str, region, name: Optional[str] = None): + if name in [n.name for n in self.lavalink.node_manager.nodes]: return await ctx.send("A node with that name already exists") - self.bot.lavalink.add_node(host, port, password, region, name=name) + self.lavalink.add_node(host, port, password, region, name=name) self.logger.debug("Adding Lavalink node: %s on %s with the port %s in %s" % (host, port, region, name,)) embed = await self._node_presenter(ctx, {'host': host, 'port': port, 'password': password, 'region': region, 'name': name}) embed.title = 'Added new node!' self.settings.set('lavalink', 'nodes', [self.get_node_properties(n) for - n in self.bot.lavalink.node_manager.nodes]) + n in self.lavalink.node_manager.nodes]) await ctx.send(embed=embed) @_node.command(name='list') @commands.is_owner() async def list_nodes(self, ctx): - embed = await self._node_presenter(ctx, self.bot.lavalink.node_manager.nodes) + embed = await self._node_presenter(ctx, self.lavalink.node_manager.nodes) embed.title = 'Lavalink nodes attatched to this bot:' await ctx.send(embed=embed) @@ -152,8 +163,8 @@ class NodeManager(commands.Cog): @commands.is_owner() async def _remove(self, ctx, node): sent_feedback = False - for _node in self.bot.lavalink.node_manager.nodes: - if len(self.bot.lavalink.node_manager.nodes) <= 1: + for _node in self.lavalink.node_manager.nodes: + if len(self.lavalink.node_manager.nodes) <= 1: await ctx.send('Cannot remove the last node') sent_feedback = True break @@ -162,11 +173,11 @@ class NodeManager(commands.Cog): embed = await self._node_presenter(ctx, _node) embed.title = 'Removed node from bot' await ctx.send(embed=embed) - self.bot.lavalink.node_manager.remove_node(_node) - self.logger.info("Removed Lavalink node: %s" % _node.host) + self.lavalink.node_manager.remove_node(_node) + self.logger.info("Removed Lavalink node: %s, %s" % (_node.name, _node.host)) self.settings.set('lavalink', 'nodes', [self.get_node_properties(n) for - n in self.bot.lavalink.node_manager.nodes]) + n in self.lavalink.node_manager.nodes]) if not sent_feedback: await ctx.send('No node found') @@ -174,7 +185,7 @@ class NodeManager(commands.Cog): @_node.command(name='change') @commands.is_owner() async def _nodechange(self, ctx, node=None): - player = self.bot.lavalink.player_manager.get(ctx.guild.id) + player = self.lavalink.player_manager.get(ctx.guild.id) if not player: return @@ -185,7 +196,7 @@ class NodeManager(commands.Cog): else: newnode = None - for _node in self.bot.lavalink.node_manager.nodes: + for _node in self.lavalink.node_manager.nodes: if (_node.name or _node.host) == node: newnode = _node diff --git a/musicbot/cogs/settings.py b/musicbot/cogs/settings.py index 44142af..ebc5031 100644 --- a/musicbot/cogs/settings.py +++ b/musicbot/cogs/settings.py @@ -2,15 +2,20 @@ import discord from discord.ext import commands +from typing import Optional + +from bot import MusicBot + from ..utils import checks +from ..utils.settingsmanager import Settings as SettingsManager from ..utils.userinteraction import ClearOn, Scroller from .helpformatter import commandhelper class Settings(commands.Cog): - def __init__(self, bot): - self.bot = bot - self.settings = self.bot.settings + def __init__(self, bot: MusicBot): + self.bot: MusicBot = bot + self.settings: SettingsManager = self.bot.settings def format_prefixes(self, prefixes): if prefixes is None: @@ -90,8 +95,8 @@ class Settings(commands.Cog): if textchannels := self.bot.settings.get(ctx.guild, 'channels.text'): embed = discord.Embed(title='Music command channels set', color=ctx.me.color) - channels = [ctx.guild.get_channel(channel) for channel in textchannels] - mentioned = [channel.mention for channel in channels if channel is not None] + configured_channels = [ctx.guild.get_channel(channel) for channel in textchannels] + mentioned = [channel.mention for channel in configured_channels if channel is not None] embed.description = ', '.join(mentioned) await ctx.send(embed=embed) else: @@ -110,8 +115,8 @@ class Settings(commands.Cog): if musicchannels := self.bot.settings.get(ctx.guild, 'channels.music'): embed = discord.Embed(title='Music channels set', color=ctx.me.color) - channels = [ctx.guild.get_channel(channel) for channel in musicchannels] - mentioned = [channel.name for channel in channels if channel is not None] + configured_channels = [ctx.guild.get_channel(channel) for channel in musicchannels] + mentioned = [channel.name for channel in configured_channels if channel is not None] embed.description = ', '.join(mentioned) await ctx.send(embed=embed) else: @@ -130,8 +135,8 @@ class Settings(commands.Cog): if listenchannels := self.bot.settings.get(ctx.guild, 'channels.listen_only'): embed = discord.Embed(title='Listen only channels set', color=ctx.me.color) - channels = [ctx.guild.get_channel(channel) for channel in listenchannels] - mentioned = [channel.name for channel in channels if channel is not None] + configured_channels = [ctx.guild.get_channel(channel) for channel in listenchannels] + mentioned = [channel.name for channel in configured_channels if channel is not None] embed.description = ', '.join(mentioned) await ctx.send(embed=embed) else: @@ -150,8 +155,8 @@ class Settings(commands.Cog): if djroles := self.bot.settings.get(ctx.guild, 'roles.dj'): embed = discord.Embed(title='DJ roles set to', color=ctx.me.color) - roles = [ctx.guild.get_role(role) for role in djroles] - mentioned = [role.name for role in roles if role is not None] + configured_roles = [ctx.guild.get_role(role) for role in djroles] + mentioned = [role.name for role in configured_roles if role is not None] embed.description = ', '.join(mentioned) await ctx.send(embed=embed) else: @@ -161,7 +166,7 @@ class Settings(commands.Cog): @checks.is_admin() @commands.guild_only() @_set.command(name='maxduration') - async def set_max_track_duration(self, ctx, duration: int = None): + async def set_max_track_duration(self, ctx, duration: Optional[int] = None): self.bot.settings.set(ctx.guild, 'duration.max', duration) if duration := self.bot.settings.get(ctx.guild, 'duration.max'): diff --git a/musicbot/utils/localisation/localizedcontext.py b/musicbot/utils/localisation/localizedcontext.py index 0071838..bcfebd5 100644 --- a/musicbot/utils/localisation/localizedcontext.py +++ b/musicbot/utils/localisation/localizedcontext.py @@ -1,6 +1,10 @@ # Discord Packages from discord.ext import commands +from typing import Optional + +from .localizer.localizerwrapper import LocalizerWrapper + class LocalizedContext(commands.Context): def __init__(self, **kwargs): @@ -9,3 +13,5 @@ class LocalizedContext(commands.Context): self.locale = self.bot.settings.get(self.message.guild, 'locale', 'default_locale') else: self.locale = self.bot.settings.default_locale + + self.localizer: Optional[LocalizerWrapper] = None diff --git a/musicbot/utils/logger.py b/musicbot/utils/logger.py index bd718fe..97abd99 100644 --- a/musicbot/utils/logger.py +++ b/musicbot/utils/logger.py @@ -54,8 +54,8 @@ class BotLogger(object): self.bot_logger.info(f"Creating logging directory: {self.log_location}") if self.log_level: self.bot_logger.info(f"Logging directory: {self.log_location}") - file_logger = logging.handlers.RotatingFileHandler(f'{self.log_location}/bot.log', mode='a', - maxBytes=5000, encoding="UTF-8", delay=0, backupCount=5) + file_logger = logging.handlers.TimedRotatingFileHandler(f'{self.log_location}/bot.log', + when="midnight", encoding="UTF-8", backupCount=5) file_logger.setLevel(self.log_level) file_logger.setFormatter(file_format) self.bot_logger.addHandler(file_logger) diff --git a/musicbot/utils/mixplayer/mixqueue.py b/musicbot/utils/mixplayer/mixqueue.py index 53eebbc..2dfb0fc 100644 --- a/musicbot/utils/mixplayer/mixqueue.py +++ b/musicbot/utils/mixplayer/mixqueue.py @@ -1,3 +1,6 @@ +# Discord Packages +from lavalink.models import AudioTrack + import logging from collections import OrderedDict, deque from itertools import chain, cycle, islice @@ -5,7 +8,7 @@ from random import shuffle from typing import Generic, Iterable, Iterator, List, Optional, Tuple, TypeVar # Would like to ensure the T has a "requester" attribute, but don't know if that is possible -T = TypeVar('T') +T = TypeVar('T', bound=AudioTrack) QueueType = List[T] @@ -31,7 +34,6 @@ class MixQueue(Generic[T]): self.looping = False self.loop_offset = 0 self.logger = logging.getLogger("musicbot").getChild("Queue") - self.logger = self.logger.getChild("Queue") def __str__(self) -> str: tmp = '' @@ -96,9 +98,9 @@ class MixQueue(Generic[T]): self._history.append(next_track) return next_track except IndexError as e: - self.logger.error(e) - self.logger.error(self.loop_offset) - self.logger.error(len(self)) + self.logger.error(f"Failed to get the next track in looping mode: loop offset {self.loop_offset}." + + " Queue length: {len(self)}") + self.logger.exception(e) self.loop_offset = min(self.loop_offset, len(self)-1) else: if first_queue := self.first_queue: @@ -123,12 +125,13 @@ class MixQueue(Generic[T]): localpos = pos # Return info about track position - return track, self._loc_to_glob(requester, localpos), localpos + global_position = self._loc_to_glob(requester, localpos) + return track, global_position, localpos - def add_next_track(self, track: T) -> None: + def add_priorty_queue_track(self, track: T) -> None: self.priority_queue.append(track) - def remove_user_queue(self, requester: int) -> None: + def remove_user_queue(self, requester: int) -> QueueType: user_queue = self.queues.get(requester, []) if user_queue: if self.looping: @@ -137,6 +140,7 @@ class MixQueue(Generic[T]): self.remove_user_track(requester, pos) else: self.queues.pop(requester) + return user_queue def remove_user_track(self, requester: int, pos: int) -> Optional[T]: user_queue = self.queues.get(requester) @@ -149,6 +153,19 @@ class MixQueue(Generic[T]): self._clear_empty() return track + def remove_track(self, track: T) -> Optional[Tuple[int, T]]: + """ + Removes a track by identity + """ + for queue in self.queues.values(): + for i, t in enumerate(queue): + if t is track: + global_pos = self._loc_to_glob(t.requester, i) + removed = queue.pop(i) + self._clear_empty() + return global_pos, removed + return None + def remove_global_track(self, pos: int) -> Optional[T]: # Get the actual index of the song index = list(self).index(self.get_queue()[pos]) @@ -172,6 +189,7 @@ class MixQueue(Generic[T]): queue.insert(final, track) return track except IndexError: + self.logger.debug(f"Got invalid index when moving track from {initial} to {final}") pass def shuffle_user_queue(self, requester: int) -> None: @@ -180,17 +198,19 @@ class MixQueue(Generic[T]): shuffle(queue) def enable_looping(self, looping: bool) -> None: - if (not self.looping) and looping: # Not enabled and turning on + if (not self.looping) and looping: # Enable only if not already enabled self.looping = looping self.loop_offset = 0 - elif self.looping and not looping: # Enabled to turning off - tmp = [track for track in self.get_queue()] - for key, _ in self.queues.items(): - self.queues = OrderedDict() + self.logger.debug("Looping enabled") + elif self.looping and not looping: # Disable only if enabled + # Copy queue in current (looping) state + queue_copy = [track for track in self.get_queue()] + self.queues = OrderedDict() self.looping = looping - for track in tmp: + for track in queue_copy: self.add_track(track.requester, track) self.loop_offset = 0 + self.logger.debug("Looping disabled, queue reordered") # Switches the order of user queues def _shift_queues(self) -> None: @@ -217,9 +237,9 @@ class MixQueue(Generic[T]): globpos += 1 return globpos - def _glob_to_loc(self, pos: int) -> Tuple[Optional[T], Optional[int]]: + def _glob_to_loc(self, pos: int) -> Tuple[Optional[int], Optional[int]]: try: - song = next(islice(self, pos, pos + 1)) + song: T = next(islice(self, pos, pos + 1)) except (ValueError, StopIteration): return None, None user_queue = self.queues.get(song.requester, []) diff --git a/musicbot/utils/mixplayer/player.py b/musicbot/utils/mixplayer/player.py index 9b7046d..0ce7386 100644 --- a/musicbot/utils/mixplayer/player.py +++ b/musicbot/utils/mixplayer/player.py @@ -5,10 +5,13 @@ from lavalink import AudioTrack, DefaultPlayer, Node from lavalink.events import QueueEndEvent, TrackEndEvent, TrackExceptionEvent, TrackStartEvent, TrackStuckEvent from lavalink.filters import Equalizer, Timescale +import logging from typing import Dict, List, Optional, Set, Tuple from .mixqueue import MixQueue +RequesterType = discord.Member + class MixPlayer(DefaultPlayer): def __init__(self, guild_id: int, node: Node): @@ -16,11 +19,12 @@ class MixPlayer(DefaultPlayer): self.queue: MixQueue[AudioTrack] = MixQueue() - self.listeners: Set[int] = set() - self.voteables: Dict[int, Set[int]] = {} + self.listeners: Set[RequesterType] = set() + self.voteables: Dict[int, Set[RequesterType]] = {} self.skip_voters: Set[int] = set() self.boosted: bool = False self.nightcore_enabled: bool = False + self.logger = logging.getLogger("musicbot").getChild("MixPlayer") bands = [ (0, 0.15), (1, 0.15), @@ -36,38 +40,53 @@ class MixPlayer(DefaultPlayer): self.nightcore_filter: lavalink.Filter = Timescale() self.nightcore_filter.update(speed=1.25, pitch=1.25) - def add(self, requester: int, track: AudioTrack, pos: Optional[int] = None) -> Tuple[AudioTrack, int, int]: + def add(self, requester: RequesterType, track: AudioTrack, + pos: Optional[int] = None) -> Tuple[AudioTrack, int, int]: """ Adds a track to the queue. """ - return self.queue.add_track(requester, track, pos) + track, global_position, localpos = self.queue.add_track(requester.id, track, pos) + self.logger.info(f"Track {track.title} added for {requester.display_name} @ ({global_position}, {localpos}).") + return track, global_position, localpos def add_priority(self, track: AudioTrack): """ Adds a track to beginning of the queue """ - self.queue.add_next_track(track) + self.queue.add_priorty_queue_track(track) + self.logger.info(f"Track {track} added to priority queue.") - def move_user_track(self, requester: int, initial: int, final: int): + def move_user_track(self, requester: RequesterType, initial: int, final: int): """ Moves a track in a users queue""" - return self.queue.move_user_track(requester, initial, final) + if moved := self.queue.move_user_track(requester.id, initial, final): + self.logger.info(f"Track {moved.title} moved from {initial} to {final}" + + f"for in queue for {requester.display_name}") - def remove_user_queue(self, requester: int): - self.queue.remove_user_queue(requester) + def remove_user_queue(self, requester: RequesterType): + removed = self.queue.remove_user_queue(requester.id) + if removed: # Removed queues can be empty + self.logger.info(f"Queue for requester {requester.display_name} removed. {len(removed)} tracks removed") + else: + self.logger.debug(f"User {requester.display_name} has no more tracks. Remove from queue") - def remove_user_track(self, requester: int, pos: int) -> Optional[AudioTrack]: + def remove_user_track(self, requester: RequesterType, pos: int) -> Optional[AudioTrack]: """ Removes the song at from the queue of requester """ - return self.queue.remove_user_track(requester, pos) + if track := self.queue.remove_user_track(requester.id, pos): + self.logger.info(f"Track {track.title} for requester {requester.display_name} removed.") + return track + + def remove_track(self, track: AudioTrack) -> Optional[Tuple[int, AudioTrack]]: + return self.queue.remove_track(track) def remove_global_track(self, pos: int) -> Optional[AudioTrack]: """ Removes the song at in the global queue """ return self.queue.remove_global_track(pos) - def shuffle_user_queue(self, requester: int): + def shuffle_user_queue(self, requester: RequesterType): """ Randomly reorders the queue of requester """ - self.queue.shuffle_user_queue(requester) + self.queue.shuffle_user_queue(requester.id) - def user_queue(self, user: int) -> List[AudioTrack]: - return self.queue.get_user_queue(user) + def user_queue(self, requester: RequesterType) -> List[AudioTrack]: + return self.queue.get_user_queue(requester.id) - def user_queue_with_global_index(self, user: int) -> List[Tuple[AudioTrack, int]]: - return self.queue.get_user_queue_with_index(user) + def user_queue_with_global_index(self, requester: RequesterType) -> List[Tuple[AudioTrack, int]]: + return self.queue.get_user_queue_with_index(requester.id) def global_queue(self) -> List[AudioTrack]: return self.queue.get_queue() @@ -76,9 +95,9 @@ class MixPlayer(DefaultPlayer): return self.queue.history def queue_duration(self, include_current: bool = False, - member: Optional[discord.Member] = None, end_pos: Optional[int] = None): + member: Optional[RequesterType] = None, end_pos: Optional[int] = None): duration = 0 - queue = self.user_queue(member.id) if member else self.queue + queue = self.user_queue(member) if member else self.queue for i, track in enumerate(queue): if i == end_pos: @@ -101,8 +120,9 @@ class MixPlayer(DefaultPlayer): if not track: if self.queue.empty: - self.boosted = False - self.nightcore_enabled = False + self.logger.debug("No track provided to play function and queue is empty. Resetting Audio filters") + await self.bassboost(False) + await self.nightcoreify(False) await self.stop() await self.node._dispatch_event(QueueEndEvent(self)) return @@ -117,11 +137,14 @@ class MixPlayer(DefaultPlayer): await self.node._send(op='play', guildId=str(self.guild_id), track=track.track, startTime=start_time) await self.node._dispatch_event(TrackStartEvent(self, track)) + self.logger.info(f"Playing track: {track.title}") async def skip(self, pos: int = 0): """ Plays the next track in the queue, if any. """ - for i in range(pos): - _ = self.queue.pop_first() + for _ in range(pos): + track = self.queue.pop_first() + self.logger.debug(f"Track {track} skipped") + self.logger.info(f"Skipped {pos + 1} tracks") self.clear_votes() await self.play() @@ -129,36 +152,43 @@ class MixPlayer(DefaultPlayer): """ Stops the player. """ await self.node._send(op='stop', guildId=str(self.guild_id)) self.current = None - self.queue.looping = False + self.queue.enable_looping(False) + self.logger.info("Music player stopped, clearing current track and stopping looping") self.clear_votes() - def update_listeners(self, member, voice_state): + def update_listeners(self, member: RequesterType, voice_state): if self.channel_id is not None: vc = int(self.channel_id) if voice_state.channel is None or voice_state.channel.id != vc: + self.logger.debug(f"User {member.display_name} left the voice channel") self.listeners.discard(member) self.remove_member_votes(member) else: if voice_state.deaf or voice_state.self_deaf: + self.logger.debug(f"User {member.display_name} is now deafened") self.listeners.discard(member) self.remove_member_votes(member) else: self.listeners.add(member) + self.logger.debug(f"Member voice state update. Votes are now {self.voteables}") def clear_listeners(self): + self.logger.debug("Listeners cleared") self.listeners.clear() - def add_vote(self, category, member): + def add_vote(self, category, member: RequesterType): if member in self.listeners: if category not in self.voteables: self.voteables[category] = set() self.voteables[category].add(member) + self.logger.debug(f"{member.display_name} added vote for {category}") - def remove_member_votes(self, member): + def remove_member_votes(self, member: RequesterType): for _, votes in self.voteables.items(): votes.discard(member) + self.logger.debug(f"Removing votes for {member.display_name}.") - def get_voters(self, category): + def get_voters(self, category) -> Set[RequesterType]: if category not in self.voteables: self.voteables[category] = set() return self.voteables[category] @@ -167,7 +197,7 @@ class MixPlayer(DefaultPlayer): for _, votes in self.voteables.items(): votes.clear() - def enable_looping(self, looping): + def enable_looping(self, looping: bool): if (not self.queue.looping) and looping: self.queue.enable_looping(looping) if track := self.current: @@ -180,23 +210,27 @@ class MixPlayer(DefaultPlayer): """ Handles the given event as necessary. """ if isinstance(event, (TrackStuckEvent, TrackExceptionEvent)) or \ isinstance(event, TrackEndEvent) and event.reason == 'FINISHED': + self.logger.debug("Track ended, clearing votes") self.skip_voters.clear() for _, votes in self.voteables.items(): votes.clear() await self.play() - async def bassboost(self, boost: bool = False): + async def bassboost(self, boost: bool): self.boosted = boost if boost: await self.set_filter(self.bass_boost_filter) else: + self.logger.info("Disabling bass boost") await self.remove_filter(self.bass_boost_filter) - async def nightcoreify(self, nightcore: bool = False): + async def nightcoreify(self, nightcore: bool): self.nightcore_enabled = nightcore if nightcore: + self.logger.info("Enabling nightcore mode") await self.set_filter(self.nightcore_filter) else: + self.logger.info("Disabling nightcore mode") await self.remove_filter(self.nightcore_filter) @property diff --git a/musicbot/utils/mixplayer/test_queue.py b/musicbot/utils/mixplayer/test_queue.py index f515e31..5b7bfef 100644 --- a/musicbot/utils/mixplayer/test_queue.py +++ b/musicbot/utils/mixplayer/test_queue.py @@ -62,8 +62,8 @@ class TestMixQueue(): def test_priority_queue(self): queue = self.setup_baisc_queue() - queue.add_next_track("priority_item1") - queue.add_next_track("priority_item2") + queue.add_priorty_queue_track("priority_item1") + queue.add_priorty_queue_track("priority_item2") assert queue.pop_first() == "priority_item1" assert queue.pop_first() == "priority_item2" @@ -121,3 +121,28 @@ class TestMixQueue(): assert item == queue.pop_first() assert list(queue) == [] + + def test_remove_by_identity(self): + queue = MixQueue() + queue.add_track(1, TrackMock(1, "a")) + queue.add_track(1, TrackMock(1, "a")) + queue.add_track(1, TrackMock(1, "b")) + queue.add_track(1, TrackMock(1, "a")) + queue.add_track(1, TrackMock(1, "b")) + queue.add_track(1, TrackMock(1, "a")) + queue.add_track(1, TrackMock(1, "b")) + queue.add_track(1, TrackMock(1, "b")) + + assert queue.remove_track(TrackMock(1, "a")) is None + to_remove = queue.get_user_queue(1)[3] + + first_remove = queue.remove_track(to_remove) + assert first_remove is not None + pos, removed = first_remove + assert to_remove is removed + assert pos == 3 + assert queue.remove_track(to_remove) is None + + queue_after_id_remove = self.list_to_requests(["1a", "1a", "1b", "1b", "1a", "1b", "1b"]) + for item in queue_after_id_remove: + assert item == queue.pop_first() diff --git a/musicbot/utils/settingsmanager/settingsmanager.py b/musicbot/utils/settingsmanager/settingsmanager.py index 5a15165..48a74be 100644 --- a/musicbot/utils/settingsmanager/settingsmanager.py +++ b/musicbot/utils/settingsmanager/settingsmanager.py @@ -4,6 +4,7 @@ import discord import codecs import locale as localee import os +from typing import Any import yaml @@ -53,7 +54,7 @@ class Settings(): with codecs.open(self._SETTINGS_PATH, 'w', encoding='utf8') as f: yaml.dump(self.settings, f, indent=2) - def get(self, identifier, setting, default=''): + def get(self, identifier, setting, default: Any = '') -> Any: """ Gets a value from the settings if a default return value is specified it will return the default if no setting is found. If that default is an attribute of the settings class, the value of the attribute will get returned.""" diff --git a/musicbot/utils/settingsmanager/test_dictmapper.py b/musicbot/utils/settingsmanager/test_dictmapper.py new file mode 100644 index 0000000..825be1e --- /dev/null +++ b/musicbot/utils/settingsmanager/test_dictmapper.py @@ -0,0 +1,91 @@ +from .dictmapper import DictMapper + + +class TestDictMapper(): + def test_dict_mapper_basic_set(self): + d = {} + DictMapper.set(d, ["a", "b", "c"], 1) + assert d == {"a": {"b": {"c": 1}}} + + def test_dict_mapper_basic_get(self): + d = {"a": {"b": {"c": 1}}} + assert DictMapper.get(d, ["a", "b", "c"]) == 1 + + def test_get_variations(self): + d = {"a": {"b": {"c": 1}}} + + assert DictMapper.get(d, ["a", "b", "c"]) == DictMapper.get(d["a"], ["b", "c"]) + assert DictMapper.get(d, ["a", "b", "c"]) == DictMapper.get(d["a"]["b"], ["c"]) + assert DictMapper.get(d, ["a", "b", "c"]) == d["a"]["b"]["c"] + + def test_get_sub_dictionaries(self): + d = {"a": {"b": {"c": 1}}} + assert DictMapper.get(d, ["a", "b"]) == d["a"]["b"] + assert DictMapper.get(d["a"], ["b"]) == DictMapper.get(d["a"], ["b"]) + + def test_set_variation(self): + d = {"a": {"b": {"c": 1}}} + d1 = {} + d2 = {} + + DictMapper.set(d1, ["a", "b"], {"c": 1}) + assert d1 == d + + DictMapper.set(d2, ["a"], {"b": {"c": 1}}) + assert d2 == d + + def test_multiple_paths(self): + d = {} + + DictMapper.set(d, ["a", "b1", "c"], 1) + DictMapper.set(d, ["a", "b1", "d"], 2) + DictMapper.set(d, ["a", "b2", "c"], 3) + DictMapper.set(d, ["a", "b2", "d"], 4) + + expected = { + "a": { + "b1": { + "c": 1, + "d": 2, + }, + "b2": { + "c": 3, + "d": 4, + } + } + } + assert d == expected + + def test_overwrite(self): + d = {} + + DictMapper.set(d, ["a", "b1", "c"], 1) + DictMapper.set(d, ["a", "b1", "d"], 2) + DictMapper.set(d, ["a", "b2", "c"], 3) + DictMapper.set(d, ["a", "b2", "d"], 4) + + expected = { + "a": { + "b1": { + "c": 1, + "d": 2, + }, + "b2": { + "c": 3, + "d": 4, + } + } + } + assert d == expected + + DictMapper.set(d, ["a", "b2"], 4) + expected = { + "a": { + "b1": { + "c": 1, + "d": 2, + }, + "b2": 4, + } + } + assert d == expected diff --git a/musicbot/utils/thumbnailer.py b/musicbot/utils/thumbnailer.py index cbc88ea..0df7b0f 100644 --- a/musicbot/utils/thumbnailer.py +++ b/musicbot/utils/thumbnailer.py @@ -1,57 +1,74 @@ +from time import perf_counter + from bs4 import BeautifulSoup as bs4 +from bot import MusicBot -class ThumbNailer(object): - def __init__(self, bot): - self.bot = bot - self.logger = self.bot.main_logger.bot_logger.getChild("ThumbNailer") - async def get_html(self, url): - async with self.bot.session.get(url, timeout=30) as response: +class Thumbnailer(object): + def __init__(self, bot: MusicBot): + self.bot: MusicBot = bot + self.logger = self.bot.main_logger.bot_logger.getChild("Thumbnailer") + + async def get_html(self, url: str): + async with self.bot.session.get(url, timeout=3) as response: assert response.status == 200 return await response.read() - @staticmethod - async def _soundcloud(self, url): - html = await ThumbNailer.get_html(self, url) + async def _soundcloud(self, url: str) -> str: + perf_start = perf_counter() + html = await self.get_html(url) try: soup = bs4(html, 'html.parser') - img = soup.find("meta", property="twitter:image") - return img["content"] - except Exception as e: - self.logger.exception("%s" % e) - - async def _bandcamp(self, url): - html = await ThumbNailer.get_html(self, url) - try: - soup = bs4(html, 'html.parser') - img = soup.find(class_="popupImage").get("href") + soup = soup.find("meta", property="twitter:image") + img = soup.get("content", default="") + perf_stop = perf_counter() + self.logger.debug("Took %s to find thumbnail from Soundcloud" % (perf_stop - perf_start)) return img except Exception as e: self.logger.exception("%s" % e) + return "" - async def _vimeo(self, url): - html = await ThumbNailer.get_html(self, url) + async def _bandcamp(self, url: str) -> str: + perf_start = perf_counter() + html = await self.get_html(url) try: soup = bs4(html, 'html.parser') - img = soup.find("meta", property="og:image") - return img["content"] + soup = soup.find(class_="popupImage") + img = soup.get("href", default="") + perf_stop = perf_counter() + self.logger.debug("Took %s to find thumbnail from Bandcamp" % (perf_stop - perf_start)) + return img except Exception as e: self.logger.exception("%s" % e) + return "" - @staticmethod - async def identify(self, identifier, uri): + async def _vimeo(self, url: str) -> str: + perf_start = perf_counter() + html = await self.get_html(url) + try: + soup = bs4(html, 'html.parser') + soup = soup.find("meta", property="og:image") + img = soup.get("content", default="") + perf_stop = perf_counter() + self.logger.debug("Took %s to find thumbnail from Vimeo" % (perf_stop - perf_start)) + return img + except Exception as e: + self.logger.exception("%s" % e) + return "" + + async def identify(self, identifier: str, uri: str) -> str: if "youtube" in uri: thumbnail_url = f"https://img.youtube.com/vi/{identifier}/0.jpg" return thumbnail_url elif "soundcloud" in uri: - thumbnail_url = await ThumbNailer._soundcloud(self, url=uri) + thumbnail_url = await self._soundcloud(url=uri) return thumbnail_url elif "bandcamp" in uri: - thumbnail_url = await ThumbNailer._bandcamp(self, url=uri) + thumbnail_url = await self._bandcamp(url=uri) return thumbnail_url elif "vimeo" in uri: - thumbnail_url = await ThumbNailer._vimeo(self, url=uri) + thumbnail_url = await self._vimeo(url=uri) return thumbnail_url else: - return None + return "" diff --git a/musicbot/utils/timeformatter.py b/musicbot/utils/timeformatter.py index a9acb16..1e71aa3 100644 --- a/musicbot/utils/timeformatter.py +++ b/musicbot/utils/timeformatter.py @@ -1,3 +1,7 @@ +# Discord Packages +import lavalink + + def format_ms(milliseconds): seconds = int(milliseconds)/1000 formatted = '' @@ -17,3 +21,7 @@ def unformat_ms(formatted): if len(hms) == 3: seconds += hms[2] * 3600 return seconds * 1000 + + +def format_track_duration(track: lavalink.AudioTrack) -> str: + return '{live}' if track.stream else format_ms(track.duration) diff --git a/musicbot/utils/userinteraction/paginators/queuepaginator.py b/musicbot/utils/userinteraction/paginators/queuepaginator.py index f946106..02d2fd3 100644 --- a/musicbot/utils/userinteraction/paginators/queuepaginator.py +++ b/musicbot/utils/userinteraction/paginators/queuepaginator.py @@ -16,7 +16,7 @@ class QueuePaginator(TextPaginator): duration = player.queue_duration(member=member, include_current=include_current) if member: - member_queue = player.user_queue_with_global_index(member.id) + member_queue = player.user_queue_with_global_index(member) title = localizer.format_str("{queue.userqueue}", _user=member.display_name, _length=len(member_queue), _duration=duration) diff --git a/musicbot/utils/userinteraction/scroller.py b/musicbot/utils/userinteraction/scroller.py index dc94660..1f9e60e 100644 --- a/musicbot/utils/userinteraction/scroller.py +++ b/musicbot/utils/userinteraction/scroller.py @@ -5,7 +5,9 @@ import discord import asyncio from enum import Flag, auto -from typing import List, Optional +from typing import List, Optional, Tuple + +from bot import MusicBot from .paginators import BasePaginator, CantScrollException @@ -42,7 +44,7 @@ class Scroller: if not self.paginator.pages: raise Exception("Paginator contained no pages to display") # TODO: proper error - self.bot = ctx.bot + self.bot: MusicBot = ctx.bot self.channel = ctx.channel self.message: Optional[discord.Message] = None @@ -64,7 +66,7 @@ class Scroller: '\N{BLACK LEFT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}', row=3) last_page_button = ScrollerButton(self.last_page, '\N{BLACK RIGHT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}', row=3) - self.stop_button = ScrollerButton(self.stop, stop_emoji, row=3) + self.stop_button = ScrollerButton(self.stop_scrolling, stop_emoji, row=3) # We start on the first page, can't go back self.back_button.disabled = True @@ -90,9 +92,9 @@ class Scroller: async def start_scrolling(self, clear_mode: ClearOn, message: Optional[discord.Message] = None, - start_page: int = 0) -> discord.Message: + start_page: int = 0) -> Tuple[discord.Message, bool]: self.clear_mode = clear_mode - self.page_number = start_page + self.page_number = min(start_page, len(self.paginator.pages)) self.build_view() self.update_view() if message: @@ -101,9 +103,9 @@ class Scroller: else: self.message = await self.channel.send(embed=self.current_page_embed, view=self.view) await self.scrolling_done.wait() - return self.message + return self.message, self.timed_out - def update_view_on_interaction(self, interaction: discord.Interaction): + def update_view_on_interaction(self, _: discord.Interaction): self.update_view() def update_view(self): @@ -124,13 +126,14 @@ class Scroller: for (i, _) in enumerate(self.paginator.pages): self.navigator.add_option(label=str(i+1), value=str(i)) - async def stop(self, user_stopped: bool, clear_scroller_view: bool = True): + async def stop(self, was_timeout: bool, clear_scroller_view: bool = True): self.is_scrolling_paginator = False self.view.stop() if clear_scroller_view: self.view.clear_items() - if (user_stopped and self.clear_mode & ClearOn.ManualExit or - not user_stopped and self.clear_mode & ClearOn.Timeout): + self.timed_out = was_timeout + if (not was_timeout and self.clear_mode & ClearOn.ManualExit or + was_timeout and self.clear_mode & ClearOn.Timeout): if self.message: await self.message.delete() self.message = None @@ -183,10 +186,10 @@ class Scroller: async def stop_scrolling(self, interaction: discord.Interaction): await interaction.response.defer() - await self.stop(user_stopped=True) + await self.stop(was_timeout=False) async def on_timeout(self): - await self.stop(user_stopped=False) + await self.stop(was_timeout=True) @property def current_page_embed(self): diff --git a/musicbot/utils/userinteraction/selector.py b/musicbot/utils/userinteraction/selector.py index 7059fc9..3f62f99 100644 --- a/musicbot/utils/userinteraction/selector.py +++ b/musicbot/utils/userinteraction/selector.py @@ -84,9 +84,9 @@ class Selector(TextPaginator, Scroller): match self.selector_mode: case SelectMode.SingleSelect: - await self.stop(True, clear_scroller_view=True) + await self.stop(was_timeout=False, clear_scroller_view=True) case SelectMode.SpanningMultiSelect: - await self.stop(True, clear_scroller_view=False) + await self.stop(was_timeout=False, clear_scroller_view=False) case _: await self.update_message() @@ -119,5 +119,5 @@ class Selector(TextPaginator, Scroller): async def start_scrolling(self, clear_mode: ClearOn = ClearOn.Timeout, message: Optional[discord.Message] = None, start_page: int = 0): - message = await super().start_scrolling(clear_mode, message, start_page) - return message, self.callback_results + message, timed_out = await super().start_scrolling(clear_mode, message, start_page) + return message, timed_out, self.callback_results