Ft/music command listener #16

Merged
FernandoJVideira merged 4 commits from ft/music-command-listener into dev 2026-08-29 18:52:50 +00:00
4 changed files with 131 additions and 53 deletions
Showing only changes of commit da66eb8dd5 - Show all commits
-40
View File
@@ -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 {
+24
View File
@@ -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)
}
+106 -12
View File
@@ -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
+1 -1
View File
@@ -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(),