Files
velox-bot/internal/music/manager.go
T
FernandoJVideira da66eb8dd5 feat(music): let the dashboard add songs, starting a session if needed
New AddToQueue appends to an already-active session without touching
voice (the dashboard can't join a channel on its own), and add_song's
dispatch case now branches: a voice_channel_id in the payload means
"start fresh", so it goes through EnqueueAndPlay (join + play) instead.
Query normalization (plain text -> YouTube search, stripping
autoplay/radio params off pasted YouTube URLs) moves out of /play's
handler into a shared NormalizeQuery, both entry points need it, not
just one.

Starting playback from the dashboard also posts the now-playing embed
into the voice channel's own built-in text chat, matching what /play
already does, instead of leaving it with nowhere to show up.
2026-08-29 19:44:24 +01:00

821 lines
19 KiB
Go

package music
import (
"context"
"fmt"
"log"
"net/url"
"strconv"
"strings"
"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
}
}
// NormalizeQuery turns plain text into a YouTube search and strips
// auto-generated "radio/mix" params off a pasted YouTube URL
// (https://www.youtube.com/watch?v=ID&list=RDID&start_radio=1 becomes
// .../watch?v=ID), while leaving normal playlist URLs and non-YouTube
// links (SoundCloud, etc.) untouched. Every entry point that accepts a
// user-supplied query - /play and the dashboard's "add song" - should
// normalize through this before handing the query to Lavalink.
func NormalizeQuery(query string) string {
if !strings.HasPrefix(query, "http://") && !strings.HasPrefix(query, "https://") && !strings.HasPrefix(query, "ytsearch:") {
return "ytsearch:" + query
}
u, err := url.Parse(query)
if err != nil || u == nil {
return query
}
host := strings.ToLower(u.Host)
if !strings.Contains(host, "youtube.com") || u.Path != "/watch" {
return query
}
q := u.Query()
v := q.Get("v")
if v == "" {
return query
}
list := q.Get("list")
_, hasStartRadio := q["start_radio"]
if !hasStartRadio && !strings.HasPrefix(list, "RD") {
return query
}
u.RawQuery = url.Values{"v": []string{v}}.Encode()
u.Fragment = ""
return u.String()
}
// resolveTracks searches Lavalink for query (already normalized) and
// returns a playable track list - a single track, a search result's
// first hit, or a whole playlist (rotated to respect Lavalink's own
// selectedTrack when present).
func resolveTracks(query string) ([]lavalink.Track, error) {
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, 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]...)
}
return loadedTracks, nil
}
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()
}
loadedTracks, err := resolveTracks(NormalizeQuery(query))
if err != nil {
return nil, false, err
}
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
}
// AddToQueue adds a track to a guild's existing queue without joining a
// voice channel, unlike EnqueueAndPlay it can't start a brand new
// session (that needs a voice channel to join, which this has no way to
// pick), so it requires one to already be active.
func AddToQueue(guildID, query, requesterID string) (*TrackEntry, error) {
if manager == nil {
return nil, fmt.Errorf("music manager not initialized")
}
gp := getOrCreateGuildPlayer(guildID)
manager.mu.Lock()
alreadyActive := len(gp.Queue) > 0
manager.mu.Unlock()
if !alreadyActive {
return nil, fmt.Errorf("nothing is currently playing in this server")
}
loadedTracks, err := resolveTracks(NormalizeQuery(query))
if err != nil {
return nil, err
}
entries := make([]TrackEntry, len(loadedTracks))
for idx, t := range loadedTracks {
entries[idx] = TrackEntry{
Track: t,
RequestedBy: "Dashboard",
RequesterID: requesterID,
QueueID: uuid.New().String(),
}
}
manager.mu.Lock()
gp.Queue = append(gp.Queue, entries...)
manager.mu.Unlock()
syncState(guildID)
return &entries[0], 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
}