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,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
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,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 +22,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 +222,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 +256,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 +273,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)
|
||||||
@@ -346,6 +358,7 @@ func EnqueueAndPlay(guildID, voiceChannelID, textChannelID, query, requester, re
|
|||||||
Track: t,
|
Track: t,
|
||||||
RequestedBy: requester,
|
RequestedBy: requester,
|
||||||
RequesterID: requesterID,
|
RequesterID: requesterID,
|
||||||
|
QueueID: uuid.New().String(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
firstEntry := entries[0]
|
firstEntry := entries[0]
|
||||||
@@ -551,12 +564,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 +581,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 +649,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 ""
|
||||||
|
|||||||
+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')),
|
||||||
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