Merge branch 'dev' into feature/improved_readme

This commit is contained in:
Ev-1 authored and GitHub committed 2022-11-10 14:40:03 +01:00
commit 4710fa28db
33 files changed
+858 -499

No files matched your search

+16 -3
View File
@@ -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:
+6 -3
View File
@@ -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()
+2 -2
View File
@@ -50,11 +50,11 @@ move:
description: 'Moves a track in your queue.'
remove:
aliases: [rem]
args: '<pos>'
args: '<member>'
description: 'Removes a song from your queue.'
DJremove:
aliases: [DJremove, djremove]
args: '<pos>'
args: '<member>'
description: 'Removes a song from the global queue.'
removeuser:
aliases: [removeuser]
+5 -4
View File
@@ -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}
+1 -1
View File
@@ -1 +1 @@
repo: "https://github.com/Ev-1/ShiteMusicBot"
repo: "https://github.com/r-Norge/ShiteMusicBot"
+32 -3
View File
@@ -54,7 +54,7 @@ remove:
description: 'Fjerner en sang fra personlige køen din.'
DJremove:
aliases: [DJfjern, djfjern]
args: '<posisjon>'
args: '<servermedlem>'
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: <True/False>
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: '<sang>' # 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.'
description: 'Stopper båtten.'
+5 -4
View File
@@ -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}
+3
View File
@@ -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}"
+5 -3
View File
@@ -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}
+1 -1
View File
@@ -54,7 +54,7 @@ remove:
description: 'Wemoves a sonyg fwom youw queue.'
DJremove:
aliases: [DJwemove, djwemove]
args: '<pos>'
args: '<member>'
description: 'Wemoves a sonyg fwom the gwobaw queue.'
removeuser:
aliases: [wemoveusew]
+5 -3
View File
@@ -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}
+6 -3
View File
@@ -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()
+53 -44
View File
@@ -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):
+4 -2
View File
@@ -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)
+9 -7
View File
@@ -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)
+224 -226
View File
@@ -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
+19 -47
View File
@@ -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)
+68 -11
View File
@@ -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)
+41
View File
@@ -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()
+33 -22
View File
@@ -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
+17 -12
View File
@@ -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'):
@@ -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
+2 -2
View File
@@ -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)
+36 -16
View File
@@ -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, [])
+65 -31
View File
@@ -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 <pos> 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 <pos> 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
+27 -2
View File
@@ -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()
@@ -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."""
@@ -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
+46 -29
View File
@@ -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 ""
+8
View File
@@ -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)
@@ -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)
+15 -12
View File
@@ -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):
+4 -4
View File
@@ -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