- Added new music commands: play, queue, and volume. - Implemented music management using disgolink for Lavalink integration. - Updated bot initialization to include Lavalink host and password. - Enhanced interaction handling for music commands, requiring DJ role for usage. - Introduced now playing message with interactive buttons for controlling playback. - Updated dependencies in go.mod for disgolink and snowflake.
539 lines
12 KiB
Go
539 lines
12 KiB
Go
package music
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/url"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/bwmarrin/discordgo"
|
|
"github.com/disgoorg/disgolink/v3/disgolink"
|
|
"github.com/disgoorg/disgolink/v3/lavalink"
|
|
"github.com/disgoorg/snowflake/v2"
|
|
)
|
|
|
|
type TrackEntry struct {
|
|
Track lavalink.Track
|
|
RequestedBy string
|
|
}
|
|
|
|
type guildPlayer struct {
|
|
Player disgolink.Player
|
|
Queue []TrackEntry
|
|
RepeatSong bool
|
|
RepeatQueue bool
|
|
Paused bool
|
|
Volume int
|
|
IdleSince time.Time
|
|
TextChannelID string
|
|
NowPlayingMsgID string
|
|
}
|
|
|
|
type Manager struct {
|
|
client disgolink.Client
|
|
session *discordgo.Session
|
|
|
|
mu sync.Mutex
|
|
players map[string]*guildPlayer
|
|
}
|
|
|
|
var manager *Manager
|
|
|
|
func Init(session *discordgo.Session, appID, lavalinkHost, lavalinkPass string) error {
|
|
if lavalinkHost == "" {
|
|
return nil
|
|
}
|
|
|
|
userID, err := snowflake.Parse(appID)
|
|
if err != nil {
|
|
return fmt.Errorf("parse app id for lavalink: %w", err)
|
|
}
|
|
|
|
m := &Manager{
|
|
client: disgolink.New(userID, disgolink.WithListenerFunc(onTrackEnd)),
|
|
session: session,
|
|
players: make(map[string]*guildPlayer),
|
|
}
|
|
|
|
u, err := url.Parse(lavalinkHost)
|
|
if err != nil {
|
|
return fmt.Errorf("parse lavalink host: %w", err)
|
|
}
|
|
|
|
secure := u.Scheme == "https" || u.Scheme == "wss"
|
|
password := lavalinkPass
|
|
if password == "" {
|
|
password = "youshallnotpass"
|
|
}
|
|
|
|
_, err = m.client.AddNode(context.Background(), disgolink.NodeConfig{
|
|
Name: "main",
|
|
Address: u.Host,
|
|
Password: password,
|
|
Secure: secure,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("add lavalink node: %w", err)
|
|
}
|
|
|
|
manager = m
|
|
go idleDisconnectLoop()
|
|
return nil
|
|
}
|
|
|
|
func idleDisconnectLoop() {
|
|
ticker := time.NewTicker(10 * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
for range ticker.C {
|
|
if manager == nil || manager.session == nil {
|
|
continue
|
|
}
|
|
|
|
now := time.Now()
|
|
var toDisconnect []string
|
|
|
|
manager.mu.Lock()
|
|
for guildID, gp := range manager.players {
|
|
if gp == nil {
|
|
continue
|
|
}
|
|
|
|
// If we're paused or have anything queued/playing, clear idle timer.
|
|
if gp.Paused || len(gp.Queue) > 0 {
|
|
gp.IdleSince = time.Time{}
|
|
continue
|
|
}
|
|
|
|
if gp.IdleSince.IsZero() {
|
|
gp.IdleSince = now
|
|
continue
|
|
}
|
|
|
|
if now.Sub(gp.IdleSince) >= time.Minute {
|
|
toDisconnect = append(toDisconnect, guildID)
|
|
gp.IdleSince = time.Time{}
|
|
}
|
|
}
|
|
manager.mu.Unlock()
|
|
|
|
for _, guildID := range toDisconnect {
|
|
// Best-effort: disconnect. Safe even if we're already disconnected.
|
|
_ = manager.session.ChannelVoiceJoinManual(guildID, "", false, false)
|
|
}
|
|
}
|
|
}
|
|
|
|
func OnVoiceStateUpdate(vs *discordgo.VoiceStateUpdate) {
|
|
if manager == nil {
|
|
return
|
|
}
|
|
if vs.UserID != manager.session.State.User.ID {
|
|
return
|
|
}
|
|
|
|
guildID := snowflake.MustParse(vs.GuildID)
|
|
var channelID *snowflake.ID
|
|
if vs.ChannelID != "" {
|
|
id := snowflake.MustParse(vs.ChannelID)
|
|
channelID = &id
|
|
}
|
|
|
|
manager.client.OnVoiceStateUpdate(
|
|
context.Background(),
|
|
guildID,
|
|
channelID,
|
|
vs.SessionID,
|
|
)
|
|
}
|
|
|
|
func OnVoiceServerUpdate(ev *discordgo.VoiceServerUpdate) {
|
|
if manager == nil {
|
|
return
|
|
}
|
|
endpoint := ""
|
|
if ev.Endpoint != "" {
|
|
endpoint = ev.Endpoint
|
|
}
|
|
|
|
manager.client.OnVoiceServerUpdate(
|
|
context.Background(),
|
|
snowflake.MustParse(ev.GuildID),
|
|
ev.Token,
|
|
endpoint,
|
|
)
|
|
}
|
|
|
|
func getOrCreateGuildPlayer(guildID string) *guildPlayer {
|
|
manager.mu.Lock()
|
|
defer manager.mu.Unlock()
|
|
|
|
if gp, ok := manager.players[guildID]; ok {
|
|
return gp
|
|
}
|
|
|
|
player := manager.client.Player(snowflake.MustParse(guildID))
|
|
gp := &guildPlayer{
|
|
Player: player,
|
|
Queue: make([]TrackEntry, 0),
|
|
Volume: 100,
|
|
}
|
|
manager.players[guildID] = gp
|
|
return gp
|
|
}
|
|
|
|
func EnqueueAndPlay(guildID, voiceChannelID, textChannelID, query, requester string) (*TrackEntry, bool, error) {
|
|
if manager == nil {
|
|
return nil, false, fmt.Errorf("music manager not initialized")
|
|
}
|
|
|
|
// Join voice channel
|
|
if err := manager.session.ChannelVoiceJoinManual(guildID, voiceChannelID, false, false); err != nil {
|
|
return nil, false, fmt.Errorf("join voice channel: %w", err)
|
|
}
|
|
|
|
gp := getOrCreateGuildPlayer(guildID)
|
|
if textChannelID != "" {
|
|
manager.mu.Lock()
|
|
gp.TextChannelID = textChannelID
|
|
manager.mu.Unlock()
|
|
}
|
|
|
|
var loadedTracks []lavalink.Track
|
|
|
|
manager.client.BestNode().LoadTracksHandler(
|
|
context.Background(),
|
|
query,
|
|
disgolink.NewResultHandler(
|
|
func(track lavalink.Track) {
|
|
loadedTracks = append(loadedTracks, track)
|
|
},
|
|
func(playlist lavalink.Playlist) {
|
|
if len(playlist.Tracks) == 0 {
|
|
return
|
|
}
|
|
|
|
// Respect Lavalink's selectedTrack when present by rotating the playlist.
|
|
selected := playlist.Info.SelectedTrack
|
|
if selected >= 0 && selected < len(playlist.Tracks) {
|
|
loadedTracks = append(loadedTracks, playlist.Tracks[selected:]...)
|
|
loadedTracks = append(loadedTracks, playlist.Tracks[:selected]...)
|
|
return
|
|
}
|
|
|
|
loadedTracks = append(loadedTracks, playlist.Tracks...)
|
|
},
|
|
func(tracks []lavalink.Track) {
|
|
if len(tracks) > 0 {
|
|
loadedTracks = append(loadedTracks, tracks[0])
|
|
}
|
|
},
|
|
func() {
|
|
},
|
|
func(err error) {
|
|
},
|
|
),
|
|
)
|
|
|
|
if len(loadedTracks) == 0 {
|
|
return nil, false, fmt.Errorf("no tracks found for query")
|
|
}
|
|
|
|
// Some sources may return unplayable entries; pick the first with an encoded track.
|
|
firstPlayableIdx := 0
|
|
for idx := range loadedTracks {
|
|
if loadedTracks[idx].Encoded != "" {
|
|
firstPlayableIdx = idx
|
|
break
|
|
}
|
|
}
|
|
if firstPlayableIdx != 0 && firstPlayableIdx < len(loadedTracks) {
|
|
loadedTracks = append(loadedTracks[firstPlayableIdx:], loadedTracks[:firstPlayableIdx]...)
|
|
}
|
|
|
|
entries := make([]TrackEntry, len(loadedTracks))
|
|
for idx, t := range loadedTracks {
|
|
entries[idx] = TrackEntry{
|
|
Track: t,
|
|
RequestedBy: requester,
|
|
}
|
|
}
|
|
firstEntry := entries[0]
|
|
|
|
manager.mu.Lock()
|
|
shouldStart := len(gp.Queue) == 0
|
|
gp.Queue = append(gp.Queue, entries...)
|
|
gp.IdleSince = time.Time{}
|
|
manager.mu.Unlock()
|
|
|
|
if shouldStart {
|
|
if err := gp.Player.Update(context.Background(), lavalink.WithTrack(firstEntry.Track)); err != nil {
|
|
return nil, false, fmt.Errorf("start track: %w", err)
|
|
}
|
|
if gp.TextChannelID != "" {
|
|
postNowPlaying(guildID, gp.TextChannelID, firstEntry)
|
|
}
|
|
}
|
|
|
|
return &firstEntry, shouldStart, nil
|
|
}
|
|
|
|
func onTrackEnd(player disgolink.Player, event lavalink.TrackEndEvent) {
|
|
if manager == nil {
|
|
return
|
|
}
|
|
if !event.Reason.MayStartNext() {
|
|
return
|
|
}
|
|
|
|
guildID := player.GuildID().String()
|
|
|
|
manager.mu.Lock()
|
|
gp, ok := manager.players[guildID]
|
|
if !ok || len(gp.Queue) == 0 {
|
|
manager.mu.Unlock()
|
|
return
|
|
}
|
|
|
|
// repeat current track
|
|
if gp.RepeatSong {
|
|
next := gp.Queue[0]
|
|
textChannelID := gp.TextChannelID
|
|
manager.mu.Unlock()
|
|
|
|
if err := gp.Player.Update(context.Background(), lavalink.WithTrack(next.Track)); err != nil {
|
|
return
|
|
}
|
|
if textChannelID != "" {
|
|
postNowPlaying(guildID, textChannelID, next)
|
|
}
|
|
return
|
|
}
|
|
|
|
// pop current and optionally cycle it to end
|
|
finished := gp.Queue[0]
|
|
gp.Queue = gp.Queue[1:]
|
|
if gp.RepeatQueue {
|
|
gp.Queue = append(gp.Queue, finished)
|
|
}
|
|
|
|
if len(gp.Queue) == 0 {
|
|
textChannelID := gp.TextChannelID
|
|
nowPlayingMsgID := gp.NowPlayingMsgID
|
|
gp.NowPlayingMsgID = ""
|
|
gp.IdleSince = time.Now()
|
|
manager.mu.Unlock()
|
|
if textChannelID != "" && nowPlayingMsgID != "" {
|
|
disableNowPlaying(textChannelID, nowPlayingMsgID)
|
|
}
|
|
_ = gp.Player.Update(context.Background(), lavalink.WithNullTrack())
|
|
return
|
|
}
|
|
|
|
next := gp.Queue[0]
|
|
textChannelID := gp.TextChannelID
|
|
manager.mu.Unlock()
|
|
|
|
if err := gp.Player.Update(context.Background(), lavalink.WithTrack(next.Track)); err != nil {
|
|
return
|
|
}
|
|
if textChannelID != "" {
|
|
postNowPlaying(guildID, textChannelID, next)
|
|
}
|
|
}
|
|
|
|
func Pause(guildID string, pause bool) error {
|
|
if manager == nil {
|
|
return fmt.Errorf("music manager not initialized")
|
|
}
|
|
gp := getOrCreateGuildPlayer(guildID)
|
|
manager.mu.Lock()
|
|
gp.Paused = pause
|
|
if pause {
|
|
gp.IdleSince = time.Time{}
|
|
}
|
|
manager.mu.Unlock()
|
|
return gp.Player.Update(context.Background(), lavalink.WithPaused(pause))
|
|
}
|
|
|
|
func Skip(guildID string) error {
|
|
if manager == nil {
|
|
return fmt.Errorf("music manager not initialized")
|
|
}
|
|
gp := getOrCreateGuildPlayer(guildID)
|
|
|
|
manager.mu.Lock()
|
|
if len(gp.Queue) == 0 {
|
|
manager.mu.Unlock()
|
|
return nil
|
|
}
|
|
// skip always advances regardless of repeat song
|
|
skipped := gp.Queue[0]
|
|
gp.Queue = gp.Queue[1:]
|
|
if gp.RepeatQueue {
|
|
gp.Queue = append(gp.Queue, skipped)
|
|
}
|
|
|
|
if len(gp.Queue) == 0 {
|
|
textChannelID := gp.TextChannelID
|
|
nowPlayingMsgID := gp.NowPlayingMsgID
|
|
gp.NowPlayingMsgID = ""
|
|
gp.IdleSince = time.Now()
|
|
manager.mu.Unlock()
|
|
if textChannelID != "" && nowPlayingMsgID != "" {
|
|
disableNowPlaying(textChannelID, nowPlayingMsgID)
|
|
}
|
|
return gp.Player.Update(context.Background(), lavalink.WithNullTrack())
|
|
}
|
|
|
|
next := gp.Queue[0]
|
|
textChannelID := gp.TextChannelID
|
|
manager.mu.Unlock()
|
|
|
|
if err := gp.Player.Update(context.Background(), lavalink.WithTrack(next.Track)); err != nil {
|
|
return err
|
|
}
|
|
if textChannelID != "" {
|
|
postNowPlaying(guildID, textChannelID, next)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func Stop(guildID string) error {
|
|
if manager == nil {
|
|
return fmt.Errorf("music manager not initialized")
|
|
}
|
|
gp := getOrCreateGuildPlayer(guildID)
|
|
|
|
manager.mu.Lock()
|
|
textChannelID := gp.TextChannelID
|
|
nowPlayingMsgID := gp.NowPlayingMsgID
|
|
gp.Queue = nil
|
|
gp.Paused = false
|
|
gp.NowPlayingMsgID = ""
|
|
gp.IdleSince = time.Now()
|
|
manager.mu.Unlock()
|
|
|
|
if textChannelID != "" && nowPlayingMsgID != "" {
|
|
disableNowPlaying(textChannelID, nowPlayingMsgID)
|
|
}
|
|
|
|
return gp.Player.Update(context.Background(), lavalink.WithNullTrack())
|
|
}
|
|
|
|
func GetQueue(guildID string) (*TrackEntry, []TrackEntry) {
|
|
if manager == nil {
|
|
return nil, nil
|
|
}
|
|
gp := getOrCreateGuildPlayer(guildID)
|
|
|
|
manager.mu.Lock()
|
|
defer manager.mu.Unlock()
|
|
|
|
if len(gp.Queue) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
current := gp.Queue[0]
|
|
rest := make([]TrackEntry, len(gp.Queue)-1)
|
|
copy(rest, gp.Queue[1:])
|
|
return ¤t, rest
|
|
}
|
|
|
|
func ToggleRepeatSong(guildID string) (bool, bool) {
|
|
if manager == nil {
|
|
return false, false
|
|
}
|
|
gp := getOrCreateGuildPlayer(guildID)
|
|
manager.mu.Lock()
|
|
defer manager.mu.Unlock()
|
|
gp.RepeatSong = !gp.RepeatSong
|
|
if gp.RepeatSong {
|
|
gp.RepeatQueue = false
|
|
}
|
|
return gp.RepeatSong, gp.RepeatQueue
|
|
}
|
|
|
|
func ToggleRepeatQueue(guildID string) (bool, bool) {
|
|
if manager == nil {
|
|
return false, false
|
|
}
|
|
gp := getOrCreateGuildPlayer(guildID)
|
|
manager.mu.Lock()
|
|
defer manager.mu.Unlock()
|
|
gp.RepeatQueue = !gp.RepeatQueue
|
|
if gp.RepeatQueue {
|
|
gp.RepeatSong = false
|
|
}
|
|
return gp.RepeatSong, gp.RepeatQueue
|
|
}
|
|
|
|
func repeatFlags(guildID string) (bool, bool) {
|
|
if manager == nil {
|
|
return false, false
|
|
}
|
|
gp := getOrCreateGuildPlayer(guildID)
|
|
manager.mu.Lock()
|
|
defer manager.mu.Unlock()
|
|
return gp.RepeatSong, gp.RepeatQueue
|
|
}
|
|
|
|
func pausedAndVolume(guildID string) (bool, int) {
|
|
if manager == nil {
|
|
return false, 100
|
|
}
|
|
gp := getOrCreateGuildPlayer(guildID)
|
|
manager.mu.Lock()
|
|
defer manager.mu.Unlock()
|
|
vol := gp.Volume
|
|
if vol <= 0 {
|
|
vol = 100
|
|
}
|
|
return gp.Paused, vol
|
|
}
|
|
|
|
func SetVolume(guildID string, volume int) error {
|
|
if manager == nil {
|
|
return fmt.Errorf("music manager not initialized")
|
|
}
|
|
if volume < 0 {
|
|
volume = 0
|
|
}
|
|
if volume > 150 {
|
|
volume = 150
|
|
}
|
|
|
|
gp := getOrCreateGuildPlayer(guildID)
|
|
manager.mu.Lock()
|
|
gp.Volume = volume
|
|
textChannelID := gp.TextChannelID
|
|
nowPlayingMsgID := gp.NowPlayingMsgID
|
|
manager.mu.Unlock()
|
|
|
|
if err := gp.Player.Update(context.Background(), lavalink.WithVolume(volume)); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Refresh the embed so the displayed volume matches.
|
|
if textChannelID != "" && nowPlayingMsgID != "" {
|
|
current, _ := GetQueue(guildID)
|
|
if current != nil {
|
|
postNowPlaying(guildID, textChannelID, *current)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func CurrentNowPlayingMessageID(guildID string) string {
|
|
if manager == nil {
|
|
return ""
|
|
}
|
|
gp := getOrCreateGuildPlayer(guildID)
|
|
manager.mu.Lock()
|
|
defer manager.mu.Unlock()
|
|
return gp.NowPlayingMsgID
|
|
}
|
|
|
|
|