Implement per guild music queue and player
This commit is contained in:
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user