├── .gitignore ├── LICENSE ├── README.md ├── bot ├── __init__.py ├── bot.py └── cogs │ └── music.py ├── config └── application.yml └── launcher.py /.gitignore: -------------------------------------------------------------------------------- 1 | ### Custom ### 2 | token.0 3 | openjdk-13.0.2_windows-x64_bin/ 4 | openjdk-13.0.2_osx-x64_bin/ 5 | openjdk-13.0.2_linux-x64_bin/ 6 | 7 | ### Python ### 8 | # Byte-compiled / optimized / DLL files 9 | __pycache__/ 10 | *.py[cod] 11 | *$py.class 12 | 13 | # C extensions 14 | *.so 15 | 16 | # Distribution / packaging 17 | .Python 18 | build/ 19 | develop-eggs/ 20 | dist/ 21 | downloads/ 22 | eggs/ 23 | .eggs/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | wheels/ 28 | pip-wheel-metadata/ 29 | share/python-wheels/ 30 | *.egg-info/ 31 | .installed.cfg 32 | *.egg 33 | MANIFEST 34 | 35 | # PyInstaller 36 | # Usually these files are written by a python script from a template 37 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 38 | *.manifest 39 | *.spec 40 | 41 | # Installer logs 42 | pip-log.txt 43 | pip-delete-this-directory.txt 44 | 45 | # Unit test / coverage reports 46 | htmlcov/ 47 | .tox/ 48 | .nox/ 49 | .coverage 50 | .coverage.* 51 | .cache 52 | nosetests.xml 53 | coverage.xml 54 | *.cover 55 | *.py,cover 56 | .hypothesis/ 57 | .pytest_cache/ 58 | pytestdebug.log 59 | 60 | # Translations 61 | *.mo 62 | *.pot 63 | 64 | # Django stuff: 65 | local_settings.py 66 | db.sqlite3 67 | db.sqlite3-journal 68 | 69 | # Flask stuff: 70 | instance/ 71 | .webassets-cache 72 | 73 | # Scrapy stuff: 74 | .scrapy 75 | 76 | # Sphinx documentation 77 | docs/_build/ 78 | doc/_build/ 79 | 80 | # PyBuilder 81 | target/ 82 | 83 | # Jupyter Notebook 84 | .ipynb_checkpoints 85 | 86 | # IPython 87 | profile_default/ 88 | ipython_config.py 89 | 90 | # pyenv 91 | .python-version 92 | 93 | # pipenv 94 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 95 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 96 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 97 | # install all needed dependencies. 98 | #Pipfile.lock 99 | 100 | # poetry 101 | #poetry.lock 102 | 103 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 104 | __pypackages__/ 105 | 106 | # Celery stuff 107 | celerybeat-schedule 108 | celerybeat.pid 109 | 110 | # SageMath parsed files 111 | *.sage.py 112 | 113 | # Environments 114 | # .env 115 | .env/ 116 | .venv/ 117 | env/ 118 | venv/ 119 | ENV/ 120 | env.bak/ 121 | venv.bak/ 122 | pythonenv* 123 | 124 | # Spyder project settings 125 | .spyderproject 126 | .spyproject 127 | 128 | # Rope project settings 129 | .ropeproject 130 | 131 | # mkdocs documentation 132 | /site 133 | 134 | # mypy 135 | .mypy_cache/ 136 | .dmypy.json 137 | dmypy.json 138 | 139 | # Pyre type checker 140 | .pyre/ 141 | 142 | # pytype static type analyzer 143 | .pytype/ 144 | 145 | # operating system-related files 146 | *.DS_Store #file properties cache/storage on macOS 147 | Thumbs.db #thumbnail cache on Windows 148 | 149 | # profiling data 150 | .prof 151 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2020-2021, Carberra Tutorials 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Building a discord.py music bot (2020) 2 | 3 | Welcome to the official GitHub repository for the [Building a discord.py music bot (2020)](https://www.youtube.com/playlist?list=PLYeOw6sTSy6ZIfraPiUsJWuxjqoL47U3u) series by [Carberra Tutorials](https://youtube.carberra.xyz)! 4 | 5 | This repository is designed purely as a supplementary aid to the series, and should **NOT** be downloaded without having watched it first. 6 | 7 | You can [browse the tags](https://github.com/Carberra/discord.py-music-tutorial/releases) to view the code as it was after a specific episode. 8 | 9 | ## Series requirements 10 | 11 | A [separate webpage](https://files.carberra.xyz/requirements/discord-music-2020) has been created to outline all the required programs and libraries. You can also watch the [introduction video](https://www.youtube.com/watch?v=tZPrkKT9QHc&list=PLYeOw6sTSy6ZIfraPiUsJWuxjqoL47U3u&index=1) for an installation walkthrough. 12 | 13 | ## License 14 | 15 | This repository is made available via the [BSD 3-Clause License](https://github.com/Carberra/discord.py-music-tutorial/blob/master/LICENSE). 16 | 17 | ## Help and further information 18 | 19 | If you need help using this repository, [watch the series](https://www.youtube.com/playlist?list=PLYeOw6sTSy6ZIfraPiUsJWuxjqoL47U3u). If you still need help beyond that, [join the Carberra Tutorials Discord server](https://discord.carberra.xyz). 20 | -------------------------------------------------------------------------------- /bot/__init__.py: -------------------------------------------------------------------------------- 1 | from .bot import MusicBot -------------------------------------------------------------------------------- /bot/bot.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import discord 4 | from discord.ext import commands 5 | 6 | 7 | class MusicBot(commands.Bot): 8 | def __init__(self): 9 | self._cogs = [p.stem for p in Path(".").glob("./bot/cogs/*.py")] 10 | super().__init__(command_prefix=self.prefix, case_insensitive=True) 11 | 12 | def setup(self): 13 | print("Running setup...") 14 | 15 | for cog in self._cogs: 16 | self.load_extension(f"bot.cogs.{cog}") 17 | print(f" Loaded `{cog}` cog.") 18 | 19 | print("Setup complete.") 20 | 21 | def run(self): 22 | self.setup() 23 | 24 | with open("data/token.0", "r", encoding="utf-8") as f: 25 | TOKEN = f.read() 26 | 27 | print("Running bot...") 28 | super().run(TOKEN, reconnect=True) 29 | 30 | async def shutdown(self): 31 | print("Closing connection to Discord...") 32 | await super().close() 33 | 34 | async def close(self): 35 | print("Closing on keyboard interrupt...") 36 | await self.shutdown() 37 | 38 | async def on_connect(self): 39 | print(f" Connected to Discord (latency: {self.latency*1000:,.0f} ms).") 40 | 41 | async def on_resumed(self): 42 | print("Bot resumed.") 43 | 44 | async def on_disconnect(self): 45 | print("Bot disconnected.") 46 | 47 | async def on_error(self, err, *args, **kwargs): 48 | raise 49 | 50 | async def on_command_error(self, ctx, exc): 51 | raise getattr(exc, "original", exc) 52 | 53 | async def on_ready(self): 54 | self.client_id = (await self.application_info()).id 55 | print("Bot ready.") 56 | 57 | async def prefix(self, bot, msg): 58 | return commands.when_mentioned_or("+")(bot, msg) 59 | 60 | async def process_commands(self, msg): 61 | ctx = await self.get_context(msg, cls=commands.Context) 62 | 63 | if ctx.command is not None: 64 | await self.invoke(ctx) 65 | 66 | async def on_message(self, msg): 67 | if not msg.author.bot: 68 | await self.process_commands(msg) 69 | -------------------------------------------------------------------------------- /bot/cogs/music.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import datetime as dt 3 | import enum 4 | import random 5 | import re 6 | import typing as t 7 | from enum import Enum 8 | 9 | import aiohttp 10 | import discord 11 | import wavelink 12 | from discord.ext import commands 13 | 14 | URL_REGEX = r"(?i)\b((?:https?://|www\d{0,3}[.]|[a-z0-9.\-]+[.][a-z]{2,4}/)(?:[^\s()<>]+|\(([^\s()<>]+|(\([^\s()<>]+\)))*\))+(?:\(([^\s()<>]+|(\([^\s()<>]+\)))*\)|[^\s`!()\[\]{};:'\".,<>?«»“”‘’]))" 15 | LYRICS_URL = "https://some-random-api.ml/lyrics?title=" 16 | HZ_BANDS = (20, 40, 63, 100, 150, 250, 400, 450, 630, 1000, 1600, 2500, 4000, 10000, 16000) 17 | TIME_REGEX = r"([0-9]{1,2})[:ms](([0-9]{1,2})s?)?" 18 | OPTIONS = { 19 | "1️⃣": 0, 20 | "2⃣": 1, 21 | "3⃣": 2, 22 | "4⃣": 3, 23 | "5⃣": 4, 24 | } 25 | 26 | 27 | class AlreadyConnectedToChannel(commands.CommandError): 28 | pass 29 | 30 | 31 | class NoVoiceChannel(commands.CommandError): 32 | pass 33 | 34 | 35 | class QueueIsEmpty(commands.CommandError): 36 | pass 37 | 38 | 39 | class NoTracksFound(commands.CommandError): 40 | pass 41 | 42 | 43 | class PlayerIsAlreadyPaused(commands.CommandError): 44 | pass 45 | 46 | 47 | class NoMoreTracks(commands.CommandError): 48 | pass 49 | 50 | 51 | class NoPreviousTracks(commands.CommandError): 52 | pass 53 | 54 | 55 | class InvalidRepeatMode(commands.CommandError): 56 | pass 57 | 58 | 59 | class VolumeTooLow(commands.CommandError): 60 | pass 61 | 62 | 63 | class VolumeTooHigh(commands.CommandError): 64 | pass 65 | 66 | 67 | class MaxVolume(commands.CommandError): 68 | pass 69 | 70 | 71 | class MinVolume(commands.CommandError): 72 | pass 73 | 74 | 75 | class NoLyricsFound(commands.CommandError): 76 | pass 77 | 78 | 79 | class InvalidEQPreset(commands.CommandError): 80 | pass 81 | 82 | 83 | class NonExistentEQBand(commands.CommandError): 84 | pass 85 | 86 | 87 | class EQGainOutOfBounds(commands.CommandError): 88 | pass 89 | 90 | 91 | class InvalidTimeString(commands.CommandError): 92 | pass 93 | 94 | 95 | class RepeatMode(Enum): 96 | NONE = 0 97 | ONE = 1 98 | ALL = 2 99 | 100 | 101 | class Queue: 102 | def __init__(self): 103 | self._queue = [] 104 | self.position = 0 105 | self.repeat_mode = RepeatMode.NONE 106 | 107 | @property 108 | def is_empty(self): 109 | return not self._queue 110 | 111 | @property 112 | def current_track(self): 113 | if not self._queue: 114 | raise QueueIsEmpty 115 | 116 | if self.position <= len(self._queue) - 1: 117 | return self._queue[self.position] 118 | 119 | @property 120 | def upcoming(self): 121 | if not self._queue: 122 | raise QueueIsEmpty 123 | 124 | return self._queue[self.position + 1:] 125 | 126 | @property 127 | def history(self): 128 | if not self._queue: 129 | raise QueueIsEmpty 130 | 131 | return self._queue[:self.position] 132 | 133 | @property 134 | def length(self): 135 | return len(self._queue) 136 | 137 | def add(self, *args): 138 | self._queue.extend(args) 139 | 140 | def get_next_track(self): 141 | if not self._queue: 142 | raise QueueIsEmpty 143 | 144 | self.position += 1 145 | 146 | if self.position < 0: 147 | return None 148 | elif self.position > len(self._queue) - 1: 149 | if self.repeat_mode == RepeatMode.ALL: 150 | self.position = 0 151 | else: 152 | return None 153 | 154 | return self._queue[self.position] 155 | 156 | def shuffle(self): 157 | if not self._queue: 158 | raise QueueIsEmpty 159 | 160 | upcoming = self.upcoming 161 | random.shuffle(upcoming) 162 | self._queue = self._queue[:self.position + 1] 163 | self._queue.extend(upcoming) 164 | 165 | def set_repeat_mode(self, mode): 166 | if mode == "none": 167 | self.repeat_mode = RepeatMode.NONE 168 | elif mode == "1": 169 | self.repeat_mode = RepeatMode.ONE 170 | elif mode == "all": 171 | self.repeat_mode = RepeatMode.ALL 172 | 173 | def empty(self): 174 | self._queue.clear() 175 | self.position = 0 176 | 177 | 178 | class Player(wavelink.Player): 179 | def __init__(self, *args, **kwargs): 180 | super().__init__(*args, **kwargs) 181 | self.queue = Queue() 182 | self.eq_levels = [0.] * 15 183 | 184 | async def connect(self, ctx, channel=None): 185 | if self.is_connected: 186 | raise AlreadyConnectedToChannel 187 | 188 | if (channel := getattr(ctx.author.voice, "channel", channel)) is None: 189 | raise NoVoiceChannel 190 | 191 | await super().connect(channel.id) 192 | return channel 193 | 194 | async def teardown(self): 195 | try: 196 | await self.destroy() 197 | except KeyError: 198 | pass 199 | 200 | async def add_tracks(self, ctx, tracks): 201 | if not tracks: 202 | raise NoTracksFound 203 | 204 | if isinstance(tracks, wavelink.TrackPlaylist): 205 | self.queue.add(*tracks.tracks) 206 | elif len(tracks) == 1: 207 | self.queue.add(tracks[0]) 208 | await ctx.send(f"Added {tracks[0].title} to the queue.") 209 | else: 210 | if (track := await self.choose_track(ctx, tracks)) is not None: 211 | self.queue.add(track) 212 | await ctx.send(f"Added {track.title} to the queue.") 213 | 214 | if not self.is_playing and not self.queue.is_empty: 215 | await self.start_playback() 216 | 217 | async def choose_track(self, ctx, tracks): 218 | def _check(r, u): 219 | return ( 220 | r.emoji in OPTIONS.keys() 221 | and u == ctx.author 222 | and r.message.id == msg.id 223 | ) 224 | 225 | embed = discord.Embed( 226 | title="Choose a song", 227 | description=( 228 | "\n".join( 229 | f"**{i+1}.** {t.title} ({t.length//60000}:{str(t.length%60).zfill(2)})" 230 | for i, t in enumerate(tracks[:5]) 231 | ) 232 | ), 233 | colour=ctx.author.colour, 234 | timestamp=dt.datetime.utcnow() 235 | ) 236 | embed.set_author(name="Query Results") 237 | embed.set_footer(text=f"Invoked by {ctx.author.display_name}", icon_url=ctx.author.avatar_url) 238 | 239 | msg = await ctx.send(embed=embed) 240 | for emoji in list(OPTIONS.keys())[:min(len(tracks), len(OPTIONS))]: 241 | await msg.add_reaction(emoji) 242 | 243 | try: 244 | reaction, _ = await self.bot.wait_for("reaction_add", timeout=60.0, check=_check) 245 | except asyncio.TimeoutError: 246 | await msg.delete() 247 | await ctx.message.delete() 248 | else: 249 | await msg.delete() 250 | return tracks[OPTIONS[reaction.emoji]] 251 | 252 | async def start_playback(self): 253 | await self.play(self.queue.current_track) 254 | 255 | async def advance(self): 256 | try: 257 | if (track := self.queue.get_next_track()) is not None: 258 | await self.play(track) 259 | except QueueIsEmpty: 260 | pass 261 | 262 | async def repeat_track(self): 263 | await self.play(self.queue.current_track) 264 | 265 | 266 | class Music(commands.Cog, wavelink.WavelinkMixin): 267 | def __init__(self, bot): 268 | self.bot = bot 269 | self.wavelink = wavelink.Client(bot=bot) 270 | self.bot.loop.create_task(self.start_nodes()) 271 | 272 | @commands.Cog.listener() 273 | async def on_voice_state_update(self, member, before, after): 274 | if not member.bot and after.channel is None: 275 | if not [m for m in before.channel.members if not m.bot]: 276 | await self.get_player(member.guild).teardown() 277 | 278 | @wavelink.WavelinkMixin.listener() 279 | async def on_node_ready(self, node): 280 | print(f" Wavelink node `{node.identifier}` ready.") 281 | 282 | @wavelink.WavelinkMixin.listener("on_track_stuck") 283 | @wavelink.WavelinkMixin.listener("on_track_end") 284 | @wavelink.WavelinkMixin.listener("on_track_exception") 285 | async def on_player_stop(self, node, payload): 286 | if payload.player.queue.repeat_mode == RepeatMode.ONE: 287 | await payload.player.repeat_track() 288 | else: 289 | await payload.player.advance() 290 | 291 | async def cog_check(self, ctx): 292 | if isinstance(ctx.channel, discord.DMChannel): 293 | await ctx.send("Music commands are not available in DMs.") 294 | return False 295 | 296 | return True 297 | 298 | async def start_nodes(self): 299 | await self.bot.wait_until_ready() 300 | 301 | nodes = { 302 | "MAIN": { 303 | "host": "127.0.0.1", 304 | "port": 2333, 305 | "rest_uri": "http://127.0.0.1:2333", 306 | "password": "youshallnotpass", 307 | "identifier": "MAIN", 308 | "region": "europe", 309 | } 310 | } 311 | 312 | for node in nodes.values(): 313 | await self.wavelink.initiate_node(**node) 314 | 315 | def get_player(self, obj): 316 | if isinstance(obj, commands.Context): 317 | return self.wavelink.get_player(obj.guild.id, cls=Player, context=obj) 318 | elif isinstance(obj, discord.Guild): 319 | return self.wavelink.get_player(obj.id, cls=Player) 320 | 321 | @commands.command(name="connect", aliases=["join"]) 322 | async def connect_command(self, ctx, *, channel: t.Optional[discord.VoiceChannel]): 323 | player = self.get_player(ctx) 324 | channel = await player.connect(ctx, channel) 325 | await ctx.send(f"Connected to {channel.name}.") 326 | 327 | @connect_command.error 328 | async def connect_command_error(self, ctx, exc): 329 | if isinstance(exc, AlreadyConnectedToChannel): 330 | await ctx.send("Already connected to a voice channel.") 331 | elif isinstance(exc, NoVoiceChannel): 332 | await ctx.send("No suitable voice channel was provided.") 333 | 334 | @commands.command(name="disconnect", aliases=["leave"]) 335 | async def disconnect_command(self, ctx): 336 | player = self.get_player(ctx) 337 | await player.teardown() 338 | await ctx.send("Disconnected.") 339 | 340 | @commands.command(name="play") 341 | async def play_command(self, ctx, *, query: t.Optional[str]): 342 | player = self.get_player(ctx) 343 | 344 | if not player.is_connected: 345 | await player.connect(ctx) 346 | 347 | if query is None: 348 | if player.queue.is_empty: 349 | raise QueueIsEmpty 350 | 351 | await player.set_pause(False) 352 | await ctx.send("Playback resumed.") 353 | 354 | else: 355 | query = query.strip("<>") 356 | if not re.match(URL_REGEX, query): 357 | query = f"ytsearch:{query}" 358 | 359 | await player.add_tracks(ctx, await self.wavelink.get_tracks(query)) 360 | 361 | @play_command.error 362 | async def play_command_error(self, ctx, exc): 363 | if isinstance(exc, QueueIsEmpty): 364 | await ctx.send("No songs to play as the queue is empty.") 365 | elif isinstance(exc, NoVoiceChannel): 366 | await ctx.send("No suitable voice channel was provided.") 367 | 368 | @commands.command(name="pause") 369 | async def pause_command(self, ctx): 370 | player = self.get_player(ctx) 371 | 372 | if player.is_paused: 373 | raise PlayerIsAlreadyPaused 374 | 375 | await player.set_pause(True) 376 | await ctx.send("Playback paused.") 377 | 378 | @pause_command.error 379 | async def pause_command_error(self, ctx, exc): 380 | if isinstance(exc, PlayerIsAlreadyPaused): 381 | await ctx.send("Already paused.") 382 | 383 | @commands.command(name="stop") 384 | async def stop_command(self, ctx): 385 | player = self.get_player(ctx) 386 | player.queue.empty() 387 | await player.stop() 388 | await ctx.send("Playback stopped.") 389 | 390 | @commands.command(name="next", aliases=["skip"]) 391 | async def next_command(self, ctx): 392 | player = self.get_player(ctx) 393 | 394 | if not player.queue.upcoming: 395 | raise NoMoreTracks 396 | 397 | await player.stop() 398 | await ctx.send("Playing next track in queue.") 399 | 400 | @next_command.error 401 | async def next_command_error(self, ctx, exc): 402 | if isinstance(exc, QueueIsEmpty): 403 | await ctx.send("This could not be executed as the queue is currently empty.") 404 | elif isinstance(exc, NoMoreTracks): 405 | await ctx.send("There are no more tracks in the queue.") 406 | 407 | @commands.command(name="previous") 408 | async def previous_command(self, ctx): 409 | player = self.get_player(ctx) 410 | 411 | if not player.queue.history: 412 | raise NoPreviousTracks 413 | 414 | player.queue.position -= 2 415 | await player.stop() 416 | await ctx.send("Playing previous track in queue.") 417 | 418 | @previous_command.error 419 | async def previous_command_error(self, ctx, exc): 420 | if isinstance(exc, QueueIsEmpty): 421 | await ctx.send("This could not be executed as the queue is currently empty.") 422 | elif isinstance(exc, NoPreviousTracks): 423 | await ctx.send("There are no previous tracks in the queue.") 424 | 425 | @commands.command(name="shuffle") 426 | async def shuffle_command(self, ctx): 427 | player = self.get_player(ctx) 428 | player.queue.shuffle() 429 | await ctx.send("Queue shuffled.") 430 | 431 | @shuffle_command.error 432 | async def shuffle_command_error(self, ctx, exc): 433 | if isinstance(exc, QueueIsEmpty): 434 | await ctx.send("The queue could not be shuffled as it is currently empty.") 435 | 436 | @commands.command(name="repeat") 437 | async def repeat_command(self, ctx, mode: str): 438 | if mode not in ("none", "1", "all"): 439 | raise InvalidRepeatMode 440 | 441 | player = self.get_player(ctx) 442 | player.queue.set_repeat_mode(mode) 443 | await ctx.send(f"The repeat mode has been set to {mode}.") 444 | 445 | @commands.command(name="queue") 446 | async def queue_command(self, ctx, show: t.Optional[int] = 10): 447 | player = self.get_player(ctx) 448 | 449 | if player.queue.is_empty: 450 | raise QueueIsEmpty 451 | 452 | embed = discord.Embed( 453 | title="Queue", 454 | description=f"Showing up to next {show} tracks", 455 | colour=ctx.author.colour, 456 | timestamp=dt.datetime.utcnow() 457 | ) 458 | embed.set_author(name="Query Results") 459 | embed.set_footer(text=f"Requested by {ctx.author.display_name}", icon_url=ctx.author.avatar_url) 460 | embed.add_field( 461 | name="Currently playing", 462 | value=getattr(player.queue.current_track, "title", "No tracks currently playing."), 463 | inline=False 464 | ) 465 | if upcoming := player.queue.upcoming: 466 | embed.add_field( 467 | name="Next up", 468 | value="\n".join(t.title for t in upcoming[:show]), 469 | inline=False 470 | ) 471 | 472 | msg = await ctx.send(embed=embed) 473 | 474 | @queue_command.error 475 | async def queue_command_error(self, ctx, exc): 476 | if isinstance(exc, QueueIsEmpty): 477 | await ctx.send("The queue is currently empty.") 478 | 479 | # Requests ----------------------------------------------------------------- 480 | 481 | @commands.group(name="volume", invoke_without_command=True) 482 | async def volume_group(self, ctx, volume: int): 483 | player = self.get_player(ctx) 484 | 485 | if volume < 0: 486 | raise VolumeTooLow 487 | 488 | if volume > 150: 489 | raise VolumeTooHigh 490 | 491 | await player.set_volume(volume) 492 | await ctx.send(f"Volume set to {volume:,}%") 493 | 494 | @volume_group.error 495 | async def volume_group_error(self, ctx, exc): 496 | if isinstance(exc, VolumeTooLow): 497 | await ctx.send("The volume must be 0% or above.") 498 | elif isinstance(exc, VolumeTooHigh): 499 | await ctx.send("The volume must be 150% or below.") 500 | 501 | @volume_group.command(name="up") 502 | async def volume_up_command(self, ctx): 503 | player = self.get_player(ctx) 504 | 505 | if player.volume == 150: 506 | raise MaxVolume 507 | 508 | await player.set_volume(value := min(player.volume + 10, 150)) 509 | await ctx.send(f"Volume set to {value:,}%") 510 | 511 | @volume_up_command.error 512 | async def volume_up_command_error(self, ctx, exc): 513 | if isinstance(exc, MaxVolume): 514 | await ctx.send("The player is already at max volume.") 515 | 516 | @volume_group.command(name="down") 517 | async def volume_down_command(self, ctx): 518 | player = self.get_player(ctx) 519 | 520 | if player.volume == 0: 521 | raise MinVolume 522 | 523 | await player.set_volume(value := max(0, player.volume - 10)) 524 | await ctx.send(f"Volume set to {value:,}%") 525 | 526 | @volume_down_command.error 527 | async def volume_down_command_error(self, ctx, exc): 528 | if isinstance(exc, MinVolume): 529 | await ctx.send("The player is already at min volume.") 530 | 531 | @commands.command(name="lyrics") 532 | async def lyrics_command(self, ctx, name: t.Optional[str]): 533 | player = self.get_player(ctx) 534 | name = name or player.queue.current_track.title 535 | 536 | async with ctx.typing(): 537 | async with aiohttp.request("GET", LYRICS_URL + name, headers={}) as r: 538 | if not 200 <= r.status <= 299: 539 | raise NoLyricsFound 540 | 541 | data = await r.json() 542 | 543 | if len(data["lyrics"]) > 2000: 544 | return await ctx.send(f"<{data['links']['genius']}>") 545 | 546 | embed = discord.Embed( 547 | title=data["title"], 548 | description=data["lyrics"], 549 | colour=ctx.author.colour, 550 | timestamp=dt.datetime.utcnow(), 551 | ) 552 | embed.set_thumbnail(url=data["thumbnail"]["genius"]) 553 | embed.set_author(name=data["author"]) 554 | await ctx.send(embed=embed) 555 | 556 | @lyrics_command.error 557 | async def lyrics_command_error(self, ctx, exc): 558 | if isinstance(exc, NoLyricsFound): 559 | await ctx.send("No lyrics could be found.") 560 | 561 | @commands.command(name="eq") 562 | async def eq_command(self, ctx, preset: str): 563 | player = self.get_player(ctx) 564 | 565 | eq = getattr(wavelink.eqs.Equalizer, preset, None) 566 | if not eq: 567 | raise InvalidEQPreset 568 | 569 | await player.set_eq(eq()) 570 | await ctx.send(f"Equaliser adjusted to the {preset} preset.") 571 | 572 | @eq_command.error 573 | async def eq_command_error(self, ctx, exc): 574 | if isinstance(exc, InvalidEQPreset): 575 | await ctx.send("The EQ preset must be either 'flat', 'boost', 'metal', or 'piano'.") 576 | 577 | @commands.command(name="adveq", aliases=["aeq"]) 578 | async def adveq_command(self, ctx, band: int, gain: float): 579 | player = self.get_player(ctx) 580 | 581 | if not 1 <= band <= 15 and band not in HZ_BANDS: 582 | raise NonExistentEQBand 583 | 584 | if band > 15: 585 | band = HZ_BANDS.index(band) + 1 586 | 587 | if abs(gain) > 10: 588 | raise EQGainOutOfBounds 589 | 590 | player.eq_levels[band - 1] = gain / 10 591 | eq = wavelink.eqs.Equalizer(levels=[(i, gain) for i, gain in enumerate(player.eq_levels)]) 592 | await player.set_eq(eq) 593 | await ctx.send("Equaliser adjusted.") 594 | 595 | @adveq_command.error 596 | async def adveq_command_error(self, ctx, exc): 597 | if isinstance(exc, NonExistentEQBand): 598 | await ctx.send( 599 | "This is a 15 band equaliser -- the band number should be between 1 and 15, or one of the following " 600 | "frequencies: " + ", ".join(str(b) for b in HZ_BANDS) 601 | ) 602 | elif isinstance(exc, EQGainOutOfBounds): 603 | await ctx.send("The EQ gain for any band should be between 10 dB and -10 dB.") 604 | 605 | @commands.command(name="playing", aliases=["np"]) 606 | async def playing_command(self, ctx): 607 | player = self.get_player(ctx) 608 | 609 | if not player.is_playing: 610 | raise PlayerIsAlreadyPaused 611 | 612 | embed = discord.Embed( 613 | title="Now playing", 614 | colour=ctx.author.colour, 615 | timestamp=dt.datetime.utcnow(), 616 | ) 617 | embed.set_author(name="Playback Information") 618 | embed.set_footer(text=f"Requested by {ctx.author.display_name}", icon_url=ctx.author.avatar_url) 619 | embed.add_field(name="Track title", value=player.queue.current_track.title, inline=False) 620 | embed.add_field(name="Artist", value=player.queue.current_track.author, inline=False) 621 | 622 | position = divmod(player.position, 60000) 623 | length = divmod(player.queue.current_track.length, 60000) 624 | embed.add_field( 625 | name="Position", 626 | value=f"{int(position[0])}:{round(position[1]/1000):02}/{int(length[0])}:{round(length[1]/1000):02}", 627 | inline=False 628 | ) 629 | 630 | await ctx.send(embed=embed) 631 | 632 | @playing_command.error 633 | async def playing_command_error(self, ctx, exc): 634 | if isinstance(exc, PlayerIsAlreadyPaused): 635 | await ctx.send("There is no track currently playing.") 636 | 637 | @commands.command(name="skipto", aliases=["playindex"]) 638 | async def skipto_command(self, ctx, index: int): 639 | player = self.get_player(ctx) 640 | 641 | if player.queue.is_empty: 642 | raise QueueIsEmpty 643 | 644 | if not 0 <= index <= player.queue.length: 645 | raise NoMoreTracks 646 | 647 | player.queue.position = index - 2 648 | await player.stop() 649 | await ctx.send(f"Playing track in position {index}.") 650 | 651 | @skipto_command.error 652 | async def skipto_command_error(self, ctx, exc): 653 | if isinstance(exc, QueueIsEmpty): 654 | await ctx.send("There are no tracks in the queue.") 655 | elif isinstance(exc, NoMoreTracks): 656 | await ctx.send("That index is out of the bounds of the queue.") 657 | 658 | @commands.command(name="restart") 659 | async def restart_command(self, ctx): 660 | player = self.get_player(ctx) 661 | 662 | if player.queue.is_empty: 663 | raise QueueIsEmpty 664 | 665 | await player.seek(0) 666 | await ctx.send("Track restarted.") 667 | 668 | @restart_command.error 669 | async def restart_command_error(self, ctx, exc): 670 | if isinstance(exc, QueueIsEmpty): 671 | await ctx.send("There are no tracks in the queue.") 672 | 673 | @commands.command(name="seek") 674 | async def seek_command(self, ctx, position: str): 675 | player = self.get_player(ctx) 676 | 677 | if player.queue.is_empty: 678 | raise QueueIsEmpty 679 | 680 | if not (match := re.match(TIME_REGEX, position)): 681 | raise InvalidTimeString 682 | 683 | if match.group(3): 684 | secs = (int(match.group(1)) * 60) + (int(match.group(3))) 685 | else: 686 | secs = int(match.group(1)) 687 | 688 | await player.seek(secs * 1000) 689 | await ctx.send("Seeked.") 690 | 691 | 692 | def setup(bot): 693 | bot.add_cog(Music(bot)) 694 | -------------------------------------------------------------------------------- /config/application.yml: -------------------------------------------------------------------------------- 1 | server: # REST and WS server 2 | port: 2333 3 | address: 127.0.0.1 4 | lavalink: 5 | server: 6 | password: "youshallnotpass" 7 | sources: 8 | youtube: true 9 | bandcamp: true 10 | soundcloud: true 11 | twitch: true 12 | vimeo: true 13 | http: true 14 | local: false 15 | bufferDurationMs: 400 16 | youtubePlaylistLoadLimit: 6 # Number of pages at 100 each 17 | playerUpdateInterval: 5 # How frequently to send player updates to clients, in seconds 18 | youtubeSearchEnabled: true 19 | soundcloudSearchEnabled: true 20 | gc-warnings: true 21 | #ratelimit: 22 | #ipBlocks: ["1.0.0.0/8", "..."] # list of ip blocks 23 | #excludedIps: ["...", "..."] # ips which should be explicit excluded from usage by lavalink 24 | #strategy: "RotateOnBan" # RotateOnBan | LoadBalance | NanoSwitch | RotatingNanoSwitch 25 | #searchTriggersFail: true # Whether a search 429 should trigger marking the ip as failing 26 | #retryLimit: -1 # -1 = use default lavaplayer value | 0 = infinity | >0 = retry will happen this numbers times 27 | 28 | metrics: 29 | prometheus: 30 | enabled: false 31 | endpoint: /metrics 32 | 33 | sentry: 34 | dsn: "" 35 | environment: "" 36 | # tags: 37 | # some_key: some_value 38 | # another_key: another_value 39 | 40 | logging: 41 | file: 42 | max-history: 30 43 | max-size: 1GB 44 | path: ./logs/ 45 | 46 | level: 47 | root: INFO 48 | lavalink: INFO 49 | -------------------------------------------------------------------------------- /launcher.py: -------------------------------------------------------------------------------- 1 | from bot import MusicBot 2 | 3 | 4 | def main(): 5 | bot = MusicBot() 6 | bot.run() 7 | 8 | 9 | if __name__ == "__main__": 10 | main() --------------------------------------------------------------------------------