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.
821 lines
19 KiB
Go
821 lines
19 KiB
Go
package music
|
|
|
|
import (
|
|
"context"
|
|
"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
|
|
}
|
|
|
|
type Manager struct {
|
|
client disgolink.Client
|
|
session *discordgo.Session
|
|
repo *musicrepo.Repo
|
|
|
|
mu sync.Mutex
|
|
players map[string]*guildPlayer
|
|
}
|
|
|
|
var manager *Manager
|
|
|
|
func Init(session *discordgo.Session, repo *musicrepo.Repo, appID, lavalinkHost, lavalinkPass string) error {
|
|
if lavalinkHost == "" {
|
|
return nil
|
|
}
|
|
|
|
userID, err := snowflake.Parse(appID)
|
|
if err != nil {
|
|
return fmt.Errorf("parse app id for lavalink: %w", err)
|
|
}
|
|
|
|
m := &Manager{
|
|
client: disgolink.New(userID, disgolink.WithListenerFunc(onTrackEnd)),
|
|
session: session,
|
|
repo: repo,
|
|
players: make(map[string]*guildPlayer),
|
|
}
|
|
|
|
u, err := url.Parse(lavalinkHost)
|
|
if err != nil {
|
|
return fmt.Errorf("parse lavalink host: %w", err)
|
|
}
|
|
|
|
secure := u.Scheme == "https" || u.Scheme == "wss"
|
|
password := lavalinkPass
|
|
if password == "" {
|
|
password = "youshallnotpass"
|
|
}
|
|
|
|
// Connecting to the Lavalink node is a blocking network call (and, on
|
|
// failure, disgolink retries indefinitely with backoff) — do it in the
|
|
// background so bot startup isn't held hostage by it. Music commands
|
|
// simply fail with "not initialized" via the manager==nil checks until
|
|
// this completes.
|
|
go func() {
|
|
_, err := m.client.AddNode(context.Background(), disgolink.NodeConfig{
|
|
Name: "main",
|
|
Address: u.Host,
|
|
Password: password,
|
|
Secure: secure,
|
|
})
|
|
if err != nil {
|
|
log.Printf("music: failed to connect to lavalink node: %v", err)
|
|
return
|
|
}
|
|
manager = m
|
|
go idleDisconnectLoop()
|
|
}()
|
|
|
|
return nil
|
|
}
|
|
|
|
func idleDisconnectLoop() {
|
|
ticker := time.NewTicker(10 * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
for range ticker.C {
|
|
if manager == nil || manager.session == nil {
|
|
continue
|
|
}
|
|
|
|
now := time.Now()
|
|
var toDisconnect []string
|
|
|
|
manager.mu.Lock()
|
|
for guildID, gp := range manager.players {
|
|
if gp == nil {
|
|
continue
|
|
}
|
|
|
|
// If we're paused or have anything queued/playing, clear idle timer.
|
|
if gp.Paused || len(gp.Queue) > 0 {
|
|
gp.IdleSince = time.Time{}
|
|
continue
|
|
}
|
|
|
|
if gp.IdleSince.IsZero() {
|
|
gp.IdleSince = now
|
|
continue
|
|
}
|
|
|
|
if now.Sub(gp.IdleSince) >= time.Minute {
|
|
toDisconnect = append(toDisconnect, guildID)
|
|
gp.IdleSince = time.Time{}
|
|
}
|
|
}
|
|
manager.mu.Unlock()
|
|
|
|
for _, guildID := range toDisconnect {
|
|
// Best-effort: disconnect. Safe even if we're already disconnected.
|
|
_ = manager.session.ChannelVoiceJoinManual(guildID, "", false, false)
|
|
}
|
|
}
|
|
}
|
|
|
|
func OnVoiceStateUpdate(vs *discordgo.VoiceStateUpdate) {
|
|
if manager == nil {
|
|
return
|
|
}
|
|
if vs.UserID != manager.session.State.User.ID {
|
|
return
|
|
}
|
|
|
|
guildID := snowflake.MustParse(vs.GuildID)
|
|
var channelID *snowflake.ID
|
|
if vs.ChannelID != "" {
|
|
id := snowflake.MustParse(vs.ChannelID)
|
|
channelID = &id
|
|
}
|
|
|
|
manager.client.OnVoiceStateUpdate(
|
|
context.Background(),
|
|
guildID,
|
|
channelID,
|
|
vs.SessionID,
|
|
)
|
|
}
|
|
|
|
func OnVoiceServerUpdate(ev *discordgo.VoiceServerUpdate) {
|
|
if manager == nil {
|
|
return
|
|
}
|
|
endpoint := ""
|
|
if ev.Endpoint != "" {
|
|
endpoint = ev.Endpoint
|
|
}
|
|
|
|
manager.client.OnVoiceServerUpdate(
|
|
context.Background(),
|
|
snowflake.MustParse(ev.GuildID),
|
|
ev.Token,
|
|
endpoint,
|
|
)
|
|
}
|
|
|
|
func getOrCreateGuildPlayer(guildID string) *guildPlayer {
|
|
manager.mu.Lock()
|
|
defer manager.mu.Unlock()
|
|
|
|
if gp, ok := manager.players[guildID]; ok {
|
|
return gp
|
|
}
|
|
|
|
player := manager.client.Player(snowflake.MustParse(guildID))
|
|
gp := &guildPlayer{
|
|
Player: player,
|
|
Queue: make([]TrackEntry, 0),
|
|
Volume: 100,
|
|
}
|
|
manager.players[guildID] = gp
|
|
return gp
|
|
}
|
|
|
|
func syncState(guidID string) {
|
|
if manager == nil || manager.repo == nil {
|
|
return
|
|
}
|
|
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(
|
|
context.Background(),
|
|
query,
|
|
disgolink.NewResultHandler(
|
|
func(track lavalink.Track) {
|
|
loadedTracks = append(loadedTracks, track)
|
|
},
|
|
func(playlist lavalink.Playlist) {
|
|
if len(playlist.Tracks) == 0 {
|
|
return
|
|
}
|
|
|
|
// Respect Lavalink's selectedTrack when present by rotating the playlist.
|
|
selected := playlist.Info.SelectedTrack
|
|
if selected >= 0 && selected < len(playlist.Tracks) {
|
|
loadedTracks = append(loadedTracks, playlist.Tracks[selected:]...)
|
|
loadedTracks = append(loadedTracks, playlist.Tracks[:selected]...)
|
|
return
|
|
}
|
|
|
|
loadedTracks = append(loadedTracks, playlist.Tracks...)
|
|
},
|
|
func(tracks []lavalink.Track) {
|
|
if len(tracks) > 0 {
|
|
loadedTracks = append(loadedTracks, tracks[0])
|
|
}
|
|
},
|
|
func() {
|
|
},
|
|
func(err error) {
|
|
},
|
|
),
|
|
)
|
|
|
|
if len(loadedTracks) == 0 {
|
|
return nil, fmt.Errorf("no tracks found for query")
|
|
}
|
|
|
|
// Some sources may return unplayable entries; pick the first with an encoded track.
|
|
firstPlayableIdx := 0
|
|
for idx := range loadedTracks {
|
|
if loadedTracks[idx].Encoded != "" {
|
|
firstPlayableIdx = idx
|
|
break
|
|
}
|
|
}
|
|
if firstPlayableIdx != 0 && firstPlayableIdx < len(loadedTracks) {
|
|
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]
|
|
|
|
manager.mu.Lock()
|
|
shouldStart := len(gp.Queue) == 0
|
|
gp.Queue = append(gp.Queue, entries...)
|
|
gp.IdleSince = time.Time{}
|
|
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)
|
|
}
|
|
if gp.TextChannelID != "" {
|
|
postNowPlaying(guildID, gp.TextChannelID, firstEntry)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
if !event.Reason.MayStartNext() {
|
|
return
|
|
}
|
|
|
|
guildID := player.GuildID().String()
|
|
|
|
manager.mu.Lock()
|
|
gp, ok := manager.players[guildID]
|
|
if !ok || len(gp.Queue) == 0 {
|
|
manager.mu.Unlock()
|
|
return
|
|
}
|
|
|
|
// repeat current track
|
|
if gp.RepeatSong {
|
|
gp.Queue[0].StartedAt = time.Now()
|
|
next := gp.Queue[0]
|
|
textChannelID := gp.TextChannelID
|
|
manager.mu.Unlock()
|
|
|
|
if err := gp.Player.Update(context.Background(), lavalink.WithTrack(next.Track)); err != nil {
|
|
return
|
|
}
|
|
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)
|
|
}
|
|
|
|
if len(gp.Queue) == 0 {
|
|
textChannelID := gp.TextChannelID
|
|
nowPlayingMsgID := gp.NowPlayingMsgID
|
|
gp.NowPlayingMsgID = ""
|
|
gp.IdleSince = time.Now()
|
|
manager.mu.Unlock()
|
|
if textChannelID != "" && nowPlayingMsgID != "" {
|
|
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()
|
|
|
|
if err := gp.Player.Update(context.Background(), lavalink.WithTrack(next.Track)); err != nil {
|
|
return
|
|
}
|
|
if textChannelID != "" {
|
|
postNowPlaying(guildID, textChannelID, next)
|
|
}
|
|
syncState(guildID)
|
|
}
|
|
|
|
func Pause(guildID string, pause bool) error {
|
|
if manager == nil {
|
|
return fmt.Errorf("music manager not initialized")
|
|
}
|
|
gp := getOrCreateGuildPlayer(guildID)
|
|
manager.mu.Lock()
|
|
gp.Paused = pause
|
|
if pause {
|
|
gp.IdleSince = time.Time{}
|
|
}
|
|
manager.mu.Unlock()
|
|
syncState(guildID)
|
|
return gp.Player.Update(context.Background(), lavalink.WithPaused(pause))
|
|
}
|
|
|
|
func Skip(guildID string) error {
|
|
if manager == nil {
|
|
return fmt.Errorf("music manager not initialized")
|
|
}
|
|
gp := getOrCreateGuildPlayer(guildID)
|
|
|
|
manager.mu.Lock()
|
|
if len(gp.Queue) == 0 {
|
|
manager.mu.Unlock()
|
|
return nil
|
|
}
|
|
// skip always advances regardless of repeat song
|
|
skipped := gp.Queue[0]
|
|
gp.Queue = gp.Queue[1:]
|
|
if gp.RepeatQueue {
|
|
gp.Queue = append(gp.Queue, skipped)
|
|
}
|
|
|
|
if len(gp.Queue) == 0 {
|
|
textChannelID := gp.TextChannelID
|
|
nowPlayingMsgID := gp.NowPlayingMsgID
|
|
gp.NowPlayingMsgID = ""
|
|
gp.IdleSince = time.Now()
|
|
manager.mu.Unlock()
|
|
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()
|
|
|
|
if err := gp.Player.Update(context.Background(), lavalink.WithTrack(next.Track)); err != nil {
|
|
return err
|
|
}
|
|
if textChannelID != "" {
|
|
postNowPlaying(guildID, textChannelID, next)
|
|
}
|
|
syncState(guildID)
|
|
return nil
|
|
}
|
|
|
|
func Stop(guildID string) error {
|
|
if manager == nil {
|
|
return fmt.Errorf("music manager not initialized")
|
|
}
|
|
gp := getOrCreateGuildPlayer(guildID)
|
|
|
|
manager.mu.Lock()
|
|
textChannelID := gp.TextChannelID
|
|
nowPlayingMsgID := gp.NowPlayingMsgID
|
|
gp.Queue = nil
|
|
gp.Paused = false
|
|
gp.NowPlayingMsgID = ""
|
|
gp.IdleSince = time.Now()
|
|
manager.mu.Unlock()
|
|
|
|
if textChannelID != "" && nowPlayingMsgID != "" {
|
|
disableNowPlaying(textChannelID, nowPlayingMsgID)
|
|
}
|
|
syncState(guildID)
|
|
|
|
return gp.Player.Update(context.Background(), lavalink.WithNullTrack())
|
|
}
|
|
|
|
func GetQueue(guildID string) (*TrackEntry, []TrackEntry) {
|
|
if manager == nil {
|
|
return nil, nil
|
|
}
|
|
gp := getOrCreateGuildPlayer(guildID)
|
|
|
|
manager.mu.Lock()
|
|
defer manager.mu.Unlock()
|
|
|
|
if len(gp.Queue) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
current := gp.Queue[0]
|
|
rest := make([]TrackEntry, len(gp.Queue)-1)
|
|
copy(rest, gp.Queue[1:])
|
|
return ¤t, rest
|
|
}
|
|
|
|
func ToggleRepeatSong(guildID string) (bool, bool) {
|
|
if manager == nil {
|
|
return false, false
|
|
}
|
|
gp := getOrCreateGuildPlayer(guildID)
|
|
manager.mu.Lock()
|
|
gp.RepeatSong = !gp.RepeatSong
|
|
if gp.RepeatSong {
|
|
gp.RepeatQueue = false
|
|
}
|
|
repeatSong, repeatQueue := gp.RepeatSong, gp.RepeatQueue
|
|
manager.mu.Unlock()
|
|
|
|
syncState(guildID)
|
|
return repeatSong, repeatQueue
|
|
}
|
|
|
|
func ToggleRepeatQueue(guildID string) (bool, bool) {
|
|
if manager == nil {
|
|
return false, false
|
|
}
|
|
gp := getOrCreateGuildPlayer(guildID)
|
|
manager.mu.Lock()
|
|
gp.RepeatQueue = !gp.RepeatQueue
|
|
if gp.RepeatQueue {
|
|
gp.RepeatSong = false
|
|
}
|
|
repeatSong, repeatQueue := gp.RepeatSong, gp.RepeatQueue
|
|
manager.mu.Unlock()
|
|
|
|
syncState(guildID)
|
|
return repeatSong, repeatQueue
|
|
}
|
|
|
|
func repeatFlags(guildID string) (bool, bool) {
|
|
if manager == nil {
|
|
return false, false
|
|
}
|
|
gp := getOrCreateGuildPlayer(guildID)
|
|
manager.mu.Lock()
|
|
defer manager.mu.Unlock()
|
|
return gp.RepeatSong, gp.RepeatQueue
|
|
}
|
|
|
|
func pausedAndVolume(guildID string) (bool, int) {
|
|
if manager == nil {
|
|
return false, 100
|
|
}
|
|
gp := getOrCreateGuildPlayer(guildID)
|
|
manager.mu.Lock()
|
|
defer manager.mu.Unlock()
|
|
vol := gp.Volume
|
|
if vol <= 0 {
|
|
vol = 100
|
|
}
|
|
return gp.Paused, vol
|
|
}
|
|
|
|
func SetVolume(guildID string, volume int) error {
|
|
if manager == nil {
|
|
return fmt.Errorf("music manager not initialized")
|
|
}
|
|
if volume < 0 {
|
|
volume = 0
|
|
}
|
|
if volume > 150 {
|
|
volume = 150
|
|
}
|
|
|
|
gp := getOrCreateGuildPlayer(guildID)
|
|
manager.mu.Lock()
|
|
gp.Volume = volume
|
|
textChannelID := gp.TextChannelID
|
|
nowPlayingMsgID := gp.NowPlayingMsgID
|
|
manager.mu.Unlock()
|
|
|
|
if err := gp.Player.Update(context.Background(), lavalink.WithVolume(volume)); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Refresh the embed so the displayed volume matches.
|
|
if textChannelID != "" && nowPlayingMsgID != "" {
|
|
current, _ := GetQueue(guildID)
|
|
if current != nil {
|
|
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
|
|
}
|
|
|
|
func CurrentNowPlayingMessageID(guildID string) string {
|
|
if manager == nil {
|
|
return ""
|
|
}
|
|
gp := getOrCreateGuildPlayer(guildID)
|
|
manager.mu.Lock()
|
|
defer manager.mu.Unlock()
|
|
return gp.NowPlayingMsgID
|
|
}
|