feat(music): persist live playback state to postgres

The queue and now-playing state only ever lived in the bot's memory,
so the dashboard had nothing real to show and a restart silently
wiped whatever was queued. Adds a musicrepo package and a syncState
call after every queue-changing action (play, skip, pause, volume,
stop, track end) that mirrors the current track and queue into
music_now_playing and music_queue.

No user-visible change yet, this is groundwork for the dashboard
queue/now-playing view.
This commit is contained in:
2026-08-29 15:48:27 +01:00
parent c7b960e14a
commit d6687879dd
5 changed files with 176 additions and 20 deletions
+5 -2
View File
@@ -3,6 +3,7 @@ package bot
import (
"log"
"velox-bot/internal/commands"
"velox-bot/internal/db/repos/musicrepo"
"velox-bot/internal/db/services"
"velox-bot/internal/events"
"velox-bot/internal/music"
@@ -17,11 +18,12 @@ type Bot struct {
Commands []*discordgo.ApplicationCommand
registeredCommands []*discordgo.ApplicationCommand
Services *services.Services
MusicRepo *musicrepo.Repo
LavalinkHost string
LavalinkPass string
}
func NewBot(token, appID, guildID, lavalinkHost, lavalinkPass string, cmds []*discordgo.ApplicationCommand, services *services.Services) (*Bot, error) {
func NewBot(token, appID, guildID, lavalinkHost, lavalinkPass string, cmds []*discordgo.ApplicationCommand, services *services.Services, musicRepo *musicrepo.Repo) (*Bot, error) {
session, err := discordgo.New("Bot " + token)
if err != nil {
return nil, err
@@ -46,6 +48,7 @@ func NewBot(token, appID, guildID, lavalinkHost, lavalinkPass string, cmds []*di
GuildID: guildID,
Commands: cmds,
Services: services,
MusicRepo: musicRepo,
LavalinkHost: lavalinkHost,
LavalinkPass: lavalinkPass,
}, nil
@@ -56,7 +59,7 @@ func (b *Bot) Start() error {
return err
}
_ = music.Init(b.Session, b.AppID, b.LavalinkHost, b.LavalinkPass)
_ = music.Init(b.Session, b.MusicRepo, b.AppID, b.LavalinkHost, b.LavalinkPass)
b.registeredCommands = make([]*discordgo.ApplicationCommand, len(b.Commands))
for _, cmd := range b.Commands {
+1 -1
View File
@@ -64,7 +64,7 @@ func PlayHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
return
}
entry, started, err := music.EnqueueAndPlay(i.GuildID, vs.ChannelID, i.ChannelID, query, i.Member.User.Username)
entry, started, err := music.EnqueueAndPlay(i.GuildID, vs.ChannelID, i.ChannelID, query, i.Member.User.Username, i.Member.User.ID)
if err != nil {
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
Content: ptr(fmt.Sprintf("Error: %v", err)),
+57
View File
@@ -0,0 +1,57 @@
package musicrepo
import (
"context"
"database/sql"
"time"
)
type QueueEntry struct {
Title string
URL string
RequestedBy int64
}
type Repo struct {
db *sql.DB
}
func NewRepo(db *sql.DB) *Repo {
return &Repo{db: db}
}
func (r *Repo) SetNowPlaying(ctx context.Context, guildID int64, title, url string, durationSeconds int, startedAt time.Time, paused bool, volume int) error {
const q = `INSERT INTO music_now_playing (guild_id, track_title, track_url, duration_seconds, started_at, paused, volume) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (guild_id) DO UPDATE SET track_title = $2, track_url = $3, duration_seconds = $4, started_at = $5, paused = $6, volume = $7`
_, err := r.db.ExecContext(ctx, q, guildID, title, url, durationSeconds, startedAt, paused, volume)
return err
}
func (r *Repo) ClearNowPlaying(ctx context.Context, guildID int64) error {
const q = `DELETE FROM music_now_playing WHERE guild_id = $1`
_, err := r.db.ExecContext(ctx, q, guildID)
return err
}
func (r *Repo) ReplaceQueue(ctx context.Context, guildID int64, entries []QueueEntry) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
const q = `DELETE FROM music_queue WHERE guild_id = $1`
_, err = tx.ExecContext(ctx, q, guildID)
if err != nil {
return err
}
for i, entry := range entries {
const q = `INSERT INTO music_queue (guild_id, track_title, track_url, requested_by, position) VALUES ($1, $2, $3, $4, $5)`
_, err = tx.ExecContext(ctx, q, guildID, entry.Title, entry.URL, entry.RequestedBy, i)
if err != nil {
return err
}
}
return tx.Commit()
}
+98 -4
View File
@@ -5,8 +5,10 @@ import (
"fmt"
"log"
"net/url"
"strconv"
"sync"
"time"
"velox-bot/internal/db/repos/musicrepo"
"github.com/bwmarrin/discordgo"
"github.com/disgoorg/disgolink/v3/disgolink"
@@ -17,6 +19,8 @@ import (
type TrackEntry struct {
Track lavalink.Track
RequestedBy string
RequesterID string
StartedAt time.Time
}
type guildPlayer struct {
@@ -34,6 +38,7 @@ type guildPlayer struct {
type Manager struct {
client disgolink.Client
session *discordgo.Session
repo *musicrepo.Repo
mu sync.Mutex
players map[string]*guildPlayer
@@ -41,7 +46,7 @@ type Manager struct {
var manager *Manager
func Init(session *discordgo.Session, appID, lavalinkHost, lavalinkPass string) error {
func Init(session *discordgo.Session, repo *musicrepo.Repo, appID, lavalinkHost, lavalinkPass string) error {
if lavalinkHost == "" {
return nil
}
@@ -54,6 +59,7 @@ func Init(session *discordgo.Session, appID, lavalinkHost, lavalinkPass string)
m := &Manager{
client: disgolink.New(userID, disgolink.WithListenerFunc(onTrackEnd)),
session: session,
repo: repo,
players: make(map[string]*guildPlayer),
}
@@ -192,7 +198,80 @@ func getOrCreateGuildPlayer(guildID string) *guildPlayer {
return gp
}
func EnqueueAndPlay(guildID, voiceChannelID, textChannelID, query, requester string) (*TrackEntry, bool, error) {
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
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)
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,
})
}
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")
}
@@ -266,6 +345,7 @@ func EnqueueAndPlay(guildID, voiceChannelID, textChannelID, query, requester str
entries[idx] = TrackEntry{
Track: t,
RequestedBy: requester,
RequesterID: requesterID,
}
}
firstEntry := entries[0]
@@ -277,6 +357,8 @@ func EnqueueAndPlay(guildID, voiceChannelID, textChannelID, query, requester str
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)
}
@@ -285,6 +367,8 @@ func EnqueueAndPlay(guildID, voiceChannelID, textChannelID, query, requester str
}
}
syncState(guildID)
return &firstEntry, shouldStart, nil
}
@@ -307,6 +391,7 @@ func onTrackEnd(player disgolink.Player, event lavalink.TrackEndEvent) {
// repeat current track
if gp.RepeatSong {
gp.Queue[0].StartedAt = time.Now()
next := gp.Queue[0]
textChannelID := gp.TextChannelID
manager.mu.Unlock()
@@ -317,12 +402,14 @@ func onTrackEnd(player disgolink.Player, event lavalink.TrackEndEvent) {
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)
}
@@ -337,9 +424,11 @@ func onTrackEnd(player disgolink.Player, event lavalink.TrackEndEvent) {
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()
@@ -350,6 +439,7 @@ func onTrackEnd(player disgolink.Player, event lavalink.TrackEndEvent) {
if textChannelID != "" {
postNowPlaying(guildID, textChannelID, next)
}
syncState(guildID)
}
func Pause(guildID string, pause bool) error {
@@ -363,6 +453,7 @@ func Pause(guildID string, pause bool) error {
gp.IdleSince = time.Time{}
}
manager.mu.Unlock()
syncState(guildID)
return gp.Player.Update(context.Background(), lavalink.WithPaused(pause))
}
@@ -393,9 +484,11 @@ func Skip(guildID string) error {
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()
@@ -406,6 +499,7 @@ func Skip(guildID string) error {
if textChannelID != "" {
postNowPlaying(guildID, textChannelID, next)
}
syncState(guildID)
return nil
}
@@ -427,6 +521,7 @@ func Stop(guildID string) error {
if textChannelID != "" && nowPlayingMsgID != "" {
disableNowPlaying(textChannelID, nowPlayingMsgID)
}
syncState(guildID)
return gp.Player.Update(context.Background(), lavalink.WithNullTrack())
}
@@ -531,6 +626,7 @@ func SetVolume(guildID string, volume int) error {
postNowPlaying(guildID, textChannelID, *current)
}
}
syncState(guildID)
return nil
}
@@ -543,5 +639,3 @@ func CurrentNowPlayingMessageID(guildID string) string {
defer manager.mu.Unlock()
return gp.NowPlayingMsgID
}
+6 -4
View File
@@ -10,16 +10,18 @@ import (
"velox-bot/internal/commands"
"velox-bot/internal/config"
"velox-bot/internal/db"
"velox-bot/internal/db/repos/defaultrolerepo"
"velox-bot/internal/db/repos/levelrepo"
"velox-bot/internal/db/repos/musicrepo"
"velox-bot/internal/db/repos/projectsrepo"
"velox-bot/internal/db/repos/rpsrepo"
"velox-bot/internal/db/repos/schedulerepo"
"velox-bot/internal/db/repos/settingsrepo"
"velox-bot/internal/db/repos/welcomerepo"
"velox-bot/internal/db/repos/twitchrepo"
"velox-bot/internal/db/repos/usersettingsrepo"
"velox-bot/internal/db/repos/defaultrolerepo"
"velox-bot/internal/db/repos/welcomerepo"
"velox-bot/internal/db/services"
"velox-bot/internal/db/services/defaultrole"
"velox-bot/internal/db/services/level"
"velox-bot/internal/db/services/levelsettings"
"velox-bot/internal/db/services/logsettings"
@@ -31,7 +33,6 @@ import (
"velox-bot/internal/db/services/twitch"
"velox-bot/internal/db/services/usersettings"
"velox-bot/internal/db/services/welcome"
"velox-bot/internal/db/services/defaultrole"
)
func main() {
@@ -50,6 +51,7 @@ func main() {
levelRepo := levelrepo.NewRepo(db)
settingsRepo := settingsrepo.NewRepo(db)
musicRepo := musicrepo.NewRepo(db)
rpsRepo := rpsrepo.NewRepo(db)
projectsRepo := projectsrepo.NewRepo(db)
scheduleRepo := schedulerepo.NewRepo(db)
@@ -71,7 +73,7 @@ func main() {
musicSettingsService := musicsettings.New(settingsRepo)
services := services.NewServices(levelService, levelSettingsService, meetingService, scheduleService, userSettingsService, projectsService, rpsService, twitchService, welcomeService, defaultRoleService, logSettingsService, musicSettingsService)
bot, err := bot.NewBot(config.BotToken, config.AppID, config.GuildID, config.LavalinkHost, config.LavalinkPass, commands.AllCommands, services)
bot, err := bot.NewBot(config.BotToken, config.AppID, config.GuildID, config.LavalinkHost, config.LavalinkPass, commands.AllCommands, services, musicRepo)
if err != nil {
log.Fatalf("Error creating bot: %v", err)
return