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(),