Building your own discord.py music bot from scratch is one of the most rewarding ways to learn Python. In this guide we'll combine the discord.py library, the yt-dlp tool that resolves audio sources, and FFmpeg which streams that audio into Discord, to create a bot that plays tracks through slash commands and manages a queue. The goal isn't just something that "works," but a clear, extensible foundation.
What you need before running the bot
Let's prepare the environment first. You'll need a modern Python version (3.9 or higher recommended), a Discord application/bot token, and FFmpeg installed on the system. FFmpeg is a separate program; you install it with your operating system's package manager rather than pip (apt install ffmpeg on Linux, brew install ffmpeg on macOS, or downloading the official build and adding it to PATH on Windows).
On the Discord side, create an application in the Discord Developer Portal, grab the token from the "Bot" tab, and enable the Message Content Intent along with permissions to join voice channels. Never hardcode your token; keep it in a .env file.
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install -U discord.py yt-dlp python-dotenv
pip install -U "discord.py[voice]"
Installing the discord.py[voice] extra also pulls in the PyNaCl dependency needed for voice connections. Without it the bot can't connect to a voice channel.
The skeleton: bot and slash commands
Modern bots use slash commands instead of text commands like !play. discord.py exposes them through app_commands. The skeleton below reads the token from the environment and syncs the commands.
import os
import discord
from discord import app_commands
from dotenv import load_dotenv
load_dotenv()
intents = discord.Intents.default()
intents.message_content = True
class MusicBot(discord.Client):
def __init__(self):
super().__init__(intents=intents)
self.tree = app_commands.CommandTree(self)
async def setup_hook(self):
await self.tree.sync()
bot = MusicBot()
@bot.event
async def on_ready():
print(f"Logged in as: {bot.user}")
bot.run(os.getenv("DISCORD_TOKEN"))
Resolving the audio source with yt-dlp
To turn a link into an audio stream we use yt-dlp. Instead of downloading the whole file we grab the stream URL directly; this is faster and uses no disk. The helper class below produces a playable FFmpegOpusAudio source from a search term or URL.
import asyncio
import yt_dlp
YTDL_OPTS = {
"format": "bestaudio/best",
"noplaylist": True,
"quiet": True,
"default_search": "ytsearch",
"source_address": "0.0.0.0",
}
FFMPEG_OPTS = {
"before_options": "-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5",
"options": "-vn",
}
ytdl = yt_dlp.YoutubeDL(YTDL_OPTS)
class Track:
def __init__(self, data):
self.title = data.get("title")
self.url = data.get("url")
@classmethod
async def from_query(cls, query):
loop = asyncio.get_event_loop()
data = await loop.run_in_executor(
None, lambda: ytdl.extract_info(query, download=False)
)
if "entries" in data:
data = data["entries"][0]
return cls(data)
Two things matter here: extract_info is a blocking (synchronous) call, so we run it in a separate thread with run_in_executor; otherwise the bot's event loop would freeze. Also, the -reconnect flags inside before_options let the stream reconnect if it drops during long tracks.
Managing the queue
The heart of a good music bot is the queue. You need a separate queue per server (guild), because it's common for the bot to play in several servers at once. For a simple structure we can keep queues in a dictionary keyed by guild id.
from collections import deque
queues = {} # guild_id -> deque[Track]
def get_queue(guild_id):
if guild_id not in queues:
queues[guild_id] = deque()
return queues[guild_id]
def play_next(voice_client, guild_id):
queue = get_queue(guild_id)
if not queue:
return
track = queue.popleft()
source = discord.FFmpegOpusAudio(track.url, **FFMPEG_OPTS)
voice_client.play(
source,
after=lambda e: play_next(voice_client, guild_id),
)
The after callback of the play method automatically starts the next track when one finishes. Be careful: this callback runs in a separate thread, so you can't use await directly inside it. If you need to do async work, hand it back to the event loop with bot.loop.call_soon_threadsafe or asyncio.run_coroutine_threadsafe.
Wiring the commands: join, play, skip
Now we can bind tracks to slash commands. The /play command joins the voice channel the user is in, resolves the source, adds it to the queue, and starts playback if nothing is playing.
@bot.tree.command(name="play", description="Play a song")
async def play(interaction: discord.Interaction, query: str):
await interaction.response.defer()
if not interaction.user.voice:
await interaction.followup.send("Join a voice channel first.")
return
channel = interaction.user.voice.channel
vc = interaction.guild.voice_client
if vc is None:
vc = await channel.connect()
track = await Track.from_query(query)
get_queue(interaction.guild.id).append(track)
await interaction.followup.send(f"Added to queue: {track.title}")
if not vc.is_playing():
play_next(vc, interaction.guild.id)
@bot.tree.command(name="skip", description="Skip to the next song")
async def skip(interaction: discord.Interaction):
vc = interaction.guild.voice_client
if vc and vc.is_playing():
vc.stop() # the 'after' callback starts the next track
await interaction.response.send_message("Skipped.")
else:
await interaction.response.send_message("Nothing is playing.")
The interaction.response.defer() call is critical: yt-dlp resolution can take a few seconds, and Discord times out interactions that aren't answered within 3 seconds. With defer we show a "thinking" state and then send the real reply via followup.
Things to check before going live
When the bot works, the job isn't done. A few practical points:
- Error handling: if a source can't be found or is age-restricted,
yt-dlpthrows an exception; wrap your commands intry/except. - Empty channel check: disconnect the bot automatically when nobody is left so it doesn't waste resources.
- Copyright and terms: respect the terms of service of your content sources; only play content you're allowed to.
- Hosting: to run 24/7, a small VPS or a container is the most reliable approach.
Frequently Asked Questions
Why does the bot connect but no sound comes out?
The most common cause is that FFmpeg isn't installed or isn't on the PATH. Run ffmpeg -version in the terminal to verify. Also, if PyNaCl isn't installed the audio stream won't work; fix it with pip install "discord.py[voice]".
My slash commands don't appear, what should I do?
Global command sync can take up to an hour to propagate across Discord. During development, syncing commands to a single server (guild) makes them appear instantly; use tree.sync(guild=discord.Object(id=...)) for that.
discord.py or a different library?
On the Python side, discord.py is the most mature and best-documented option; it fully supports slash commands, voice, and modern API features. If you prefer JavaScript, discord.js offers a similar path, but every example in this guide is Python.
Want to take your bot to the next level? We can work together to add features like filters, playlists, a web panel, or multi-server support. Get in touch to talk about your project.