diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..3972a59 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,11 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +charset = utf-8 + +[*.go] +indent_style = tab +indent_size = 4 \ No newline at end of file diff --git a/event.go b/event.go index ec373f3..350ce6d 100644 --- a/event.go +++ b/event.go @@ -1,27 +1,27 @@ -package gavalink - -// EventHandler defines events that Lavalink may send to a player -type EventHandler interface { - OnTrackEnd(player *Player, track string, reason string) error - OnTrackException(player *Player, track string, reason string) error - OnTrackStuck(player *Player, track string, threshold int) error -} - -// DummyEventHandler provides an empty event handler for users who -// wish to drop events outright. This is not recommended. -type DummyEventHandler struct{} - -// OnTrackEnd is raised when a track ends -func (d DummyEventHandler) OnTrackEnd(player *Player, track string, reason string) error { - return nil -} - -// OnTrackException is raised when a track throws an exception -func (d DummyEventHandler) OnTrackException(player *Player, track string, reason string) error { - return nil -} - -// OnTrackStuck is raised when a track gets stuck -func (d DummyEventHandler) OnTrackStuck(player *Player, track string, threshold int) error { - return nil -} +package gavalink + +// EventHandler defines events that Lavalink may send to a player +type EventHandler interface { + OnTrackEnd(player *Player, track string, reason string) error + OnTrackException(player *Player, track string, reason string) error + OnTrackStuck(player *Player, track string, threshold int) error +} + +// DummyEventHandler provides an empty event handler for users who +// wish to drop events outright. This is not recommended. +type DummyEventHandler struct{} + +// OnTrackEnd is raised when a track ends +func (d DummyEventHandler) OnTrackEnd(player *Player, track string, reason string) error { + return nil +} + +// OnTrackException is raised when a track throws an exception +func (d DummyEventHandler) OnTrackException(player *Player, track string, reason string) error { + return nil +} + +// OnTrackStuck is raised when a track gets stuck +func (d DummyEventHandler) OnTrackStuck(player *Player, track string, threshold int) error { + return nil +} diff --git a/lavalink.go b/lavalink.go index 3762067..aca0648 100644 --- a/lavalink.go +++ b/lavalink.go @@ -1,110 +1,110 @@ -package gavalink - -import ( - "errors" - "log" - "os" - "sort" -) - -// Log sets the log.Logger gavalink will write to -var Log *log.Logger - -func init() { - Log = log.New(os.Stdout, "(gavalink) ", 0) -} - -// Lavalink manages a connection to Lavalink Nodes -type Lavalink struct { - shards string - userID string - - nodes []Node - players map[string]*Player -} - -var ( - errNoNodes = errors.New("No nodes present") - errNodeNotFound = errors.New("Couldn't find that node") - errPlayerNotFound = errors.New("Couldn't find a player for that guild") - errVolumeOutOfRange = errors.New("Volume is out of range, must be within [0, 1000]") - errInvalidVersion = errors.New("This library requires Lavalink >= 3") - errUnknownPayload = errors.New("Lavalink sent an unknown payload") - errNilHandler = errors.New("You must provide an event handler. Use gavalink.DummyEventHandler if you wish to ignore events") -) - -// NewLavalink creates a new Lavalink manager -func NewLavalink(shards string, userID string) *Lavalink { - return &Lavalink{ - shards: shards, - userID: userID, - /* nodes: make([]Node, 1),*/ - players: make(map[string]*Player), - } -} - -// AddNodes adds a node to the Lavalink manager -func (lavalink *Lavalink) AddNodes(nodeConfigs ...NodeConfig) error { - nodes := make([]Node, len(nodeConfigs)) - for i, c := range nodeConfigs { - n := Node{ - config: c, - manager: lavalink, - } - err := n.open() - if err != nil { - return err - } - nodes[i] = n - } - lavalink.nodes = append(lavalink.nodes, nodes...) - return nil -} - -// RemoveNode removes a node from the manager -func (lavalink *Lavalink) removeNode(node *Node) error { - idx := -1 - for i, n := range lavalink.nodes { - if n == *node { - idx = i - break - } - } - if idx == -1 { - return errNodeNotFound - } - - node.stop() - - // temp var for easier reading - n := lavalink.nodes - z := len(n) - 1 - - n[idx] = n[z] // swap idx with last - n = n[:z] - - lavalink.nodes = n - return nil -} - -// BestNode returns the Node with the lowest latency -func (lavalink *Lavalink) BestNode() (*Node, error) { - if len(lavalink.nodes) < 1 { - return nil, errNoNodes - } - - sort.SliceStable(lavalink.nodes, func(i, j int) bool { - return lavalink.nodes[i].load < lavalink.nodes[j].load - }) - - return &lavalink.nodes[0], nil -} - -// GetPlayer gets a player for a guild -func (lavalink *Lavalink) GetPlayer(guild string) (*Player, error) { - p, ok := lavalink.players[guild] - if !ok { - return nil, errPlayerNotFound - } - return p, nil -} +package gavalink + +import ( + "errors" + "log" + "os" + "sort" +) + +// Log sets the log.Logger gavalink will write to +var Log *log.Logger + +func init() { + Log = log.New(os.Stdout, "(gavalink) ", 0) +} + +// Lavalink manages a connection to Lavalink Nodes +type Lavalink struct { + shards string + userID string + + nodes []Node + players map[string]*Player +} + +var ( + errNoNodes = errors.New("No nodes present") + errNodeNotFound = errors.New("Couldn't find that node") + errPlayerNotFound = errors.New("Couldn't find a player for that guild") + errVolumeOutOfRange = errors.New("Volume is out of range, must be within [0, 1000]") + errInvalidVersion = errors.New("This library requires Lavalink >= 3") + errUnknownPayload = errors.New("Lavalink sent an unknown payload") + errNilHandler = errors.New("You must provide an event handler. Use gavalink.DummyEventHandler if you wish to ignore events") +) + +// NewLavalink creates a new Lavalink manager +func NewLavalink(shards string, userID string) *Lavalink { + return &Lavalink{ + shards: shards, + userID: userID, + /* nodes: make([]Node, 1),*/ + players: make(map[string]*Player), + } +} + +// AddNodes adds a node to the Lavalink manager +func (lavalink *Lavalink) AddNodes(nodeConfigs ...NodeConfig) error { + nodes := make([]Node, len(nodeConfigs)) + for i, c := range nodeConfigs { + n := Node{ + config: c, + manager: lavalink, + } + err := n.open() + if err != nil { + return err + } + nodes[i] = n + } + lavalink.nodes = append(lavalink.nodes, nodes...) + return nil +} + +// RemoveNode removes a node from the manager +func (lavalink *Lavalink) removeNode(node *Node) error { + idx := -1 + for i, n := range lavalink.nodes { + if n == *node { + idx = i + break + } + } + if idx == -1 { + return errNodeNotFound + } + + node.stop() + + // temp var for easier reading + n := lavalink.nodes + z := len(n) - 1 + + n[idx] = n[z] // swap idx with last + n = n[:z] + + lavalink.nodes = n + return nil +} + +// BestNode returns the Node with the lowest latency +func (lavalink *Lavalink) BestNode() (*Node, error) { + if len(lavalink.nodes) < 1 { + return nil, errNoNodes + } + + sort.SliceStable(lavalink.nodes, func(i, j int) bool { + return lavalink.nodes[i].load < lavalink.nodes[j].load + }) + + return &lavalink.nodes[0], nil +} + +// GetPlayer gets a player for a guild +func (lavalink *Lavalink) GetPlayer(guild string) (*Player, error) { + p, ok := lavalink.players[guild] + if !ok { + return nil, errPlayerNotFound + } + return p, nil +} diff --git a/node.go b/node.go index a0982c2..fe89abb 100644 --- a/node.go +++ b/node.go @@ -1,197 +1,197 @@ -package gavalink - -import ( - "encoding/json" - "fmt" - "io/ioutil" - "net/http" - "strconv" - - "github.com/gorilla/websocket" -) - -// NodeConfig configures a Lavalink Node -type NodeConfig struct { - // REST is the host where Lavalink's REST server runs - // - // This value is expected without a trailing slash, e.g. like - // `http://localhost:2333` - REST string - // WebSocket is the host where Lavalink's WebSocket server runs - // - // This value is expected without a trailing slash, e.g. like - // `http://localhost:8012` - WebSocket string - // Password is the expected Authorization header for the Node - Password string -} - -// Node wraps a Lavalink Node -type Node struct { - config NodeConfig - load float32 - manager *Lavalink - wsConn *websocket.Conn -} - -func (node *Node) open() error { - header := http.Header{} - header.Set("Authorization", node.config.Password) - header.Set("Num-Shards", node.manager.shards) - header.Set("User-Id", node.manager.userID) - - ws, resp, err := websocket.DefaultDialer.Dial(node.config.WebSocket, header) - if err != nil { - return err - } - vstr := resp.Header.Get("Lavalink-Major-Version") - v, err := strconv.Atoi(vstr) - if err != nil { - return err - } - if v < 3 { - return errInvalidVersion - } - - node.wsConn = ws - go node.listen() - - Log.Println("node", node.config.WebSocket, "opened") - - return nil -} - -func (node *Node) stop() { - // someone already stopped this - if node.wsConn == nil { - return - } - _ = node.wsConn.Close() -} - -func (node *Node) listen() { - for { - msgType, msg, err := node.wsConn.ReadMessage() - if err != nil { - Log.Println(err) - // try to reconnect - oerr := node.open() - if oerr != nil { - Log.Println("node", node.config.WebSocket, "failed and could not reconnect, destroying.", err, oerr) - node.manager.removeNode(node) - return - } - Log.Println("node", node.config.WebSocket, "reconnected") - return - } - err = node.onEvent(msgType, msg) - // TODO: better error handling? - - if err != nil { - Log.Println(err) - } - } -} - -func (node *Node) onEvent(msgType int, msg []byte) error { - if msgType != websocket.TextMessage { - return errUnknownPayload - } - - m := message{} - err := json.Unmarshal(msg, &m) - if err != nil { - return err - } - - switch m.Op { - case opPlayerUpdate: - player, err := node.manager.GetPlayer(m.GuildID) - if err != nil { - return err - } - player.time = m.State.Time - player.position = m.State.Position - case opEvent: - player, err := node.manager.GetPlayer(m.GuildID) - if err != nil { - return err - } - - switch m.Type { - case eventTrackEnd: - err = player.handler.OnTrackEnd(player, m.Track, m.Reason) - case eventTrackException: - err = player.handler.OnTrackException(player, m.Track, m.Reason) - case eventTrackStuck: - err = player.handler.OnTrackStuck(player, m.Track, m.ThresholdMs) - } - - return err - case opStats: - node.load = m.StatCPU.Load - Log.Println("dbg-node", node.config.WebSocket, "load", node.load) - default: - return errUnknownPayload - } - - return nil -} - -// CreatePlayer creates an audio player on this node -func (node *Node) CreatePlayer(guildID string, sessionID string, event VoiceServerUpdate, handler EventHandler) (*Player, error) { - msg := message{ - Op: opVoiceUpdate, - GuildID: guildID, - SessionID: sessionID, - Event: &event, - } - data, err := json.Marshal(msg) - if err != nil { - return nil, err - } - err = node.wsConn.WriteMessage(websocket.TextMessage, data) - if err != nil { - return nil, err - } - player := &Player{ - guildID: guildID, - manager: node.manager, - node: node, - handler: handler, - } - node.manager.players[guildID] = player - return player, nil -} - -// LoadTracks queries lavalink to return a Tracks object -// -// query should be a valid Lavaplayer query, including but not limited to: -// - A direct media URI -// - A direct Youtube /watch URI -// - A search query, prefixed with ytsearch: or scsearch: -// -// See the Lavaplayer Source Code for all valid options. -func (node *Node) LoadTracks(query string) (*Tracks, error) { - url := fmt.Sprintf("%s/loadtracks?identifier=%s", node.config.REST, query) - req, err := http.NewRequest(http.MethodGet, url, nil) - if err != nil { - return nil, err - } - req.Header.Set("Authorization", node.config.Password) - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, err - } - data, err := ioutil.ReadAll(resp.Body) - if err != nil { - return nil, err - } - tracks := new(Tracks) - err = json.Unmarshal(data, &tracks) - if err != nil { - return nil, err - } - return tracks, nil -} +package gavalink + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "strconv" + + "github.com/gorilla/websocket" +) + +// NodeConfig configures a Lavalink Node +type NodeConfig struct { + // REST is the host where Lavalink's REST server runs + // + // This value is expected without a trailing slash, e.g. like + // `http://localhost:2333` + REST string + // WebSocket is the host where Lavalink's WebSocket server runs + // + // This value is expected without a trailing slash, e.g. like + // `http://localhost:8012` + WebSocket string + // Password is the expected Authorization header for the Node + Password string +} + +// Node wraps a Lavalink Node +type Node struct { + config NodeConfig + load float32 + manager *Lavalink + wsConn *websocket.Conn +} + +func (node *Node) open() error { + header := http.Header{} + header.Set("Authorization", node.config.Password) + header.Set("Num-Shards", node.manager.shards) + header.Set("User-Id", node.manager.userID) + + ws, resp, err := websocket.DefaultDialer.Dial(node.config.WebSocket, header) + if err != nil { + return err + } + vstr := resp.Header.Get("Lavalink-Major-Version") + v, err := strconv.Atoi(vstr) + if err != nil { + return err + } + if v < 3 { + return errInvalidVersion + } + + node.wsConn = ws + go node.listen() + + Log.Println("node", node.config.WebSocket, "opened") + + return nil +} + +func (node *Node) stop() { + // someone already stopped this + if node.wsConn == nil { + return + } + _ = node.wsConn.Close() +} + +func (node *Node) listen() { + for { + msgType, msg, err := node.wsConn.ReadMessage() + if err != nil { + Log.Println(err) + // try to reconnect + oerr := node.open() + if oerr != nil { + Log.Println("node", node.config.WebSocket, "failed and could not reconnect, destroying.", err, oerr) + node.manager.removeNode(node) + return + } + Log.Println("node", node.config.WebSocket, "reconnected") + return + } + err = node.onEvent(msgType, msg) + // TODO: better error handling? + + if err != nil { + Log.Println(err) + } + } +} + +func (node *Node) onEvent(msgType int, msg []byte) error { + if msgType != websocket.TextMessage { + return errUnknownPayload + } + + m := message{} + err := json.Unmarshal(msg, &m) + if err != nil { + return err + } + + switch m.Op { + case opPlayerUpdate: + player, err := node.manager.GetPlayer(m.GuildID) + if err != nil { + return err + } + player.time = m.State.Time + player.position = m.State.Position + case opEvent: + player, err := node.manager.GetPlayer(m.GuildID) + if err != nil { + return err + } + + switch m.Type { + case eventTrackEnd: + err = player.handler.OnTrackEnd(player, m.Track, m.Reason) + case eventTrackException: + err = player.handler.OnTrackException(player, m.Track, m.Reason) + case eventTrackStuck: + err = player.handler.OnTrackStuck(player, m.Track, m.ThresholdMs) + } + + return err + case opStats: + node.load = m.StatCPU.Load + Log.Println("dbg-node", node.config.WebSocket, "load", node.load) + default: + return errUnknownPayload + } + + return nil +} + +// CreatePlayer creates an audio player on this node +func (node *Node) CreatePlayer(guildID string, sessionID string, event VoiceServerUpdate, handler EventHandler) (*Player, error) { + msg := message{ + Op: opVoiceUpdate, + GuildID: guildID, + SessionID: sessionID, + Event: &event, + } + data, err := json.Marshal(msg) + if err != nil { + return nil, err + } + err = node.wsConn.WriteMessage(websocket.TextMessage, data) + if err != nil { + return nil, err + } + player := &Player{ + guildID: guildID, + manager: node.manager, + node: node, + handler: handler, + } + node.manager.players[guildID] = player + return player, nil +} + +// LoadTracks queries lavalink to return a Tracks object +// +// query should be a valid Lavaplayer query, including but not limited to: +// - A direct media URI +// - A direct Youtube /watch URI +// - A search query, prefixed with ytsearch: or scsearch: +// +// See the Lavaplayer Source Code for all valid options. +func (node *Node) LoadTracks(query string) (*Tracks, error) { + url := fmt.Sprintf("%s/loadtracks?identifier=%s", node.config.REST, query) + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", node.config.Password) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + data, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + tracks := new(Tracks) + err = json.Unmarshal(data, &tracks) + if err != nil { + return nil, err + } + return tracks, nil +} diff --git a/player.go b/player.go index 57520cd..ce97c84 100644 --- a/player.go +++ b/player.go @@ -1,163 +1,163 @@ -package gavalink - -import ( - "encoding/json" - "strconv" - - "github.com/gorilla/websocket" -) - -// Player is a Lavalink player -type Player struct { - guildID string - time int - position int - paused bool - manager *Lavalink - node *Node - handler EventHandler -} - -// Play will play the given track completely -func (player *Player) Play(track string) error { - return player.PlayAt(track, 0, 0) -} - -// PlayAt will play the given track at the specified start and end times -// -// Setting a time to 0 will omit it. -func (player *Player) PlayAt(track string, startTime int, endTime int) error { - start := strconv.Itoa(startTime) - end := strconv.Itoa(endTime) - - msg := message{ - Op: opPlay, - GuildID: player.guildID, - Track: track, - StartTime: start, - EndTime: end, - } - data, err := json.Marshal(msg) - if err != nil { - return err - } - err = player.node.wsConn.WriteMessage(websocket.TextMessage, data) - return err -} - -// Stop will stop the currently playing track -func (player *Player) Stop() error { - msg := message{ - Op: opStop, - GuildID: player.guildID, - } - data, err := json.Marshal(msg) - if err != nil { - return err - } - err = player.node.wsConn.WriteMessage(websocket.TextMessage, data) - return err -} - -// Pause will pause or resume the player, depending on the pause parameter -func (player *Player) Pause(pause bool) error { - player.paused = pause - msg := message{ - Op: opPause, - GuildID: player.guildID, - Pause: &pause, - } - data, err := json.Marshal(msg) - if err != nil { - return err - } - err = player.node.wsConn.WriteMessage(websocket.TextMessage, data) - return err -} - -// Paused returns whether or not the player is currently paused -func (player *Player) Paused() bool { - return player.paused -} - -// Seek will seek the player to the speicifed position, in millis -func (player *Player) Seek(position int) error { - msg := message{ - Op: opSeek, - GuildID: player.guildID, - Position: &position, - } - data, err := json.Marshal(msg) - if err != nil { - return err - } - err = player.node.wsConn.WriteMessage(websocket.TextMessage, data) - return err -} - -// Position returns the player's position, as reported by Lavalink -func (player *Player) Position() int { - return player.position -} - -// Volume will set the player's volume to the specified value -// -// volume must be within [0, 1000] -func (player *Player) Volume(volume int) error { - if volume < 0 || volume > 1000 { - return errVolumeOutOfRange - } - - msg := message{ - Op: opVolume, - GuildID: player.guildID, - Volume: &volume, - } - data, err := json.Marshal(msg) - if err != nil { - return err - } - err = player.node.wsConn.WriteMessage(websocket.TextMessage, data) - return err -} - -// Forward will forward a new VOICE_SERVER_UPDATE to a Lavalink node for -// this player. -// -// This should always be used if a VOICE_SERVER_UPDATE is received for -// a guild which already has a player. -// -// To move a player to a new Node, first player.Destroy() it, and then -// create a new player on the new node. -func (player *Player) Forward(sessionID string, event VoiceServerUpdate) error { - msg := message{ - Op: opVoiceUpdate, - GuildID: player.guildID, - SessionID: sessionID, - Event: &event, - } - data, err := json.Marshal(msg) - if err != nil { - return err - } - err = player.node.wsConn.WriteMessage(websocket.TextMessage, data) - return err -} - -// Destroy will destroy this player -func (player *Player) Destroy() error { - msg := message{ - Op: opDestroy, - GuildID: player.guildID, - } - data, err := json.Marshal(msg) - if err != nil { - return err - } - err = player.node.wsConn.WriteMessage(websocket.TextMessage, data) - if err != nil { - return err - } - player.manager.players[player.guildID] = nil - return nil -} +package gavalink + +import ( + "encoding/json" + "strconv" + + "github.com/gorilla/websocket" +) + +// Player is a Lavalink player +type Player struct { + guildID string + time int + position int + paused bool + manager *Lavalink + node *Node + handler EventHandler +} + +// Play will play the given track completely +func (player *Player) Play(track string) error { + return player.PlayAt(track, 0, 0) +} + +// PlayAt will play the given track at the specified start and end times +// +// Setting a time to 0 will omit it. +func (player *Player) PlayAt(track string, startTime int, endTime int) error { + start := strconv.Itoa(startTime) + end := strconv.Itoa(endTime) + + msg := message{ + Op: opPlay, + GuildID: player.guildID, + Track: track, + StartTime: start, + EndTime: end, + } + data, err := json.Marshal(msg) + if err != nil { + return err + } + err = player.node.wsConn.WriteMessage(websocket.TextMessage, data) + return err +} + +// Stop will stop the currently playing track +func (player *Player) Stop() error { + msg := message{ + Op: opStop, + GuildID: player.guildID, + } + data, err := json.Marshal(msg) + if err != nil { + return err + } + err = player.node.wsConn.WriteMessage(websocket.TextMessage, data) + return err +} + +// Pause will pause or resume the player, depending on the pause parameter +func (player *Player) Pause(pause bool) error { + player.paused = pause + msg := message{ + Op: opPause, + GuildID: player.guildID, + Pause: &pause, + } + data, err := json.Marshal(msg) + if err != nil { + return err + } + err = player.node.wsConn.WriteMessage(websocket.TextMessage, data) + return err +} + +// Paused returns whether or not the player is currently paused +func (player *Player) Paused() bool { + return player.paused +} + +// Seek will seek the player to the speicifed position, in millis +func (player *Player) Seek(position int) error { + msg := message{ + Op: opSeek, + GuildID: player.guildID, + Position: &position, + } + data, err := json.Marshal(msg) + if err != nil { + return err + } + err = player.node.wsConn.WriteMessage(websocket.TextMessage, data) + return err +} + +// Position returns the player's position, as reported by Lavalink +func (player *Player) Position() int { + return player.position +} + +// Volume will set the player's volume to the specified value +// +// volume must be within [0, 1000] +func (player *Player) Volume(volume int) error { + if volume < 0 || volume > 1000 { + return errVolumeOutOfRange + } + + msg := message{ + Op: opVolume, + GuildID: player.guildID, + Volume: &volume, + } + data, err := json.Marshal(msg) + if err != nil { + return err + } + err = player.node.wsConn.WriteMessage(websocket.TextMessage, data) + return err +} + +// Forward will forward a new VOICE_SERVER_UPDATE to a Lavalink node for +// this player. +// +// This should always be used if a VOICE_SERVER_UPDATE is received for +// a guild which already has a player. +// +// To move a player to a new Node, first player.Destroy() it, and then +// create a new player on the new node. +func (player *Player) Forward(sessionID string, event VoiceServerUpdate) error { + msg := message{ + Op: opVoiceUpdate, + GuildID: player.guildID, + SessionID: sessionID, + Event: &event, + } + data, err := json.Marshal(msg) + if err != nil { + return err + } + err = player.node.wsConn.WriteMessage(websocket.TextMessage, data) + return err +} + +// Destroy will destroy this player +func (player *Player) Destroy() error { + msg := message{ + Op: opDestroy, + GuildID: player.guildID, + } + data, err := json.Marshal(msg) + if err != nil { + return err + } + err = player.node.wsConn.WriteMessage(websocket.TextMessage, data) + if err != nil { + return err + } + player.manager.players[player.guildID] = nil + return nil +}