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