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