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:
+5
-2
@@ -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 {
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
+107
-13
@@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user