The queue and now-playing state only ever lived in the bot's memory, so the dashboard had nothing real to show and a restart silently wiped whatever was queued. Adds a musicrepo package and a syncState call after every queue-changing action (play, skip, pause, volume, stop, track end) that mirrors the current track and queue into music_now_playing and music_queue. No user-visible change yet, this is groundwork for the dashboard queue/now-playing view.
136 lines
3.7 KiB
Go
136 lines
3.7 KiB
Go
package public
|
|
|
|
import (
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
"velox-bot/internal/commands/music/shared"
|
|
"velox-bot/internal/music"
|
|
|
|
"github.com/bwmarrin/discordgo"
|
|
)
|
|
|
|
var Play = &discordgo.ApplicationCommand{
|
|
Name: "play",
|
|
Description: "Play a song via Lavalink",
|
|
Options: []*discordgo.ApplicationCommandOption{
|
|
{
|
|
Type: discordgo.ApplicationCommandOptionString,
|
|
Name: "query",
|
|
Description: "Song name or URL",
|
|
Required: true,
|
|
},
|
|
},
|
|
}
|
|
|
|
func PlayHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
|
if !shared.RequireMusicEnabled(s, i) {
|
|
return
|
|
}
|
|
|
|
data := i.ApplicationCommandData()
|
|
if len(data.Options) == 0 {
|
|
return
|
|
}
|
|
query := data.Options[0].StringValue()
|
|
|
|
// Always acknowledge quickly to avoid "application did not respond".
|
|
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
|
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
|
|
Data: &discordgo.InteractionResponseData{
|
|
Content: "Searching...",
|
|
},
|
|
})
|
|
|
|
if !memberHasDJRole(s, i.GuildID, i.Member) {
|
|
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
|
|
Content: ptr("You need the **DJ** role to use music commands."),
|
|
})
|
|
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{
|
|
Content: ptr(fmt.Sprintf("Error: %v", err)),
|
|
})
|
|
return
|
|
}
|
|
|
|
entry, started, err := music.EnqueueAndPlay(i.GuildID, vs.ChannelID, i.ChannelID, query, i.Member.User.Username, i.Member.User.ID)
|
|
if err != nil {
|
|
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
|
|
Content: ptr(fmt.Sprintf("Error: %v", err)),
|
|
})
|
|
return
|
|
}
|
|
|
|
title := entry.Track.Info.Title
|
|
author := entry.Track.Info.Author
|
|
length := entry.Track.Info.Length
|
|
|
|
if started {
|
|
// manager will post now-playing message & manage invalidating old buttons
|
|
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
|
|
Content: ptr(fmt.Sprintf("Starting: **%s** by **%s** `[%ds]`", title, author, length/1000)),
|
|
})
|
|
} else {
|
|
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
|
|
Content: ptr(fmt.Sprintf("Added to queue: **%s** by **%s** `[%ds]`", title, author, length/1000)),
|
|
})
|
|
}
|
|
}
|
|
|
|
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 {
|
|
return nil, fmt.Errorf("cannot find guild voice state")
|
|
}
|
|
for _, vs := range g.VoiceStates {
|
|
if vs.UserID == userID {
|
|
return vs, nil
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("you must be in a voice channel")
|
|
}
|