Compare commits

...
Author SHA1 Message Date
FernandoJVideira 52dfe859ee feat(music): add a stop command
Clears the whole queue and disconnects, mirrors the existing Stop
Manager function, just wires it up to the outbox like every other
dashboard command.
2026-08-29 19:48:43 +01:00
FernandoJVideira da66eb8dd5 feat(music): let the dashboard add songs, starting a session if needed
New AddToQueue appends to an already-active session without touching
voice (the dashboard can't join a channel on its own), and add_song's
dispatch case now branches: a voice_channel_id in the payload means
"start fresh", so it goes through EnqueueAndPlay (join + play) instead.
Query normalization (plain text -> YouTube search, stripping
autoplay/radio params off pasted YouTube URLs) moves out of /play's
handler into a shared NormalizeQuery, both entry points need it, not
just one.

Starting playback from the dashboard also posts the now-playing embed
into the voice channel's own built-in text chat, matching what /play
already does, instead of leaving it with nowhere to show up.
2026-08-29 19:44:24 +01:00
FernandoJVideira fcc7c9c66a feat(music): consume playback commands via LISTEN/NOTIFY
The dashboard has been writing skip/pause/volume/etc. requests into
music_commands with nothing on this end reading them. Adds a
dedicated LISTEN connection (can't use the pooled *sql.DB for this,
LISTEN has to stay bound to one specific connection) that wakes on
NOTIFY, drains unprocessed rows, and dispatches each to the matching
Manager function. A command's error is logged, not fatal, and every
row gets marked processed regardless of outcome so a permanently
broken command doesn't retry forever.
2026-08-29 19:22:16 +01:00
FernandoJVideira 7a17c55774 feat(music): stable queue identity, remove/reorder, and repeat commands
TrackEntry gets a QueueID generated once when a track is queued and
kept for its whole life there, and musicrepo.ReplaceQueue now upserts
by that ID instead of deleting and reinserting the whole queue on
every sync (which churned every track's database id constantly, even
ones that hadn't moved, and would've made remove/reorder commands
target the wrong track). RemoveFromQueue and Reorder are new Manager
functions built on top of that stable identity. Also adds repeat_song/
repeat_queue to the persisted now-playing state, and makes the two
existing toggle functions actually sync it, they never did before.
2026-08-29 19:22:02 +01:00
FernandoJVideira 539c0a69af Merge pull request 'feat(music): persist live playback state to postgres' (#14) from ft/music-live-state into dev
Reviewed-on: #14
2026-08-29 15:35:05 +00:00
FernandoJVideira d6687879dd feat(music): persist live playback state to postgres
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.
2026-08-29 15:48:27 +01:00
9 changed files with 611 additions and 80 deletions

No files matched your search

+1
View File
@@ -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
+2
View File
@@ -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=
+14 -2
View File
@@ -1,8 +1,10 @@
package bot
import (
"context"
"log"
"velox-bot/internal/commands"
"velox-bot/internal/db/repos/musicrepo"
"velox-bot/internal/db/services"
"velox-bot/internal/events"
"velox-bot/internal/music"
@@ -17,11 +19,13 @@ type Bot struct {
Commands []*discordgo.ApplicationCommand
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) (*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
@@ -46,6 +50,8 @@ func NewBot(token, appID, guildID, lavalinkHost, lavalinkPass string, cmds []*di
GuildID: guildID,
Commands: cmds,
Services: services,
MusicRepo: musicRepo,
DBHost: dbHost,
LavalinkHost: lavalinkHost,
LavalinkPass: lavalinkPass,
}, nil
@@ -56,7 +62,13 @@ func (b *Bot) Start() error {
return err
}
_ = music.Init(b.Session, 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))
for _, cmd := range b.Commands {
+1 -41
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{
@@ -64,7 +55,7 @@ func PlayHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
return
}
entry, started, err := music.EnqueueAndPlay(i.GuildID, vs.ChannelID, i.ChannelID, query, i.Member.User.Username)
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)),
@@ -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 {
+144
View File
@@ -0,0 +1,144 @@
package musicrepo
import (
"context"
"database/sql"
"time"
)
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 {
db *sql.DB
}
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, 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
}
func (r *Repo) ClearNowPlaying(ctx context.Context, guildID int64) error {
const q = `DELETE FROM music_now_playing WHERE guild_id = $1`
_, err := r.db.ExecContext(ctx, q, guildID)
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 {
return err
}
defer tx.Rollback()
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 {
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
}
+133
View File
@@ -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)
}
}
+303 -30
View File
@@ -5,35 +5,50 @@ import (
"fmt"
"log"
"net/url"
"strconv"
"strings"
"sync"
"time"
"velox-bot/internal/db/repos/musicrepo"
"github.com/bwmarrin/discordgo"
"github.com/disgoorg/disgolink/v3/disgolink"
"github.com/disgoorg/disgolink/v3/lavalink"
"github.com/disgoorg/snowflake/v2"
"github.com/google/uuid"
)
type TrackEntry struct {
Track lavalink.Track
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 {
Player disgolink.Player
Queue []TrackEntry
RepeatSong bool
RepeatQueue bool
Paused bool
Volume int
IdleSince time.Time
TextChannelID string
NowPlayingMsgID string
Player disgolink.Player
Queue []TrackEntry
RepeatSong bool
RepeatQueue bool
Paused bool
Volume int
IdleSince time.Time
TextChannelID string
NowPlayingMsgID string
}
type Manager struct {
client disgolink.Client
session *discordgo.Session
repo *musicrepo.Repo
mu sync.Mutex
players map[string]*guildPlayer
@@ -41,7 +56,7 @@ type Manager struct {
var manager *Manager
func Init(session *discordgo.Session, appID, lavalinkHost, lavalinkPass string) error {
func Init(session *discordgo.Session, repo *musicrepo.Repo, appID, lavalinkHost, lavalinkPass string) error {
if lavalinkHost == "" {
return nil
}
@@ -54,6 +69,7 @@ func Init(session *discordgo.Session, appID, lavalinkHost, lavalinkPass string)
m := &Manager{
client: disgolink.New(userID, disgolink.WithListenerFunc(onTrackEnd)),
session: session,
repo: repo,
players: make(map[string]*guildPlayer),
}
@@ -192,23 +208,126 @@ func getOrCreateGuildPlayer(guildID string) *guildPlayer {
return gp
}
func EnqueueAndPlay(guildID, voiceChannelID, textChannelID, query, requester string) (*TrackEntry, bool, error) {
if manager == nil {
return nil, false, fmt.Errorf("music manager not initialized")
func syncState(guidID string) {
if manager == nil || manager.repo == nil {
return
}
// 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.Lock()
gp, ok := manager.players[guidID]
if !ok {
manager.mu.Unlock()
return
}
queue := make([]TrackEntry, len(gp.Queue))
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)
if err != nil {
return
}
if len(queue) == 0 {
err := manager.repo.ClearNowPlaying(context.Background(), gID)
if err != nil {
return
}
err = manager.repo.ReplaceQueue(context.Background(), gID, nil)
if err != nil {
return
}
return
}
current := queue[0]
title := current.Track.Info.Title
var uri string
if current.Track.Info.URI != nil {
uri = *current.Track.Info.URI
}
length := int(current.Track.Info.Length / 1000)
startedAt := current.StartedAt
err = manager.repo.SetNowPlaying(context.Background(), gID, title, uri, length, startedAt, paused, volume, repeatSong, repeatQueue)
if err != nil {
return
}
rest := queue[1:]
var queueEntries []musicrepo.QueueEntry
for _, entry := range rest {
reqID, _ := strconv.ParseInt(entry.RequesterID, 10, 64)
var uri string
if entry.Track.Info.URI != nil {
uri = *entry.Track.Info.URI
}
queueEntries = append(queueEntries, musicrepo.QueueEntry{
Title: entry.Track.Info.Title,
URL: uri,
RequestedBy: reqID,
ClientID: entry.QueueID,
})
}
err = manager.repo.ReplaceQueue(context.Background(), gID, queueEntries)
if err != nil {
return
}
}
// 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
}
u, err := url.Parse(query)
if err != nil || u == nil {
return query
}
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(
@@ -246,7 +365,7 @@ func EnqueueAndPlay(guildID, voiceChannelID, textChannelID, query, requester str
)
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.
@@ -261,11 +380,38 @@ func EnqueueAndPlay(guildID, voiceChannelID, textChannelID, query, requester str
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]
@@ -277,6 +423,8 @@ func EnqueueAndPlay(guildID, voiceChannelID, textChannelID, query, requester str
manager.mu.Unlock()
if shouldStart {
gp.Queue[0].StartedAt = time.Now()
if err := gp.Player.Update(context.Background(), lavalink.WithTrack(firstEntry.Track)); err != nil {
return nil, false, fmt.Errorf("start track: %w", err)
}
@@ -285,9 +433,52 @@ func EnqueueAndPlay(guildID, voiceChannelID, textChannelID, query, requester str
}
}
syncState(guildID)
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
@@ -307,6 +498,7 @@ func onTrackEnd(player disgolink.Player, event lavalink.TrackEndEvent) {
// repeat current track
if gp.RepeatSong {
gp.Queue[0].StartedAt = time.Now()
next := gp.Queue[0]
textChannelID := gp.TextChannelID
manager.mu.Unlock()
@@ -317,12 +509,14 @@ func onTrackEnd(player disgolink.Player, event lavalink.TrackEndEvent) {
if textChannelID != "" {
postNowPlaying(guildID, textChannelID, next)
}
syncState(guildID)
return
}
// pop current and optionally cycle it to end
finished := gp.Queue[0]
gp.Queue = gp.Queue[1:]
if gp.RepeatQueue {
gp.Queue = append(gp.Queue, finished)
}
@@ -337,9 +531,11 @@ func onTrackEnd(player disgolink.Player, event lavalink.TrackEndEvent) {
disableNowPlaying(textChannelID, nowPlayingMsgID)
}
_ = gp.Player.Update(context.Background(), lavalink.WithNullTrack())
syncState(guildID)
return
}
gp.Queue[0].StartedAt = time.Now()
next := gp.Queue[0]
textChannelID := gp.TextChannelID
manager.mu.Unlock()
@@ -350,6 +546,7 @@ func onTrackEnd(player disgolink.Player, event lavalink.TrackEndEvent) {
if textChannelID != "" {
postNowPlaying(guildID, textChannelID, next)
}
syncState(guildID)
}
func Pause(guildID string, pause bool) error {
@@ -363,6 +560,7 @@ func Pause(guildID string, pause bool) error {
gp.IdleSince = time.Time{}
}
manager.mu.Unlock()
syncState(guildID)
return gp.Player.Update(context.Background(), lavalink.WithPaused(pause))
}
@@ -393,9 +591,11 @@ func Skip(guildID string) error {
if textChannelID != "" && nowPlayingMsgID != "" {
disableNowPlaying(textChannelID, nowPlayingMsgID)
}
syncState(guildID)
return gp.Player.Update(context.Background(), lavalink.WithNullTrack())
}
gp.Queue[0].StartedAt = time.Now()
next := gp.Queue[0]
textChannelID := gp.TextChannelID
manager.mu.Unlock()
@@ -406,6 +606,7 @@ func Skip(guildID string) error {
if textChannelID != "" {
postNowPlaying(guildID, textChannelID, next)
}
syncState(guildID)
return nil
}
@@ -427,6 +628,7 @@ func Stop(guildID string) error {
if textChannelID != "" && nowPlayingMsgID != "" {
disableNowPlaying(textChannelID, nowPlayingMsgID)
}
syncState(guildID)
return gp.Player.Update(context.Background(), lavalink.WithNullTrack())
}
@@ -456,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) {
@@ -470,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) {
@@ -531,6 +739,73 @@ func SetVolume(guildID string, volume int) error {
postNowPlaying(guildID, textChannelID, *current)
}
}
syncState(guildID)
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
}
@@ -543,5 +818,3 @@ func CurrentNowPlayingMessageID(guildID string) string {
defer manager.mu.Unlock()
return gp.NowPlayingMsgID
}
+6 -4
View File
@@ -10,16 +10,18 @@ import (
"velox-bot/internal/commands"
"velox-bot/internal/config"
"velox-bot/internal/db"
"velox-bot/internal/db/repos/defaultrolerepo"
"velox-bot/internal/db/repos/levelrepo"
"velox-bot/internal/db/repos/musicrepo"
"velox-bot/internal/db/repos/projectsrepo"
"velox-bot/internal/db/repos/rpsrepo"
"velox-bot/internal/db/repos/schedulerepo"
"velox-bot/internal/db/repos/settingsrepo"
"velox-bot/internal/db/repos/welcomerepo"
"velox-bot/internal/db/repos/twitchrepo"
"velox-bot/internal/db/repos/usersettingsrepo"
"velox-bot/internal/db/repos/defaultrolerepo"
"velox-bot/internal/db/repos/welcomerepo"
"velox-bot/internal/db/services"
"velox-bot/internal/db/services/defaultrole"
"velox-bot/internal/db/services/level"
"velox-bot/internal/db/services/levelsettings"
"velox-bot/internal/db/services/logsettings"
@@ -31,7 +33,6 @@ import (
"velox-bot/internal/db/services/twitch"
"velox-bot/internal/db/services/usersettings"
"velox-bot/internal/db/services/welcome"
"velox-bot/internal/db/services/defaultrole"
)
func main() {
@@ -50,6 +51,7 @@ func main() {
levelRepo := levelrepo.NewRepo(db)
settingsRepo := settingsrepo.NewRepo(db)
musicRepo := musicrepo.NewRepo(db)
rpsRepo := rpsrepo.NewRepo(db)
projectsRepo := projectsrepo.NewRepo(db)
scheduleRepo := schedulerepo.NewRepo(db)
@@ -71,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)
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
View File
@@ -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(),