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/bot/bot.go b/internal/bot/bot.go index 1ba731b..cd2ad87 100644 --- a/internal/bot/bot.go +++ b/internal/bot/bot.go @@ -1,6 +1,7 @@ package bot import ( + "context" "log" "velox-bot/internal/commands" "velox-bot/internal/db/repos/musicrepo" @@ -19,11 +20,12 @@ type Bot struct { registeredCommands []*discordgo.ApplicationCommand Services *services.Services MusicRepo *musicrepo.Repo + DBHost string LavalinkHost string LavalinkPass string } -func NewBot(token, appID, guildID, lavalinkHost, lavalinkPass string, cmds []*discordgo.ApplicationCommand, services *services.Services, musicRepo *musicrepo.Repo) (*Bot, error) { +func NewBot(token, appID, guildID, lavalinkHost, lavalinkPass string, cmds []*discordgo.ApplicationCommand, services *services.Services, musicRepo *musicrepo.Repo, dbHost string) (*Bot, error) { session, err := discordgo.New("Bot " + token) if err != nil { return nil, err @@ -49,6 +51,7 @@ func NewBot(token, appID, guildID, lavalinkHost, lavalinkPass string, cmds []*di Commands: cmds, Services: services, MusicRepo: musicRepo, + DBHost: dbHost, LavalinkHost: lavalinkHost, LavalinkPass: lavalinkPass, }, nil @@ -61,6 +64,12 @@ func (b *Bot) Start() error { _ = music.Init(b.Session, b.MusicRepo, b.AppID, b.LavalinkHost, b.LavalinkPass) + go func() { + if err := music.StartCommandListener(context.Background(), b.DBHost, b.MusicRepo); err != nil { + log.Printf("music: command listener stopped: %v", err) + } + }() + b.registeredCommands = make([]*discordgo.ApplicationCommand, len(b.Commands)) for _, cmd := range b.Commands { created, err := b.Session.ApplicationCommandCreate(b.AppID, b.GuildID, cmd) diff --git a/internal/commands/music/public/play.go b/internal/commands/music/public/play.go index fc0c36f..81e040f 100644 --- a/internal/commands/music/public/play.go +++ b/internal/commands/music/public/play.go @@ -2,8 +2,6 @@ package public import ( "fmt" - "net/url" - "strings" "velox-bot/internal/commands/music/shared" "velox-bot/internal/music" @@ -49,13 +47,6 @@ func PlayHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { return } - // If user provided plain text, turn it into a YouTube search - if !strings.HasPrefix(query, "http://") && !strings.HasPrefix(query, "https://") && !strings.HasPrefix(query, "ytsearch:") { - query = "ytsearch:" + query - } else { - query = normalizeYouTubeRadioURL(query) - } - vs, err := findUserVoiceState(s, i.GuildID, i.Member.User.ID) if err != nil { _, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ @@ -90,37 +81,6 @@ func PlayHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { func ptr[T any](v T) *T { return new(v) } -// normalizeYouTubeRadioURL strips auto-generated "radio/mix" params like: -// https://www.youtube.com/watch?v=ID&list=RDID&start_radio=1 -> https://www.youtube.com/watch?v=ID -// It intentionally does NOT strip normal playlist URLs. -func normalizeYouTubeRadioURL(raw string) string { - u, err := url.Parse(raw) - if err != nil || u == nil { - return raw - } - - host := strings.ToLower(u.Host) - if !strings.Contains(host, "youtube.com") || u.Path != "/watch" { - return raw - } - - q := u.Query() - v := q.Get("v") - if v == "" { - return raw - } - - list := q.Get("list") - _, hasStartRadio := q["start_radio"] - if !hasStartRadio && !strings.HasPrefix(list, "RD") { - return raw - } - - u.RawQuery = url.Values{"v": []string{v}}.Encode() - u.Fragment = "" - return u.String() -} - func findUserVoiceState(s *discordgo.Session, guildID, userID string) (*discordgo.VoiceState, error) { g, err := s.State.Guild(guildID) if err != nil { 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/listener.go b/internal/music/listener.go new file mode 100644 index 0000000..4b9757a --- /dev/null +++ b/internal/music/listener.go @@ -0,0 +1,133 @@ +package music + +import ( + "context" + "encoding/json" + "fmt" + "log" + "strconv" + "velox-bot/internal/db/repos/musicrepo" + + "github.com/jackc/pgx/v5" +) + +func StartCommandListener(ctx context.Context, connString string, repo *musicrepo.Repo) error { + conn, err := pgx.Connect(ctx, connString) + if err != nil { + return fmt.Errorf("connect: %w", err) + } + defer conn.Close(ctx) + + _, err = conn.Exec(ctx, "LISTEN music_commands") + if err != nil { + return fmt.Errorf("exec: %w", err) + } + + for { + _, err := conn.WaitForNotification(ctx) + if err != nil { + return fmt.Errorf("waitfornotification: %w", err) + } + + processCommands(ctx, repo) + } +} + +func processCommands(ctx context.Context, repo *musicrepo.Repo) { + cmds, err := repo.ListUnprocessedCommands(ctx) + if err != nil { + log.Printf("music: failed to list unprocessed commands: %v", err) + return + } + + for _, cmd := range cmds { + if err := dispatchCommand(cmd); err != nil { + log.Printf("music: command %d (%s) for guild %d failed: %v", cmd.ID, cmd.CommandType, cmd.GuildID, err) + } + + if err := repo.MarkCommandProcessed(ctx, cmd.ID); err != nil { + log.Printf("music: failed to mark command %d processed: %v", cmd.ID, err) + } + } +} + +func dispatchCommand(cmd musicrepo.MusicCommand) error { + guildID := strconv.FormatInt(cmd.GuildID, 10) + + switch cmd.CommandType { + case "skip": + return Skip(guildID) + + case "pause": + return Pause(guildID, true) + + case "resume": + return Pause(guildID, false) + + case "stop": + return Stop(guildID) + + case "set_volume": + var payload struct { + Volume int `json:"volume"` + } + if err := json.Unmarshal(cmd.Payload, &payload); err != nil { + return fmt.Errorf("unmarshal payload: %w", err) + } + return SetVolume(guildID, payload.Volume) + + case "remove_track": + var payload struct { + QueueID string `json:"queue_id"` + } + if err := json.Unmarshal(cmd.Payload, &payload); err != nil { + return fmt.Errorf("unmarshal payload: %w", err) + } + return RemoveFromQueue(guildID, payload.QueueID) + + case "reorder": + var payload struct { + QueueID string `json:"queue_id"` + Position int `json:"position"` + } + if err := json.Unmarshal(cmd.Payload, &payload); err != nil { + return fmt.Errorf("unmarshal payload: %w", err) + } + return Reorder(guildID, payload.QueueID, payload.Position) + + case "repeat_song": + ToggleRepeatSong(guildID) + return nil + + case "repeat_queue": + ToggleRepeatQueue(guildID) + return nil + + case "add_song": + var payload struct { + Query string `json:"query"` + VoiceChannelID string `json:"voice_channel_id"` + } + if err := json.Unmarshal(cmd.Payload, &payload); err != nil { + return fmt.Errorf("unmarshal payload: %w", err) + } + requesterID := strconv.FormatInt(cmd.RequestedBy, 10) + + // A voice channel means "start fresh" (nothing was playing, the + // dashboard had the user pick one), otherwise this just adds to + // whatever's already going. + if payload.VoiceChannelID != "" { + // Voice channels have their own built-in text chat at the same + // channel ID, post the now-playing embed there so starting + // playback from the dashboard still shows up somewhere in + // Discord, same as starting it with /play would. + _, _, err := EnqueueAndPlay(guildID, payload.VoiceChannelID, payload.VoiceChannelID, payload.Query, "Dashboard", requesterID) + return err + } + _, err := AddToQueue(guildID, payload.Query, requesterID) + return err + + default: + return fmt.Errorf("unknown command type %q", cmd.CommandType) + } +} diff --git a/internal/music/manager.go b/internal/music/manager.go index 8c865bd..e740543 100644 --- a/internal/music/manager.go +++ b/internal/music/manager.go @@ -6,6 +6,7 @@ import ( "log" "net/url" "strconv" + "strings" "sync" "time" "velox-bot/internal/db/repos/musicrepo" @@ -14,6 +15,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 +23,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 +223,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 +257,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 +274,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) @@ -271,23 +284,50 @@ func syncState(guidID string) { } -func EnqueueAndPlay(guildID, voiceChannelID, textChannelID, query, requester, requesterID string) (*TrackEntry, bool, error) { - if manager == nil { - return nil, false, fmt.Errorf("music manager not initialized") +// NormalizeQuery turns plain text into a YouTube search and strips +// auto-generated "radio/mix" params off a pasted YouTube URL +// (https://www.youtube.com/watch?v=ID&list=RDID&start_radio=1 becomes +// .../watch?v=ID), while leaving normal playlist URLs and non-YouTube +// links (SoundCloud, etc.) untouched. Every entry point that accepts a +// user-supplied query - /play and the dashboard's "add song" - should +// normalize through this before handing the query to Lavalink. +func NormalizeQuery(query string) string { + if !strings.HasPrefix(query, "http://") && !strings.HasPrefix(query, "https://") && !strings.HasPrefix(query, "ytsearch:") { + return "ytsearch:" + query } - // Join voice channel - if err := manager.session.ChannelVoiceJoinManual(guildID, voiceChannelID, false, false); err != nil { - return nil, false, fmt.Errorf("join voice channel: %w", err) + u, err := url.Parse(query) + if err != nil || u == nil { + return query } - gp := getOrCreateGuildPlayer(guildID) - if textChannelID != "" { - manager.mu.Lock() - gp.TextChannelID = textChannelID - manager.mu.Unlock() + host := strings.ToLower(u.Host) + if !strings.Contains(host, "youtube.com") || u.Path != "/watch" { + return query } + q := u.Query() + v := q.Get("v") + if v == "" { + return query + } + + list := q.Get("list") + _, hasStartRadio := q["start_radio"] + if !hasStartRadio && !strings.HasPrefix(list, "RD") { + return query + } + + u.RawQuery = url.Values{"v": []string{v}}.Encode() + u.Fragment = "" + return u.String() +} + +// resolveTracks searches Lavalink for query (already normalized) and +// returns a playable track list - a single track, a search result's +// first hit, or a whole playlist (rotated to respect Lavalink's own +// selectedTrack when present). +func resolveTracks(query string) ([]lavalink.Track, error) { var loadedTracks []lavalink.Track manager.client.BestNode().LoadTracksHandler( @@ -325,7 +365,7 @@ func EnqueueAndPlay(guildID, voiceChannelID, textChannelID, query, requester, re ) if len(loadedTracks) == 0 { - return nil, false, fmt.Errorf("no tracks found for query") + return nil, fmt.Errorf("no tracks found for query") } // Some sources may return unplayable entries; pick the first with an encoded track. @@ -340,12 +380,38 @@ func EnqueueAndPlay(guildID, voiceChannelID, textChannelID, query, requester, re loadedTracks = append(loadedTracks[firstPlayableIdx:], loadedTracks[:firstPlayableIdx]...) } + return loadedTracks, nil +} + +func EnqueueAndPlay(guildID, voiceChannelID, textChannelID, query, requester, requesterID string) (*TrackEntry, bool, error) { + if manager == nil { + return nil, false, fmt.Errorf("music manager not initialized") + } + + // Join voice channel + if err := manager.session.ChannelVoiceJoinManual(guildID, voiceChannelID, false, false); err != nil { + return nil, false, fmt.Errorf("join voice channel: %w", err) + } + + gp := getOrCreateGuildPlayer(guildID) + if textChannelID != "" { + manager.mu.Lock() + gp.TextChannelID = textChannelID + manager.mu.Unlock() + } + + loadedTracks, err := resolveTracks(NormalizeQuery(query)) + if err != nil { + return nil, false, err + } + entries := make([]TrackEntry, len(loadedTracks)) for idx, t := range loadedTracks { entries[idx] = TrackEntry{ Track: t, RequestedBy: requester, RequesterID: requesterID, + QueueID: uuid.New().String(), } } firstEntry := entries[0] @@ -372,6 +438,47 @@ func EnqueueAndPlay(guildID, voiceChannelID, textChannelID, query, requester, re return &firstEntry, shouldStart, nil } +// AddToQueue adds a track to a guild's existing queue without joining a +// voice channel, unlike EnqueueAndPlay it can't start a brand new +// session (that needs a voice channel to join, which this has no way to +// pick), so it requires one to already be active. +func AddToQueue(guildID, query, requesterID string) (*TrackEntry, error) { + if manager == nil { + return nil, fmt.Errorf("music manager not initialized") + } + gp := getOrCreateGuildPlayer(guildID) + + manager.mu.Lock() + alreadyActive := len(gp.Queue) > 0 + manager.mu.Unlock() + if !alreadyActive { + return nil, fmt.Errorf("nothing is currently playing in this server") + } + + loadedTracks, err := resolveTracks(NormalizeQuery(query)) + if err != nil { + return nil, err + } + + entries := make([]TrackEntry, len(loadedTracks)) + for idx, t := range loadedTracks { + entries[idx] = TrackEntry{ + Track: t, + RequestedBy: "Dashboard", + RequesterID: requesterID, + QueueID: uuid.New().String(), + } + } + + manager.mu.Lock() + gp.Queue = append(gp.Queue, entries...) + manager.mu.Unlock() + + syncState(guildID) + + return &entries[0], nil +} + func onTrackEnd(player disgolink.Player, event lavalink.TrackEndEvent) { if manager == nil { return @@ -551,12 +658,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 +675,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 +743,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/main.go b/main.go index f246a65..a03d2c2 100644 --- a/main.go +++ b/main.go @@ -73,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, musicRepo) + bot, err := bot.NewBot(config.BotToken, config.AppID, config.GuildID, config.LavalinkHost, config.LavalinkPass, commands.AllCommands, services, musicRepo, config.DBHost) if err != nil { log.Fatalf("Error creating bot: %v", err) return diff --git a/schema.sql b/schema.sql index db31c9d..d752ea7 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', 'add_song', 'stop')), payload JSONB, requested_by BIGINT NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW(),