Skip to main content

Overview

MusicBot uses a per-guild queue system to manage multiple song requests. Each Discord server has its own independent queue, managed by the GuildPlayerState class.

GuildPlayerState Class

All queue and playback state is encapsulated in the GuildPlayerState class (bot.py:29-46):
class GuildPlayerState:
    """Encapsulates all playback-related state for a single guild."""
    def __init__(self, guild_id: int, bot_instance: commands.Bot):
        self.guild_id: int = guild_id
        self.bot: commands.Bot = bot_instance
        self.queue: list[str] = []  # List of file paths for songs
        self.song_titles: list[str] = []  # List of song titles corresponding to the queue
        self.current_song_path: str | None = None  # File path of currently playing song
        self.current_song_title: str | None = None  # Title of currently playing song
        self.inactive_timer: asyncio.TimerHandle | None = None  # Timer for inactivity disconnect
        self.voice_client: discord.VoiceClient | None = None  # Voice client for this guild
        self.last_ctx: commands.Context | None = None  # Last command context

Key Attributes

  • queue: List of file paths pointing to downloaded MP3 files
  • song_titles: Parallel list of human-readable song titles
  • current_song_path: Path to the currently playing file (None if idle)
  • current_song_title: Title of the current song
  • voice_client: Discord voice connection for this guild

Per-Guild Isolation

Each guild gets its own GuildPlayerState instance (bot.py:47):
guild_states: dict[int, GuildPlayerState] = {}
This means:
  • Server A’s queue doesn’t affect Server B’s queue
  • Bot can play different songs in different servers simultaneously
  • Each server has independent playback state

Adding Songs to Queue

The !play Command

When !play is used, songs are added to the queue (bot.py:379-389):
if file_path_to_play and title_to_play:
    logger.info(f"Adding to queue: '{title_to_play}' from {file_path_to_play}")
    guild_state.queue.append(file_path_to_play)
    guild_state.song_titles.append(title_to_play)
    await ctx.send(f"Added to queue: '{title_to_play}'")
    
    if not guild_state.current_song_path and guild_state.voice_client.is_connected():
        play_next_song(guild_state)

Queue Behavior

If nothing is playing:
  • Song is added to queue
  • play_next_song() is immediately called
  • Song starts playing right away
  • Queue is now empty again
If a song is already playing:
  • Song is added to the end of queue
  • Message: “Added to queue: ‘Song Title’”
  • Song waits until current song finishes
  • Playback continues automatically

Example Flow

User: !play song1
Bot: Downloading audio...
     Added to queue: 'Song 1'
     Now playing: 'Song 1'
     [queue is now empty, current_song = Song 1]

User: !play song2
Bot: Added to queue: 'Song 2'
     [queue = [Song 2], current_song = Song 1]

User: !play song3
Bot: Added to queue: 'Song 3'
     [queue = [Song 2, Song 3], current_song = Song 1]

[Song 1 finishes]
Bot: Now playing: 'Song 2'
     [queue = [Song 3], current_song = Song 2]

[Song 2 finishes]
Bot: Now playing: 'Song 3'
     [queue is empty, current_song = Song 3]

[Song 3 finishes]
     [queue is empty, current_song = None]
     [inactivity timer starts]

Playing Songs from Queue

The play_next_song() Function

This function manages automatic queue progression (bot.py:570-661):
def play_next_song(guild_state: GuildPlayerState):
    """Plays the next song in the queue."""
    voice_client = guild_state.voice_client
    ctx = guild_state.last_ctx
    
    if guild_state.queue:  # If there are songs in the queue
        file_path = guild_state.queue.pop(0)  # Get first song (FIFO)
        song_title = guild_state.song_titles.pop(0)
        
        guild_state.current_song_path = file_path
        guild_state.current_song_title = song_title
        
        # Play the audio
        audio_source = discord.FFmpegPCMAudio(file_path)
        voice_client.play(
            audio_source,
            after=lambda e: song_finished(file_path, guild_state, error=e)
        )
        
        asyncio.run_coroutine_threadsafe(
            ctx.send(f"Now playing: '{song_title}'"),
            guild_state.bot.loop
        )

Queue Order (FIFO)

Songs are played in First-In-First-Out order:
file_path = guild_state.queue.pop(0)  # Remove from front
song_title = guild_state.song_titles.pop(0)
This means:
  • Oldest song in queue plays first
  • New songs go to the back of the queue
  • No shuffle or priority system

Automatic Progression

When a song finishes, the song_finished() callback is triggered (bot.py:663-699):
def song_finished(file_path: str, guild_state: GuildPlayerState, error=None):
    """Callback when a song finishes playing."""
    cleanup(file_path)  # Log retention (files not deleted)
    guild_state.current_song_path = None
    guild_state.current_song_title = None
    
    if voice_client and voice_client.is_connected():
        play_next_song(guild_state)  # Play next song automatically
This creates a continuous playback loop:
  1. Song finishes
  2. State is cleared
  3. play_next_song() is called
  4. Next song in queue starts playing
  5. Repeat until queue is empty

Skipping Songs

The !skip Command

Users can skip the current song (bot.py:413-434):
@bot.command(name='skip', help='Skips the currently playing song.')
async def skip(ctx: commands.Context):
    guild_state = get_or_create_guild_state(ctx)
    voice_client = guild_state.voice_client
    
    if voice_client and voice_client.is_playing():
        voice_client.stop()  # Stopping triggers song_finished callback
        await ctx.send('Song skipped.')

Skip Behavior

  1. Current song stops immediately
  2. song_finished() callback is triggered
  3. Next song in queue starts automatically
  4. If queue is empty, playback stops

Skip Example

[Current song: Song 1, Queue: Song 2, Song 3]

User: !skip
Bot: Song skipped.
     Now playing: 'Song 2'
     
[Current song: Song 2, Queue: Song 3]

User: !skip
Bot: Song skipped.
     Now playing: 'Song 3'
     
[Current song: Song 3, Queue: empty]

User: !skip
Bot: Song skipped.
     [No more songs, playback stops]

Queue Clearing

Manual Clearing with !leave

The !leave command clears the entire queue (bot.py:450-454):
# Clear queue and reset playback state
guild_state.queue.clear()
guild_state.song_titles.clear()
guild_state.current_song_path = None
guild_state.current_song_title = None

Automatic Clearing

The queue is also cleared when:
  1. Bot is disconnected due to errors
  2. Inactivity timeout triggers (bot.py:730-732):
    guild_state.queue.clear()
    guild_state.song_titles.clear()
    
  3. Voice connection is lost (bot.py:692-694):
    guild_state.queue.clear()
    guild_state.song_titles.clear()
    

Empty Queue Behavior

When Queue Becomes Empty

From play_next_song() (bot.py:652-660):
else:  # If the queue is empty
    logger.info(f"Queue is empty for guild '{ctx.guild.name}'. No more songs to play.")
    guild_state.current_song_path = None
    guild_state.current_song_title = None
    # Inactivity timer continues - will disconnect after 5 minutes
Behavior:
  • No new song starts playing
  • Bot remains in voice channel
  • Inactivity timer continues (started when last song began)
  • Bot disconnects after 300 seconds (5 minutes) if no new songs are added

Inactivity Disconnect

After queue empties, the inactivity timer runs (bot.py:701-742):
def check_inactive(guild_state: GuildPlayerState):
    """Callback for the inactivity timer."""
    if guild_state.current_song_path or (voice_client and voice_client.is_playing()):
        return  # Still active
    
    # Bot is inactive - disconnect
    asyncio.run_coroutine_threadsafe(voice_client.disconnect(), guild_state.bot.loop)
    asyncio.run_coroutine_threadsafe(
        ctx.send('Disconnected due to inactivity. The song queue has been cleared.'),
        guild_state.bot.loop
    )
    
    guild_state.queue.clear()
    guild_state.song_titles.clear()
Timer is set when a song starts (bot.py:635-639):
if guild_state.inactive_timer:
    guild_state.inactive_timer.cancel()

guild_state.inactive_timer = guild_state.bot.loop.call_later(
    inactive_time,  # 300 seconds
    check_inactive,
    guild_state
)

Multi-Song Queuing Examples

Example 1: Basic Queue

User: !play song A
Bot: Added to queue: 'Song A'
     Now playing: 'Song A'

User: !play song B
Bot: Added to queue: 'Song B'

User: !play song C
Bot: Added to queue: 'Song C'

[Queue state: current=Song A, queue=[Song B, Song C]]

Example 2: Rapid Queuing

# User rapidly adds 5 songs:
!play never gonna give you up
!play lofi hip hop
!play beethoven 9th
!play pink floyd
!play led zeppelin

# Bot processes in order:
Bot: Added to queue: 'Never Gonna Give You Up'
     Now playing: 'Never Gonna Give You Up'
     Added to queue: 'Lofi Hip Hop'
     Added to queue: 'Symphony No. 9'
     Added to queue: 'Pink Floyd - Comfortably Numb'
     Added to queue: 'Led Zeppelin - Stairway to Heaven'

# Playback proceeds sequentially without user intervention

Example 3: Queue with Skips

[Queue: Song A (playing), Song B, Song C, Song D]

User: !skip
[Queue: Song B (playing), Song C, Song D]

User: !skip
[Queue: Song C (playing), Song D]

User: !play Song E
[Queue: Song C (playing), Song D, Song E]

[Song C finishes naturally]
[Queue: Song D (playing), Song E]

Queue Limitations

No Queue Inspection

The bot does not have commands to:
  • View the current queue
  • See how many songs are queued
  • Reorder songs in queue
  • Remove specific songs from queue

No Queue Limit

There is no maximum queue size:
  • Users can add unlimited songs
  • Queue is only limited by system memory
  • Each queue entry is just a file path string

No Duplicate Prevention

The same song can be queued multiple times:
!play song A
!play song B  
!play song A  # Allowed - will play again

Queue State Management

State Persistence

Queue state is not persistent:
  • Queue is lost when bot restarts
  • Queue is cleared when bot leaves channel
  • No queue saving to disk

State Retrieval

To get a guild’s state (bot.py:49-63):
def get_or_create_guild_state(ctx: commands.Context) -> GuildPlayerState:
    guild_id = ctx.guild.id
    if guild_id not in guild_states:
        guild_states[guild_id] = GuildPlayerState(guild_id=guild_id, bot_instance=bot)
    
    guild_states[guild_id].last_ctx = ctx  # Update context
    return guild_states[guild_id]

State Cleanup

States remain in memory but become inactive when:
  • Bot leaves the voice channel
  • Guild is no longer active
State objects are not automatically removed from guild_states dict.

Best Practices

  1. Add multiple songs at once for uninterrupted playback
  2. Use cached songs for instant queuing
  3. Use !skip instead of !leave if you just want to change songs
  4. Let songs finish naturally - queue progresses automatically
  5. Be aware of inactivity timeout - bot disconnects after 5 minutes of silence

Technical Details

  • Queue data structure: Python list
  • Queue operations: append() to add, pop(0) to remove
  • Parallel queues: File paths and titles maintained separately
  • Thread safety: Uses asyncio.run_coroutine_threadsafe() for callbacks
  • State isolation: Dict keyed by guild ID

Build docs developers (and LLMs) love