v3.4.0 #17
@@ -15,6 +15,7 @@ require (
|
|||||||
require (
|
require (
|
||||||
github.com/disgoorg/json v1.2.0 // indirect
|
github.com/disgoorg/json v1.2.0 // indirect
|
||||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // 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/gorilla/websocket v1.5.3 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
|||||||
@@ -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/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 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
||||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
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.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 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
|||||||
+10
-1
@@ -1,6 +1,7 @@
|
|||||||
package bot
|
package bot
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"log"
|
"log"
|
||||||
"velox-bot/internal/commands"
|
"velox-bot/internal/commands"
|
||||||
"velox-bot/internal/db/repos/musicrepo"
|
"velox-bot/internal/db/repos/musicrepo"
|
||||||
@@ -19,11 +20,12 @@ type Bot struct {
|
|||||||
registeredCommands []*discordgo.ApplicationCommand
|
registeredCommands []*discordgo.ApplicationCommand
|
||||||
Services *services.Services
|
Services *services.Services
|
||||||
MusicRepo *musicrepo.Repo
|
MusicRepo *musicrepo.Repo
|
||||||
|
DBHost string
|
||||||
LavalinkHost string
|
LavalinkHost string
|
||||||
LavalinkPass 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)
|
session, err := discordgo.New("Bot " + token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -49,6 +51,7 @@ func NewBot(token, appID, guildID, lavalinkHost, lavalinkPass string, cmds []*di
|
|||||||
Commands: cmds,
|
Commands: cmds,
|
||||||
Services: services,
|
Services: services,
|
||||||
MusicRepo: musicRepo,
|
MusicRepo: musicRepo,
|
||||||
|
DBHost: dbHost,
|
||||||
LavalinkHost: lavalinkHost,
|
LavalinkHost: lavalinkHost,
|
||||||
LavalinkPass: lavalinkPass,
|
LavalinkPass: lavalinkPass,
|
||||||
}, nil
|
}, nil
|
||||||
@@ -61,6 +64,12 @@ func (b *Bot) Start() error {
|
|||||||
|
|
||||||
_ = music.Init(b.Session, b.MusicRepo, b.AppID, b.LavalinkHost, b.LavalinkPass)
|
_ = 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))
|
b.registeredCommands = make([]*discordgo.ApplicationCommand, len(b.Commands))
|
||||||
for _, cmd := range b.Commands {
|
for _, cmd := range b.Commands {
|
||||||
created, err := b.Session.ApplicationCommandCreate(b.AppID, b.GuildID, cmd)
|
created, err := b.Session.ApplicationCommandCreate(b.AppID, b.GuildID, cmd)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ type QueueEntry struct {
|
|||||||
Title string
|
Title string
|
||||||
URL string
|
URL string
|
||||||
RequestedBy int64
|
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 {
|
type Repo struct {
|
||||||
@@ -20,9 +25,15 @@ func NewRepo(db *sql.DB) *Repo {
|
|||||||
return &Repo{db: db}
|
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 {
|
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) 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`
|
const q = `
|
||||||
_, err := r.db.ExecContext(ctx, q, guildID, title, url, durationSeconds, startedAt, paused, volume)
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,6 +43,12 @@ func (r *Repo) ClearNowPlaying(ctx context.Context, guildID int64) error {
|
|||||||
return err
|
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 {
|
func (r *Repo) ReplaceQueue(ctx context.Context, guildID int64, entries []QueueEntry) error {
|
||||||
tx, err := r.db.BeginTx(ctx, nil)
|
tx, err := r.db.BeginTx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -39,19 +56,89 @@ func (r *Repo) ReplaceQueue(ctx context.Context, guildID int64, entries []QueueE
|
|||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
|
||||||
const q = `DELETE FROM music_queue WHERE guild_id = $1`
|
existing := make(map[string]bool)
|
||||||
_, err = tx.ExecContext(ctx, q, guildID)
|
rows, err := tx.QueryContext(ctx, `SELECT client_id FROM music_queue WHERE guild_id = $1`, guildID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 {
|
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)`
|
keep[entry.ClientID] = true
|
||||||
_, err = tx.ExecContext(ctx, q, guildID, entry.Title, entry.URL, entry.RequestedBy, i)
|
const q = `
|
||||||
if err != nil {
|
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 err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return tx.Commit()
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
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 "stop":
|
||||||
|
return Stop(guildID)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+196
-17
@@ -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"
|
||||||
@@ -14,6 +15,7 @@ import (
|
|||||||
"github.com/disgoorg/disgolink/v3/disgolink"
|
"github.com/disgoorg/disgolink/v3/disgolink"
|
||||||
"github.com/disgoorg/disgolink/v3/lavalink"
|
"github.com/disgoorg/disgolink/v3/lavalink"
|
||||||
"github.com/disgoorg/snowflake/v2"
|
"github.com/disgoorg/snowflake/v2"
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
type TrackEntry struct {
|
type TrackEntry struct {
|
||||||
@@ -21,6 +23,14 @@ type TrackEntry struct {
|
|||||||
RequestedBy string
|
RequestedBy string
|
||||||
RequesterID string
|
RequesterID string
|
||||||
StartedAt time.Time
|
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 {
|
type guildPlayer struct {
|
||||||
@@ -213,6 +223,8 @@ func syncState(guidID string) {
|
|||||||
copy(queue, gp.Queue)
|
copy(queue, gp.Queue)
|
||||||
paused := gp.Paused
|
paused := gp.Paused
|
||||||
volume := gp.Volume
|
volume := gp.Volume
|
||||||
|
repeatSong := gp.RepeatSong
|
||||||
|
repeatQueue := gp.RepeatQueue
|
||||||
manager.mu.Unlock()
|
manager.mu.Unlock()
|
||||||
|
|
||||||
gID, err := strconv.ParseInt(guidID, 10, 64)
|
gID, err := strconv.ParseInt(guidID, 10, 64)
|
||||||
@@ -245,7 +257,7 @@ func syncState(guidID string) {
|
|||||||
length := int(current.Track.Info.Length / 1000)
|
length := int(current.Track.Info.Length / 1000)
|
||||||
startedAt := current.StartedAt
|
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 {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -262,6 +274,7 @@ func syncState(guidID string) {
|
|||||||
Title: entry.Track.Info.Title,
|
Title: entry.Track.Info.Title,
|
||||||
URL: uri,
|
URL: uri,
|
||||||
RequestedBy: reqID,
|
RequestedBy: reqID,
|
||||||
|
ClientID: entry.QueueID,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
err = manager.repo.ReplaceQueue(context.Background(), gID, queueEntries)
|
err = manager.repo.ReplaceQueue(context.Background(), gID, queueEntries)
|
||||||
@@ -271,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(
|
||||||
@@ -325,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.
|
||||||
@@ -340,12 +380,38 @@ 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{
|
||||||
Track: t,
|
Track: t,
|
||||||
RequestedBy: requester,
|
RequestedBy: requester,
|
||||||
RequesterID: requesterID,
|
RequesterID: requesterID,
|
||||||
|
QueueID: uuid.New().String(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
firstEntry := entries[0]
|
firstEntry := entries[0]
|
||||||
@@ -372,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
|
||||||
@@ -551,12 +658,15 @@ func ToggleRepeatSong(guildID string) (bool, bool) {
|
|||||||
}
|
}
|
||||||
gp := getOrCreateGuildPlayer(guildID)
|
gp := getOrCreateGuildPlayer(guildID)
|
||||||
manager.mu.Lock()
|
manager.mu.Lock()
|
||||||
defer manager.mu.Unlock()
|
|
||||||
gp.RepeatSong = !gp.RepeatSong
|
gp.RepeatSong = !gp.RepeatSong
|
||||||
if gp.RepeatSong {
|
if gp.RepeatSong {
|
||||||
gp.RepeatQueue = false
|
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) {
|
func ToggleRepeatQueue(guildID string) (bool, bool) {
|
||||||
@@ -565,12 +675,15 @@ func ToggleRepeatQueue(guildID string) (bool, bool) {
|
|||||||
}
|
}
|
||||||
gp := getOrCreateGuildPlayer(guildID)
|
gp := getOrCreateGuildPlayer(guildID)
|
||||||
manager.mu.Lock()
|
manager.mu.Lock()
|
||||||
defer manager.mu.Unlock()
|
|
||||||
gp.RepeatQueue = !gp.RepeatQueue
|
gp.RepeatQueue = !gp.RepeatQueue
|
||||||
if gp.RepeatQueue {
|
if gp.RepeatQueue {
|
||||||
gp.RepeatSong = false
|
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) {
|
func repeatFlags(guildID string) (bool, bool) {
|
||||||
@@ -630,6 +743,72 @@ func SetVolume(guildID string, volume int) error {
|
|||||||
return nil
|
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 {
|
func CurrentNowPlayingMessageID(guildID string) string {
|
||||||
if manager == nil {
|
if manager == nil {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ func main() {
|
|||||||
musicSettingsService := musicsettings.New(settingsRepo)
|
musicSettingsService := musicsettings.New(settingsRepo)
|
||||||
services := services.NewServices(levelService, levelSettingsService, meetingService, scheduleService, userSettingsService, projectsService, rpsService, twitchService, welcomeService, defaultRoleService, logSettingsService, musicSettingsService)
|
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 {
|
if err != nil {
|
||||||
log.Fatalf("Error creating bot: %v", err)
|
log.Fatalf("Error creating bot: %v", err)
|
||||||
return
|
return
|
||||||
|
|||||||
+7
-3
@@ -150,7 +150,9 @@ CREATE TABLE IF NOT EXISTS music_now_playing (
|
|||||||
duration_seconds INT NOT NULL,
|
duration_seconds INT NOT NULL,
|
||||||
started_at TIMESTAMPTZ NOT NULL,
|
started_at TIMESTAMPTZ NOT NULL,
|
||||||
paused BOOLEAN NOT NULL DEFAULT FALSE,
|
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 (
|
CREATE TABLE IF NOT EXISTS music_queue (
|
||||||
@@ -160,13 +162,15 @@ CREATE TABLE IF NOT EXISTS music_queue (
|
|||||||
track_title TEXT NOT NULL,
|
track_title TEXT NOT NULL,
|
||||||
track_url TEXT NOT NULL,
|
track_url TEXT NOT NULL,
|
||||||
requested_by BIGINT 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 (
|
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')),
|
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,
|
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