Implement per guild music queue and player

This commit is contained in:
2026-09-21 18:48:06 +02:00
parent d5f81252b2
commit c4757933e1
4 changed files with 461 additions and 0 deletions
+53
View File
@@ -0,0 +1,53 @@
package music
import (
"context"
"log/slog"
"sync"
"github.com/disgoorg/disgolink/v4/disgolink"
"github.com/disgoorg/snowflake/v2"
)
type Manager struct {
lavalink *disgolink.Client
logger *slog.Logger
mu sync.Mutex
guilds map[snowflake.ID]*GuildPlayer
}
func NewManager(lavalinkClient *disgolink.Client, logger *slog.Logger) *Manager {
return &Manager{
lavalink: lavalinkClient,
logger: logger,
guilds: make(map[snowflake.ID]*GuildPlayer),
}
}
func (m *Manager) Guild(guildID snowflake.ID) *GuildPlayer {
m.mu.Lock()
defer m.mu.Unlock()
if gp, ok := m.guilds[guildID]; ok {
return gp
}
gp := newGuildPlayer(m.lavalink.Player(guildID))
m.guilds[guildID] = gp
return gp
}
func (m *Manager) OnTrackEnd(event *disgolink.PlayerTrackEndEvent) {
if !event.Reason.MayStartNext() {
return
}
ctx := context.Background()
if err := m.Guild(event.Player.GuildID).PlayNext(ctx, event.Track); err != nil {
m.logger.ErrorContext(ctx, "failed to play next queued track",
slog.String("guild_id", event.Player.GuildID.String()),
slog.Any("err", err),
)
}
}
+207
View File
@@ -0,0 +1,207 @@
package music
import (
"context"
"errors"
"fmt"
"sync"
"github.com/disgoorg/disgolink/v4/disgolink"
"github.com/disgoorg/disgolink/v4/lavalink"
)
var (
ErrNoTrackPlaying = errors.New("no track is currently playing")
ErrStreamNotSeekable = errors.New("cannot seek within a live stream")
)
type LoopMode int
const (
LoopOff LoopMode = iota
LoopTrack
LoopQueue
)
func (m LoopMode) String() string {
switch m {
case LoopTrack:
return "track"
case LoopQueue:
return "queue"
default:
return "off"
}
}
type GuildPlayer struct {
mu sync.Mutex
player *disgolink.Player
queue *Queue
loopMode LoopMode
}
func newGuildPlayer(player *disgolink.Player) *GuildPlayer {
return &GuildPlayer{
player: player,
queue: NewQueue(),
}
}
type EnqueueResult struct {
Started lavalink.Track
PlayingNow bool
Queued int
}
func (g *GuildPlayer) EnqueueMany(ctx context.Context, tracks []lavalink.Track) (EnqueueResult, error) {
g.mu.Lock()
defer g.mu.Unlock()
if len(tracks) == 0 {
return EnqueueResult{}, nil
}
if g.player.Track != nil {
for _, track := range tracks {
g.queue.Push(track)
}
return EnqueueResult{Queued: len(tracks)}, nil
}
first := tracks[0]
if err := g.player.Update(ctx, disgolink.WithTrack(first)); err != nil {
return EnqueueResult{}, fmt.Errorf("starting track: %w", err)
}
for _, track := range tracks[1:] {
g.queue.Push(track)
}
return EnqueueResult{Started: first, PlayingNow: true, Queued: len(tracks) - 1}, nil
}
func (g *GuildPlayer) Skip(ctx context.Context) (next lavalink.Track, hasNext bool, err error) {
g.mu.Lock()
defer g.mu.Unlock()
next, hasNext = g.queue.Pop()
if !hasNext {
if err := g.player.Update(ctx, disgolink.WithNullTrack()); err != nil {
return lavalink.Track{}, false, fmt.Errorf("stopping player: %w", err)
}
return lavalink.Track{}, false, nil
}
if err := g.player.Update(ctx, disgolink.WithTrack(next)); err != nil {
return lavalink.Track{}, false, fmt.Errorf("starting next track: %w", err)
}
return next, true, nil
}
func (g *GuildPlayer) PlayNext(ctx context.Context, ended lavalink.Track) error {
g.mu.Lock()
defer g.mu.Unlock()
if g.loopMode == LoopTrack {
if err := g.player.Update(ctx, disgolink.WithTrack(ended)); err != nil {
return fmt.Errorf("repeating track: %w", err)
}
return nil
}
if g.loopMode == LoopQueue {
g.queue.Push(ended)
}
next, ok := g.queue.Pop()
if !ok {
return nil
}
if err := g.player.Update(ctx, disgolink.WithTrack(next)); err != nil {
return fmt.Errorf("starting queued track: %w", err)
}
return nil
}
func (g *GuildPlayer) Seek(ctx context.Context, position lavalink.Duration) error {
g.mu.Lock()
defer g.mu.Unlock()
if g.player.Track == nil {
return ErrNoTrackPlaying
}
if g.player.Track.Info.IsStream {
return ErrStreamNotSeekable
}
if position < 0 {
position = 0
} else if position > g.player.Track.Info.Length {
position = g.player.Track.Info.Length
}
if err := g.player.Update(ctx, disgolink.WithPosition(position)); err != nil {
return fmt.Errorf("seeking: %w", err)
}
return nil
}
func (g *GuildPlayer) SetVolume(ctx context.Context, volume int) error {
g.mu.Lock()
defer g.mu.Unlock()
if err := g.player.Update(ctx, disgolink.WithVolume(volume)); err != nil {
return fmt.Errorf("updating volume: %w", err)
}
return nil
}
func (g *GuildPlayer) SetLoopMode(mode LoopMode) {
g.mu.Lock()
defer g.mu.Unlock()
g.loopMode = mode
}
func (g *GuildPlayer) Stop(ctx context.Context) error {
g.mu.Lock()
defer g.mu.Unlock()
g.queue.Clear()
if err := g.player.Update(ctx, disgolink.WithNullTrack()); err != nil {
return fmt.Errorf("stopping player: %w", err)
}
return nil
}
func (g *GuildPlayer) SetPaused(ctx context.Context, paused bool) error {
g.mu.Lock()
defer g.mu.Unlock()
if err := g.player.Update(ctx, disgolink.WithPaused(paused)); err != nil {
return fmt.Errorf("updating pause state: %w", err)
}
return nil
}
func (g *GuildPlayer) NowPlaying() (lavalink.Track, bool) {
g.mu.Lock()
defer g.mu.Unlock()
if g.player.Track == nil {
return lavalink.Track{}, false
}
return *g.player.Track, true
}
func (g *GuildPlayer) Queued() []lavalink.Track {
return g.queue.List()
}
func (g *GuildPlayer) Shuffle() int {
return g.queue.Shuffle()
}
func (g *GuildPlayer) RemoveQueued(index int) (lavalink.Track, bool) {
return g.queue.RemoveAt(index)
}
+83
View File
@@ -0,0 +1,83 @@
package music
import (
"math/rand"
"sync"
"github.com/disgoorg/disgolink/v4/lavalink"
)
type Queue struct {
mu sync.Mutex
tracks []lavalink.Track
}
func NewQueue() *Queue {
return &Queue{}
}
func (q *Queue) Push(track lavalink.Track) {
q.mu.Lock()
defer q.mu.Unlock()
q.tracks = append(q.tracks, track)
}
func (q *Queue) Pop() (lavalink.Track, bool) {
q.mu.Lock()
defer q.mu.Unlock()
if len(q.tracks) == 0 {
return lavalink.Track{}, false
}
track := q.tracks[0]
q.tracks = q.tracks[1:]
return track, true
}
func (q *Queue) Clear() {
q.mu.Lock()
defer q.mu.Unlock()
q.tracks = nil
}
func (q *Queue) List() []lavalink.Track {
q.mu.Lock()
defer q.mu.Unlock()
tracks := make([]lavalink.Track, len(q.tracks))
copy(tracks, q.tracks)
return tracks
}
func (q *Queue) Len() int {
q.mu.Lock()
defer q.mu.Unlock()
return len(q.tracks)
}
func (q *Queue) Shuffle() int {
q.mu.Lock()
defer q.mu.Unlock()
rand.Shuffle(len(q.tracks), func(i, j int) {
q.tracks[i], q.tracks[j] = q.tracks[j], q.tracks[i]
})
return len(q.tracks)
}
func (q *Queue) RemoveAt(index int) (lavalink.Track, bool) {
q.mu.Lock()
defer q.mu.Unlock()
if index < 0 || index >= len(q.tracks) {
return lavalink.Track{}, false
}
track := q.tracks[index]
q.tracks = append(q.tracks[:index], q.tracks[index+1:]...)
return track, true
}
+118
View File
@@ -0,0 +1,118 @@
package music
import (
"testing"
"github.com/disgoorg/disgolink/v4/lavalink"
)
func TestQueuePushPop(t *testing.T) {
q := NewQueue()
if _, ok := q.Pop(); ok {
t.Fatal("expected empty queue to return ok=false")
}
first := lavalink.Track{Info: lavalink.TrackInfo{Title: "first"}}
second := lavalink.Track{Info: lavalink.TrackInfo{Title: "second"}}
q.Push(first)
q.Push(second)
if got := q.Len(); got != 2 {
t.Fatalf("expected length 2, got %d", got)
}
track, ok := q.Pop()
if !ok || track.Info.Title != "first" {
t.Fatalf("expected first track, got %+v ok=%v", track, ok)
}
track, ok = q.Pop()
if !ok || track.Info.Title != "second" {
t.Fatalf("expected second track, got %+v ok=%v", track, ok)
}
if _, ok := q.Pop(); ok {
t.Fatal("expected queue to be empty after popping all tracks")
}
}
func TestQueueClear(t *testing.T) {
q := NewQueue()
q.Push(lavalink.Track{})
q.Push(lavalink.Track{})
q.Clear()
if got := q.Len(); got != 0 {
t.Fatalf("expected length 0 after clear, got %d", got)
}
}
func TestQueueRemoveAt(t *testing.T) {
q := NewQueue()
q.Push(lavalink.Track{Info: lavalink.TrackInfo{Title: "first"}})
q.Push(lavalink.Track{Info: lavalink.TrackInfo{Title: "second"}})
q.Push(lavalink.Track{Info: lavalink.TrackInfo{Title: "third"}})
track, ok := q.RemoveAt(1)
if !ok || track.Info.Title != "second" {
t.Fatalf("expected to remove second track, got %+v ok=%v", track, ok)
}
if got := q.Len(); got != 2 {
t.Fatalf("expected length 2 after removal, got %d", got)
}
list := q.List()
if list[0].Info.Title != "first" || list[1].Info.Title != "third" {
t.Fatalf("unexpected queue order after removal: %+v", list)
}
if _, ok := q.RemoveAt(5); ok {
t.Fatal("expected out-of-range removal to return ok=false")
}
if _, ok := q.RemoveAt(-1); ok {
t.Fatal("expected negative index removal to return ok=false")
}
}
func TestQueueShuffle(t *testing.T) {
q := NewQueue()
for i := 0; i < 5; i++ {
q.Push(lavalink.Track{Info: lavalink.TrackInfo{Title: string(rune('a' + i))}})
}
if count := q.Shuffle(); count != 5 {
t.Fatalf("expected shuffle to report 5 tracks, got %d", count)
}
if got := q.Len(); got != 5 {
t.Fatalf("expected length to stay 5 after shuffle, got %d", got)
}
seen := make(map[string]bool)
for _, track := range q.List() {
seen[track.Info.Title] = true
}
for i := 0; i < 5; i++ {
title := string(rune('a' + i))
if !seen[title] {
t.Fatalf("expected shuffled queue to still contain %q", title)
}
}
}
func TestQueueListReturnsCopy(t *testing.T) {
q := NewQueue()
q.Push(lavalink.Track{Info: lavalink.TrackInfo{Title: "only"}})
list := q.List()
list[0].Info.Title = "mutated"
track, _ := q.Pop()
if track.Info.Title != "only" {
t.Fatalf("expected internal queue to be unaffected by mutation of returned list, got %q", track.Info.Title)
}
}