How I added music functionality to My Discord Bot - Muli-Bot

How I added music functionality to My Discord Bot - Muli-Bot

Who am I, and what is Muli-bot?

Hello! I am Aryan Dongre - a 15-year-old boy who loves to code, play games, and watch anime sometimes! I go by the name of Mulitate4 on the internet, and that's why I named my bot - Muli-bot! Muli-bot originally started out as a small project for me to learn python, but quickly evolved into something I loved to add new commands to, and one such module I decided to add was music playing functionality!

This module allows me to now play music directly from discord voice channels using simple commands like - wow play (song name) and wow pause.

Please note -

This is not a tutorial, per sé, but more of a journey, and documentation of all the problems I ran into! Muli-bot is currently on 21 servers at the time of writing this, but I hope to grow the bot's userbase even further. :) Even with that said, the purpose of writing this article is to help others who are struggling with the same problems I faced while adding music functionality to my bot!

Also, I assume you know what discord is, what a server is, and what voice channels are!

Check out my bot here -

Also, the code for my bot isn't available to view directly, but you can test out the bot in my bot support server - here


Actually adding the Music🎵 module!

Pre-Planning

Before I even started any coding, I worked out what I was going to do and how I was going to do it. I always ponder upon my projects when I'm not ACTUALLY coding, and it always gives me a head-start when I start the project.

So I had worked out a few things, which included - what commands I would implement - join, play the music, auto-play songs in the queue, and of course, leave the voice channel. To play the music, I would download, the song using the youtube-dl python library, and play that to the Voice Channel. I didn't know completely how I would implement them yet, but I was confident it would be an easy enough task

Underestimating the task

A week later, and countless hours banging my head against a wall, I can positively tell you, that it hasn't been an easy journey 😭. I thought the functionality would be done in maybe 100 lines of code or something, but that wasn't the case either. Anyways, enough rambling, on with my journey-

Adding the join command

Adding the join command to my bot was actually pretty easy. I had a few checks, which checked if the person who typed the command was in a voice channel, and if the bot was already in some other voice channel (If so, join the other voice channel).

This would give me an instance of VoiceClient (Nevermind this, if you don't understand. Cause it was confusing at first for me too). This VoiceClient Object will allow me to play songs, pause songs, and leave the VC (voice channel).

Adding the join command took me less than 20 mins, so I was confident the rest of the project would be a breeze too.

At this point, I was referring to quite a few resources, which included - an entire music bot with its source code, a youtube tutorial from 2018 (which didn't help), and the discord.py discord server (Very helpful people there, although they won't directly help you by spoonfeeding you with code, rather, they will hint at the solution, and it is up to you to understand).

Adding the leave, pause, and resume command

These were pretty easy to add too. I added a simple function to give me the Voice Channel which the bot was connected to in a particular server and performed in-built methods (of discord.py) to leave, pause, and resume. (keep in mind, the bot can play to multiple different servers at the same time, so everything has to be dynamic)

This was how I got the voice channel. (It probably will look cryptic to non-coders 😂)

def get_voice_client(self, server):
    voice_client = None
    for index, client in enumerate(self.bot.voice_clients):
        if client.guild == server:
            voice_client = self.bot.voice_clients[index]

    return voice_client

Then I can do simple stuff like -

voice_client.stop()

and the bot would just leave👋. I was SO happy (😄<- me) seeing all of this work. Then came adding the main command - play. The making of this command will give me nightmares forever👿

Boss Level - Play🎵

Just thinking about it makes me shiver. The COUNTLESS errors I ran into, the confusion in my own mind about what MY OWN CODE does, and asyncio hell😣. And so I, a naive boy, started.

The first steps were clear, check to see if the bot is a Voice Channel, else, just join the channel of the command-giver. This was the easy part. I added the same checks as my join command and started with a linear approach to playing music.

The first step for me was to download the song that was requested based on the query. For this, I used the Youtube API, as I already had worked on a song downloader earlier, and also Youtube had most of the songs. The function which downloaded the song ->

def song_dload(self, query):
        vid_req = youtube.search().list(q=query, type='video', maxResults=1, part='snippet')
        vid_resp = vid_req.execute()
        video = vid_resp['items'][0]['snippet']

        video_id = vid_resp['items'][0]['id']['videoId']
        video_title = video['title']

        video_url = ("https://www.youtube.com/watch?v=" + video_id)

        with youtube_dl.YoutubeDL(ydl_opts) as ydl:
            ydl.download([video_url])
        return video_id, video_title, video_url

Here, youtube is an instance of the official Google's Python library for their APIs. the second-last line is what is used to download the song. Using these settings -

ydl_opts = {
    'format': 'bestaudio/best',
    'keepvideo': False,
    'outtmpl': '%(id)s.webm',
}

I saved the song with its id as the file-name and returned the video id, video title (to show what song is currently playing), and the video URL (again, to show what song is playing). With this, I was set to start playing the song.

Side Note - There is a way to stream songs directly with youtube-dl but I wasn't aware of this, and also I was already fixed on using this method of downloading the song and then playing it.

Now I used

voice_client.play(discord.FFmpegPCMAudio(source=vid_id+".webm"), after=lambda x: os.remove(vid_id+".webm"))

This would remove the song after the song finished playing. This was all cool, and it worked too! And the journey should have ended here, BUT (of course, there is a but 🤦‍♂️), I realized that I had to work on a queue system, which would auto-play the next song in the queue, and this is where the problem started 😭.

My first approach to the Queue problem

I was baffled as to how I would add the queue system when it hit me - I would make playing the song a function inside the play function, and then keep looping it, till the queue was over! If you are confused, this is what I mean (in pseudo-code) ->

async def play(*args):
    def another_play_function(*args):
        ~~ bunch of code ~~
        voice_client.play(discord.FFmpegPCMAudio(source=vid_id+".webm"), after=lambda x: [os.remove(vid_id+".webm"), another_play_function(next_song))

In essence, after the song would finish playing, the first song (which would be the currently playing song) from the queue would be removed, and another_play_function would be used to play the next song, thus making a semi-infinite loop as long as there were songs in the queue.

Now, the problem with this was that the function needed to be async (Asynchronous, meaning that the function would run regardless of whatever is going in the code) in order for the bot to play songs to multiple servers. So I made the server async, so it looked like this ->

async def play(*args):
    async def another_play_function(*args):
        ~~ bunch of code ~~
        voice_client.play(discord.FFmpegPCMAudio(source=vid_id+".webm"), after=lambda x: [os.remove(vid_id+".webm"), another_play_function(next_song))

Now, for the FIRST time in all of my coding, I ran into a limitation of python! This was that lambda functions can't execute async commands (this is understandable, as it can break python itself).

A simple explanation of Lambda Functions

Lambda functions basically are one-line functions, that execute mostly one statement. An example of lambda function ->

def print_something(str_to_print):
    print(str_to_print)

#Now this can be written as ->
print_something = lambda str_to_print: print(str_to_print)

#Both can be executed in the same way ->
print_something("Bruh moment")

There is much more to lambda functions, but this is the basic usage!

My Second Approach using Asyncio

Now coming back, the problem was that I couldn't run async functions, and hence, I couldn't loop through the songs. This completely BAFFLED me for the LONGEST time! My queue logic itself wasn't flawed, so I didn't have to worry about that. Then I decided to look at this repo to understand how other people approached this problem.

Looking at the code, I thought, "Maybe, it's cause I'm not using classes?", but on further inspection, I realized that I could continue building my bot in the same way, cause it should not matter. (I realized this because the feature I was implementing wasn't exclusive to the OOP principles). So I decided to stick with my method.

Then I decided to use some of the things in the above Github repository. The main thing I decided to implement was - using Asyncio to do the looping stuff, as the entire discord.py library had been written using Asyncio. Now, I implemented using the bot's main thread loop to continue looping through the songs queue and play it. I used asyncio.Event() to block the code until the song had been played through, and then did the rest of the logic stuff. So my code essentially looked like this ->

async def play(*args):
    def another_play_function(*args):
        next = asyncio.Event()

        ~~ bunch of code ~~

        voice_client.play(discord.FFmpegPCMAudio(source=vid_id+".webm"), after=lambda x: self.bot.loop.call_soon_threadsafe(next.set()))

        await next.wait()
        os.remove(vid_id+".webm")

        ~~ rest of code ~~

    self.bot.loop.create_task(another_play_function)

Break Down of what's happening here

next = asyncio.Event() Creates a new Event object that has a few methods, which include -> next.set(), next.clear() and await next.wait()

NOTE: Next is just the name of the variable. It indirectly references asyncio.Event(). This means, asyncio.Event.set() is just a generalised term for next.set()

Basically, next.wait() "blocks" the code from going further, until next is set. Think of it as an On and Off switch. When you create the next or Event object, the switch is set to Off. next.wait() then waits for the switch to be On. Now, next.set() turns the switch On.

This is useful because everything after voice_client.play() is executed immediately, rather than waiting for it to finish playing the song. Hence, we use asyncio.Event() to make the function wait before the rest of the function is executed!

So it works! Oh, wait, it doesn't. :/

Technically, it should work perfectly, leaving no error, and allowing the bot to play songs to multiple servers. So what's the problem? I'm not sure EITHER! Let me explain. There is a SUPER annoying bug that keeps popping up ->

self._context.run(self._callback, *self._args)
TypeError: 'NoneType' object is not callable

It gives me ABSOLUTELY NO information, NO specificity, NOTHING to work with. Just a generalized error. I tried asking people for an ENTIRE DAY, to no avail. Literally, NOBODY knew what the error was. Stack Overflow was baffled as well!

So I settled on a quick fix, ->

async def play(*args):
    def another_play_function(*args):
        next = asyncio.Event()

        ~~ bunch of code ~~

        voice_client.play(discord.FFmpegPCMAudio(source=vid_id+".webm"), after=lambda x: self.bot.loop.call_soon_threadsafe(next.set()))

        await next.wait()
        os.remove(vid_id+".webm")

        ~~ rest of code ~~

    while queue != [] # Do this while queue is not empty
        await another_play_function()

This STILL gives me the error, but at least it doesn't break the code and stop playing songs, so I'm not touching it (at least, for now). I know, it is a bad coding practice, but I just CAN'T get it to work, without the error😭.


Well, that was it!

What I learned -

I learned quite a few things. Some of them being -

  • Asyncio.loop and Asyncio.Event
  • The importance of having functions be async
  • NOT underestimating projects
  • Sometimes, it's better to just let go. I want to work on bettering the music system and add a few fun little commands, but for now, I'm staying absolutely AWAY from the music commands. They have given me nightmares for 3 days in a row.

Thanks for reading :)

I hope you enjoyed reading this blog, and hope you learned something useful too!