From d6687879dddb8d80f800eb4b0dd4cb5890046bf0 Mon Sep 17 00:00:00 2001 From: FernandoJVideira <03.pleaser-minster@icloud.com> Date: Sat, 29 Aug 2026 15:48:27 +0100 Subject: [PATCH] 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. --- internal/bot/bot.go | 7 +- internal/commands/music/public/play.go | 2 +- internal/db/repos/musicrepo/repo.go | 57 ++++++++++++ internal/music/manager.go | 120 ++++++++++++++++++++++--- main.go | 10 ++- 5 files changed, 176 insertions(+), 20 deletions(-) create mode 100644 internal/db/repos/musicrepo/repo.go diff --git a/internal/bot/bot.go b/internal/bot/bot.go index ff3f3b3..1ba731b 100644 --- a/internal/bot/bot.go +++ b/internal/bot/bot.go @@ -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 { diff --git a/internal/commands/music/public/play.go b/internal/commands/music/public/play.go index 921dd51..fc0c36f 100644 --- a/internal/commands/music/public/play.go +++ b/internal/commands/music/public/play.go @@ -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)), diff --git a/internal/db/repos/musicrepo/repo.go b/internal/db/repos/musicrepo/repo.go new file mode 100644 index 0000000..44250b0 --- /dev/null +++ b/internal/db/repos/musicrepo/repo.go @@ -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() +} diff --git a/internal/music/manager.go b/internal/music/manager.go index dd6b593..8c865bd 100644 --- a/internal/music/manager.go +++ b/internal/music/manager.go @@ -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,23 +19,26 @@ import ( type TrackEntry struct { Track lavalink.Track RequestedBy string + RequesterID string + StartedAt time.Time } type guildPlayer struct { - Player disgolink.Player - Queue []TrackEntry - RepeatSong bool - RepeatQueue bool - Paused bool - Volume int - IdleSince time.Time - TextChannelID string - NowPlayingMsgID string + 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 @@ -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 } - - diff --git a/main.go b/main.go index 7de0ac5..f246a65 100644 --- a/main.go +++ b/main.go @@ -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 -- 2.54.0