From 7a17c55774300fdbbb0457bbd1fa0d11990cfb3b Mon Sep 17 00:00:00 2001 From: FernandoJVideira <03.pleaser-minster@icloud.com> Date: Sat, 29 Aug 2026 19:22:02 +0100 Subject: [PATCH] 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. --- go.mod | 1 + go.sum | 2 + internal/db/repos/musicrepo/repo.go | 103 +++++++++++++++++++++++++--- internal/music/manager.go | 95 +++++++++++++++++++++++-- schema.sql | 10 ++- 5 files changed, 195 insertions(+), 16 deletions(-) diff --git a/go.mod b/go.mod index 82844e6..4e9206a 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( require ( github.com/disgoorg/json v1.2.0 // indirect github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect diff --git a/go.sum b/go.sum index a38ba93..a149284 100644 --- a/go.sum +++ b/go.sum @@ -13,6 +13,8 @@ github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= diff --git a/internal/db/repos/musicrepo/repo.go b/internal/db/repos/musicrepo/repo.go index 44250b0..80fdc3a 100644 --- a/internal/db/repos/musicrepo/repo.go +++ b/internal/db/repos/musicrepo/repo.go @@ -10,6 +10,11 @@ type QueueEntry struct { Title string URL string RequestedBy int64 + + // ClientID is the track's stable identity (set once by the manager + // when a track is first queued), used to upsert this row across + // syncs instead of assigning it a fresh DB id every single time. + ClientID string } type Repo struct { @@ -20,9 +25,15 @@ 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) +func (r *Repo) SetNowPlaying(ctx context.Context, guildID int64, title, url string, durationSeconds int, startedAt time.Time, paused bool, volume int, repeatSong, repeatQueue bool) error { + const q = ` + INSERT INTO music_now_playing (guild_id, track_title, track_url, duration_seconds, started_at, paused, volume, repeat_song, repeat_queue) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (guild_id) DO UPDATE SET + track_title = $2, track_url = $3, duration_seconds = $4, started_at = $5, + paused = $6, volume = $7, repeat_song = $8, repeat_queue = $9 + ` + _, err := r.db.ExecContext(ctx, q, guildID, title, url, durationSeconds, startedAt, paused, volume, repeatSong, repeatQueue) return err } @@ -32,6 +43,12 @@ func (r *Repo) ClearNowPlaying(ctx context.Context, guildID int64) error { return err } +// ReplaceQueue syncs music_queue with the current in-memory queue by +// upserting each entry on its stable ClientID, rather than deleting and +// recreating every row on every call. That matters because music_queue.id +// is only an internal DB identity, remove/reorder commands target a +// track by ClientID, and that has to survive a track just sitting in the +// queue unchanged across syncs. func (r *Repo) ReplaceQueue(ctx context.Context, guildID int64, entries []QueueEntry) error { tx, err := r.db.BeginTx(ctx, nil) if err != nil { @@ -39,19 +56,89 @@ func (r *Repo) ReplaceQueue(ctx context.Context, guildID int64, entries []QueueE } defer tx.Rollback() - const q = `DELETE FROM music_queue WHERE guild_id = $1` - _, err = tx.ExecContext(ctx, q, guildID) + existing := make(map[string]bool) + rows, err := tx.QueryContext(ctx, `SELECT client_id FROM music_queue WHERE guild_id = $1`, guildID) if err != nil { return err } + for rows.Next() { + var clientID string + if err := rows.Scan(&clientID); err != nil { + rows.Close() + return err + } + existing[clientID] = true + } + if err := rows.Err(); err != nil { + rows.Close() + return err + } + rows.Close() + keep := make(map[string]bool, len(entries)) 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 { + keep[entry.ClientID] = true + const q = ` + INSERT INTO music_queue (guild_id, position, track_title, track_url, requested_by, client_id) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (guild_id, client_id) DO UPDATE SET + position = EXCLUDED.position, + track_title = EXCLUDED.track_title, + track_url = EXCLUDED.track_url, + requested_by = EXCLUDED.requested_by + ` + if _, err := tx.ExecContext(ctx, q, guildID, i, entry.Title, entry.URL, entry.RequestedBy, entry.ClientID); err != nil { + return err + } + } + + for clientID := range existing { + if keep[clientID] { + continue + } + const q = `DELETE FROM music_queue WHERE guild_id = $1 AND client_id = $2` + if _, err := tx.ExecContext(ctx, q, guildID, clientID); err != nil { return err } } return tx.Commit() } + +type MusicCommand struct { + ID int64 + GuildID int64 + CommandType string + Payload []byte + RequestedBy int64 +} + +func (r *Repo) ListUnprocessedCommands(ctx context.Context) ([]MusicCommand, error) { + const q = `SELECT id, guild_id, command_type, payload, requested_by FROM music_commands WHERE processed_at IS NULL ORDER BY id ASC` + + rows, err := r.db.QueryContext(ctx, q) + if err != nil { + return nil, err + } + defer rows.Close() + + var commands []MusicCommand + for rows.Next() { + var cmd MusicCommand + if err := rows.Scan(&cmd.ID, &cmd.GuildID, &cmd.CommandType, &cmd.Payload, &cmd.RequestedBy); err != nil { + return nil, err + } + commands = append(commands, cmd) + } + if err := rows.Err(); err != nil { + return nil, err + } + + return commands, nil +} + +func (r *Repo) MarkCommandProcessed(ctx context.Context, id int64) error { + const q = `UPDATE music_commands SET processed_at = NOW() WHERE id = $1` + _, err := r.db.ExecContext(ctx, q, id) + return err +} diff --git a/internal/music/manager.go b/internal/music/manager.go index 8c865bd..5e80b14 100644 --- a/internal/music/manager.go +++ b/internal/music/manager.go @@ -14,6 +14,7 @@ import ( "github.com/disgoorg/disgolink/v3/disgolink" "github.com/disgoorg/disgolink/v3/lavalink" "github.com/disgoorg/snowflake/v2" + "github.com/google/uuid" ) type TrackEntry struct { @@ -21,6 +22,14 @@ type TrackEntry struct { 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 { @@ -213,6 +222,8 @@ func syncState(guidID string) { 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) @@ -245,7 +256,7 @@ func syncState(guidID string) { length := int(current.Track.Info.Length / 1000) startedAt := current.StartedAt - err = manager.repo.SetNowPlaying(context.Background(), gID, title, uri, length, startedAt, paused, volume) + err = manager.repo.SetNowPlaying(context.Background(), gID, title, uri, length, startedAt, paused, volume, repeatSong, repeatQueue) if err != nil { return } @@ -262,6 +273,7 @@ func syncState(guidID string) { Title: entry.Track.Info.Title, URL: uri, RequestedBy: reqID, + ClientID: entry.QueueID, }) } err = manager.repo.ReplaceQueue(context.Background(), gID, queueEntries) @@ -346,6 +358,7 @@ func EnqueueAndPlay(guildID, voiceChannelID, textChannelID, query, requester, re Track: t, RequestedBy: requester, RequesterID: requesterID, + QueueID: uuid.New().String(), } } firstEntry := entries[0] @@ -551,12 +564,15 @@ func ToggleRepeatSong(guildID string) (bool, bool) { } gp := getOrCreateGuildPlayer(guildID) manager.mu.Lock() - defer manager.mu.Unlock() gp.RepeatSong = !gp.RepeatSong if gp.RepeatSong { gp.RepeatQueue = false } - return gp.RepeatSong, gp.RepeatQueue + repeatSong, repeatQueue := gp.RepeatSong, gp.RepeatQueue + manager.mu.Unlock() + + syncState(guildID) + return repeatSong, repeatQueue } func ToggleRepeatQueue(guildID string) (bool, bool) { @@ -565,12 +581,15 @@ func ToggleRepeatQueue(guildID string) (bool, bool) { } gp := getOrCreateGuildPlayer(guildID) manager.mu.Lock() - defer manager.mu.Unlock() gp.RepeatQueue = !gp.RepeatQueue if gp.RepeatQueue { gp.RepeatSong = false } - return gp.RepeatSong, gp.RepeatQueue + repeatSong, repeatQueue := gp.RepeatSong, gp.RepeatQueue + manager.mu.Unlock() + + syncState(guildID) + return repeatSong, repeatQueue } func repeatFlags(guildID string) (bool, bool) { @@ -630,6 +649,72 @@ func SetVolume(guildID string, volume int) error { 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 "" diff --git a/schema.sql b/schema.sql index db31c9d..276a5e8 100644 --- a/schema.sql +++ b/schema.sql @@ -150,7 +150,9 @@ CREATE TABLE IF NOT EXISTS music_now_playing ( duration_seconds INT NOT NULL, started_at TIMESTAMPTZ NOT NULL, paused BOOLEAN NOT NULL DEFAULT FALSE, - volume INT NOT NULL DEFAULT 100 + volume INT NOT NULL DEFAULT 100, + repeat_song BOOLEAN NOT NULL DEFAULT FALSE, + repeat_queue BOOLEAN NOT NULL DEFAULT FALSE ); CREATE TABLE IF NOT EXISTS music_queue ( @@ -160,13 +162,15 @@ CREATE TABLE IF NOT EXISTS music_queue ( track_title TEXT NOT NULL, track_url TEXT NOT NULL, requested_by BIGINT NOT NULL, - added_at TIMESTAMPTZ DEFAULT NOW() + added_at TIMESTAMPTZ DEFAULT NOW(), + client_id UUID NOT NULL DEFAULT gen_random_uuid(), + UNIQUE (guild_id, client_id) ); CREATE TABLE IF NOT EXISTS music_commands ( id BIGSERIAL PRIMARY KEY, guild_id BIGINT NOT NULL, - command_type TEXT NOT NULL CHECK (command_type IN ('skip', 'pause', 'resume', 'set_volume', 'remove_track', 'reorder')), + command_type TEXT NOT NULL CHECK (command_type IN ('skip', 'pause', 'resume', 'set_volume', 'remove_track', 'reorder', 'repeat_song', 'repeat_queue')), payload JSONB, requested_by BIGINT NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW(),