diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a0ffb87..70be659 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,25 +4,9 @@ on: [push, pull_request] jobs: lint: runs-on: ubuntu-latest - env: - CheckFiles: "bot.py musicbot/" steps: - uses: actions/checkout@v3 - - - name: Set up Python 3.10 - uses: actions/setup-python@v4.5.0 - with: - python-version: 3.10.7 - - - name: Lint with flake8 - run: | - pip install flake8 - flake8 ${CheckFiles} - - - name: Check with Isort - run: | - pip install isort - isort --check --sp setup.cfg ${CheckFiles} + - uses: chartboost/ruff-action@v1 test: runs-on: ubuntu-latest diff --git a/Dockerfile b/Dockerfile index 1e5490a..3885845 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.10.7-alpine +FROM python:3.11.8-alpine LABEL maintainer="Roxedus" diff --git a/README.md b/README.md index 678b881..c16cd60 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ networks: services: lavalink: hostname: lavalink - image: fredboat/lavalink:dev + image: ghcr.io/lavalink-devs/lavalink:4 networks: - internal volumes: diff --git a/bot.py b/bot.py index 01d1c4e..112cf23 100644 --- a/bot.py +++ b/bot.py @@ -1,9 +1,3 @@ -# Discord Packages -import discord -import lavalink -from discord.ext import commands -from discord.flags import MemberCacheFlags - import codecs import os import time @@ -11,10 +5,14 @@ from argparse import ArgumentParser, RawTextHelpFormatter from typing import Optional +import discord +import lavalink +from discord.ext import commands +from discord.flags import MemberCacheFlags + import aiohttp import yaml -# Bot Utilities from musicbot.utils.localisation import Aliaser, LocalizedContext, Localizer, LocalizerWrapper from musicbot.utils.logger import BotLogger from musicbot.utils.settingsmanager import Settings @@ -49,12 +47,12 @@ def __init__(self, datadir, debug: bool = False): self.settings = Settings(datadir, **conf['default server settings']) self.APIkeys = conf.get('APIkeys', {}) - self.localizer = Localizer(conf.get('locale path', "./localization"), conf.get('locale', 'en_en')) - self.aliaser = Aliaser(conf.get('locale path', "./localization"), conf.get('locale', 'en_en')) + self.localizer: Localizer = Localizer(conf.get('locale path', "./localization"), conf.get('locale', 'en_en')) + self.aliaser: Aliaser = Aliaser(conf.get('locale path', "./localization"), conf.get('locale', 'en_en')) self.datadir = datadir - self.debug = debug - self.main_logger = logger + self.debug: bool = debug + self.main_logger: BotLogger = logger self.logger = self.main_logger.bot_logger.getChild("Bot") self.logger.debug("Debug: %s" % debug) self.lavalink: Optional[lavalink.Client] = None @@ -68,7 +66,7 @@ async def process_commands(self, message): ctx = await self.get_context(message, cls=LocalizedContext) # Replace aliases with commands - ctx = self.aliaser.get_command(ctx) + ctx: LocalizedContext = self.aliaser.get_command(ctx) # Add the localizer if ctx.command and ctx.command.cog_name: @@ -99,9 +97,11 @@ async def on_ready(self): self.logger.debug("Bot Ready") self.session = aiohttp.ClientSession(loop=self.loop) - await self.change_presence(activity=discord.Game(type=0, - name=conf["bot"]["playing status"]), - status=discord.Status.online) + + if presence := conf["bot"]["playing status"]: + await self.change_presence(activity=discord.Game(type=0, + name=presence), + status=discord.Status.online) def run(self): try: diff --git a/justfile b/justfile index a9baf00..4483287 100644 --- a/justfile +++ b/justfile @@ -7,12 +7,9 @@ default: # Make a new virtual environment [private] make_venv: - python3.10 -m venv {{env_name}} + python3 -m venv {{env_name}} {{python}} -m pip install --upgrade pip {{python}} -m pip install -r requirements.txt - {{python}} -m pip install flake8 - {{python}} -m pip install isort - {{python}} -m pip install pytest # Make the environment if it does not exit [private] @@ -24,23 +21,16 @@ run: venv {{python}} bot.py # Run with debug logging enabled -debug: run - --debug +debug: venv + {{python}} bot.py --debug clean: rm -rf {{env_name}} # Lint with flake8 -flake: venv - {{python}} -m flake8 bot.py musicbot -# Fix import order with isort -isort: venv - {{python}} -m isort --sp setup.cfg bot.py musicbot - -# Run both isort and flake8 -lint: venv - just isort - just flake +# Run ruff +lint: venv + {{python}} -m ruff check . --fix # Run tests test: venv diff --git a/musicbot/cogs/cogmanager.py b/musicbot/cogs/cogmanager.py index 14825be..204cdea 100644 --- a/musicbot/cogs/cogmanager.py +++ b/musicbot/cogs/cogmanager.py @@ -1,11 +1,10 @@ -# Discord Packages -from discord.ext import commands - import traceback +from discord.ext import commands + from bot import MusicBot +from musicbot.utils.userinteraction import ClearMode, Scroller -from ..utils.userinteraction import ClearOn, Scroller from .helpformatter import commandhelper @@ -21,7 +20,7 @@ async def _cogmanager(self, ctx): ctx.localizer.prefix = 'help' # Ensure the bot looks for locales in the context of help, not cogmanager. paginator = commandhelper(ctx, ctx.command, ctx.invoker, include_subcmd=True) scroller = Scroller(ctx, paginator) - await scroller.start_scrolling(ClearOn.AnyExit) + await scroller.start_scrolling(ClearMode.AnyExit) @_cogmanager.command() @commands.is_owner() @@ -59,7 +58,7 @@ async def _reload(self, ctx, *, module): @_cogmanager.command(name='reloadall') @commands.is_owner() async def _relaod_all(self, ctx): - """Reloads all extensions""" + """Reloads all extensions.""" try: for extension in self.bot.extensions: if extension == 'musicbot.cogs.cogmanager': diff --git a/musicbot/cogs/errors.py b/musicbot/cogs/errors.py index 4098d04..9042309 100644 --- a/musicbot/cogs/errors.py +++ b/musicbot/cogs/errors.py @@ -1,12 +1,11 @@ -# Discord Packages +import sys + import discord from discord.ext import commands -import sys - from bot import MusicBot +from musicbot.utils.userinteraction import ClearMode, Scroller -from ..utils.userinteraction import ClearOn, Scroller from .helpformatter import commandhelper from .music import music_errors @@ -31,7 +30,7 @@ async def on_command_error(self, ctx, err): isinstance(err, commands.BadArgument)): paginator = commandhelper(ctx, ctx.command, ctx.invoker, include_subcmd=False) scroller = Scroller(ctx, paginator) - await scroller.start_scrolling(ClearOn.AnyExit) + await scroller.start_scrolling(ClearMode.AnyExit) if isinstance(err, (commands.CommandNotFound)): return diff --git a/musicbot/cogs/helpformatter/help.py b/musicbot/cogs/helpformatter/help.py index 328cee7..4988060 100644 --- a/musicbot/cogs/helpformatter/help.py +++ b/musicbot/cogs/helpformatter/help.py @@ -1,14 +1,14 @@ -# Discord Packages from discord.ext import commands from bot import MusicBot +from musicbot.utils.userinteraction.scroller import ClearMode, Scroller -from ...utils.userinteraction.scroller import ClearOn, Scroller from .helpformatter import coghelper, commandhelper, helper, prefix_cleaner class Help(commands.Cog): - """Help command""" + """Help command.""" + def __init__(self, bot: MusicBot): self.bot: MusicBot = bot @@ -35,4 +35,4 @@ async def help(self, ctx): # Takes no args because reasons(using the view direc paginator = await coghelper(ctx, thing) scroller = Scroller(ctx, paginator) - await scroller.start_scrolling(ClearOn.Timeout | ClearOn.ManualExit) + await scroller.start_scrolling(ClearMode.Timeout | ClearMode.ManualExit) diff --git a/musicbot/cogs/helpformatter/helpformatter.py b/musicbot/cogs/helpformatter/helpformatter.py index db49186..71e798c 100644 --- a/musicbot/cogs/helpformatter/helpformatter.py +++ b/musicbot/cogs/helpformatter/helpformatter.py @@ -1,9 +1,8 @@ -# Discord Packages -from discord.ext import commands - import re -from ...utils.userinteraction import HelpPaginator +from discord.ext import commands + +from musicbot.utils.userinteraction import HelpPaginator usermention = r"<@!?\d{17,19}>" @@ -91,14 +90,14 @@ def commandhelper(ctx, command, invoker, include_subcmd=True): description = f"```{description}```" paginator = HelpPaginator(max_size=5000, max_fields=5, color=ctx.me.color, title=cmd, description=description) - for sub_command, sub_cmd_dict in sub_commands.items(): + for _sub_command, sub_cmd_dict in sub_commands.items(): paginator.add_command_field(sub_cmd_dict) paginator.add_page_indicator(ctx.localizer, "{pageindicator}", _prefix=ctx.prefix) return paginator def prefix_cleaner(ctx): - """ Changes mentions to prefixes when commands are invoked with mentions.""" + """Changes mentions to prefixes when commands are invoked with mentions.""" bot = ctx.bot prefix = ctx.prefix if re.match(usermention, prefix): diff --git a/musicbot/cogs/misc.py b/musicbot/cogs/misc.py index 7624f00..9232751 100644 --- a/musicbot/cogs/misc.py +++ b/musicbot/cogs/misc.py @@ -1,14 +1,12 @@ -# Discord Packages +import platform +import time + import discord from discord.ext import commands from lavalink import __version__ as LavalinkVersion -import platform -import time - from bot import MusicBot - -from ..utils import bot_version +from musicbot.utils import bot_version class Misc(commands.Cog): @@ -48,9 +46,7 @@ async def _guilds(self, ctx): @commands.command() async def musicinfo(self, ctx): - """ - Info about the music player - """ + """Info about the music player.""" embed = discord.Embed(title='{music.title}', color=ctx.me.color) listeners = 0 @@ -79,9 +75,7 @@ async def reload_alias(self, ctx): @commands.command() async def info(self, ctx): - """ - Info about the bot - """ + """Info about the bot.""" guilds = len(self.bot.guilds) members = len(self.bot.users) diff --git a/musicbot/cogs/music/__init__.py b/musicbot/cogs/music/__init__.py index 2933941..2161b13 100644 --- a/musicbot/cogs/music/__init__.py +++ b/musicbot/cogs/music/__init__.py @@ -1,34 +1,44 @@ -# Discord Packages +import asyncio +import re +import urllib.parse as urlparse +from typing import List, Optional, Tuple + import discord import lavalink from discord import VoiceChannel from discord.ext import commands, tasks +from lavalink import AudioTrack from lavalink.events import ( - NodeChangedEvent, NodeConnectedEvent, NodeDisconnectedEvent, PlayerUpdateEvent, QueueEndEvent, TrackEndEvent, - TrackStartEvent, TrackStuckEvent) -from lavalink.models import AudioTrack - -import asyncio -import re -import urllib.parse as urlparse -from typing import List, Optional, Tuple + NodeChangedEvent, + NodeConnectedEvent, + NodeDisconnectedEvent, + PlayerUpdateEvent, + QueueEndEvent, + TrackEndEvent, + TrackStartEvent, + TrackStuckEvent, +) from bs4 import BeautifulSoup from bot import MusicBot - -# Bot Utilities +from musicbot.utils import checks, timeformatter from musicbot.utils.mixplayer.player import MixPlayer -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 musicbot.utils.thumbnailer import Thumbnailer +from musicbot.utils.userinteraction.paginators import QueuePaginator, TextPaginator +from musicbot.utils.userinteraction.scroller import ClearMode, Scroller +from musicbot.utils.userinteraction.selector import ( + SelectMode, + Selector, + SelectorButton, + SelectorItem, + selector_button_callback, +) + 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\\.)?.+') @@ -54,7 +64,7 @@ async def cog_check(self, ctx): return True async def cog_before_invoke(self, ctx): - """ Ensures a valid player exists whenever a command is run """ + """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. if ctx.guild: self.lavalink.player_manager.create(ctx.guild.id) @@ -174,40 +184,34 @@ async def _search_and_play_query(self, ctx, query: str, check_max_length: bool): @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. """ + """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. """ + """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. """ + async def _seek(self, ctx, *, seconds: int): + """Seeks to a given position in a track.""" 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(int(track_time)) - msg = ctx.localizer.format_str("{seek.track_moved}", _position=timeformatter.format_ms(track_time)) - await ctx.send(msg) - else: - await ctx.send(ctx.localizer.format_str("{seek.missing_amount}")) + track_time = player.position + seconds * 1000 # milliseconds + await player.seek(int(track_time)) + msg = ctx.localizer.format_str("{seek.track_moved}", _position=timeformatter.format_ms(track_time)) + return await ctx.send(msg) @commands.command(name='skip') @require_voice_connection() @require_playing() @voteable(requester_override=True, react_to_vote=True) async def _skip(self, ctx): - """ Skips the current track. """ + """Skips the current track.""" player = self.get_player(ctx.guild) await player.skip() @@ -218,7 +222,7 @@ async def _skip(self, ctx): @checks.dj_or(alone=True) @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. """ + """Plays the queue from a specific point. Disregards tracks before the pos.""" player = self.get_player(ctx.guild) # TODO: Do all queue out of range messages the same way @@ -240,7 +244,7 @@ async def _skip_to(self, ctx, pos: int = 1): @require_playing(require_user_listening=True) @voteable(DJ_override=True, react_to_vote=True) async def _stop(self, ctx): - """ Stops the player and clears its queue. """ + """Stops the player and clears its queue.""" player = self.get_player(ctx.guild) player.queue.clear() await player.stop() @@ -258,16 +262,16 @@ async def _now(self, ctx): @commands.command(name='queue') @require_queue(require_member_queue=True) async def _queue(self, ctx, *, member: Optional[discord.Member] = None): - """ Shows the player's queue. """ + """Shows the player's queue.""" 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) + await scroller.start_scrolling(ClearMode.ManualExit | ClearMode.Timeout) @commands.command(name='myqueue') @require_queue(require_author_queue=True) async def _myqueue(self, ctx): - """ Shows your queue. """ + """Shows your queue.""" await self._queue(ctx, member=ctx.author) @commands.command(name='pause') @@ -275,7 +279,7 @@ async def _myqueue(self, ctx): @require_voice_connection() @require_playing() async def _pause(self, ctx): - """ Pauses/Resumes the current track. """ + """Pauses/Resumes the current track.""" player = self.get_player(ctx.guild) await player.set_pause(not player.paused) if player.paused: @@ -287,7 +291,7 @@ async def _pause(self, ctx): @require_voice_connection() @require_queue(require_author_queue=True) async def _shuffle(self, ctx): - """ Shuffles your queue. """ + """Shuffles your queue.""" player = self.get_player(ctx.guild) player.shuffle_user_queue(ctx.author) await ctx.send(ctx.localizer.format_str("{shuffle}")) @@ -296,12 +300,13 @@ async def _shuffle(self, ctx): @require_voice_connection() @require_queue(require_author_queue=True) async def _move(self, ctx): - """ Moves a song in your queue. """ + """Moves a song in your queue.""" player = self.get_player(ctx.guild) # We create a new selector for each selection def build_move_selector(ctx, queue: List[AudioTrack], title: str, first: bool): - async def return_track(track): + @selector_button_callback + async def return_track(_interaction, _button, track): return track choices = [] @@ -312,7 +317,7 @@ async def return_track(track): else: prefix = f'`{index:<3}-> `\n`{blank}`' label = f'{prefix} [{track.title}]({track.uri})' - selection = SelectorItem(label, str(index), wrap_in_button_callback(return_track, track)) + selection = SelectorItem(label, str(index), return_track(track)) choices.append(selection) return Selector(ctx, choices, select_mode=SelectMode.SpanningMultiSelect, use_tick_for_stop_emoji=True, @@ -323,7 +328,7 @@ async def return_track(track): while True: # Prompt the user for which track to move 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) + message, timed_out, track_to_move = await selector.start_scrolling(ClearMode.Timeout, message, page) page = selector.page_number if not track_to_move or timed_out: @@ -331,7 +336,7 @@ async def return_track(track): # Prompt the user for where to move it 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) + message, timed_out, track_to_replace = await selector.start_scrolling(ClearMode.Timeout, message, page) page = selector.page_number if not track_to_replace or timed_out: @@ -355,34 +360,27 @@ async def return_track(track): await message.delete() async def _interactive_remove(self, ctx, queue: List[AudioTrack]): - """ - Helper function for creating an interactive selector over a given queue + """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: AudioTrack): + @selector_button_callback + async def update_remove_list(_interaction, button: SelectorButton, tracks_list, track: AudioTrack): # It seems duplicate songs still don't satisfy equality - # which meas remove is sufficient to preserve order + # which means remove is sufficient to preserve order # of similar items if track in tracks_list: tracks_list.remove(track) else: tracks_list.append(track) - # The callback from the selector takes a discord interaction and - # the button class. We wrap the track remove callback to handle those - # arguments and update button color - def add_change_button_color(func, *args): - async def inner(_, button: SelectorButton): - if button.style == discord.ButtonStyle.red: - button.style = discord.ButtonStyle.gray - else: - button.style = discord.ButtonStyle.red - return await func(*args) - return inner + if button.style == discord.ButtonStyle.red: + button.style = discord.ButtonStyle.gray + else: + button.style = discord.ButtonStyle.red selector_buttons = [] # Build each selection from the queue, a visible string and a callback. @@ -390,11 +388,11 @@ async def inner(_, button: SelectorButton): requester = self.bot.get_user(track.requester) selector_buttons.append( 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))) + str(index), 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') - _, timed_out, _ = await remove_selector.start_scrolling(ClearOn.AnyExit) + _, timed_out, _ = await remove_selector.start_scrolling(ClearMode.AnyExit) # If any tracks were removed create a scroller for navigating them if tracks_to_remove and not timed_out: @@ -414,13 +412,13 @@ async def inner(_, button: SelectorButton): paginator.close_page() scroller = Scroller(ctx, paginator) - await scroller.start_scrolling(ClearOn.ManualExit | ClearOn.Timeout) + await scroller.start_scrolling(ClearMode.ManualExit | ClearMode.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. """ + """Remove a song from your queue.""" player = self.get_player(ctx.guild) return await self._interactive_remove(ctx, player.user_queue(ctx.author)) @@ -429,7 +427,7 @@ async def _remove(self, ctx): @require_voice_connection() @require_queue() async def _djremove(self, ctx, member: Optional[discord.Member] = None): - """ Remove a song from either the global queue or a users queue""" + """Remove a song from either the global queue or a users queue.""" 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) if member else player.global_queue() @@ -440,7 +438,7 @@ async def _djremove(self, ctx, member: Optional[discord.Member] = None): @require_voice_connection() @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""" + """Remove a song from either the global queue or a users queue.""" player = self.get_player(ctx.guild) player.remove_user_queue(member) @@ -465,19 +463,24 @@ async def _search(self, ctx, *, query): embed = ctx.localizer.format_embed(embed) return await ctx.send(embed=embed) + def make_enqueue_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 + # 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, embed)) + make_enqueue_callback(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, timed_out, _ = await search_selector.start_scrolling(ClearOn.Timeout) + message, timed_out, _ = await search_selector.start_scrolling(ClearMode.Timeout) if timed_out: return @@ -491,7 +494,7 @@ async def _search(self, ctx, *, query): @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. """ + """Disconnects the player from the voice channel and clears its queue.""" player = self.get_player(ctx.guild) player.queue.clear() @@ -504,15 +507,15 @@ async def _disconnect(self, ctx): @commands.command(name='reconnect') @require_voice_connection() @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 """ + async def _reconnect(self, ctx, force: bool = False): + """Tries to disconnect then reconnect the player in case the bot gets stuck on a song.""" player = self.get_player(ctx.guild) current_channel = player.channel_id async def inner_reconnect(): await player.stop() if ctx.voice_client: - await ctx.voice_client.disconnect() + await ctx.voice_client.disconnect(force=force) await asyncio.sleep(1) # Pretend stuff is happening/give everything some time to reset. channel = ctx.guild.get_channel(current_channel) await channel.connect(cls=BasicVoiceClient) @@ -530,7 +533,7 @@ async def inner_reconnect(): @checks.dj_or(alone=True, track_requester=True) @require_playing(require_user_listening=True) 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. """ + """Changes the player's volume. Must be between 0 and 1000. Error Handling for that is done by Lavalink.""" player = self.get_player(ctx.guild) if not volume: @@ -550,7 +553,7 @@ async def _volume(self, ctx, volume: Optional[int] = None): @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. """ + """Attempts to force disconnect the bot without checking if it is connected initially.""" try: player = self.get_player(ctx.guild) player.queue.clear() @@ -567,7 +570,7 @@ async def _forcedisconnect(self, ctx): @checks.dj_or(alone=True) @require_voice_connection() async def _normalize(self, ctx): - """ Reset the equalizer and volume """ + """Reset the equalizer and volume.""" player = self.get_player(ctx.guild) await player.set_volume(100) @@ -581,7 +584,7 @@ async def _normalize(self, ctx): @commands.command(name='boost') @checks.dj_or(alone=True) async def _boost(self, ctx, boost: bool = False): - """ Set the equalizer to bass boost the music """ + """Set the equalizer to bass boost the music.""" player = self.get_player(ctx.guild) if boost is not None: @@ -596,7 +599,7 @@ async def _boost(self, ctx, boost: bool = False): @commands.command(name='nightcore') @checks.dj_or(alone=True) async def _nightcore(self, ctx, boost: bool = False): - """ Set a filter mimicking nightcore the music """ + """Set a filter mimicking nightcore the music.""" player = self.get_player(ctx.guild) await player.nightcoreify(boost) @@ -608,7 +611,7 @@ async def _nightcore(self, ctx, boost: bool = False): @commands.command(name='history') async def _history(self, ctx): - """ Show the last 10 songs played """ + """Show the last 10 songs played.""" player = self.get_player(ctx.guild) history = player.get_history() if not history: @@ -634,7 +637,7 @@ async def _history(self, ctx): @commands.cooldown(1, 5, commands.BucketType.guild) @commands.command(name='lyrics') async def _lyrics(self, ctx, *query: str): - """ Search for lyrics of a song """ + """Search for lyrics of a song.""" # Check for API key if not (genius_access_token := self.bot.APIkeys.get('genius')): return await ctx.send(ctx.localizer.format_str('{errors.missing_api_key}')) @@ -726,7 +729,7 @@ async def get_site_content(url: str, scrape: bool = False) -> Optional[dict | st await ctx.send(embed=page) else: paginator.add_page_indicator(ctx.localizer) - await Scroller(ctx, paginator).start_scrolling(ClearOn.AnyExit) + await Scroller(ctx, paginator).start_scrolling(ClearMode.AnyExit) # Since we are using a paginator, we need to delete the status message # because the final output may consist of multiple pages @@ -737,34 +740,31 @@ async def get_site_content(url: str, scrape: bool = False) -> Optional[dict | st @require_voice_connection() @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 """ + """Shows a set of controls which can be used to skip forward or backwards in the song.""" player = self.get_player(ctx.guild) controls = '{scrub.controls}' - def seek_callback(player: MixPlayer, seconds): - async def inner(_interaction, _button): - newpos = player.position + seconds * 1000 - return await player.seek(newpos) - return inner + @selector_button_callback + async def seek(_interacton, _button, seconds): + newpos = player.position + seconds * 1000 + return await player.seek(newpos) - def toggle_pause_callback(player: MixPlayer): - async def inner(_interaction, button): - should_pause = not player.paused - button.style = discord.ButtonStyle.red if should_pause else discord.ButtonStyle.gray - return await player.set_pause(should_pause) - return inner + @selector_button_callback + async def toggle_pause(_interaction, button: SelectorButton): + should_pause = not player.paused + button.style = discord.ButtonStyle.red if should_pause else discord.ButtonStyle.gray + return await player.set_pause(should_pause) scrubber_controls = [ - SelectorItem("", '\N{BLACK LEFT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}', seek_callback(player, -1000)), - SelectorItem("", '\N{BLACK LEFT-POINTING DOUBLE TRIANGLE}', seek_callback(player, -15)), - SelectorItem("", '\N{Black Right-Pointing Triangle with Double Vertical Bar}', - toggle_pause_callback(player)), - SelectorItem("", '\N{BLACK RIGHT-POINTING DOUBLE TRIANGLE}', seek_callback(player, 15)), - SelectorItem("", '\N{BLACK RIGHT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}', seek_callback(player, 1000)) + SelectorItem("", '\N{BLACK LEFT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}', seek(-1000)), + SelectorItem("", '\N{BLACK LEFT-POINTING DOUBLE TRIANGLE}', seek(-15)), + SelectorItem("", '\N{Black Right-Pointing Triangle with Double Vertical Bar}', toggle_pause()), + SelectorItem("", '\N{BLACK RIGHT-POINTING DOUBLE TRIANGLE}', seek(15)), + SelectorItem("", '\N{BLACK RIGHT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}', seek(1000)) ] scrubber = Selector(ctx, scrubber_controls, select_mode=SelectMode.MultiSelect, use_tick_for_stop_emoji=True, default_text=ctx.localizer.format_str(controls), color=ctx.me.color) - await scrubber.start_scrolling(ClearOn.AnyExit) + await scrubber.start_scrolling(ClearMode.AnyExit) @commands.group(name='loop') async def _loop(self, ctx): @@ -783,7 +783,7 @@ async def _loop(self, ctx): @require_playing(require_user_listening=True) @voteable(DJ_override=True, react_to_vote=True) async def _loop_start(self, ctx): - """ Set the equalizer to bass boost the music """ + """Set the equalizer to bass boost the music.""" player = self.get_player(ctx.guild) player.enable_looping(True) embed = discord.Embed(color=ctx.me.color) @@ -795,7 +795,7 @@ async def _loop_start(self, ctx): @require_playing(require_user_listening=True) @voteable(DJ_override=True, react_to_vote=True) async def _loop_stop(self, ctx): - """ Set the equalizer to bass boost the music """ + """Set the equalizer to bass boost the music.""" player = self.get_player(ctx.guild) player.enable_looping(False) embed = discord.Embed(color=ctx.me.color) @@ -832,7 +832,8 @@ async def track_hook(self, event): @commands.Cog.listener() 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 """ + """Updates listeners when the bot or a user changes voice state.""" + self.logger.debug("Voice state update, member: %s, new_state: %s", member, after) if self.bot.user is None: return # Bot not logged in if member.id == self.bot.user.id and after.channel is not None: @@ -846,6 +847,11 @@ async def on_voice_state_update(self, member: discord.Member, _: discord.VoiceSt if not member.bot: player.update_listeners(member, member.voice) + if member.id == self.bot.user.id and after.channel is None: + voice_client: BasicVoiceClient + if voice_client := member.guild.voice_client: + await voice_client.disconnect(force=True) + if not member.bot: try: player = self.get_player(member.guild) @@ -855,14 +861,15 @@ async def on_voice_state_update(self, member: discord.Member, _: discord.VoiceSt await self.check_leave_voice(member.guild) async def check_leave_voice(self, guild: discord.Guild): - """ Checks if the bot should leave the voice channel """ + """Checks if the bot should leave the voice channel.""" # TODO, disconnect timer? 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() + voice_client: BasicVoiceClient if voice_client := guild.voice_client: - await voice_client.disconnect(force=False) + await voice_client.disconnect(force=True) async def leave_check(self): for player_id in self.lavalink.player_manager.players: @@ -878,14 +885,5 @@ async def leave_timer(self): 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 - - async def setup(bot): await bot.add_cog(Music(bot)) diff --git a/musicbot/cogs/music/decorators.py b/musicbot/cogs/music/decorators.py index 07217b1..5e8681f 100644 --- a/musicbot/cogs/music/decorators.py +++ b/musicbot/cogs/music/decorators.py @@ -1,27 +1,25 @@ -# Discord Packages -import discord - import asyncio import functools import inspect import math -# Bot Utilities +import discord + +from musicbot.utils.checks import is_dj from musicbot.utils.mixplayer.player import MixPlayer -from ...utils.checks import is_dj + from . import music_errors from .voice_client import BasicVoiceClient def require_voice_connection(should_connect=False): - """ - Checks if the bot is in a valid voice channel for the command - should_connect indicates whether the bot should try to join a channel + """Checks if the bot is in a valid voice channel for the command + should_connect indicates whether the bot should try to join a channel. """ def ensure_voice(func): @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. """ + """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") @@ -63,9 +61,8 @@ async def ensure_voice_inner(self, ctx, *command_args, **kwargs): def require_playing(require_user_listening=False): - """ - Checks if the bot is currently playing a track - ensure_user_listening: also checks if the user is listening to the bot + """Checks if the bot is currently playing a track + ensure_user_listening: also checks if the user is listening to the bot. """ def ensure_play(func): @functools.wraps(func) @@ -89,9 +86,8 @@ async def ensure_play_inner(self, ctx, *command_args, **kwargs): def require_queue(require_member_queue=False, require_author_queue=False): - """ - Checks if there is something queued - require_member_queue also checks if the queue of a member is empty, only works when member is an argument + """Checks if there is something queued + require_member_queue also checks if the queue of a member is empty, only works when member is an argument. """ def ensure_queue(func): if require_member_queue: @@ -114,7 +110,8 @@ async def ensure_queue_inner(self, ctx, *command_args, **kwargs): embed = ctx.localizer.format_embed(embed, _user=member.display_name) return await ctx.send(embed=embed) except KeyError: - raise Exception("require_member_error can only be used on commands with a member keyword argument") + raise Exception("require_member_error can only be used \ + on commands with a member keyword argument") from None if require_author_queue: user_queue = player.user_queue(ctx.author) diff --git a/musicbot/cogs/music/music_errors.py b/musicbot/cogs/music/music_errors.py index 4d68218..7c7c5a4 100644 --- a/musicbot/cogs/music/music_errors.py +++ b/musicbot/cogs/music/music_errors.py @@ -1,8 +1,7 @@ -# Discord Packages -from discord.ext.commands import CommandError - from typing import Optional +from discord.ext.commands import CommandError + class MusicError(CommandError): def __init__(self, message: str, inner_error: Optional[Exception] = None): diff --git a/musicbot/cogs/music/voice_client.py b/musicbot/cogs/music/voice_client.py index 52f1855..2c35703 100644 --- a/musicbot/cogs/music/voice_client.py +++ b/musicbot/cogs/music/voice_client.py @@ -1,33 +1,46 @@ -# Discord Packages import discord from bot import MusicBot from musicbot.cogs.music.music_errors import MusicError -class BasicVoiceClient(discord.VoiceClient): +class BasicVoiceClient(discord.VoiceProtocol): 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 + self.logger = self.client.main_logger.bot_logger.getChild("VoiceClient") if self.client.lavalink: self.lavalink = self.client.lavalink else: + self.logger.debug("Client did not have defined lavalink before connect.") 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}) + self.logger.debug("BasicVoiceClient server update, %s", 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}) + self.logger.debug("BasicVoiceClient state update, %s", data) + channel_id = data['channel_id'] - async def connect(self, *, timeout: float, reconnect: bool, self_deaf: bool = False, - self_mute: bool = False) -> None: + if not channel_id: + self.cleanup() + return + + self.channel = self.client.get_channel(int(channel_id)) + 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: + self.logger.debug("Connecting to %s", self.channel) 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: + self.logger.debug("Disconnecting from voice. Force: %s", force) player = self.lavalink.player_manager.get(self.channel.guild.id) if player: @@ -36,6 +49,11 @@ async def disconnect(self, *, force: bool = False) -> None: return # None means disconnect + self.logger.debug("Player found, disconnecting and resetting player channel") await self.channel.guild.change_voice_state(channel=None) player.channel_id = None self.cleanup() + elif force: + self.logger.debug("No player found, disconnecting") + await self.channel.guild.change_voice_state(channel=None) + self.cleanup() diff --git a/musicbot/cogs/nodemanager.py b/musicbot/cogs/nodemanager.py index e8ba3d6..0942172 100644 --- a/musicbot/cogs/nodemanager.py +++ b/musicbot/cogs/nodemanager.py @@ -1,20 +1,18 @@ -# Discord Packages +import codecs +from typing import List, Optional, Union + import discord import lavalink from discord.ext import commands -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.mixplayer import MixPlayer from musicbot.utils.settingsmanager import Settings -from ..utils.mixplayer import MixPlayer -from ..utils.userinteraction import ClearOn, Scroller +from musicbot.utils.userinteraction import ClearMode, Scroller + from .helpformatter import commandhelper @@ -61,12 +59,12 @@ def load_nodes_from_file(self): if node['name'] in name_cache: continue - self.lavalink.add_node(**node) + added = 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}) + new_nodes.append(added) name_cache.append(node['name']) return new_nodes @@ -91,31 +89,27 @@ async def _regioner(self, region): except KeyError: return ':question:' - async def _node_presenter(self, ctx, node): + async def _node_presenter(self, ctx, node: Union[List[lavalink.Node], lavalink.Node]): embed = discord.Embed(color=ctx.me.color) embed.title = 'Added new node!' if isinstance(node, list): for n in node: embed.add_field(name=f'{await self._regioner(n.region)} **Name:** {n.name}', - value=f'**Host:** {n.host}\n **Port:** {n.port}') + value=f'**Host:** {n._transport._host}\n **Port:** {n._transport._port}') 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}') - - if isinstance(node, dict): - embed.description = f'**Name:** {node.get("name")}\n **Host:** {node.get("host")}\n ' \ - f'**Port:** {node.get("port")}\n **Region:** {await self._regioner(node.get("region"))}' + value=f'**Host:** {node._transport._host}\n **Port:** {node._transport._port}') return embed - def get_node_properties(self, node): + def get_node_properties(self, node: lavalink.Node): return { 'name': node.name, - 'host': node.host, - 'port': node.port, - 'password': node.password, + 'host': node._transport._host, + 'port': node._transport._port, + 'password': node._transport._password, 'region': node.region } @@ -126,7 +120,7 @@ async def _node(self, ctx): ctx.localizer.prefix = 'help' # Ensure the bot looks for locales in the context of help, not cogmanager. paginator = commandhelper(ctx, ctx.command, ctx.invoker, include_subcmd=True) scroller = Scroller(ctx, paginator) - await scroller.start_scrolling(ClearOn.AnyExit) + await scroller.start_scrolling(ClearMode.AnyExit) @_node.command(name='reload_file') @commands.is_owner() @@ -142,10 +136,10 @@ async def _add(self, ctx, host: str, port: int, password: str, region, name: Opt 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.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}) + node = self.lavalink.add_node(host, port, password, region, name=name) + self.logger.debug("Adding Lavalink node: %s", (node)) + + embed = await self._node_presenter(ctx, node) embed.title = 'Added new node!' self.settings.set('lavalink', 'nodes', [self.get_node_properties(n) for @@ -168,13 +162,14 @@ async def _remove(self, ctx, node): await ctx.send('Cannot remove the last node') sent_feedback = True break - if (_node.name or _node.host) == node: + if _node.name == node: sent_feedback = True embed = await self._node_presenter(ctx, _node) embed.title = 'Removed node from bot' await ctx.send(embed=embed) - self.lavalink.node_manager.remove_node(_node) - self.logger.info("Removed Lavalink node: %s, %s" % (_node.name, _node.host)) + self.lavalink.node_manager.remove(_node) + await _node.destroy() + self.logger.info("Removed Lavalink node: %s" % (_node.name)) self.settings.set('lavalink', 'nodes', [self.get_node_properties(n) for n in self.lavalink.node_manager.nodes]) @@ -197,7 +192,7 @@ async def _nodechange(self, ctx, node=None): else: newnode = None for _node in self.lavalink.node_manager.nodes: - if (_node.name or _node.host) == node: + if _node.name == node: newnode = _node if not newnode: diff --git a/musicbot/cogs/settings.py b/musicbot/cogs/settings.py index ebc5031..4bc37c3 100644 --- a/musicbot/cogs/settings.py +++ b/musicbot/cogs/settings.py @@ -1,14 +1,13 @@ -# Discord Packages +from typing import Optional + import discord from discord.ext import commands -from typing import Optional - from bot import MusicBot +from musicbot.utils import checks +from musicbot.utils.settingsmanager import Settings as SettingsManager +from musicbot.utils.userinteraction import ClearMode, Scroller -from ..utils import checks -from ..utils.settingsmanager import Settings as SettingsManager -from ..utils.userinteraction import ClearOn, Scroller from .helpformatter import commandhelper @@ -31,7 +30,7 @@ async def _set(self, ctx): ctx.localizer.prefix = 'help' # Ensure the bot looks for locales in the context of help, not cogmanager. paginator = commandhelper(ctx, ctx.command, ctx.invoker, include_subcmd=True) scroller = Scroller(ctx, paginator) - await scroller.start_scrolling(ClearOn.AnyExit) + await scroller.start_scrolling(ClearMode.AnyExit) @checks.is_admin() @commands.guild_only() diff --git a/musicbot/utils/checks.py b/musicbot/utils/checks.py index cce1aa2..ab692b6 100644 --- a/musicbot/utils/checks.py +++ b/musicbot/utils/checks.py @@ -1,4 +1,3 @@ -# Discord Packages import discord from discord.ext import commands diff --git a/musicbot/utils/localisation/alias.py b/musicbot/utils/localisation/alias.py index 8a68d61..36f2ec1 100644 --- a/musicbot/utils/localisation/alias.py +++ b/musicbot/utils/localisation/alias.py @@ -1,9 +1,8 @@ -# Discord Packages -from discord.ext import commands - from glob import glob from os import path +from discord.ext import commands + import yaml """ @@ -21,7 +20,7 @@ def __init__(self, localization_folder, default_lang): self.load_localizations() def _gen_alias_dict(self, commands): - """ Reverses the direction of a dict of commans→aliases to be alias→command. """ + """Reverses the direction of a dict of commans→aliases to be alias→command.""" inverted = {} subcommands = {} for cmd, properties in commands.items(): @@ -79,7 +78,7 @@ def traverse(alias_tree, parents, alias): return default def get_cmd_help(self, locale, command=None, parents=None): - """ Fetches the command info dictionary """ + """Fetches the command info dictionary.""" if parents is None: parents = [] locale = self.localization_table[locale] @@ -103,7 +102,7 @@ def traverse(command_tree, parents, command): return command_tree def get_command(self, ctx): - """ Get a top level command. """ + """Get a top level command.""" if not ctx.prefix: ctx.command = None return ctx @@ -124,7 +123,7 @@ def _replace_command(self, view, index, alias, command): return view def get_subcommand(self, ctx, group=None, parents=None): - """ Recursicely replaces all subcommand aliases with subcommands.""" + """Recursicely replaces all subcommand aliases with subcommands.""" if parents is None: parents = [] view = ctx.view diff --git a/musicbot/utils/localisation/localizedcontext.py b/musicbot/utils/localisation/localizedcontext.py index bcfebd5..c24f359 100644 --- a/musicbot/utils/localisation/localizedcontext.py +++ b/musicbot/utils/localisation/localizedcontext.py @@ -1,8 +1,7 @@ -# Discord Packages -from discord.ext import commands - from typing import Optional +from discord.ext import commands + from .localizer.localizerwrapper import LocalizerWrapper diff --git a/musicbot/utils/localisation/localizer/localizer.py b/musicbot/utils/localisation/localizer/localizer.py index f6237f5..7d23576 100644 --- a/musicbot/utils/localisation/localizer/localizer.py +++ b/musicbot/utils/localisation/localizer/localizer.py @@ -1,11 +1,10 @@ -# Discord Packages -from discord import Embed - import copy import re from glob import glob from os import path +from discord import Embed + import yaml from .dict_utils import SafeDict, flatten @@ -40,7 +39,7 @@ def load_localizations(self): self._load_localization(lang) self.all_localizations = flatten(self.localization_table) - for lang, d in self.localization_table.items(): + for lang, _ in self.localization_table.items(): self.localization_table[lang] = Localizer._parse_localization_dictionary(self.localization_table[lang], self.all_localizations) @@ -69,7 +68,7 @@ def _load_localization(self, lang): l_table = flatten(l_table) # parsing a few times to resolve all values - for i in range(0, 5): + for _ in range(0, 5): l_table = Localizer._parse_localization_dictionary(l_table, l_table) self.localization_table[lang] = Localizer._parse_localization_dictionary(l_table, l_table) @@ -79,7 +78,7 @@ def _load_localization(self, lang): def _parse_localization_dictionary(d, lookup, prefix=None): n_dict = {} for k, v in d.items(): - if type(v) is str: + if isinstance(v, str): n_dict[k] = Localizer._parse_localization_string(v, lookup, prefix) else: n_dict[k] = v @@ -138,11 +137,11 @@ def format_dict(self, d, lang=None, prefix=None, **kvpairs): cursorQueue = [nd] while cursorQueue: cursor = cursorQueue.pop() - for k, v in (cursor.items() if type(cursor) == dict else enumerate(cursor)): - if type(v) == str: + for k, v in (cursor.items() if isinstance(cursor, dict) else enumerate(cursor)): + if isinstance(v, str): # insert translations based on lang cursor[k] = self.format_str(v, lang, prefix, **kvpairs) - elif type(v) == dict or type(v) == list: + elif isinstance(v, dict) or isinstance(v, list): cursorQueue.append(v) return nd diff --git a/musicbot/utils/mixplayer/mixqueue.py b/musicbot/utils/mixplayer/mixqueue.py index 2dfb0fc..3609f30 100644 --- a/musicbot/utils/mixplayer/mixqueue.py +++ b/musicbot/utils/mixplayer/mixqueue.py @@ -1,19 +1,18 @@ -# Discord Packages -from lavalink.models import AudioTrack - import logging from collections import OrderedDict, deque from itertools import chain, cycle, islice from random import shuffle from typing import Generic, Iterable, Iterator, List, Optional, Tuple, TypeVar +from lavalink import AudioTrack + # Would like to ensure the T has a "requester" attribute, but don't know if that is possible T = TypeVar('T', bound=AudioTrack) QueueType = List[T] def roundrobin(*iterables: Iterable[T]) -> Iterator[T]: - """roundrobin('ABC', 'D', 'EF') --> A D E B F C""" + """roundrobin('ABC', 'D', 'EF') --> A D E B F C.""" # Recipe credited to George Sakkis num_active = len(iterables) nexts = cycle(iter(it).__next__ for it in iterables) @@ -79,7 +78,7 @@ def get_user_queue_with_index(self, requester: int) -> List[Tuple[T, int]]: pos = [self._loc_to_glob(requester, i) for i in range(len(queue))] if self.looping: pos = [(p + self.loop_offset) % len(self) for p in pos] - combined = zip(queue, pos) + combined = zip(queue, pos, strict=True) return list(combined) def pop_first(self) -> Optional[T]: @@ -154,9 +153,7 @@ def remove_user_track(self, requester: int, pos: int) -> Optional[T]: return track def remove_track(self, track: T) -> Optional[Tuple[int, T]]: - """ - Removes a track by identity - """ + """Removes a track by identity.""" for queue in self.queues.values(): for i, t in enumerate(queue): if t is track: diff --git a/musicbot/utils/mixplayer/player.py b/musicbot/utils/mixplayer/player.py index 0ce7386..9265cca 100644 --- a/musicbot/utils/mixplayer/player.py +++ b/musicbot/utils/mixplayer/player.py @@ -1,13 +1,12 @@ -# Discord Packages +import logging +from typing import Dict, List, Optional, Set, Tuple + import discord import lavalink 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 @@ -42,18 +41,18 @@ def __init__(self, guild_id: int, node: Node): def add(self, requester: RequesterType, track: AudioTrack, pos: Optional[int] = None) -> Tuple[AudioTrack, int, int]: - """ Adds a track to the queue. """ + """Adds a track to the queue.""" 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 """ + """Adds a track to beginning of the queue.""" self.queue.add_priorty_queue_track(track) self.logger.info(f"Track {track} added to priority queue.") def move_user_track(self, requester: RequesterType, initial: int, final: int): - """ Moves a track in a users queue""" + """Moves a track in a users queue.""" 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}") @@ -66,7 +65,7 @@ def remove_user_queue(self, requester: RequesterType): self.logger.debug(f"User {requester.display_name} has no more tracks. Remove from queue") def remove_user_track(self, requester: RequesterType, pos: int) -> Optional[AudioTrack]: - """ Removes the song at from the queue of requester """ + """Removes the song at from the queue of requester.""" 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 @@ -75,11 +74,11 @@ 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 """ + """Removes the song at in the global queue.""" return self.queue.remove_global_track(pos) def shuffle_user_queue(self, requester: RequesterType): - """ Randomly reorders the queue of requester """ + """Randomly reorders the queue of requester.""" self.queue.shuffle_user_queue(requester.id) def user_queue(self, requester: RequesterType) -> List[AudioTrack]: @@ -124,7 +123,7 @@ async def play(self, track: Optional[AudioTrack] = None, start_time: int = 0): await self.bassboost(False) await self.nightcoreify(False) await self.stop() - await self.node._dispatch_event(QueueEndEvent(self)) + await self.client._dispatch_event(QueueEndEvent(self)) return else: # At this point track will not be None, as the queue is not empty @@ -134,13 +133,13 @@ async def play(self, track: Optional[AudioTrack] = None, start_time: int = 0): if track is None or track.track is None: # Ignore, if the queue was empty we would have dispatched the event already return - 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)) + await self.play_track(track, start_time) + + await self.client._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. """ + """Plays the next track in the queue, if any.""" for _ in range(pos): track = self.queue.pop_first() self.logger.debug(f"Track {track} skipped") @@ -149,8 +148,9 @@ async def skip(self, pos: int = 0): await self.play() async def stop(self): - """ Stops the player. """ - await self.node._send(op='stop', guildId=str(self.guild_id)) + """Stops the player.""" + # await self.node._send(op='stop', guildId=str(self.guild_id)) + await super().stop() self.current = None self.queue.enable_looping(False) self.logger.info("Music player stopped, clearing current track and stopping looping") @@ -207,9 +207,15 @@ def enable_looping(self, looping: bool): self.queue.enable_looping(looping) async def handle_event(self, event): - """ Handles the given event as necessary. """ + """Handles the given event as necessary.""" if isinstance(event, (TrackStuckEvent, TrackExceptionEvent)) or \ - isinstance(event, TrackEndEvent) and event.reason == 'FINISHED': + (isinstance(event, TrackEndEvent) and event.reason.may_start_next()): + if isinstance(event, (TrackStuckEvent, TrackExceptionEvent)): + self.logger.debug("Event stuck or except: %s" % event) + if isinstance(event, TrackEndEvent) and event.reason.may_start_next(): + self.logger.debug("Event end: %s, %s, %s" % (event, event.reason, event.reason.may_start_next())) + if event.track is not None: + self.logger.debug("Event end track: %s, pos: %s" % (event.track, event.track.position)) self.logger.debug("Track ended, clearing votes") self.skip_voters.clear() for _, votes in self.voteables.items(): @@ -217,20 +223,23 @@ async def handle_event(self, event): await self.play() async def bassboost(self, boost: bool): + changed = self.boosted != boost self.boosted = boost + if changed: + self.logger.info(f"{'Enabling' if self.boosted else 'Disabling'} bass 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): + changed = self.nightcore_enabled != nightcore self.nightcore_enabled = nightcore + if changed: + self.logger.info(f"{'Enabling' if self.nightcore_enabled else 'Disabling'} nightcore mode") 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/settingsmanager/dictmapper.py b/musicbot/utils/settingsmanager/dictmapper.py index 045add5..be06ffc 100644 --- a/musicbot/utils/settingsmanager/dictmapper.py +++ b/musicbot/utils/settingsmanager/dictmapper.py @@ -1,10 +1,9 @@ class DictMapper: - """ - Class for creating and interacting with nested dictionaries through lists of keys - E.g. mapping ["a", "b", "c"] to example_dictionary["a"]["b"]["c"]. + """Class for creating and interacting with nested dictionaries through lists of keys + E.g. mapping ["a", "b", "c"] to example_dictionary["a"]["b"]["c"]. - DictMapper.get(d["a"], ["b", "c"]) and DictMapper.get(d, ["a", "b", "c"]) are equivalent. + DictMapper.get(d["a"], ["b", "c"]) and DictMapper.get(d, ["a", "b", "c"]) are equivalent. """ @staticmethod diff --git a/musicbot/utils/settingsmanager/settingsmanager.py b/musicbot/utils/settingsmanager/settingsmanager.py index 48a74be..02d50d9 100644 --- a/musicbot/utils/settingsmanager/settingsmanager.py +++ b/musicbot/utils/settingsmanager/settingsmanager.py @@ -1,11 +1,10 @@ -# Discord Packages -import discord - import codecs import locale as localee import os from typing import Any +import discord + import yaml from .dictmapper import DictMapper @@ -38,7 +37,7 @@ def __init__(self, datadir, **default_settings): self.settings = yaml.load(f, Loader=yaml.SafeLoader) def set(self, identifier, setting, value): - """ Set value in settings, will overwrite any existing values. """ + """Set value in settings, will overwrite any existing values.""" guild_name = None if isinstance(identifier, discord.Guild): guild_name = identifier.name @@ -55,9 +54,10 @@ def set(self, identifier, setting, value): yaml.dump(self.settings, f, indent=2) def get(self, identifier, setting, default: Any = '') -> Any: - """ Gets a value from the settings if a default return value is specified + """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.""" + attribute of the settings class, the value of the attribute will get returned. + """ if isinstance(identifier, discord.Guild): identifier = str(identifier.id) diff --git a/musicbot/utils/timeformatter.py b/musicbot/utils/timeformatter.py index 1e71aa3..612aa22 100644 --- a/musicbot/utils/timeformatter.py +++ b/musicbot/utils/timeformatter.py @@ -1,4 +1,3 @@ -# Discord Packages import lavalink diff --git a/musicbot/utils/userinteraction/__init__.py b/musicbot/utils/userinteraction/__init__.py index 68b276d..aeb8a47 100644 --- a/musicbot/utils/userinteraction/__init__.py +++ b/musicbot/utils/userinteraction/__init__.py @@ -1,6 +1,6 @@ -from .paginators import BasePaginator, CantScrollException, FieldPaginator, HelpPaginator, QueuePaginator, TextPaginator -from .scroller import ClearOn, Scroller +from .paginators import BasePaginator, FieldPaginator, HelpPaginator, QueuePaginator, TextPaginator +from .scroller import ClearMode, Scroller from .selector import Selector __all__ = ['Scroller', 'Selector', 'QueuePaginator', 'HelpPaginator', - 'TextPaginator', 'FieldPaginator', 'CantScrollException', 'BasePaginator', 'ClearOn'] + 'TextPaginator', 'FieldPaginator', 'CantScrollError', 'BasePaginator', 'ClearMode'] diff --git a/musicbot/utils/userinteraction/navbar_range.py b/musicbot/utils/userinteraction/navbar_range.py new file mode 100644 index 0000000..2eb37be --- /dev/null +++ b/musicbot/utils/userinteraction/navbar_range.py @@ -0,0 +1,31 @@ + +class NavBarRange: + def __init__(self, num_items: int, current_item: int, max_iter_length: int, add_ends: bool = True): + self._num_items = num_items + self._current_item = current_item + self._max_iter_length = max_iter_length + self._add_ends = add_ends + + # Start can not be before 0 + start = max(0, self._current_item - self._max_iter_length // 2) + # Start can not be closer to end than max_iter_length + self._start = min(start, self._num_items - self._max_iter_length) + self._end = self._start + self._max_iter_length + + def __iter__(self): + if (self._max_iter_length > self._num_items): + return iter(range(0, self._num_items)) + + mid_select_items = list(range(self._start, self._end)) + if self._add_ends: + mid_select_items[0] = 0 + mid_select_items[-1] = self._num_items - 1 + return iter(mid_select_items) + + @property + def end(self): + return self._end + + @property + def start(self): + return self._end diff --git a/musicbot/utils/userinteraction/paginators/__init__.py b/musicbot/utils/userinteraction/paginators/__init__.py index c5412aa..1fdd2f0 100644 --- a/musicbot/utils/userinteraction/paginators/__init__.py +++ b/musicbot/utils/userinteraction/paginators/__init__.py @@ -1,7 +1,7 @@ -from .basepaginator import BasePaginator, CantScrollException +from .basepaginator import BasePaginator, CantScrollError from .fieldpaginator import FieldPaginator from .helppaginator import HelpPaginator from .queuepaginator import QueuePaginator from .textpaginator import TextPaginator -__all__ = ['QueuePaginator', 'HelpPaginator', 'TextPaginator', 'FieldPaginator', 'CantScrollException', 'BasePaginator'] +__all__ = ['QueuePaginator', 'HelpPaginator', 'TextPaginator', 'FieldPaginator', 'CantScrollError', 'BasePaginator'] diff --git a/musicbot/utils/userinteraction/paginators/basepaginator.py b/musicbot/utils/userinteraction/paginators/basepaginator.py index adb5fb1..edc3598 100644 --- a/musicbot/utils/userinteraction/paginators/basepaginator.py +++ b/musicbot/utils/userinteraction/paginators/basepaginator.py @@ -1,4 +1,4 @@ -class CantScrollException(Exception): +class CantScrollError(Exception): pass diff --git a/musicbot/utils/userinteraction/paginators/fieldpaginator.py b/musicbot/utils/userinteraction/paginators/fieldpaginator.py index 0398f5d..9f93c20 100644 --- a/musicbot/utils/userinteraction/paginators/fieldpaginator.py +++ b/musicbot/utils/userinteraction/paginators/fieldpaginator.py @@ -1,4 +1,3 @@ -# Discord Packages import discord from .basepaginator import BasePaginator diff --git a/musicbot/utils/userinteraction/paginators/helppaginator.py b/musicbot/utils/userinteraction/paginators/helppaginator.py index 026851a..def704f 100644 --- a/musicbot/utils/userinteraction/paginators/helppaginator.py +++ b/musicbot/utils/userinteraction/paginators/helppaginator.py @@ -1,4 +1,3 @@ -# Discord Packages import discord from .fieldpaginator import FieldPaginator diff --git a/musicbot/utils/userinteraction/paginators/queuepaginator.py b/musicbot/utils/userinteraction/paginators/queuepaginator.py index 02d2fd3..ea42e10 100644 --- a/musicbot/utils/userinteraction/paginators/queuepaginator.py +++ b/musicbot/utils/userinteraction/paginators/queuepaginator.py @@ -1,9 +1,9 @@ -# Discord Packages +from typing import Optional + import discord -from typing import Optional +from musicbot.utils.mixplayer.player import MixPlayer -from ...mixplayer.player import MixPlayer from .textpaginator import TextPaginator diff --git a/musicbot/utils/userinteraction/paginators/textpaginator.py b/musicbot/utils/userinteraction/paginators/textpaginator.py index a396e2c..e97eeb7 100644 --- a/musicbot/utils/userinteraction/paginators/textpaginator.py +++ b/musicbot/utils/userinteraction/paginators/textpaginator.py @@ -1,4 +1,3 @@ -# Discord Packages import discord from .basepaginator import BasePaginator diff --git a/musicbot/utils/userinteraction/scroller.py b/musicbot/utils/userinteraction/scroller.py index 1f9e60e..2f96875 100644 --- a/musicbot/utils/userinteraction/scroller.py +++ b/musicbot/utils/userinteraction/scroller.py @@ -1,18 +1,20 @@ from __future__ import annotations -# Discord Packages -import discord - import asyncio from enum import Flag, auto from typing import List, Optional, Tuple +import discord + from bot import MusicBot -from .paginators import BasePaginator, CantScrollException +from .navbar_range import NavBarRange +from .paginators import BasePaginator, CantScrollError + +DISCORD_MAX_SELECTOR_OPTIONS = 25 -class ClearOn(Flag): +class ClearMode(Flag): Timeout = auto() ManualExit = auto() AnyExit = Timeout | ManualExit @@ -85,12 +87,12 @@ def __init__(self, ctx, paginator, timeout=20.0, use_tick_for_stop_emoji: bool = self.permissions = self.channel.permissions_for(bot_user) if not self.permissions.embed_links: - raise CantScrollException('Bot does not have embed links permission.') + raise CantScrollError('Bot does not have embed links permission.') if not self.permissions.send_messages: - raise CantScrollException('Bot cannot send messages.') + raise CantScrollError('Bot cannot send messages.') - async def start_scrolling(self, clear_mode: ClearOn, + async def start_scrolling(self, clear_mode: ClearMode, message: Optional[discord.Message] = None, start_page: int = 0) -> Tuple[discord.Message, bool]: self.clear_mode = clear_mode @@ -110,7 +112,7 @@ def update_view_on_interaction(self, _: discord.Interaction): def update_view(self): if self.use_nav_bar: - self.navigator.placeholder = f"Page: {self.page_number + 1}/{len(self.paginator.pages)}" + self._update_navbar_items() if self.is_scrolling_paginator: self.back_button.disabled = self.page_number == 0 @@ -121,10 +123,32 @@ def build_view(self): self.view.add_item(item=button) if self.use_nav_bar: - self.navigator = ScrollerNav(self.navigate, placeholder="Navigate to page", row=4) + self.navigator = None + self._update_navbar_items() + + def _update_navbar_items(self): + placeholder = f"Page: {self.page_number + 1}/{len(self.paginator.pages)}" + did_exist = False + # If we have more than 25 items, the navigator needs to be re-created. + num_selectable_items = len(self.paginator.pages) + if num_selectable_items > DISCORD_MAX_SELECTOR_OPTIONS and self.navigator is not None: + self.view.remove_item(self.navigator) + self.navigator = None + did_exist = True + + if self.navigator is None: + if not did_exist: + placeholder = "Navigate to page" + self.navigator = ScrollerNav(self.navigate, placeholder=placeholder, row=4) self.view.add_item(item=self.navigator) - for (i, _) in enumerate(self.paginator.pages): - self.navigator.add_option(label=str(i+1), value=str(i)) + navigatable_items = NavBarRange(num_items=num_selectable_items, + current_item=self.page_number, + max_iter_length=DISCORD_MAX_SELECTOR_OPTIONS, + add_ends=True) + for page in navigatable_items: + self.navigator.add_option(label=str(page+1), value=str(page)) + else: + self.navigator.placeholder = placeholder async def stop(self, was_timeout: bool, clear_scroller_view: bool = True): self.is_scrolling_paginator = False @@ -132,8 +156,8 @@ async def stop(self, was_timeout: bool, clear_scroller_view: bool = True): if clear_scroller_view: self.view.clear_items() 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 (not was_timeout and self.clear_mode & ClearMode.ManualExit or + was_timeout and self.clear_mode & ClearMode.Timeout): if self.message: await self.message.delete() self.message = None diff --git a/musicbot/utils/userinteraction/selector.py b/musicbot/utils/userinteraction/selector.py index 3f62f99..bd25152 100644 --- a/musicbot/utils/userinteraction/selector.py +++ b/musicbot/utils/userinteraction/selector.py @@ -1,13 +1,21 @@ from __future__ import annotations -# Discord Packages -import discord - from enum import Enum, auto from typing import Callable, Coroutine, List, Optional +import discord + from .paginators import TextPaginator -from .scroller import ClearOn, Scroller +from .scroller import ClearMode, Scroller + + +def selector_button_callback(f): + def wrapper(*args, **kwargs): + async def base_button_callback(_interaction, _button): + # call the decorated function f with provided args and button info + return await f(_interaction, _button, *args, **kwargs) + return base_button_callback + return wrapper class SelectMode(Enum): @@ -108,7 +116,6 @@ def update_buttons(self): self.view.remove_item(item=button) self.currently_visible_buttons = [] - self.page_number start = self.page_number * self.selections_per_page end = min((self.page_number + 1) * self.selections_per_page, len(self.selections)) @@ -116,7 +123,7 @@ def update_buttons(self): self.view.add_item(item=button) self.currently_visible_buttons.append(button) - async def start_scrolling(self, clear_mode: ClearOn = ClearOn.Timeout, + async def start_scrolling(self, clear_mode: ClearMode = ClearMode.Timeout, message: Optional[discord.Message] = None, start_page: int = 0): message, timed_out = await super().start_scrolling(clear_mode, message, start_page) diff --git a/musicbot/utils/userinteraction/test_navbar_range.py b/musicbot/utils/userinteraction/test_navbar_range.py new file mode 100644 index 0000000..5c4d22c --- /dev/null +++ b/musicbot/utils/userinteraction/test_navbar_range.py @@ -0,0 +1,63 @@ +from .navbar_range import NavBarRange + + +class TestCenteredRange(): + def test_centered_basic_range(self): + nav_range = NavBarRange(num_items=15, current_item=6, max_iter_length=7, add_ends=False) + assert list(nav_range) == [3, 4, 5, 6, 7, 8, 9] + + def test_centered_padded_range(self): + nav_range = NavBarRange(num_items=15, current_item=6, max_iter_length=7, add_ends=True) + assert list(nav_range) == [0, 4, 5, 6, 7, 8, 14] + + def test_beginning_basic_range(self): + nav_range = NavBarRange(num_items=15, current_item=0, max_iter_length=7, add_ends=False) + assert list(nav_range) == [0, 1, 2, 3, 4, 5, 6] + nav_range = NavBarRange(num_items=15, current_item=1, max_iter_length=7, add_ends=False) + assert list(nav_range) == [0, 1, 2, 3, 4, 5, 6] + nav_range = NavBarRange(num_items=15, current_item=3, max_iter_length=7, add_ends=False) + assert list(nav_range) == [0, 1, 2, 3, 4, 5, 6] + + # Should be shifted + nav_range = NavBarRange(num_items=15, current_item=4, max_iter_length=7, add_ends=False) + assert list(nav_range) == [1, 2, 3, 4, 5, 6, 7] + + def test_beginning_padded_range(self): + nav_range = NavBarRange(num_items=15, current_item=0, max_iter_length=7, add_ends=True) + assert list(nav_range) == [0, 1, 2, 3, 4, 5, 14] + nav_range = NavBarRange(num_items=15, current_item=1, max_iter_length=7, add_ends=True) + assert list(nav_range) == [0, 1, 2, 3, 4, 5, 14] + nav_range = NavBarRange(num_items=15, current_item=3, max_iter_length=7, add_ends=True) + assert list(nav_range) == [0, 1, 2, 3, 4, 5, 14] + + # Should be shifted + nav_range = NavBarRange(num_items=15, current_item=4, max_iter_length=7, add_ends=True) + assert list(nav_range) == [0, 2, 3, 4, 5, 6, 14] + + def test_end_basic_range(self): + nav_range = NavBarRange(num_items=15, current_item=14, max_iter_length=7, add_ends=False) + assert list(nav_range) == [8, 9, 10, 11, 12, 13, 14] + nav_range = NavBarRange(num_items=15, current_item=13, max_iter_length=7, add_ends=False) + assert list(nav_range) == [8, 9, 10, 11, 12, 13, 14] + nav_range = NavBarRange(num_items=15, current_item=11, max_iter_length=7, add_ends=False) + assert list(nav_range) == [8, 9, 10, 11, 12, 13, 14] + + # Should be shifted + nav_range = NavBarRange(num_items=15, current_item=10, max_iter_length=7, add_ends=False) + assert list(nav_range) == [7, 8, 9, 10, 11, 12, 13] + + def test_end_padded_range(self): + nav_range = NavBarRange(num_items=15, current_item=14, max_iter_length=7, add_ends=True) + assert list(nav_range) == [0, 9, 10, 11, 12, 13, 14] + nav_range = NavBarRange(num_items=15, current_item=13, max_iter_length=7, add_ends=True) + assert list(nav_range) == [0, 9, 10, 11, 12, 13, 14] + nav_range = NavBarRange(num_items=15, current_item=11, max_iter_length=7, add_ends=True) + assert list(nav_range) == [0, 9, 10, 11, 12, 13, 14] + + # Should be shifted + nav_range = NavBarRange(num_items=15, current_item=10, max_iter_length=7, add_ends=True) + assert list(nav_range) == [0, 8, 9, 10, 11, 12, 14] + + def test_short_range(self): + nav_range = NavBarRange(num_items=15, current_item=7, max_iter_length=25) + assert list(nav_range) == list(range(15)) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..3770942 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,16 @@ + + +# Required in order to make pytest handle imports correctly. +[tool.pytest.ini_options] +pythonpath = ["."] + +[tool.ruff] +# Allow lines to be as long as 120 characters. +lint.select = ["F", "E", "B", "W", "I001"] +line-length = 120 + +[tool.ruff.lint.isort] +section-order = ["future", "standard-library", "discord", "third-party", "first-party", "local-folder"] +[tool.ruff.lint.isort.sections] +"discord" = ["discord", "lavalink"] + diff --git a/requirements.txt b/requirements.txt index 43aced9..eba5d03 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,8 @@ -discord.py == 2.0.* -lavalink == 4.0.* +discord.py == 2.3.* +lavalink == 5.3.* asyncio pyyaml BeautifulSoup4 +pytest +ruff +pre-commit diff --git a/setup.cfg b/setup.cfg index e5640bc..dfe3c01 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,18 +3,6 @@ max-line-length = 120 count = True statistics = True -[isort] -known_discord = discord,lavalink -known_utils = cogs.utils,cogs.helpformatter,musicbot.utils -import_heading_discord = Discord Packages -import_heading_utils = Bot Utilities -sections = FUTURE,DISCORD,STDLIB,THIRDPARTY,FIRSTPARTY,UTILS,LOCALFOLDER -no_lines_before = LOCALFOLDER -indent = " " -line_length = 120 -balanced_wrapping = True -multi_line_output = 4 - [pycodestyle] max_line_length = 120