v3.4.0 #17
@@ -2,8 +2,6 @@ package public
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
"velox-bot/internal/commands/music/shared"
|
"velox-bot/internal/commands/music/shared"
|
||||||
"velox-bot/internal/music"
|
"velox-bot/internal/music"
|
||||||
|
|
||||||
@@ -49,13 +47,6 @@ func PlayHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
|||||||
return
|
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)
|
vs, err := findUserVoiceState(s, i.GuildID, i.Member.User.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{
|
_, _ = 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) }
|
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) {
|
func findUserVoiceState(s *discordgo.Session, guildID, userID string) (*discordgo.VoiceState, error) {
|
||||||
g, err := s.State.Guild(guildID)
|
g, err := s.State.Guild(guildID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -100,6 +100,30 @@ func dispatchCommand(cmd musicrepo.MusicCommand) error {
|
|||||||
ToggleRepeatQueue(guildID)
|
ToggleRepeatQueue(guildID)
|
||||||
return nil
|
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:
|
default:
|
||||||
return fmt.Errorf("unknown command type %q", cmd.CommandType)
|
return fmt.Errorf("unknown command type %q", cmd.CommandType)
|
||||||
}
|
}
|
||||||
|
|||||||
+106
-12
@@ -6,6 +6,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
"velox-bot/internal/db/repos/musicrepo"
|
"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) {
|
// NormalizeQuery turns plain text into a YouTube search and strips
|
||||||
if manager == nil {
|
// auto-generated "radio/mix" params off a pasted YouTube URL
|
||||||
return nil, false, fmt.Errorf("music manager not initialized")
|
// (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
|
u, err := url.Parse(query)
|
||||||
if err := manager.session.ChannelVoiceJoinManual(guildID, voiceChannelID, false, false); err != nil {
|
if err != nil || u == nil {
|
||||||
return nil, false, fmt.Errorf("join voice channel: %w", err)
|
return query
|
||||||
}
|
}
|
||||||
|
|
||||||
gp := getOrCreateGuildPlayer(guildID)
|
host := strings.ToLower(u.Host)
|
||||||
if textChannelID != "" {
|
if !strings.Contains(host, "youtube.com") || u.Path != "/watch" {
|
||||||
manager.mu.Lock()
|
return query
|
||||||
gp.TextChannelID = textChannelID
|
|
||||||
manager.mu.Unlock()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
var loadedTracks []lavalink.Track
|
||||||
|
|
||||||
manager.client.BestNode().LoadTracksHandler(
|
manager.client.BestNode().LoadTracksHandler(
|
||||||
@@ -337,7 +365,7 @@ func EnqueueAndPlay(guildID, voiceChannelID, textChannelID, query, requester, re
|
|||||||
)
|
)
|
||||||
|
|
||||||
if len(loadedTracks) == 0 {
|
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.
|
// 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]...)
|
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))
|
entries := make([]TrackEntry, len(loadedTracks))
|
||||||
for idx, t := range loadedTracks {
|
for idx, t := range loadedTracks {
|
||||||
entries[idx] = TrackEntry{
|
entries[idx] = TrackEntry{
|
||||||
@@ -385,6 +438,47 @@ func EnqueueAndPlay(guildID, voiceChannelID, textChannelID, query, requester, re
|
|||||||
return &firstEntry, shouldStart, nil
|
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) {
|
func onTrackEnd(player disgolink.Player, event lavalink.TrackEndEvent) {
|
||||||
if manager == nil {
|
if manager == nil {
|
||||||
return
|
return
|
||||||
|
|||||||
+1
-1
@@ -170,7 +170,7 @@ CREATE TABLE IF NOT EXISTS music_queue (
|
|||||||
CREATE TABLE IF NOT EXISTS music_commands (
|
CREATE TABLE IF NOT EXISTS music_commands (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
guild_id BIGINT NOT NULL,
|
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,
|
payload JSONB,
|
||||||
requested_by BIGINT NOT NULL,
|
requested_by BIGINT NOT NULL,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
|||||||
Reference in New Issue
Block a user