diff --git a/NOTICE.txt b/NOTICE.txt new file mode 100644 index 0000000..82540df --- /dev/null +++ b/NOTICE.txt @@ -0,0 +1,11 @@ +This project includes code from the Apache-licensed project erela.js + +Portions of this software are licensed under the Apache License, Version 2.0 (the "Apache License"); +you may not use this file except in compliance with the Apache License. You may obtain a copy of the +Apache License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under the Apache License +is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the Apache License for the specific language governing permissions and limitations under the Apache License. + +Copyright 2021 MenuDocs diff --git a/README.md b/README.md index 6038865..d87da6d 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ - [🎈 Usage ](#-usage-) - [🚀 Deployment ](#-deployment-) - [⛏️ Built Using ](#️-built-using-) -- [✍️ Authors ](#️-authors-) +- [Credits](#credits) ## 🧐 About @@ -62,25 +62,7 @@ That's it! You have successfully installed Sunday.ts and are ready to start usin ## 🎈 Usage ```ts -import { Node } from "sunday.ts" -const node = new Node({ - host: 'localhost', - port: 2333, - password: 'youshallnotpass', -}); - -node.on("ready", () => { - console.log("Ready"); -}); -node.on("stats", () => { - console.log(node.stats); -}); -node.on("raw", (data) => { - console.log(data); -}); -node.connect(); -node.rest?.on("get", (data) => console.log(data)); -node.rest?.get("/loadtracks?identifier=dQw4w9WgXcQ") + ``` ## 🚀 Deployment @@ -91,9 +73,8 @@ Add additional notes about how to deploy this on a live system. - [WebSocket](https://github.com/websockets/ws) - WebSocket Client - [Axios](https://github.com/axios/axios) - HTTP Request +## Credits -## ✍️ Authors - -- [FAYStarNext](https://github.com/FAYStarNext) - Idea & Initial work +- [Erela.Js](https://github.com/MenuDocs/erela.js) See also the list of [contributors](https://github.com/FAYStarNext/Sunday.ts/contributors) who participated in this project. \ No newline at end of file diff --git a/package.json b/package.json index 63175c3..af6d109 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "type": "module", "version": "1.0.1-indev", "description": "Sunday a lavalink wrapper", + "license": "MIT", "scripts": { "build:js": "npx babel src --out-dir dist --extensions \".ts,.tsx\" --source-maps inline", "lint": "npx x eslint src/**/*.ts", diff --git a/src/index.ts b/src/index.ts index 1d1e768..dcd61d6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,5 @@ -export * from "./structures/Node"; -export * from "./types/Node"; export * from "./structures/Manager"; -export * from "./types/Manager"; +export * from "./structures/Node"; export * from "./structures/Player"; -export * from "./types/Player"; -export * from "./structures/Rest"; -export * from "./types/Rest"; +export * from "./structures/Queue"; +export * from "./structures/Utils"; diff --git a/src/structures/Manager.ts b/src/structures/Manager.ts index b696f8a..abef611 100644 --- a/src/structures/Manager.ts +++ b/src/structures/Manager.ts @@ -1,117 +1,345 @@ -import { TypedEmitter } from "tiny-typed-emitter"; -import type { ManagerEventEmitter, ManagerOptions } from "../types/Manager"; -import { Node } from "./Node"; -import type { PlayerCreate } from "../types/Player"; -import { Player } from "./Player"; +/* eslint-disable no-async-promise-executor */ import { Collection } from "@discordjs/collection"; -import type { VoiceState } from "discord.js"; -import type { VoicePacket, VoiceServer } from "../types/Discord"; +import { VoiceState } from ".."; +import { Node } from "./Node"; +import { Player, PlayerOptions, Track } from "./Player"; +import { + LoadTypeV3, + LoadTypeV4, + Plugin, + Structure, + TrackData, + TrackUtils, + VoicePacket, + VoiceServer, +} from "./Utils"; +import { TypedEmitter } from "tiny-typed-emitter"; +import { ManagerEventEmitter } from "../types/Manager"; +import { NodeOptions } from "../types/Node"; + +const REQUIRED_KEYS = ["event", "guildId", "op", "sessionId"]; + +function check(options: ManagerOptions) { + if (!options) throw new TypeError("ManagerOptions must not be empty."); + + if (typeof options.send !== "function") + throw new TypeError('Manager option "send" must be present and a function.'); + + if ( + typeof options.clientId !== "undefined" && + !/^\d+$/.test(options.clientId) + ) + throw new TypeError('Manager option "clientId" must be a non-empty string.'); + + if ( + typeof options.nodes !== "undefined" && + !Array.isArray(options.nodes) + ) + throw new TypeError('Manager option "nodes" must be a array.'); + + if ( + typeof options.shards !== "undefined" && + typeof options.shards !== "number" + ) + throw new TypeError('Manager option "shards" must be a number.'); + + if ( + typeof options.plugins !== "undefined" && + !Array.isArray(options.plugins) + ) + throw new TypeError('Manager option "plugins" must be a Plugin array.'); + + if ( + typeof options.autoPlay !== "undefined" && + typeof options.autoPlay !== "boolean" + ) + throw new TypeError('Manager option "autoPlay" must be a boolean.'); + + if ( + typeof options.trackPartial !== "undefined" && + !Array.isArray(options.trackPartial) + ) + throw new TypeError('Manager option "trackPartial" must be a string array.'); + + if ( + typeof options.clientName !== "undefined" && + typeof options.clientName !== "string" + ) + throw new TypeError('Manager option "clientName" must be a string.'); + + if ( + typeof options.defaultSearchPlatform !== "undefined" && + typeof options.defaultSearchPlatform !== "string" + ) + throw new TypeError('Manager option "defaultSearchPlatform" must be a string.'); +} export class Manager extends TypedEmitter { - options: ManagerOptions; - public readonly players = new Collection(); - public readonly nodes = new Collection(); - constructor(options: ManagerOptions) { - super(); - this.options = options; - for (const node of options.nodes) { - this.nodes.set(node.host, new Node(node)); - } + public static readonly DEFAULT_SOURCES: Record = { + "youtube music": "ytmsearch", + "youtube": "ytsearch", + "soundcloud": "scsearch" } - private get priorityNode(): Node { - const filteredNodes = this.nodes.filter((node) => node.connected && node.options.priority > 0); - const totalWeight = filteredNodes.reduce((total, node) => total + node.options.priority, 0); - const weightedNodes = filteredNodes.map((node) => ({ - node, - weight: node.options.priority / totalWeight, - })); - const randomNumber = Math.random(); - - let cumulativeWeight = 0; - - for (const { node, weight } of weightedNodes) { - cumulativeWeight += weight; - if (randomNumber <= cumulativeWeight) { - return node; - } - } + /** The map of players. */ + public readonly players = new Collection(); + /** The map of nodes. */ + public readonly nodes = new Collection(); + /** The options that were set. */ + public readonly options: ManagerOptions; + private initiated = false; - return this.options.useNode === "leastLoad" ? this.leastLoadNode.first() : this.leastPlayersNode.first(); + /** Returns the least used Nodes. */ + public get leastUsedNodes(): Collection { + return this.nodes + .filter((node) => node.connected) + .sort((a, b) => b.calls - a.calls); } - private get leastLoadNode(): Collection { + /** Returns the least system load Nodes. */ + public get leastLoadNodes(): Collection { return this.nodes .filter((node) => node.connected) .sort((a, b) => { - const aload = a.stats.cpu ? (a.stats.cpu.lavalinkLoad / a.stats.cpu.cores) * 100 : 0; - const bload = b.stats.cpu ? (b.stats.cpu.lavalinkLoad / b.stats.cpu.cores) * 100 : 0; + const aload = a.stats.cpu + ? (a.stats.cpu.systemLoad / a.stats.cpu.cores) * 100 + : 0; + const bload = b.stats.cpu + ? (b.stats.cpu.systemLoad / b.stats.cpu.cores) * 100 + : 0; return aload - bload; }); } - private get leastPlayersNode(): Collection { - return this.nodes.filter((node) => node.connected).sort((a, b) => a.stats.players - b.stats.players); - } + /** + * Initiates the Manager class. + * @param options + */ + constructor(options: ManagerOptions) { + super(); - public get useableNodes(): Node { - return this.options.usePriority ? this.priorityNode : this.options.useNode === "leastLoad" ? this.leastLoadNode.first() : this.leastPlayersNode.first(); - } + check(options); + + Structure.get("Player").init(this); + Structure.get("Node").init(this); + TrackUtils.init(this); + + if (options.trackPartial) { + TrackUtils.setTrackPartial(options.trackPartial); + delete options.trackPartial; + } + + this.options = { + plugins: [], + nodes: [ + { identifier: "default", host: "localhost" } + ], + shards: 1, + autoPlay: true, + clientName: "Sunday.ts", + defaultSearchPlatform: "youtube", + ...options, + }; - public create(options: PlayerCreate): Player { - if (this.players.has(options.guild_id)) { - return this.players.get(options.guild_id); + if (this.options.plugins) { + for (const [index, plugin] of this.options.plugins.entries()) { + if (!(plugin instanceof Plugin)) + throw new RangeError(`Plugin at index ${index} does not extend Plugin.`); + plugin.load(this); + } } - return new Player(this, options); + if (this.options.nodes) { + for (const nodeOptions of this.options.nodes) + new (Structure.get("Node"))(nodeOptions); + } } - public init() { - this.emit("raw", "Manager initialized"); + /** + * Initiates the Manager. + * @param clientId + */ + public init(clientId?: string): this { + if (this.initiated) return this; + if (typeof clientId !== "undefined") this.options.clientId = clientId; + + if (typeof this.options.clientId !== "string") + throw new Error('"clientId" set is not type of "string"'); + + if (!this.options.clientId) + throw new Error( + '"clientId" is not set. Pass it in Manager#init() or as a option in the constructor.' + ); + for (const node of this.nodes.values()) { try { - node.on("connect", () => { - this.emit("NodeConnect", node); - }).on("disconnect", () => { - this.emit("NodeDisconnect", node); - }).on("error", (err) => { - this.emit("NodeError", node, err); - }).on("stats", (stats) => { - this.emit("NodeStats", node, stats); - }).on("ready", () => { - this.emit("NodeReady", node); - }).on("raw", (data) => { - this.emit("NodeRaw", node, data); - }); node.connect(); } catch (err) { this.emit("NodeError", node, err); } } + + this.initiated = true; + return this; + } + + /** + * Searches the enabled sources based off the URL or the `source` property. + * @param query + * @param requester + * @returns The search result. + */ + public search( + query: string | SearchQuery, + requester?: unknown + ): Promise { + return new Promise(async (resolve, reject) => { + const node = this.leastUsedNodes.first(); + if (!node) throw new Error("No available nodes."); + + const _query: SearchQuery = typeof query === "string" ? { query } : query; + const _source = Manager.DEFAULT_SOURCES[_query.source ?? this.options.defaultSearchPlatform] ?? _query.source; + + let search = _query.query; + if (!/^https?:\/\//.test(search)) { + search = `${_source}:${search}`; + } + + const res = await node.rest.get(`/loadtracks?identifier=${encodeURIComponent(search)}`) + + if (!res) { + return reject(new Error("Query not found.")); + } + + const result: SearchResult = { + loadType: res.loadType, + exception: res.exception ?? null, + tracks: res.tracks?.map((track: TrackData) => + TrackUtils.build(track, requester) + ) ?? [], + }; + + if (result.loadType === "PLAYLIST_LOADED") { + result.playlist = { + name: res.playlistInfo.name, + selectedTrack: res.playlistInfo.selectedTrack === -1 ? null : + TrackUtils.build( + res.tracks[res.playlistInfo.selectedTrack], + requester + ), + duration: result.tracks + .reduce((acc: number, cur: Track) => acc + (cur.duration || 0), 0), + }; + } + + return resolve(result); + }); + } + + /** + * Decodes the base64 encoded tracks and returns a TrackData array. + * @param tracks + */ + public decodeTracks(tracks: string[]): Promise { + return new Promise(async (resolve, reject) => { + const node = this.nodes.first(); + if (!node) throw new Error("No available nodes."); + const res = await node.rest.post(`/decodetracks`, JSON.stringify(tracks)) + if (!res) { + return reject(new Error("No data returned from query.")); + } + return resolve(res); + }); + } + + /** + * Decodes the base64 encoded track and returns a TrackData. + * @param track + */ + public async decodeTrack(track: string): Promise { + const res = await this.decodeTracks([track]); + return res[0]; + } + + /** + * Creates a player or returns one if it already exists. + * @param options + */ + public create(options: PlayerOptions): Player { + if (this.players.has(options.guild)) { + return this.players.get(options.guild); + } + + return new (Structure.get("Player"))(options); + } + + /** + * Returns a player or undefined if it does not exist. + * @param guild + */ + public get(guild: string): Player | undefined { + return this.players.get(guild); } + + /** + * Destroys a player if it exists. + * @param guild + */ + public destroy(guild: string): void { + this.players.delete(guild); + } + + /** + * Creates a node or returns one if it already exists. + * @param options + */ + public createNode(options: NodeOptions): Node { + if (this.nodes.has(options.identifier || options.host)) { + return this.nodes.get(options.identifier || options.host); + } + + return new (Structure.get("Node"))(options); + } + + /** + * Destroys a node if it exists. + * @param identifier + */ + public destroyNode(identifier: string): void { + const node = this.nodes.get(identifier); + if (!node) return; + node.destroy() + this.nodes.delete(identifier) + } + + /** + * Sends voice data to the Lavalink server. + * @param data + */ public async updateVoiceState(data: VoicePacket | VoiceServer | VoiceState): Promise { if ("t" in data && !["VOICE_STATE_UPDATE", "VOICE_SERVER_UPDATE"].includes(data.t)) return; + const update = "d" in data ? data.d : data; + if (!update || (!("token" in update) && !("session_id" in update))) return; + const player = this.players.get(update.guild_id); + if (!player) return; if ("token" in update) { - // @ts-ignore - if (!player.voiceState) player.voiceState = { event: {} }; - player.voiceState.event = update; - - const { - sessionId, - event: { token, endpoint }, - } = player.voiceState; - console.log(player.voiceState) - await player.node.rest.updatePlayer({ - guildId: player.guild_id, - data: { voice: { token, endpoint, sessionId } }, - }); - - return; - } + player.voiceState.event = update; + + const { + event: { token, endpoint }, + } = player.voiceState; + + await player.node.rest.updatePlayer({ + guildId: player.guild, + data: { voice: { token, endpoint, sessionId: player.sessionId } }, + }); + + return; + } if (update.user_id !== this.options.clientId) return; if (update.channel_id) { @@ -130,4 +358,89 @@ export class Manager extends TypedEmitter { player.destroy(); return; } -} \ No newline at end of file +} + +export interface Payload { + /** The OP code */ + op: number; + d: { + guild_id: string; + channel_id: string | null; + self_mute: boolean; + self_deaf: boolean; + }; +} + +export interface ManagerOptions { + /** The array of nodes to connect to. */ + nodes?: NodeOptions[]; + /** The client ID to use. */ + clientId?: string; + /** Value to use for the `Client-Name` header. */ + clientName?: string; + /** The shard count. */ + shards?: number; + /** A array of plugins to use. */ + plugins?: Plugin[]; + /** Whether players should automatically play the next song. */ + autoPlay?: boolean; + /** An array of track properties to keep. `track` will always be present. */ + trackPartial?: string[]; + /** The default search platform to use, can be "youtube", "youtube music", or "soundcloud". */ + defaultSearchPlatform?: SearchPlatform; + /** + * Function to send data to the websocket. + * @param id + * @param payload + */ + send(id: string, payload: Payload): void; +} + +export type SearchPlatform = "youtube" | "youtube music" | "soundcloud"; + +export interface SearchQuery { + /** The source to search from. */ + source?: SearchPlatform | string; + /** The query to search for. */ + query: string; +} + +export interface SearchResult { + /** The load type of the result. */ + loadType: LoadTypeV3 | LoadTypeV4; + /** The array of tracks from the result. */ + tracks: Track[]; + /** The playlist info if the load type is PLAYLIST_LOADED. */ + playlist?: PlaylistInfo; + /** The exception when searching if one. */ + exception?: { + /** The message for the exception. */ + message: string; + /** The severity of exception. */ + severity: string; + }; +} + +export interface PlaylistInfo { + /** The playlist name. */ + name: string; + /** The playlist selected track. */ + selectedTrack?: Track; + /** The duration of the playlist. */ + duration: number; +} + +export interface LavalinkResult { + tracks: TrackData[]; + loadType: LoadTypeV3 | LoadTypeV4; + exception?: { + /** The message for the exception. */ + message: string; + /** The severity of exception. */ + severity: string; + }; + playlistInfo: { + name: string; + selectedTrack?: number; + }; +} diff --git a/src/structures/Node.ts b/src/structures/Node.ts index ba4ac80..b465728 100644 --- a/src/structures/Node.ts +++ b/src/structures/Node.ts @@ -1,119 +1,410 @@ -import { TypedEmitter } from "tiny-typed-emitter"; -import { WebSocket } from "ws"; -import type { NodeConfig, NodeEventEmitter, NodeStats } from "../types/Node"; -import { Rest } from "./Rest"; -import type { Manager } from "./Manager"; - -/* This TypeScript class extends TypedEmitter with a generic type of NodeEventEmitter. */ -export class Node extends TypedEmitter { - /* The line `public socket: WebSocket | null = null;` in the Node class is declaring a public - property named `socket` with a type of `WebSocket` or `null`. This means that the `socket` - property can hold a value of type `WebSocket` (imported from the "ws" module) representing a - WebSocket connection, or it can be `null` if no value is assigned to it. This property is - initialized with a default value of `null` when a new instance of the Node class is created. */ - public socket: WebSocket | null = null; - - /* The line `public options: NodeConfig;` in the Node class is declaring a public property named - `options` with a type of `NodeConfig`. This means that the `options` property can hold values - that conform to the structure defined by the `NodeConfig` type. The `NodeConfig` type likely - represents a configuration object containing various settings or parameters related to a Node - instance, such as `host`, `password`, and `port`. */ - public options: NodeConfig; - /* The line `public stats: NodeStats | null = null;` in the Node class is declaring a public - property named `stats` with a type of `NodeStats` or `null`. This means that the `stats` - property can hold a value of type `NodeStats` (defined elsewhere in the codebase) or it can be - `null` if no value is assigned to it. */ - public stats: NodeStats | null = null; - /* The line `session_id: string | null = null;` in the Node class is declaring a public property - named `session_id` with a type of `string` or `null`. This means that the `session_id` property - can hold a value of type `string`, representing a session identifier, or it can be `null` if no - value is assigned to it. */ - session_id: string | null = null; - /* The line `public rest: Rest | null = null;` in the Node class is declaring a public property - named `rest` of type `Rest` or `null`. */ - public rest: Rest | null = null; - /** - * The function checks if a WebSocket connection is open and returns a boolean value accordingly. - * @returns The `get connected` method returns a boolean value indicating whether the WebSocket - * connection is open or not. It returns `true` if the socket is open and `false` if the socket is - * either closed or not initialized. - */ - public get connected(): boolean { - if (!this.socket) return false; - return this.socket.readyState === WebSocket.OPEN; +/* eslint-disable no-case-declarations */ +import WebSocket from "ws"; +import { Manager } from "./Manager"; +import { Player, Track, UnresolvedTrack } from "./Player"; +import { + PlayerEvent, + PlayerEvents, + Structure, + TrackEndEvent, + TrackExceptionEvent, + TrackStartEvent, + TrackStuckEvent, + WebSocketClosedEvent, +} from "./Utils"; +import { NodeOptions, NodeStats } from "../types/Node"; +import { Rest, ModifyRequest } from "./Rest"; + +function check(options: NodeOptions) { + if (!options) throw new TypeError("NodeOptions must not be empty."); + + if ( + typeof options.host !== "string" || + !/.+/.test(options.host) + ) + throw new TypeError('Node option "host" must be present and be a non-empty string.'); + + if ( + typeof options.port !== "undefined" && + typeof options.port !== "number" + ) + throw new TypeError('Node option "port" must be a number.'); + + if ( + typeof options.password !== "undefined" && + (typeof options.password !== "string" || + !/.+/.test(options.password)) + ) + throw new TypeError('Node option "password" must be a non-empty string.'); + + if ( + typeof options.secure !== "undefined" && + typeof options.secure !== "boolean" + ) + throw new TypeError('Node option "secure" must be a boolean.'); + + if ( + typeof options.identifier !== "undefined" && + typeof options.identifier !== "string" + ) + throw new TypeError('Node option "identifier" must be a non-empty string.'); + + if ( + typeof options.retryAmount !== "undefined" && + typeof options.retryAmount !== "number" + ) + throw new TypeError('Node option "retryAmount" must be a number.'); + + if ( + typeof options.retryDelay !== "undefined" && + typeof options.retryDelay !== "number" + ) + throw new TypeError('Node option "retryDelay" must be a number.'); + + if ( + typeof options.requestTimeout !== "undefined" && + typeof options.requestTimeout !== "number" + ) + throw new TypeError('Node option "requestTimeout" must be a number.'); +} + +export class Node { + /** The socket for the node. */ + public socket: WebSocket | null = null; + /** The HTTP rest client. */ + public rest: Rest; + /** The stats for the node. */ + public stats: NodeStats; + public manager: Manager + + private static _manager: Manager; + private reconnectTimeout: NodeJS.Timeout | null = null; + private reconnectAttempts = 1; + public calls: number = 0; + sessionId: string; + + /** Returns if connected to the Node. */ + public get connected(): boolean { + if (!this.socket) return false; + return this.socket.readyState === WebSocket.OPEN; + } + + /** Returns the address for this node. */ + public get address(): string { + return `${this.options.host}:${this.options.port}`; + } + + /** @hidden */ + public static init(manager: Manager): void { + this._manager = manager; + } + + /** + * Creates an instance of Node. + * @param options + */ + constructor(public options: NodeOptions) { + if (!this.manager) this.manager = Structure.get("Node")._manager; + if (!this.manager) throw new RangeError("Manager has not been initiated."); + + if (this.manager.nodes.has(options.identifier || options.host)) { + return this.manager.nodes.get(options.identifier || options.host); } - /* The line `private static _manager: Manager;` in the Node class is declaring a private static - property named `_manager` with a type of `Manager`. */ - /** - * The constructor initializes a Rest object with the provided NodeConfig options. - * @param {NodeConfig} options - The `options` parameter is an object that contains configuration - * settings for a Node instance. It typically includes properties such as `host`, `password`, and - * `port` that are used to establish a connection to a server or service. - */ - constructor(options: NodeConfig) { - super(); - this.options = options; - this.rest = new Rest(this); + + check(options); + + this.options = { + port: 2333, + password: "youshallnotpass", + secure: false, + retryAmount: 5, + retryDelay: 30e3, + ...options, + }; + + if (this.options.secure) { + this.options.port = 443; } - /** - * The `connect` function establishes a WebSocket connection with specified headers and event - * handlers for connection, closure, error, and message reception. - * @returns If the `connect()` method is called when `this.connected` is already `true`, the method - * will return early without establishing a new connection. - */ - public connect() { - if (this.connected) return; - const headers = Object.assign({ - "Authorization": this.options.password, - "Client-Name": this.options.clientName || `Sunday.ts/${require("../../package.json").version}`, - "User-Id": "213", - }) - this.socket = new WebSocket(`ws${this.options.secure ? "s" : ""}://${this.options.host}:${this.options.port}/v4/websocket`, { headers }); - this.socket.on("open", () => { - this.emit("connect"); - }); - this.socket.on("close", (data) => { - this.emit("disconnect", data); - }); - this.socket.on("error", (error) => { - this.emit("error", error); - }); - this.socket.on("message", this.onMessage.bind(this)); + + this.rest = new Rest(this.options); + + this.options.identifier = options.identifier || options.host; + this.stats = { + players: 0, + playingPlayers: 0, + uptime: 0, + memory: { + free: 0, + used: 0, + allocated: 0, + reservable: 0, + }, + cpu: { + cores: 0, + systemLoad: 0, + lavalinkLoad: 0, + }, + frameStats: { + sent: 0, + nulled: 0, + deficit: 0, + }, + }; + + this.manager.nodes.set(this.options.identifier, this); + this.manager.emit("NodeCreate", this); + } + + /** Connects to the Node. */ + public connect(): void { + if (this.connected) return; + + const headers = { + Authorization: this.options.password, + "Num-Shards": String(this.manager.options.shards), + "User-Id": this.manager.options.clientId, + "Client-Name": this.manager.options.clientName, + }; + console.log(headers); + if (this.options.version === "v4") { + this.socket = new WebSocket(`ws${this.options.secure ? "s" : ""}://${this.address}/v4/websocket`, { headers }); + } else { + this.socket = new WebSocket(`ws${this.options.secure ? "s" : ""}://${this.address}/websocket`, { headers }); } - /** - * The function `onMessage` processes incoming data, parses it as JSON, and emits events based on - * the payload's operation type. - * @param {Buffer | string} data - The `data` parameter in the `onMessage` function can be either a - * Buffer or a string. If it is an array, the function concatenates the buffers in the array. If it - * is an ArrayBuffer, it converts it to a Buffer. Then it parses the data as JSON and emits a - */ - private onMessage(data: Buffer | string) { - if (Array.isArray(data)) data = Buffer.concat(data); - else if (data instanceof ArrayBuffer) data = Buffer.from(data); - const payload = JSON.parse(data.toString()); - this.emit("raw", payload); - switch (payload?.op) { - case "ready": { - this.rest.setSessionId(payload.sessionId); - this.session_id = payload.sessionId; + this.socket.on("open", this.open.bind(this)); + this.socket.on("close", this.close.bind(this)); + this.socket.on("message", this.message.bind(this)); + this.socket.on("error", this.error.bind(this)); + } + + /** Destroys the Node and all players connected with it. */ + public destroy(): void { + if (!this.connected) return; + + const players = this.manager.players.filter(p => p.node == this); + if (players.size) players.forEach(p => p.destroy()); + + this.socket.close(1000, "destroy"); + this.socket.removeAllListeners(); + this.socket = null; + + this.reconnectAttempts = 1; + clearTimeout(this.reconnectTimeout); + + this.manager.emit("NodeDestroy", this); + this.manager.destroyNode(this.options.identifier); + } + + /** + * Sends data to the Node. + * @param data + */ + public send(data: unknown): Promise { + return new Promise((resolve, reject) => { + if (!this.connected) return resolve(false); + if (!data || !JSON.stringify(data).startsWith("{")) { + return reject(false); + } + this.socket.send(JSON.stringify(data), (error: Error) => { + if (error) reject(error); + else resolve(true); + }); + }); + } + + private reconnect(): void { + this.reconnectTimeout = setTimeout(() => { + if (this.reconnectAttempts >= this.options.retryAmount) { + const error = new Error( + `Unable to connect after ${this.options.retryAmount} attempts.` + ); + + this.manager.emit("NodeError", this, error); + return this.destroy(); + } + this.socket.removeAllListeners(); + this.socket = null; + this.manager.emit("NodeReconnect", this); + this.connect(); + this.reconnectAttempts++; + }, this.options.retryDelay) as unknown as NodeJS.Timeout; + } + + protected open(): void { + if (this.reconnectTimeout) clearTimeout(this.reconnectTimeout); + this.manager.emit("NodeConnect", this); + } + + protected close(code: number, reason: string): void { + this.manager.emit("NodeDisconnect", this, { code, reason }); + if (code !== 1000 || reason !== "destroy") this.reconnect(); + } + + protected error(error: Error): void { + if (!error) return; + this.manager.emit("NodeError", this, error); + } + + protected message(d: Buffer | string): void { + if (Array.isArray(d)) d = Buffer.concat(d); + else if (d instanceof ArrayBuffer) d = Buffer.from(d); + + const payload = JSON.parse(d.toString()); + + if (!payload.op) return; + this.manager.emit("NodeRaw", payload); + + switch (payload.op) { + case "stats": + delete payload.op; + this.stats = ({ ...payload } as unknown) as NodeStats; + break; + case "ready": { + this.rest.setSessionId(payload.sessionId); + this.sessionId = payload.sessionId; if (this.options.resumeStatus) { - this.rest.patch(`/v4/sessions/${this.session_id}`, { + this.rest.patch(`/v4/sessions/${this.sessionId}`, { resuming: this.options.resumeStatus, timeout: this.options.resumeTimeout, - }); + }).then((data) => { console.log(data) }); } - this.emit("ready") - break; - } - case "stats": { - this.stats = payload; - this.emit("stats", payload); - break; - } - } + this.manager.emit("NodeReady", this); + break; + } + case "playerUpdate": + const player = this.manager.players.get(payload.guildId); + if (player) player.position = payload.state.position || 0; + break; + case "event": + this.handleEvent(payload); + break; + default: + this.manager.emit( + "NodeError", + this, + new Error(`Unexpected op "${payload.op}" with data: ${payload}`) + ); + return; } - private debug(message: string) { - return this.emit("raw", message); + } + + protected handleEvent(payload: PlayerEvent & PlayerEvents): void { + if (!payload.guildId) return; + + const player = this.manager.players.get(payload.guildId); + if (!player) return; + + const track = player.queue.current; + const type = payload.type; + + if (payload.type === "TrackStartEvent") { + this.trackStart(player, track as Track, payload); + } else if (payload.type === "TrackEndEvent") { + this.trackEnd(player, track as Track, payload); + } else if (payload.type === "TrackStuckEvent") { + this.trackStuck(player, track as Track, payload); + } else if (payload.type === "TrackExceptionEvent") { + this.trackError(player, track, payload); + } else if (payload.type === "WebSocketClosedEvent") { + this.socketClosed(player, payload); + } else { + const error = new Error(`Node#event unknown event '${type}'.`); + this.manager.emit("NodeError", this, error); + } + } + + protected trackStart(player: Player, track: Track, payload: TrackStartEvent): void { + player.playing = true; + player.paused = false; + this.manager.emit("TrackStart", player, track, payload); + } + + protected trackEnd(player: Player, track: Track, payload: TrackEndEvent): void { + // If a track had an error while starting + if (["LOAD_FAILED", "CLEAN_UP"].includes(payload.reason)) { + player.queue.previous = player.queue.current; + player.queue.current = player.queue.shift(); + + if (!player.queue.current) return this.queueEnd(player, track, payload); + + this.manager.emit("TrackEnd", player, track, payload); + if (this.manager.options.autoPlay) player.play(); + return; + } + + // If a track was forcibly played + if (payload.reason === "REPLACED") { + this.manager.emit("TrackEnd", player, track, payload); + return; } + + // If a track ended and is track repeating + if (track && player.trackRepeat) { + if (payload.reason === "STOPPED") { + player.queue.previous = player.queue.current; + player.queue.current = player.queue.shift(); + } + + if (!player.queue.current) return this.queueEnd(player, track, payload); + + this.manager.emit("TrackEnd", player, track, payload); + if (this.manager.options.autoPlay) player.play(); + return; + } + + // If a track ended and is track repeating + if (track && player.queueRepeat) { + player.queue.previous = player.queue.current; + + if (payload.reason === "STOPPED") { + player.queue.current = player.queue.shift(); + if (!player.queue.current) return this.queueEnd(player, track, payload); + } else { + player.queue.add(player.queue.current); + player.queue.current = player.queue.shift(); + } + + this.manager.emit("TrackEnd", player, track, payload); + if (this.manager.options.autoPlay) player.play(); + return; + } + + // If there is another song in the queue + if (player.queue.length) { + player.queue.previous = player.queue.current; + player.queue.current = player.queue.shift(); + + this.manager.emit("TrackEnd", player, track, payload); + if (this.manager.options.autoPlay) player.play(); + return; + } + + // If there are no songs in the queue + if (!player.queue.length) return this.queueEnd(player, track, payload); + } + + protected queueEnd(player: Player, track: Track, payload: TrackEndEvent): void { + player.queue.current = null; + player.playing = false; + this.manager.emit("QueueEnd", player, track, payload); + } + + protected trackStuck(player: Player, track: Track, payload: TrackStuckEvent): void { + player.stop(); + this.manager.emit("TrackStuck", player, track, payload); + } + + protected trackError( + player: Player, + track: Track | UnresolvedTrack, + payload: TrackExceptionEvent + ): void { + player.stop(); + this.manager.emit("TrackError", player, track, payload); + } + + protected socketClosed(player: Player, payload: WebSocketClosedEvent): void { + this.manager.emit("SocketClosed", player, payload); + } } \ No newline at end of file diff --git a/src/structures/Player.ts b/src/structures/Player.ts index 0c8edfe..5de877d 100644 --- a/src/structures/Player.ts +++ b/src/structures/Player.ts @@ -1,48 +1,189 @@ -import type { VoiceState } from "../types/Discord"; -import type { PlayerCreate } from "../types/Player"; -import type { Manager } from "./Manager"; -import type { Node } from "./Node"; +import { Manager, SearchQuery, SearchResult } from "./Manager"; +import { Node } from "./Node"; import { Queue } from "./Queue"; +import { Sizes, State, Structure, TrackUtils, VoiceState } from "./Utils"; + +function check(options: PlayerOptions) { + if (!options) throw new TypeError("PlayerOptions must not be empty."); + + if (!/^\d+$/.test(options.guild)) + throw new TypeError( + 'Player option "guild" must be present and be a non-empty string.' + ); + + if (options.textChannel && !/^\d+$/.test(options.textChannel)) + throw new TypeError( + 'Player option "textChannel" must be a non-empty string.' + ); + + if (options.voiceChannel && !/^\d+$/.test(options.voiceChannel)) + throw new TypeError( + 'Player option "voiceChannel" must be a non-empty string.' + ); + + if (options.node && typeof options.node !== "string") + throw new TypeError('Player option "node" must be a non-empty string.'); + + if ( + typeof options.volume !== "undefined" && + typeof options.volume !== "number" + ) + throw new TypeError('Player option "volume" must be a number.'); + + if ( + typeof options.selfMute !== "undefined" && + typeof options.selfMute !== "boolean" + ) + throw new TypeError('Player option "selfMute" must be a boolean.'); + + if ( + typeof options.selfDeafen !== "undefined" && + typeof options.selfDeafen !== "boolean" + ) + throw new TypeError('Player option "selfDeafen" must be a boolean.'); +} export class Player { - manager: Manager; - options: PlayerCreate; - node: Node; - voiceChannel: string; - state: "CONNECTING" | "CONNECTED" | "DISCONNECTED" | "DISCONNECTING" | "DESTROYING" = "DISCONNECTED"; - guild_id: string; - public voiceState: VoiceState; - paused: boolean = false; - playing: boolean = false; - textChannel: string - public readonly queue = new Queue(); - constructor(manager: Manager, options: PlayerCreate) { - this.manager = manager; - if (!this.manager) throw new RangeError("Manager has not been initiated."); - this.options = options; - this.voiceChannel = options.voiceChannel; - this.voiceState = Object.assign({ - op: "voiceUpdate", - guild_id: options.guild_id, - }); - if (options.voiceChannel) this.voiceChannel = options.voiceChannel; + /** The Queue for the Player. */ + public readonly queue = new (Structure.get("Queue"))() as Queue; + /** Whether the queue repeats the track. */ + public trackRepeat = false; + /** Whether the queue repeats the queue. */ + public queueRepeat = false; + /** The time the player is in the track. */ + public position = 0; + /** Whether the player is playing. */ + public playing = false; + /** Whether the player is paused. */ + public paused = false; + /** The volume for the player */ + public volume: number; + /** The Node for the Player. */ + public node: Node; + /** The guild for the player. */ + public guild: string; + /** The voice channel for the player. */ + public voiceChannel: string | null = null; + /** The text channel for the player. */ + public textChannel: string | null = null; + /** The current state of the player. */ + public state: State = "DISCONNECTED"; + /** The equalizer bands array. */ + public bands = new Array(15).fill(0.0); + /** The voice state object from Discord. */ + public voiceState: VoiceState; + public sessionId: string; + /** The Manager. */ + public manager: Manager; + private static _manager: Manager; + private readonly data: Record = {}; + + /** + * Set custom data. + * @param key + * @param value + */ + public set(key: string, value: unknown): void { + this.data[key] = value; + } + + /** + * Get custom data. + * @param key + */ + public get(key: string): T { + return this.data[key] as T; + } + + /** @hidden */ + public static init(manager: Manager): void { + this._manager = manager; + } + + /** + * Creates a new player, returns one if it already exists. + * @param options + */ + constructor(public options: PlayerOptions) { + if (!this.manager) this.manager = Structure.get("Player")._manager; + if (!this.manager) throw new RangeError("Manager has not been initiated."); + if (this.manager.players.has(options.guild)) { + return this.manager.players.get(options.guild); + } + check(options); + this.guild = options.guild; + this.voiceState = Object.assign({ op: "voiceUpdate", guildId: options.guild }); + if (options.voiceChannel) this.voiceChannel = options.voiceChannel; if (options.textChannel) this.textChannel = options.textChannel; - const node = this.manager.nodes.get(options.node); - this.node = node || this.manager.useableNodes; + const node = this.manager.nodes.get(options.node); + this.node = node || this.manager.leastLoadNodes.first(); if (!this.node) throw new RangeError("No available nodes."); - this.guild_id = options.guild_id; - this.manager.players.set(options.guild_id, this); - this.manager.emit("PlayerCreate", this); - } + this.sessionId = this.node.sessionId; + this.manager.players.set(options.guild, this); + this.manager.emit("PlayerCreate", this); + this.setVolume(options.volume ?? 100); + } + + /** + * Same as Manager#search() but a shortcut on the player itself. + * @param query + * @param requester + */ + public search( + query: string | SearchQuery, + requester?: unknown + ): Promise { + return this.manager.search(query, requester); + } - public connect(): this { - if (!this.voiceChannel) throw new RangeError("No voice channel has been set."); + /** + * Sets the players equalizer band on-top of the existing ones. + * @param bands + */ + public setEQ(...bands: EqualizerBand[]): this { + // Hacky support for providing an array + if (Array.isArray(bands[0])) bands = bands[0] as unknown as EqualizerBand[] + + if (!bands.length || !bands.every( + (band) => JSON.stringify(Object.keys(band).sort()) === '["band","gain"]' + ) + ) + throw new TypeError("Bands must be a non-empty object array containing 'band' and 'gain' properties."); + + for (const { band, gain } of bands) this.bands[band] = gain; + + this.node.send({ + op: "equalizer", + guildId: this.guild, + bands: this.bands.map((gain, band) => ({ band, gain })), + }); + + return this; + } + + /** Clears the equalizer bands. */ + public clearEQ(): this { + this.bands = new Array(15).fill(0.0); + + this.node.send({ + op: "equalizer", + guildId: this.guild, + bands: this.bands.map((gain, band) => ({ band, gain })), + }); + + return this; + } + + /** Connect to the voice channel. */ + public connect(): this { + if (!this.voiceChannel) + throw new RangeError("No voice channel has been set."); this.state = "CONNECTING"; - this.manager.options.send(this.guild_id, { + this.manager.options.send(this.guild, { op: 4, d: { - guild_id: this.guild_id, + guild_id: this.guild, channel_id: this.voiceChannel, self_mute: this.options.selfMute || false, self_deaf: this.options.selfDeafen || false, @@ -52,15 +193,17 @@ export class Player { this.state = "CONNECTED"; return this; } - public disconnect(): this { + + /** Disconnect from the voice channel. */ + public disconnect(): this { if (this.voiceChannel === null) return this; this.state = "DISCONNECTING"; this.pause(true); - this.manager.options.send(this.guild_id, { + this.manager.options.send(this.guild, { op: 4, d: { - guild_id: this.guild_id, + guild_id: this.guild, channel_id: null, self_mute: false, self_deaf: false, @@ -75,33 +218,296 @@ export class Player { /** Destroys the player. */ public destroy(disconnect = true): void { this.state = "DESTROYING"; - if (disconnect) { this.disconnect(); } - //this.node.rest.destroyPlayer(this.guild); + this.node.send({ + op: "destroy", + guildId: this.guild, + }); + this.node.rest.destroyPlayer(this.guild); + this.manager.emit("PlayerDestroy", this); - this.manager.players.delete(this.guild_id); + this.manager.players.delete(this.guild); } - public pause(pause: boolean): this { - if (typeof pause !== "boolean") throw new RangeError('Pause can only be "true" or "false".'); - if (this.paused === pause || !this.queue.totalSize) return this; + /** + * Sets the player voice channel. + * @param channel + */ + public setVoiceChannel(channel: string): this { + if (typeof channel !== "string") + throw new TypeError("Channel must be a non-empty string."); + + this.voiceChannel = channel; + this.connect(); + return this; + } + + /** + * Sets the player text channel. + * @param channel + */ + public setTextChannel(channel: string): this { + if (typeof channel !== "string") + throw new TypeError("Channel must be a non-empty string."); + + this.textChannel = channel; + return this; + } + + /** Plays the next track. */ + public async play(): Promise; + + /** + * Plays the specified track. + * @param track + */ + public async play(track: Track | UnresolvedTrack): Promise; + + /** + * Plays the next track with some options. + * @param options + */ + public async play(options: PlayOptions): Promise; + + /** + * Plays the specified track with some options. + * @param track + * @param options + */ + public async play( + optionsOrTrack?: PlayOptions | Track | UnresolvedTrack, + playOptions?: PlayOptions + ): Promise { + if ( + typeof optionsOrTrack !== "undefined" && + TrackUtils.validate(optionsOrTrack) + ) { + if (this.queue.current) this.queue.previous = this.queue.current; + this.queue.current = optionsOrTrack as Track; + } - const oldPlayer = { ...this }; + if (!this.queue.current) throw new RangeError("No current track."); + + const finalOptions = playOptions + ? playOptions + : ["startTime", "endTime", "noReplace"].every((v) => + Object.keys(optionsOrTrack || {}).includes(v) + ) + ? (optionsOrTrack as PlayOptions) + : {}; + + if (TrackUtils.isUnresolvedTrack(this.queue.current)) { + try { + this.queue.current = await TrackUtils.getClosestTrack(this.queue.current as UnresolvedTrack); + } catch (error) { + this.manager.emit("TrackError", this, this.queue.current, error); + if (this.queue[0]) return this.play(this.queue[0]); + return; + } + } + + const options = { + op: "play", + guildId: this.guild, + track: this.queue.current.track, + ...finalOptions, + }; + + if (typeof options.track !== "string") { + options.track = (options.track as Track).track; + } + + await this.node.send(options); + } + + /** + * Sets the player volume. + * @param volume + */ + public setVolume(volume: number): this { + volume = Number(volume); + + if (isNaN(volume)) throw new TypeError("Volume must be a number."); + this.volume = Math.max(Math.min(volume, 1000), 0); + + this.node.send({ + op: "volume", + guildId: this.guild, + volume: this.volume, + }); + + return this; + } + + /** + * Sets the track repeat. + * @param repeat + */ + public setTrackRepeat(repeat: boolean): this { + if (typeof repeat !== "boolean") + throw new TypeError('Repeat can only be "true" or "false".'); + + if (repeat) { + this.trackRepeat = true; + this.queueRepeat = false; + } else { + this.trackRepeat = false; + this.queueRepeat = false; + } + + return this; + } + + /** + * Sets the queue repeat. + * @param repeat + */ + public setQueueRepeat(repeat: boolean): this { + if (typeof repeat !== "boolean") + throw new TypeError('Repeat can only be "true" or "false".'); + + if (repeat) { + this.trackRepeat = false; + this.queueRepeat = true; + } else { + this.trackRepeat = false; + this.queueRepeat = false; + } + + return this; + } + + /** Stops the current track, optionally give an amount to skip to, e.g 5 would play the 5th song. */ + public stop(amount?: number): this { + if (typeof amount === "number" && amount > 1) { + if (amount > this.queue.length) throw new RangeError("Cannot skip more than the queue length."); + this.queue.splice(0, amount - 1); + } + + this.node.send({ + op: "stop", + guildId: this.guild, + }); + + return this; + } + + /** + * Pauses the current track. + * @param pause + */ + public pause(pause: boolean): this { + if (typeof pause !== "boolean") + throw new RangeError('Pause can only be "true" or "false".'); + if (this.paused === pause || !this.queue.totalSize) return this; this.playing = !pause; this.paused = pause; - this.node.rest.updatePlayer({ - guildId: this.guild_id, - data: { - paused: pause, - }, + this.node.send({ + op: "pause", + guildId: this.guild, + pause, }); - this.manager.emit("PlayerStateUpdate", oldPlayer, this); return this; } -} \ No newline at end of file + + /** + * Seeks to the position in the current track. + * @param position + */ + public seek(position: number): this { + if (!this.queue.current) return undefined; + position = Number(position); + + if (isNaN(position)) { + throw new RangeError("Position must be a number."); + } + if (position < 0 || position > this.queue.current.duration) + position = Math.max(Math.min(position, this.queue.current.duration), 0); + + this.position = position; + this.node.send({ + op: "seek", + guildId: this.guild, + position, + }); + + return this; + } +} + +export interface PlayerOptions { + /** The guild the Player belongs to. */ + guild: string; + /** The text channel the Player belongs to. */ + textChannel: string; + /** The voice channel the Player belongs to. */ + voiceChannel?: string; + /** The node the Player uses. */ + node?: string; + /** The initial volume the Player will use. */ + volume?: number; + /** If the player should mute itself. */ + selfMute?: boolean; + /** If the player should deaf itself. */ + selfDeafen?: boolean; +} + +/** If track partials are set some of these will be `undefined` as they were removed. */ +export interface Track { + /** The base64 encoded track. */ + readonly track: string; + /** The title of the track. */ + readonly title: string; + /** The identifier of the track. */ + readonly identifier: string; + /** The author of the track. */ + readonly author: string; + /** The duration of the track. */ + readonly duration: number; + /** If the track is seekable. */ + readonly isSeekable: boolean; + /** If the track is a stream.. */ + readonly isStream: boolean; + /** The uri of the track. */ + readonly uri: string; + /** The thumbnail of the track or null if it's a unsupported source. */ + readonly thumbnail: string | null; + /** The user that requested the track. */ + readonly requester: unknown | null; + /** Displays the track thumbnail with optional size or null if it's a unsupported source. */ + displayThumbnail(size?: Sizes): string; +} + +/** Unresolved tracks can't be played normally, they will resolve before playing into a Track. */ +export interface UnresolvedTrack extends Partial { + /** The title to search against. */ + title: string; + /** The author to search against. */ + author?: string; + /** The duration to search within 1500 milliseconds of the results from YouTube. */ + duration?: number; + /** Resolves into a Track. */ + resolve(): Promise; +} + +export interface PlayOptions { + /** The position to start the track. */ + readonly startTime?: number; + /** The position to end the track. */ + readonly endTime?: number; + /** Whether to not replace the track if a play payload is sent. */ + readonly noReplace?: boolean; +} + +export interface EqualizerBand { + /** The band number being 0 to 14. */ + band: number; + /** The gain amount being -0.25 to 1.00, 0.25 being double. */ + gain: number; +} diff --git a/src/structures/Queue.ts b/src/structures/Queue.ts index 9f57ea9..7d272eb 100644 --- a/src/structures/Queue.ts +++ b/src/structures/Queue.ts @@ -1,6 +1,118 @@ -export class Queue { - totalSize: number = 0; - constructor() { - +import { Track, UnresolvedTrack } from "./Player"; +import { TrackUtils } from "./Utils"; + +/** + * The player's queue, the `current` property is the currently playing track, think of the rest as the up-coming tracks. + * @noInheritDoc + */ +export class Queue extends Array { + /** The total duration of the queue. */ + public get duration(): number { + const current = this.current?.duration ?? 0; + return this + .reduce( + (acc: number, cur: Track) => acc + (cur.duration || 0), + current + ); + } + + /** The total size of tracks in the queue including the current track. */ + public get totalSize(): number { + return this.length + (this.current ? 1 : 0); + } + + /** The size of tracks in the queue. */ + public get size(): number { + return this.length + } + + /** The current track */ + public current: Track | UnresolvedTrack | null = null; + + /** The previous track */ + public previous: Track | UnresolvedTrack | null = null; + + /** + * Adds a track to the queue. + * @param track + * @param [offset=null] + */ + public add( + track: (Track | UnresolvedTrack) | (Track | UnresolvedTrack)[], + offset?: number + ): void { + if (!TrackUtils.validate(track)) { + throw new RangeError('Track must be a "Track" or "Track[]".'); } -} \ No newline at end of file + + if (!this.current) { + if (!Array.isArray(track)) { + this.current = track; + return; + } else { + this.current = (track = [...track]).shift(); + } + } + + if (typeof offset !== "undefined" && typeof offset === "number") { + if (isNaN(offset)) { + throw new RangeError("Offset must be a number."); + } + + if (offset < 0 || offset > this.length) { + throw new RangeError(`Offset must be or between 0 and ${this.length}.`); + } + } + + if (typeof offset === "undefined" && typeof offset !== "number") { + if (track instanceof Array) this.push(...track); + else this.push(track); + } else { + if (track instanceof Array) this.splice(offset, 0, ...track); + else this.splice(offset, 0, track); + } + } + + /** + * Removes a track from the queue. Defaults to the first track, returning the removed track, EXCLUDING THE `current` TRACK. + * @param [position=0] + */ + public remove(position?: number): Track[]; + + /** + * Removes an amount of tracks using a exclusive start and end exclusive index, returning the removed tracks, EXCLUDING THE `current` TRACK. + * @param start + * @param end + */ + public remove(start: number, end: number): (Track | UnresolvedTrack)[]; + public remove(startOrPosition = 0, end?: number): (Track | UnresolvedTrack)[] { + if (typeof end !== "undefined") { + if (isNaN(Number(startOrPosition))) { + throw new RangeError(`Missing "start" parameter.`); + } else if (isNaN(Number(end))) { + throw new RangeError(`Missing "end" parameter.`); + } else if (startOrPosition >= end) { + throw new RangeError("Start can not be bigger than end."); + } else if (startOrPosition >= this.length) { + throw new RangeError(`Start can not be bigger than ${this.length}.`); + } + + return this.splice(startOrPosition, end - startOrPosition); + } + + return this.splice(startOrPosition, 1); + } + + /** Clears the queue. */ + public clear(): void { + this.splice(0); + } + + /** Shuffles the queue. */ + public shuffle(): void { + for (let i = this.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [this[i], this[j]] = [this[j], this[i]]; + } + } +} diff --git a/src/structures/Rest.ts b/src/structures/Rest.ts index 7ae82d8..19764b9 100644 --- a/src/structures/Rest.ts +++ b/src/structures/Rest.ts @@ -1,94 +1,45 @@ -import { TypedEmitter } from "tiny-typed-emitter"; -import type { RestConfig, RestEventEmitter } from "../types/Rest"; -import axios, { type AxiosInstance } from "axios"; -import type { Node } from "./Node"; -import type { PlayOptions } from "../types/Player"; +import { Dispatcher, Pool } from "undici"; +import { NodeOptions } from "../types/Node"; +import axios, { AxiosInstance } from "axios"; +import { PlayOptionsData } from "../types/Rest"; -export class Rest extends TypedEmitter { - /* The line `public req: AxiosInstance` in the TypeScript class `Rest` is declaring a public - property named `req` of type `AxiosInstance`. This property is used to store an instance of - Axios, which is a popular JavaScript library used for making HTTP requests. By storing an - instance of Axios in the `req` property, the `Rest` class can utilize Axios methods to make HTTP - requests such as GET and POST to interact with REST APIs based on the provided configuration - options. */ - public req: AxiosInstance - /* The line `public config: RestConfig;` in the TypeScript class `Rest` is declaring a public - property named `config` of type `RestConfig`. This property is used to store the configuration - options provided when initializing an instance of the `Rest` class. */ - public config: RestConfig; - /** - * The constructor function initializes a REST client with the provided configuration options. - * @param {RestConfig} options - The `options` parameter in the constructor function seems to be an - * object of type `RestConfig`. It likely contains configuration options for making REST API - * requests. Some of the properties in the `options` object could include: - */ - /** The Node that this Rest instance is connected to. */ - private node: Node; - /** The ID of the current session. */ - private sessionId: string; - /** The password for the Node. */ - private readonly password: string; - /** The URL of the Node. */ - - constructor(node: Node) { - super(); - this.node = node; - this.req = axios.create({ - baseURL: `http${node.options.secure ? "s" : ""}://${node.options.host}:${node.options.port}/v4`, +export class Rest { + public rest: AxiosInstance; + public options: NodeOptions; + sessionId: string; + constructor(options: NodeOptions) { + this.rest = axios.create({ + baseURL: `http${options.secure ? "s" : ""}://${options.host}:${options.port}${options.version === "v4" ? "/v4" : ""}`, headers: { - "Authorization": node.options.password + Authorization: options.password } - }); - this.password = node.options.password; - } - /** - * The function `get` sends a GET request to a specified path and emits events based on the response - * or error. - * @param {string} path - The `path` parameter in the `get` method is a string that represents the - * URL path to make the GET request to. - * @param [options] - The `options` parameter in the `get` method is an optional object that allows - * you to pass additional configuration or data to the HTTP request being made. It is a key-value - * pair object where the keys are strings and the values can be of any type. These options can - * include headers, query parameters - */ - public async get(path: string, options?: { [key: string]: any }) { - await this.req.get(path, options).then((res) => { - this.emit("get", res.data); - return res.data; - }).catch((err) => { - this.emit("error", err); }); } - /** - * This TypeScript function sends a POST request using the provided path and options, emitting - * events for successful responses and errors. - * @param {string} path - The `path` parameter is a string that represents the URL path where the - * POST request will be sent. - * @param [options] - The `options` parameter in the `post` method is an optional object that can - * contain key-value pairs for additional configuration or data to be sent in the POST request. It - * allows for flexibility in customizing the request based on specific requirements. - */ - public async post(path: string, options?: { [key: string]: any }) { - await this.req.post(path, options).then((res) => { - this.emit("post", res.data); - return res.data; - }).catch((err) => { - this.emit("error", err); - }); + + public async get(path: string) { + return (await this.rest.get(path)).data; } - public async updatePlayer(options: PlayOptions): Promise { - return await this.patch(`/sessions/${this.sessionId}/players/${options.guildId}?noReplace=false`, options.data); - } - public async patch(path: string, data: any) { - this.req.patch(path, data).then((res) => { - this.emit("patch", res.data); - return res.data; - }).catch((err) => { - this.emit("error", err); - }); + public async post(path: string, data: unknown) { + return (await this.rest.post(path, data)).data; } - public setSessionId(sessionId: string): string { - this.sessionId = sessionId; - return this.sessionId; + public async patch(path: string, data: unknown) { + return (await this.rest.patch(path, data)).data; + } + public async delete(path: string, data?: unknown) { + return (await this.rest.delete(path, data)).data; + } + public async put(path: string, data: unknown) { + return (await this.rest.put(path, data)).data; + } + public setSessionId(sessionId: string) { + this.sessionId = sessionId; + } + public async destroyPlayer(guildId: string): Promise { + return await this.delete(`/sessions/${this.sessionId}/players/${guildId}`); } -} \ No newline at end of file + public async updatePlayer(options: PlayOptionsData): Promise { + return await this.patch(`/sessions/${this.sessionId}/players/${options.guildId}?noReplace=false`, options.data); + } +} + +export type ModifyRequest = (options: Dispatcher.RequestOptions) => void; diff --git a/src/structures/Utils.ts b/src/structures/Utils.ts new file mode 100644 index 0000000..01d7cc8 --- /dev/null +++ b/src/structures/Utils.ts @@ -0,0 +1,403 @@ +/* eslint-disable @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars, @typescript-eslint/no-var-requires*/ +import { NodeStats } from "../types/Node"; +import { Manager } from "./Manager"; +import { Node } from "./Node"; +import { Player, Track, UnresolvedTrack } from "./Player"; +import { Queue } from "./Queue"; + +/** @hidden */ +const TRACK_SYMBOL = Symbol("track"), + /** @hidden */ + UNRESOLVED_TRACK_SYMBOL = Symbol("unresolved"), + SIZES = [ + "0", + "1", + "2", + "3", + "default", + "mqdefault", + "hqdefault", + "maxresdefault", + ]; + +/** @hidden */ +const escapeRegExp = (str: string): string => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +export abstract class TrackUtils { + static trackPartial: string[] | null = null; + private static manager: Manager; + + /** @hidden */ + public static init(manager: Manager): void { + this.manager = manager; + } + + static setTrackPartial(partial: string[]): void { + if (!Array.isArray(partial) || !partial.every(str => typeof str === "string")) + throw new Error("Provided partial is not an array or not a string array."); + if (!partial.includes("track")) partial.unshift("track"); + + this.trackPartial = partial; + } + + /** + * Checks if the provided argument is a valid Track or UnresolvedTrack, if provided an array then every element will be checked. + * @param trackOrTracks + */ + static validate(trackOrTracks: unknown): boolean { + if (typeof trackOrTracks === "undefined") + throw new RangeError("Provided argument must be present."); + + if (Array.isArray(trackOrTracks) && trackOrTracks.length) { + for (const track of trackOrTracks) { + if (!(track[TRACK_SYMBOL] || track[UNRESOLVED_TRACK_SYMBOL])) return false + } + return true; + } + + return ( + trackOrTracks[TRACK_SYMBOL] || + trackOrTracks[UNRESOLVED_TRACK_SYMBOL] + ) === true; + } + + /** + * Checks if the provided argument is a valid UnresolvedTrack. + * @param track + */ + static isUnresolvedTrack(track: unknown): boolean { + if (typeof track === "undefined") + throw new RangeError("Provided argument must be present."); + return track[UNRESOLVED_TRACK_SYMBOL] === true; + } + + /** + * Checks if the provided argument is a valid Track. + * @param track + */ + static isTrack(track: unknown): boolean { + if (typeof track === "undefined") + throw new RangeError("Provided argument must be present."); + return track[TRACK_SYMBOL] === true; + } + + /** + * Builds a Track from the raw data from Lavalink and a optional requester. + * @param data + * @param requester + */ + static build(data: TrackData, requester?: unknown): Track { + if (typeof data === "undefined") + throw new RangeError('Argument "data" must be present.'); + + try { + const track: Track = { + track: data.track, + title: data.info.title, + identifier: data.info.identifier, + author: data.info.author, + duration: data.info.length, + isSeekable: data.info.isSeekable, + isStream: data.info.isStream, + uri: data.info.uri, + thumbnail: data.info.uri.includes("youtube") + ? `https://img.youtube.com/vi/${data.info.identifier}/default.jpg` + : null, + displayThumbnail(size = "default"): string | null { + const finalSize = SIZES.find((s) => s === size) ?? "default"; + return this.uri.includes("youtube") + ? `https://img.youtube.com/vi/${data.info.identifier}/${finalSize}.jpg` + : null; + }, + requester, + }; + + track.displayThumbnail = track.displayThumbnail.bind(track); + + if (this.trackPartial) { + for (const key of Object.keys(track)) { + if (this.trackPartial.includes(key)) continue; + delete track[key]; + } + } + + Object.defineProperty(track, TRACK_SYMBOL, { + configurable: true, + value: true + }); + + return track; + } catch (error) { + throw new RangeError(`Argument "data" is not a valid track: ${error.message}`); + } + } + + /** + * Builds a UnresolvedTrack to be resolved before being played . + * @param query + * @param requester + */ + static buildUnresolved(query: string | UnresolvedQuery, requester?: unknown): UnresolvedTrack { + if (typeof query === "undefined") + throw new RangeError('Argument "query" must be present.'); + + let unresolvedTrack: Partial = { + requester, + async resolve(): Promise { + const resolved = await TrackUtils.getClosestTrack(this) + Object.getOwnPropertyNames(this).forEach(prop => delete this[prop]); + Object.assign(this, resolved); + } + }; + + if (typeof query === "string") unresolvedTrack.title = query; + else unresolvedTrack = { ...unresolvedTrack, ...query } + + Object.defineProperty(unresolvedTrack, UNRESOLVED_TRACK_SYMBOL, { + configurable: true, + value: true + }); + + return unresolvedTrack as UnresolvedTrack; + } + + static async getClosestTrack( + unresolvedTrack: UnresolvedTrack + ): Promise { + if (!TrackUtils.manager) throw new RangeError("Manager has not been initiated."); + + if (!TrackUtils.isUnresolvedTrack(unresolvedTrack)) + throw new RangeError("Provided track is not a UnresolvedTrack."); + + const query = [unresolvedTrack.author, unresolvedTrack.title].filter(str => !!str).join(" - "); + const res = await TrackUtils.manager.search(query, unresolvedTrack.requester); + + if (res.loadType !== "SEARCH_RESULT") throw res.exception ?? { + message: "No tracks found.", + severity: "COMMON", + }; + + if (unresolvedTrack.author) { + const channelNames = [unresolvedTrack.author, `${unresolvedTrack.author} - Topic`]; + + const originalAudio = res.tracks.find(track => { + return ( + channelNames.some(name => new RegExp(`^${escapeRegExp(name)}$`, "i").test(track.author)) || + new RegExp(`^${escapeRegExp(unresolvedTrack.title)}$`, "i").test(track.title) + ); + }); + + if (originalAudio) return originalAudio; + } + + if (unresolvedTrack.duration) { + const sameDuration = res.tracks.find(track => + (track.duration >= (unresolvedTrack.duration - 1500)) && + (track.duration <= (unresolvedTrack.duration + 1500)) + ); + + if (sameDuration) return sameDuration; + } + + return res.tracks[0]; + } +} + +/** Gets or extends structures to extend the built in, or already extended, classes to add more functionality. */ +export abstract class Structure { + /** + * Extends a class. + * @param name + * @param extender + */ + public static extend( + name: K, + extender: (target: Extendable[K]) => T + ): T { + if (!structures[name]) throw new TypeError(`"${name} is not a valid structure`); + const extended = extender(structures[name]); + structures[name] = extended; + return extended; + } + + /** + * Get a structure from available structures by name. + * @param name + */ + public static get(name: K): Extendable[K] { + const structure = structures[name]; + if (!structure) throw new TypeError('"structure" must be provided.'); + return structure; + } +} + +export class Plugin { + public load(manager: Manager): void {} + + public unload(manager: Manager): void {} +} + +const structures = { + Player: require("./Player").Player, + Queue: require("./Queue").Queue, + Node: require("./Node").Node, +}; + +export interface UnresolvedQuery { + /** The title of the unresolved track. */ + title: string; + /** The author of the unresolved track. If provided it will have a more precise search. */ + author?: string; + /** The duration of the unresolved track. If provided it will have a more precise search. */ + duration?: number; +} + +export type Sizes = + | "0" + | "1" + | "2" + | "3" + | "default" + | "mqdefault" + | "hqdefault" + | "maxresdefault"; + +export type LoadTypeV3 = + | "TRACK_LOADED" + | "PLAYLIST_LOADED" + | "SEARCH_RESULT" + | "LOAD_FAILED" + | "NO_MATCHES"; +export type LoadTypeV4 = "track" | "playlist" | "search" | "empty" | "error"; + +export type State = + | "CONNECTED" + | "CONNECTING" + | "DISCONNECTED" + | "DISCONNECTING" + | "DESTROYING"; + +export type PlayerEvents = + | TrackStartEvent + | TrackEndEvent + | TrackStuckEvent + | TrackExceptionEvent + | WebSocketClosedEvent; + +export type PlayerEventType = + | "TrackStartEvent" + | "TrackEndEvent" + | "TrackExceptionEvent" + | "TrackStuckEvent" + | "WebSocketClosedEvent"; + +export type TrackEndReason = + | "FINISHED" + | "LOAD_FAILED" + | "STOPPED" + | "REPLACED" + | "CLEANUP"; + +export type Severity = "COMMON" | "SUSPICIOUS" | "FAULT"; + +export interface TrackData { + track: string; + info: TrackDataInfo; +} + +export interface TrackDataInfo { + title: string; + identifier: string; + author: string; + length: number; + isSeekable: boolean; + isStream: boolean; + uri: string; +} + +export interface Extendable { + Player: typeof Player; + Queue: typeof Queue; + Node: typeof Node; +} + +export interface VoiceState { + op: "voiceUpdate"; + guildId: string; + event: VoiceServer; + sessionId?: string; +} + +export interface VoiceServer { + token: string; + guild_id: string; + endpoint: string; +} + +export interface VoiceState { + guild_id: string; + user_id: string; + session_id: string; + channel_id: string; +} + +export interface VoicePacket { + t?: "VOICE_SERVER_UPDATE" | "VOICE_STATE_UPDATE"; + d: VoiceState | VoiceServer; +} + +export interface NodeMessage extends NodeStats { + type: PlayerEventType; + op: "stats" | "playerUpdate" | "event"; + guildId: string; +} + +export interface PlayerEvent { + op: "event"; + type: PlayerEventType; + guildId: string; +} + +export interface Exception { + severity: Severity; + message: string; + cause: string; +} + +export interface TrackStartEvent extends PlayerEvent { + type: "TrackStartEvent"; + track: string; +} + +export interface TrackEndEvent extends PlayerEvent { + type: "TrackEndEvent"; + track: string; + reason: TrackEndReason; +} + +export interface TrackExceptionEvent extends PlayerEvent { + type: "TrackExceptionEvent"; + exception?: Exception; + error: string; +} + +export interface TrackStuckEvent extends PlayerEvent { + type: "TrackStuckEvent"; + thresholdMs: number; +} + +export interface WebSocketClosedEvent extends PlayerEvent { + type: "WebSocketClosedEvent"; + code: number; + byRemote: boolean; + reason: string; +} + +export interface PlayerUpdate { + op: "playerUpdate"; + state: { + position: number; + time: number; + }; + guildId: string; +} \ No newline at end of file diff --git a/src/types/Discord.ts b/src/types/Discord.ts deleted file mode 100644 index aec0e65..0000000 --- a/src/types/Discord.ts +++ /dev/null @@ -1,24 +0,0 @@ -export interface VoiceState { - op: "voiceUpdate"; - guildId: string; - event: VoiceServer; - sessionId?: string; -} - -export interface VoiceServer { - token: string; - guild_id: string; - endpoint: string; -} - -export interface VoiceState { - guild_id: string; - user_id: string; - session_id: string; - channel_id: string; -} - -export interface VoicePacket { - t?: "VOICE_SERVER_UPDATE" | "VOICE_STATE_UPDATE"; - d: VoiceState | VoiceServer; -} \ No newline at end of file diff --git a/src/types/Manager.ts b/src/types/Manager.ts index 329775d..263eb32 100644 --- a/src/types/Manager.ts +++ b/src/types/Manager.ts @@ -1,52 +1,116 @@ -import type { Node } from "../structures/Node"; -import type { Player } from "../structures/Player"; -import type { NodeConfig, NodeStats } from "./Node"; - -/* The `ManagerEventEmitter` interface is defining a set of event listener functions that can be used -to handle various events related to nodes and players in a manager system. Each property in the -interface represents a specific event type along with the expected parameters and return type of the -event handler function. */ -interface ManagerEventEmitter { +import { Node } from "../structures/Node"; +import { Player, Track, UnresolvedTrack } from "../structures/Player"; +import { TrackEndEvent, TrackExceptionEvent, TrackStartEvent, TrackStuckEvent, WebSocketClosedEvent } from "../structures/Utils"; + +export interface ManagerEventEmitter { + NodeReady: (node: Node) => void; + /** + * Emitted when a Node is created. + * @event Manager#NodeCreate + */ + NodeCreate: (node: Node) => void; + + /** + * Emitted when a Node is destroyed. + * @event Manager#NodeDestroy + */ + NodeDestroy: (node: Node) => void; + + /** + * Emitted when a Node connects. + * @event Manager#NodeConnect + */ NodeConnect: (node: Node) => void; - NodeDisconnect: (node: Node) => void; + + /** + * Emitted when a Node reconnects. + * @event Manager#NodeReconnect + */ + NodeReconnect: (node: Node) => void; + + /** + * Emitted when a Node disconnects. + * @event Manager#NodeDisconnect + */ + NodeDisconnect: (node: Node, reason: { code: number; reason: string }) => void; + + /** + * Emitted when a Node has an error. + * @event Manager#NodeError + */ NodeError: (node: Node, error: Error) => void; - NodeStats: (node: Node, stats: NodeStats) => void; - NodeReady: (node: Node) => void; - NodeRaw: (node: Node, data: unknown) => void; + + /** + * Emitted whenever any Lavalink event is received. + * @event Manager#NodeRaw + */ + NodeRaw: (payload: unknown) => void; + + /** + * Emitted when a player is created. + * @event Manager#playerCreate + */ PlayerCreate: (player: Player) => void; + + /** + * Emitted when a player is destroyed. + * @event Manager#playerDestroy + */ PlayerDestroy: (player: Player) => void; - PlayerConnect: (player: Player) => void; - PlayerDisconnect: (player: Player, voiceChannel: string) => void; - PlayerError: (player: Player, error: Error) => void; - PlayerUpdate: (player: Player, state: unknown) => void; - PlayerVoiceUpdate: (player: Player, state: unknown) => void; - PlayerMove: (player: Player, oldChannel: string, newChannel: string) => void; - PlayerStateUpdate: (player: Player, state: unknown) => void; - raw: (data: unknown) => void; -} -interface Payload { - op: number; - d: { - guild_id: string; - channel_id: string; - self_mute: boolean; - self_deaf: boolean; - } -} + /** + * Emitted when a player queue ends. + * @event Manager#queueEnd + */ + QueueEnd: ( + player: Player, + track: Track | UnresolvedTrack, + payload: TrackEndEvent + ) => void; -/* The `ManagerOptions` interface is defining a set of properties that can be used when creating an -instance of a manager. Here's what each property does: */ -interface ManagerOptions { - nodes: NodeConfig[]; - clientName?: string; - usePriority?: boolean; - clientId: string; - useNode?: "leastLoad" | "leastPlayers"; - send: (guild_id: string, payload: Payload) => void; -} + /** + * Emitted when a player is moved to a new voice channel. + * @event Manager#playerMove + */ + PlayerMove: (player: Player, initChannel: string, newChannel: string) => void; + + /** + * Emitted when a player is disconnected from it's current voice channel. + * @event Manager#playerDisconnect + */ + PlayerDisconnect: (player: Player, oldChannel: string) => void; -export type { - ManagerEventEmitter, - ManagerOptions -} \ No newline at end of file + /** + * Emitted when a track starts. + * @event Manager#trackStart + */ + TrackStart: (player: Player, track: Track, payload: TrackStartEvent) => void; + + /** + * Emitted when a track ends. + * @event Manager#trackEnd + */ + TrackEnd: (player: Player, track: Track, payload: TrackEndEvent) => void; + + /** + * Emitted when a track gets stuck during playback. + * @event Manager#trackStuck + */ + TrackStuck: (player: Player, track: Track, payload: TrackStuckEvent) => void; + + /** + * Emitted when a track has an error during playback. + * @event Manager#trackError + */ + TrackError: ( + player: Player, + track: Track | UnresolvedTrack, + payload: TrackExceptionEvent + ) => void; + + /** + * Emitted when a voice connection is closed. + * @event Manager#socketClosed + */ + SocketClosed: (player: Player, payload: WebSocketClosedEvent) => void; +} diff --git a/src/types/Node.ts b/src/types/Node.ts index bed411c..1a3a6c3 100644 --- a/src/types/Node.ts +++ b/src/types/Node.ts @@ -1,57 +1,68 @@ -/* The `interface NodeEventEmitter` is defining a structure for an object that can emit specific events -related to a Node. It specifies four event types along with their corresponding callback functions: */ -interface NodeEventEmitter { - ready: () => void; - raw: (data: unknown) => void; - stats: (stats: NodeStats) => void; - connect: () => void; - error(error: Error): void; - disconnect: (data: unknown) => void; -} - -/* The `interface NodeConfig` is defining a structure for configuring a Node. It specifies the -properties that can be set for a Node, including the host (as a string), port (as a number), -password (as a string), and an optional property secure (as a boolean). This interface provides a -blueprint for creating objects that hold configuration settings for a Node. */ -interface NodeConfig { +import { Pool } from "undici"; +export interface NodeOptions { + /** The host for the node. */ host: string; - port: number; - password: string; + /** The port for the node. */ + port?: number; + /** The password for the node. */ + password?: string; + /** Whether the host uses SSL. */ secure?: boolean; - clientName?: string; - version?: number; - priority?: number; - resumeStatus?: string; + /** The identifier for the node. */ + identifier?: string; + /** The version for the node. */ + version?: "v4" | "v3"; + /** The retryAmount for the node. */ + retryAmount?: number; + /** The retryDelay for the node. */ + retryDelay?: number; + /** The timeout used for api calls */ + requestTimeout?: number; + resumeStatus?: boolean; resumeTimeout?: number; + +} + +export interface NodeStats { + /** The amount of players on the node. */ + players: number; + /** The amount of playing players on the node. */ + playingPlayers: number; + /** The uptime for the node. */ + uptime: number; + /** The memory stats for the node. */ + memory: MemoryStats; + /** The cpu stats for the node. */ + cpu: CPUStats; + /** The frame stats for the node. */ + frameStats: FrameStats; +} + +export interface MemoryStats { + /** The free memory of the allocated amount. */ + free: number; + /** The used memory of the allocated amount. */ + used: number; + /** The total allocated memory. */ + allocated: number; + /** The reservable memory. */ + reservable: number; } -/* The `interface NodeStats` is defining a structure for representing statistical data related to a -Node. It includes various properties such as `frameStats`, `players`, `playingPlayers`, `uptime`, -`memory`, and `cpu`. Each property has a specific data type associated with it: */ -interface NodeStats { - frameStats: { - sent: number, - nulled: number, - deficit: number - }, - players: number, - playingPlayers: number, - uptime: number, - memory: { - free: number, - used: number, - allocated: number, - reservable: number, - }, - cpu: { - cores: number, - systemLoad: number, - lavalinkLoad: number, - }, +export interface CPUStats { + /** The core amount the host machine has. */ + cores: number; + /** The system load. */ + systemLoad: number; + /** The lavalink load. */ + lavalinkLoad: number; } -export type { - NodeEventEmitter, - NodeConfig, - NodeStats -} \ No newline at end of file +export interface FrameStats { + /** The amount of sent frames. */ + sent?: number; + /** The amount of nulled frames. */ + nulled?: number; + /** The amount of deficit frames. */ + deficit?: number; +} diff --git a/src/types/Player.ts b/src/types/Player.ts deleted file mode 100644 index 0010c82..0000000 --- a/src/types/Player.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Node } from "../structures/Node"; - -interface PlayerCreate { - guild_id: string; - textChannel: string; - voiceChannel: string; - volume?: number; - selfDeafen: boolean; - selfMute: boolean; - node?: string; -} -interface PlayOptions { - guildId: string; - data: { - /** The base64 encoded track. */ - encodedTrack?: string; - /** The track ID. */ - identifier?: string; - /** The track time to start at. */ - startTime?: number; - /** The track time to end at. */ - endTime?: number; - /** The player volume level. */ - volume?: number; - /** The player position in a track. */ - position?: number; - /** Whether the player is paused. */ - paused?: boolean; - /** The audio effects. */ - filters?: object; - /** voice payload. */ - voice?: { - token: string; - sessionId: string; - endpoint: string; - }; - /** Whether to not replace the track if a play payload is sent. */ - noReplace?: boolean; - }; -} -export type { - PlayerCreate, - PlayOptions -} \ No newline at end of file diff --git a/src/types/Rest.ts b/src/types/Rest.ts index 59b7851..54a1c0d 100644 --- a/src/types/Rest.ts +++ b/src/types/Rest.ts @@ -1,26 +1,29 @@ -/* The `interface RestEventEmitter` in the TypeScript code snippet is defining a structure for an event -emitter related to a REST service. It specifies the types of functions that can be included in an -object implementing the `RestEventEmitter` interface. */ -interface RestEventEmitter { - ready: () => void; - raw: (data: unknown) => void; - get: (data: unknown) => void; - error: (error: Error) => void; - post: (data: unknown) => void; - patch: (data: unknown) => void; -} - -/* The `interface RestConfig` in the TypeScript code snippet defines a structure for configuration -settings related to a REST service. It specifies the properties that can be included in a -`RestConfig` object, which are: */ -interface RestConfig { - host: string; - port: number; - password: string; - secure?: boolean; -} - -export type { - RestEventEmitter, - RestConfig +export interface PlayOptionsData { + guildId: string; + data: { + /** The base64 encoded track. */ + encodedTrack?: string; + /** The track ID. */ + identifier?: string; + /** The track time to start at. */ + startTime?: number; + /** The track time to end at. */ + endTime?: number; + /** The player volume level. */ + volume?: number; + /** The player position in a track. */ + position?: number; + /** Whether the player is paused. */ + paused?: boolean; + /** The audio effects. */ + filters?: object; + /** voice payload. */ + voice?: { + token: string; + sessionId: string; + endpoint: string; + }; + /** Whether to not replace the track if a play payload is sent. */ + noReplace?: boolean; + }; } \ No newline at end of file diff --git a/test/connect.ts b/test/connect.ts deleted file mode 100644 index be2dfc0..0000000 --- a/test/connect.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Node } from '../src/index' -const node = new Node({ - host: 'localhost', - port: 2333, - password: 'youshallnotpass', -}); - -node.on("ready", () => { - console.log("Ready"); -}); -node.on("stats", () => { - console.log(node.stats); -}); -node.on("raw", (data) => { - console.log(data); -}); -node.connect(); -node.rest?.on("get", (data) => console.log(data)); -node.rest?.get("/loadtracks?identifier=dQw4w9WgXcQ").catch(console.error); \ No newline at end of file diff --git a/test/manager.ts b/test/manager.ts index a62bd00..5393182 100644 --- a/test/manager.ts +++ b/test/manager.ts @@ -17,10 +17,7 @@ let client = new Manager({ client.on("NodeConnect", (node) => { console.log(`Node ${node.options.host} connected`); }); -client.on("NodeRaw", async (node, data) => { - console.log(`Node ${node.options.host} sent raw data: ${JSON.stringify(data)}`); -}); -client.on("raw", (data) => { - console.log(data); +client.on("NodeRaw", (node) => { + console.log(node) }); client.init() \ No newline at end of file diff --git a/test/player.ts b/test/player.ts index a798645..1dc1e2b 100644 --- a/test/player.ts +++ b/test/player.ts @@ -16,6 +16,7 @@ let manager = new Manager({ host: 'localhost', port: 2333, password: 'youshallnotpass', + version: "v4", }, ], clientId: "1234567890", @@ -29,27 +30,30 @@ let manager = new Manager({ manager.on("NodeConnect", (node) => { console.log(`Node ${node.options.host} connected`); }); -manager.on("NodeRaw", async (node, data) => { - console.log(`Node ${node.options.host} sent raw data: ${JSON.stringify(data)}`); -}); -manager.on("raw", (data) => { - console.log(data); +manager.on("NodeRaw", async (node) => { + console.log(`sent raw data: ${JSON.stringify(node)}`); }); client.on("ready", () => { manager.init(); }); manager.on("PlayerCreate", (player) => { - console.log(`Player created in guild ${player.guild_id}`); + console.log(`Player created in guild ${player.guild}`); +}); +manager.on("NodeError" , (node, error) => { + console.log(`Node ${node.options.host} has an error: ${error.message}`); }); -client.on("messageCreate", (message) => { +client.on("messageCreate", async (message) => { console.log(message.content) - message.content === "message" && manager.create({ - voiceChannel: message.member?.voice.channel?.id as string, - textChannel: message.channel.id, - guild_id: message.guild?.id as string, - selfDeafen: true, - selfMute: false, - }).connect(); + if (message.content === "message") { + let player = manager.create({ + voiceChannel: message.member?.voice.channel?.id as string, + textChannel: message.channel.id, + guild: message.guild?.id as string, + selfDeafen: true, + selfMute: false, + }) + if (player.state !== "CONNECTED") await player.connect(); + } }) client.on("raw", (data) => manager.updateVoiceState(data)); client.login(""); \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index b018483..4d662ba 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,7 +8,6 @@ "allowJs": true, "moduleResolution": "bundler", "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, "skipLibCheck": true, "noFallthroughCasesInSwitch": true, "noUnusedLocals": false,