Files
velox-bot/internal/music/manager.go
T
FernandoJVideira 7a17c55774 feat(music): stable queue identity, remove/reorder, and repeat commands
TrackEntry gets a QueueID generated once when a track is queued and
kept for its whole life there, and musicrepo.ReplaceQueue now upserts
by that ID instead of deleting and reinserting the whole queue on
every sync (which churned every track's database id constantly, even
ones that hadn't moved, and would've made remove/reorder commands
target the wrong track). RemoveFromQueue and Reorder are new Manager
functions built on top of that stable identity. Also adds repeat_song/
repeat_queue to the persisted now-playing state, and makes the two
existing toggle functions actually sync it, they never did before.
2026-08-29 19:22:02 +01:00

727 lines
16 KiB
Go

package music
import (
"context"
"fmt"
"log"
"net/url"
"strconv"
"sync"
"time"
"velox-bot/internal/db/repos/musicrepo"
"github.com/bwmarrin/discordgo"
"github.com/disgoorg/disgolink/v3/disgolink"
"github.com/disgoorg/disgolink/v3/lavalink"
"github.com/disgoorg/snowflake/v2"
"github.com/google/uuid"
)
type TrackEntry struct {
Track lavalink.Track
RequestedBy string
RequesterID string
StartedAt time.Time
// QueueID is generated once, when the track is first added to the
// queue, and never changes for the rest of its life there - it's
// what lets syncState upsert this track's row across syncs instead
// of deleting and recreating it (and its DB id) every single time,
// which is what remove/reorder commands need to reliably target a
// specific track.
QueueID 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
repo *musicrepo.Repo
mu sync.Mutex
players map[string]*guildPlayer
}
var manager *Manager
func Init(session *discordgo.Session, repo *musicrepo.Repo, 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,
repo: repo,
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"
}
// Connecting to the Lavalink node is a blocking network call (and, on
// failure, disgolink retries indefinitely with backoff) — do it in the
// background so bot startup isn't held hostage by it. Music commands
// simply fail with "not initialized" via the manager==nil checks until
// this completes.
go func() {
_, err := m.client.AddNode(context.Background(), disgolink.NodeConfig{
Name: "main",
Address: u.Host,
Password: password,
Secure: secure,
})
if err != nil {
log.Printf("music: failed to connect to lavalink node: %v", err)
return
}
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 syncState(guidID string) {
if manager == nil || manager.repo == nil {
return
}
manager.mu.Lock()
gp, ok := manager.players[guidID]
if !ok {
manager.mu.Unlock()
return
}
queue := make([]TrackEntry, len(gp.Queue))
copy(queue, gp.Queue)
paused := gp.Paused
volume := gp.Volume
repeatSong := gp.RepeatSong
repeatQueue := gp.RepeatQueue
manager.mu.Unlock()
gID, err := strconv.ParseInt(guidID, 10, 64)
if err != nil {
return
}
if len(queue) == 0 {
err := manager.repo.ClearNowPlaying(context.Background(), gID)
if err != nil {
return
}
err = manager.repo.ReplaceQueue(context.Background(), gID, nil)
if err != nil {
return
}
return
}
current := queue[0]
title := current.Track.Info.Title
var uri string
if current.Track.Info.URI != nil {
uri = *current.Track.Info.URI
}
length := int(current.Track.Info.Length / 1000)
startedAt := current.StartedAt
err = manager.repo.SetNowPlaying(context.Background(), gID, title, uri, length, startedAt, paused, volume, repeatSong, repeatQueue)
if err != nil {
return
}
rest := queue[1:]
var queueEntries []musicrepo.QueueEntry
for _, entry := range rest {
reqID, _ := strconv.ParseInt(entry.RequesterID, 10, 64)
var uri string
if entry.Track.Info.URI != nil {
uri = *entry.Track.Info.URI
}
queueEntries = append(queueEntries, musicrepo.QueueEntry{
Title: entry.Track.Info.Title,
URL: uri,
RequestedBy: reqID,
ClientID: entry.QueueID,
})
}
err = manager.repo.ReplaceQueue(context.Background(), gID, queueEntries)
if err != nil {
return
}
}
func EnqueueAndPlay(guildID, voiceChannelID, textChannelID, query, requester, requesterID 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,
RequesterID: requesterID,
QueueID: uuid.New().String(),
}
}
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 {
gp.Queue[0].StartedAt = time.Now()
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)
}
}
syncState(guildID)
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 {
gp.Queue[0].StartedAt = time.Now()
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)
}
syncState(guildID)
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())
syncState(guildID)
return
}
gp.Queue[0].StartedAt = time.Now()
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)
}
syncState(guildID)
}
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()
syncState(guildID)
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)
}
syncState(guildID)
return gp.Player.Update(context.Background(), lavalink.WithNullTrack())
}
gp.Queue[0].StartedAt = time.Now()
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)
}
syncState(guildID)
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)
}
syncState(guildID)
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 &current, rest
}
func ToggleRepeatSong(guildID string) (bool, bool) {
if manager == nil {
return false, false
}
gp := getOrCreateGuildPlayer(guildID)
manager.mu.Lock()
gp.RepeatSong = !gp.RepeatSong
if gp.RepeatSong {
gp.RepeatQueue = false
}
repeatSong, repeatQueue := gp.RepeatSong, gp.RepeatQueue
manager.mu.Unlock()
syncState(guildID)
return repeatSong, repeatQueue
}
func ToggleRepeatQueue(guildID string) (bool, bool) {
if manager == nil {
return false, false
}
gp := getOrCreateGuildPlayer(guildID)
manager.mu.Lock()
gp.RepeatQueue = !gp.RepeatQueue
if gp.RepeatQueue {
gp.RepeatSong = false
}
repeatSong, repeatQueue := gp.RepeatSong, gp.RepeatQueue
manager.mu.Unlock()
syncState(guildID)
return repeatSong, 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)
}
}
syncState(guildID)
return nil
}
func RemoveFromQueue(guildID, queueID string) error {
if manager == nil {
return fmt.Errorf("music manager not initialized")
}
gp := getOrCreateGuildPlayer(guildID)
manager.mu.Lock()
idx := -1
for i, q := range gp.Queue {
if q.QueueID == queueID {
idx = i
break
}
}
if idx == -1 {
manager.mu.Unlock()
return fmt.Errorf("queue not found")
}
if idx == 0 {
manager.mu.Unlock()
return Skip(guildID)
}
gp.Queue = append(gp.Queue[:idx], gp.Queue[idx+1:]...)
manager.mu.Unlock()
syncState(guildID)
return nil
}
func Reorder(guildID, queueID string, newPosition int) error {
if manager == nil {
return fmt.Errorf("music manager not initialized")
}
gp := getOrCreateGuildPlayer(guildID)
manager.mu.Lock()
idx := -1
for i, q := range gp.Queue {
if q.QueueID == queueID {
idx = i
break
}
}
if idx == -1 {
manager.mu.Unlock()
return fmt.Errorf("queue not found")
}
if idx == 0 {
manager.mu.Unlock()
return Skip(guildID)
}
entry := gp.Queue[idx]
gp.Queue = append(gp.Queue[:idx], gp.Queue[idx+1:]...)
pos := max(1, min(newPosition, len(gp.Queue)))
gp.Queue = append(gp.Queue[:pos], append([]TrackEntry{entry}, gp.Queue[pos:]...)...)
manager.mu.Unlock()
syncState(guildID)
return nil
}
func CurrentNowPlayingMessageID(guildID string) string {
if manager == nil {
return ""
}
gp := getOrCreateGuildPlayer(guildID)
manager.mu.Lock()
defer manager.mu.Unlock()
return gp.NowPlayingMsgID
}