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