From c4757933e1343597afa16fe15f634f098a13204e Mon Sep 17 00:00:00 2001 From: XShine555 Date: Mon, 21 Sep 2026 18:48:06 +0200 Subject: [PATCH] Implement per guild music queue and player --- internal/music/manager.go | 53 +++++++++ internal/music/player.go | 207 +++++++++++++++++++++++++++++++++++ internal/music/queue.go | 83 ++++++++++++++ internal/music/queue_test.go | 118 ++++++++++++++++++++ 4 files changed, 461 insertions(+) create mode 100644 internal/music/manager.go create mode 100644 internal/music/player.go create mode 100644 internal/music/queue.go create mode 100644 internal/music/queue_test.go diff --git a/internal/music/manager.go b/internal/music/manager.go new file mode 100644 index 0000000..9565a11 --- /dev/null +++ b/internal/music/manager.go @@ -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), + ) + } +} diff --git a/internal/music/player.go b/internal/music/player.go new file mode 100644 index 0000000..4e9edc2 --- /dev/null +++ b/internal/music/player.go @@ -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) +} diff --git a/internal/music/queue.go b/internal/music/queue.go new file mode 100644 index 0000000..972f22c --- /dev/null +++ b/internal/music/queue.go @@ -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 +} diff --git a/internal/music/queue_test.go b/internal/music/queue_test.go new file mode 100644 index 0000000..9614ce9 --- /dev/null +++ b/internal/music/queue_test.go @@ -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) + } +}