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 1/4] 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(), -- 2.54.0 From fcc7c9c66aee94fbbbb0e87236ac61581012b1ce Mon Sep 17 00:00:00 2001 From: FernandoJVideira <03.pleaser-minster@icloud.com> Date: Sat, 29 Aug 2026 19:22:16 +0100 Subject: [PATCH 2/4] feat(music): consume playback commands via LISTEN/NOTIFY The dashboard has been writing skip/pause/volume/etc. requests into music_commands with nothing on this end reading them. Adds a dedicated LISTEN connection (can't use the pooled *sql.DB for this, LISTEN has to stay bound to one specific connection) that wakes on NOTIFY, drains unprocessed rows, and dispatches each to the matching Manager function. A command's error is logged, not fatal, and every row gets marked processed regardless of outcome so a permanently broken command doesn't retry forever. --- internal/bot/bot.go | 11 +++- internal/music/listener.go | 106 +++++++++++++++++++++++++++++++++++++ main.go | 2 +- 3 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 internal/music/listener.go 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/music/listener.go b/internal/music/listener.go new file mode 100644 index 0000000..b5ba307 --- /dev/null +++ b/internal/music/listener.go @@ -0,0 +1,106 @@ +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 "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 + + default: + return fmt.Errorf("unknown command type %q", cmd.CommandType) + } +} 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 -- 2.54.0 From da66eb8dd53237ac62b7d780bfce1ba0a87bceb8 Mon Sep 17 00:00:00 2001 From: FernandoJVideira <03.pleaser-minster@icloud.com> Date: Sat, 29 Aug 2026 19:44:24 +0100 Subject: [PATCH 3/4] feat(music): let the dashboard add songs, starting a session if needed New AddToQueue appends to an already-active session without touching voice (the dashboard can't join a channel on its own), and add_song's dispatch case now branches: a voice_channel_id in the payload means "start fresh", so it goes through EnqueueAndPlay (join + play) instead. Query normalization (plain text -> YouTube search, stripping autoplay/radio params off pasted YouTube URLs) moves out of /play's handler into a shared NormalizeQuery, both entry points need it, not just one. Starting playback from the dashboard also posts the now-playing embed into the voice channel's own built-in text chat, matching what /play already does, instead of leaving it with nowhere to show up. --- internal/commands/music/public/play.go | 40 --------- internal/music/listener.go | 24 +++++ internal/music/manager.go | 118 ++++++++++++++++++++++--- schema.sql | 2 +- 4 files changed, 131 insertions(+), 53 deletions(-) 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/music/listener.go b/internal/music/listener.go index b5ba307..bf8cc20 100644 --- a/internal/music/listener.go +++ b/internal/music/listener.go @@ -100,6 +100,30 @@ func dispatchCommand(cmd musicrepo.MusicCommand) error { 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 5e80b14..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" @@ -283,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( @@ -337,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. @@ -352,6 +380,31 @@ 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{ @@ -385,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 diff --git a/schema.sql b/schema.sql index 276a5e8..a8cf7c0 100644 --- a/schema.sql +++ b/schema.sql @@ -170,7 +170,7 @@ CREATE TABLE IF NOT EXISTS music_queue ( 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', 'repeat_song', 'repeat_queue')), + command_type TEXT NOT NULL CHECK (command_type IN ('skip', 'pause', 'resume', 'set_volume', 'remove_track', 'reorder', 'repeat_song', 'repeat_queue', 'add_song')), payload JSONB, requested_by BIGINT NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW(), -- 2.54.0 From 52dfe859ee6e3b7b78c53609703e8263860d00ef Mon Sep 17 00:00:00 2001 From: FernandoJVideira <03.pleaser-minster@icloud.com> Date: Sat, 29 Aug 2026 19:48:43 +0100 Subject: [PATCH 4/4] feat(music): add a stop command Clears the whole queue and disconnects, mirrors the existing Stop Manager function, just wires it up to the outbox like every other dashboard command. --- internal/music/listener.go | 3 +++ schema.sql | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/music/listener.go b/internal/music/listener.go index bf8cc20..4b9757a 100644 --- a/internal/music/listener.go +++ b/internal/music/listener.go @@ -64,6 +64,9 @@ func dispatchCommand(cmd musicrepo.MusicCommand) error { case "resume": return Pause(guildID, false) + case "stop": + return Stop(guildID) + case "set_volume": var payload struct { Volume int `json:"volume"` diff --git a/schema.sql b/schema.sql index a8cf7c0..d752ea7 100644 --- a/schema.sql +++ b/schema.sql @@ -170,7 +170,7 @@ CREATE TABLE IF NOT EXISTS music_queue ( 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', 'repeat_song', 'repeat_queue', 'add_song')), + 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(), -- 2.54.0