v3.4.0 #17

Merged
FernandoJVideira merged 6 commits from dev into master 2026-08-29 18:53:28 +00:00
5 changed files with 195 additions and 16 deletions
Showing only changes of commit 7a17c55774 - Show all commits
+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=
+95 -8
View File
@@ -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
}
+90 -5
View File
@@ -14,6 +14,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 +22,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 +222,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 +256,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 +273,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)
@@ -346,6 +358,7 @@ func EnqueueAndPlay(guildID, voiceChannelID, textChannelID, query, requester, re
Track: t,
RequestedBy: requester,
RequesterID: requesterID,
QueueID: uuid.New().String(),
}
}
firstEntry := entries[0]
@@ -551,12 +564,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 +581,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 +649,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 ""
+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')),
payload JSONB,
requested_by BIGINT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),