From 2e77254b1bb02bf11e5c532f2672beb4fb5d8e27 Mon Sep 17 00:00:00 2001 From: Christopher F Date: Wed, 22 Aug 2018 18:00:04 -0400 Subject: [PATCH] feature: add manager, node, track loading --- lavalink.go | 76 +++++++++++++++++++++++++++++++ model.go | 53 +++++++++++++++++++++ node.go | 129 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 258 insertions(+) create mode 100644 lavalink.go create mode 100644 model.go create mode 100644 node.go diff --git a/lavalink.go b/lavalink.go new file mode 100644 index 0000000..b85de12 --- /dev/null +++ b/lavalink.go @@ -0,0 +1,76 @@ +package gavalink + +import ( + "errors" +) + +// Lavalink manages a connection to Lavalink Nodes +type Lavalink struct { + // Shards is the total number of shards the bot is running + Shards int + // UserID is the Discord User ID of the bot + UserID int + + nodes []Node +} + +var ( + errNoNodes = errors.New("No nodes present") + errNodeNotFound = errors.New("Couldn't find that node") + errInvalidVersion = errors.New("This library requires Lavalink >= 3") + errUnknownPayload = errors.New("Lavalink sent an unknown payload") +) + +// NewLavalink creates a new Lavalink manager +func NewLavalink() *Lavalink { + return &Lavalink{} +} + +// AddNodes adds a node to the Lavalink manager +func (lavalink *Lavalink) AddNodes(nodeConfigs ...NodeConfig) { + nodes := make([]Node, len(nodeConfigs)) + for i, c := range nodeConfigs { + n := Node{ + config: c, + shards: lavalink.Shards, + userID: lavalink.UserID, + } + nodes[i] = n + } + lavalink.nodes = append(lavalink.nodes, nodes...) +} + +// 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 + } + // TODO: lookup latency + return &lavalink.nodes[0], nil +} diff --git a/model.go b/model.go new file mode 100644 index 0000000..7adef7e --- /dev/null +++ b/model.go @@ -0,0 +1,53 @@ +package gavalink + +const ( + // TrackLoaded is a Tracks Type for a succesful single track load + TrackLoaded = "TRACK_LOADED" + // PlaylistLoaded is a Tracks Type for a succseful playlist load + PlaylistLoaded = "PLAYLIST_LOADED" + // SearchResult is a Tracks Type for a search containing many tracks + SearchResult = "SEARCH_RESULT" + // NoMatches is a Tracks Type for a query yielding no matches + NoMatches = "NO_MATCHES" + // LoadFailed is a Tracks Type for an internal Lavalink error + LoadFailed = "LOAD_FAILED" +) + +// Tracks contains data for a Lavalink Tracks response +type Tracks struct { + // Type contains the type of response + // + // This will be one of TrackLoaded, PlaylistLoaded, SearchResult, + // NoMatches, or LoadFailed + Type string `json:"loadType"` + PlaylistInfo *PlaylistInfo `json:"playlistInfo"` + Tracks []Track `json:"tracks"` +} + +// PlaylistInfo contains information about a loaded playlist +type PlaylistInfo struct { + // Name is the friendly of the playlist + Name string `json:"name"` + // SelectedTrack is the index of the track that loaded the playlist, + // if one is present. + SelectedTrack int `json:"selectedTrack"` +} + +// Track contains information about a loaded track +type Track struct { + // Data contains the base64 encoded Lavaplayer track + Data string `json:"track"` + Info TrackInfo `json:"info"` +} + +// TrackInfo contains more data about a loaded track +type TrackInfo struct { + Identifier string `json:"identifier"` + Title string `json:"title"` + Author string `json:"author"` + URI string `json:"uri"` + Seekable bool `json:"isSeekable"` + Stream bool `json:"isStream"` + Length int `json:"length"` + Position int `json:"position"` +} diff --git a/node.go b/node.go new file mode 100644 index 0000000..bfbd1d1 --- /dev/null +++ b/node.go @@ -0,0 +1,129 @@ +package gavalink + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "log" + "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 + shards int + userID int + manager *Lavalink + wsConn *websocket.Conn +} + +func (node *Node) open() error { + header := http.Header{} + header.Set("Authorization", node.config.Password) + + 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 { + // 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 + log.Println(err) + } +} + +func (node *Node) onEvent(msgType int, msg []byte) error { + if msgType != websocket.TextMessage { + return errUnknownPayload + } + return 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 +}