From 0ecd2a61a15614e407cdeab8593b926495384ea6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?FAY=E3=82=B7?= <103030954+FAYStarNext@users.noreply.github.com> Date: Mon, 22 Jul 2024 10:41:15 +0700 Subject: [PATCH 1/3] feat: Add messageCreate event listener to handle specific message content --- example/src/index.ts | 13 +++++++++++++ src/structures/Manager.ts | 6 +++--- src/structures/Player.ts | 15 +++++++-------- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/example/src/index.ts b/example/src/index.ts index 0d03db5..ceb8062 100644 --- a/example/src/index.ts +++ b/example/src/index.ts @@ -42,5 +42,18 @@ manager.on("PlayerCreate", (player) => { manager.on("NodeError" , (node, error) => { console.log(`Node ${node.options.host} has an error: ${error.message}`); }); +client.on("messageCreate", async (message) => { + console.log(message.content) + 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(process.env.TOKEN); \ No newline at end of file diff --git a/src/structures/Manager.ts b/src/structures/Manager.ts index 5b2a2aa..1fd441e 100644 --- a/src/structures/Manager.ts +++ b/src/structures/Manager.ts @@ -239,14 +239,14 @@ export class Manager extends TypedEmitter { * @param tracks */ public decodeTracks(tracks: string[]): Promise { - return new Promise(async (resolve, reject) => { + 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 reject(new Error("No data returned from query.")); } - return res; + resolve(res); }); } diff --git a/src/structures/Player.ts b/src/structures/Player.ts index 4c016d2..7569ea6 100644 --- a/src/structures/Player.ts +++ b/src/structures/Player.ts @@ -143,17 +143,16 @@ export class Player { 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((a, b) => a.localeCompare(b))) === '["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 })), - }); - + if (this.node.options.version === "v3") { + this.node.send({ + op: "equalizer", + guildId: this.guild, + bands: this.bands.map((gain, band) => ({ band, gain })), + }); + } return this; } From 45438b43331de3894637e6990364b423875b0fbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?FAY=E3=82=B7?= <103030954+FAYStarNext@users.noreply.github.com> Date: Mon, 22 Jul 2024 13:25:40 +0700 Subject: [PATCH 2/3] chore: Update Player class to conditionally send equalizer bands only for v3 version --- src/index.ts | 4 ++++ src/structures/Player.ts | 25 ++++++++++++++++++------- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/index.ts b/src/index.ts index dcd61d6..35536bb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,3 +3,7 @@ export * from "./structures/Node"; export * from "./structures/Player"; export * from "./structures/Queue"; export * from "./structures/Utils"; +export * from "./structures/Rest"; +export * from "./types/Manager"; +export * from "./types/Node"; +export * from "./types/Rest"; \ No newline at end of file diff --git a/src/structures/Player.ts b/src/structures/Player.ts index 7569ea6..da9f933 100644 --- a/src/structures/Player.ts +++ b/src/structures/Player.ts @@ -159,12 +159,13 @@ export class Player { /** 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 })), - }); + if (this.node.options.version === "v3") { + this.node.send({ + op: "equalizer", + guildId: this.guild, + bands: this.bands.map((gain, band) => ({ band, gain })), + }); + } return this; } @@ -315,7 +316,17 @@ export class Player { options.track = (options.track as Track).track; } - await this.node.send(options); + if (this.node.options.version === "v4") { + await this.node.rest.updatePlayer({ + guildId: this.guild, + data: { + encodedTrack: this.queue.current?.track, + ...finalOptions, + }, + }); + } else { + await this.node.send(options); + } } /** From e896fe3fccb43e2007af2adac372998acb609b14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?FAY=E3=82=B7?= <103030954+FAYStarNext@users.noreply.github.com> Date: Mon, 22 Jul 2024 14:35:31 +0700 Subject: [PATCH 3/3] chore: Update Player --- NOTICE.txt | 3 +- README.md | 51 +- example/src/index.ts | 50 +- package.json | 7 +- src/index.ts | 4 - src/structures/Filters.ts | 265 +++++++++++ src/structures/Manager.ts | 803 ++++++++++++++++--------------- src/structures/Node.ts | 842 ++++++++++++++++++--------------- src/structures/Player.ts | 347 ++++++++------ src/structures/Queue.ts | 215 ++++----- src/structures/Rest.ts | 155 ++++-- src/structures/Utils.ts | 616 +++++++++++------------- src/types/Manager.ts | 116 ----- src/types/Node.ts | 67 --- src/types/Rest.ts | 29 -- src/utils/filtersEqualizers.ts | 93 ++++ src/utils/managerCheck.ts | 70 +++ src/utils/nodeCheck.ts | 51 ++ src/utils/playerCheck.ts | 35 ++ test/player.ts | 1 - 20 files changed, 2188 insertions(+), 1632 deletions(-) create mode 100644 src/structures/Filters.ts delete mode 100644 src/types/Manager.ts delete mode 100644 src/types/Node.ts delete mode 100644 src/types/Rest.ts create mode 100644 src/utils/filtersEqualizers.ts create mode 100644 src/utils/managerCheck.ts create mode 100644 src/utils/nodeCheck.ts create mode 100644 src/utils/playerCheck.ts diff --git a/NOTICE.txt b/NOTICE.txt index 82540df..8d5077f 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -1,4 +1,4 @@ -This project includes code from the Apache-licensed project erela.js +This project includes code from the Apache-licensed project magmastream, 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 @@ -9,3 +9,4 @@ is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND See the Apache License for the specific language governing permissions and limitations under the Apache License. Copyright 2021 MenuDocs +Copyright 2023 Blackfort-Hosting diff --git a/README.md b/README.md index e8a8e0e..dee8bcb 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ That's it! You have successfully installed Sunday.ts and are ready to start usin ```ts import { Client } from "discord.js"; import { Manager } from "sunday.ts"; +import "dotenv/config"; let client = new Client({ intents: [ @@ -79,7 +80,7 @@ let manager = new Manager({ host: 'localhost', port: 2333, password: 'youshallnotpass', - version: "v4", + resumeStatus: true, }, ], clientId: "1234567890", @@ -104,8 +105,53 @@ manager.on("PlayerCreate", (player) => { manager.on("NodeError" , (node, error) => { console.log(`Node ${node.options.host} has an error: ${error.message}`); }); +client.on("messageCreate", async (message) => { + console.log(message.content) + const [command, ...args] = message.content.slice(0).split(/\s+/g); + console.log(command) + console.log(command === 'play') + if (command === 'play') { + if (!message.member?.voice.channel) return message.reply('you need to join a voice channel.'); + if (!args.length) return message.reply('you need to give me a URL or a search term.'); + + const search = args.join(' '); + let res; + try { + // Search for tracks using a query or url, using a query searches youtube automatically and the track requester object + res = await manager.search(search, message.author); + // Check the load type as this command is not that advanced for basics + if (res.loadType === 'empty') throw res; + if (res.loadType === 'playlist') { + throw { message: 'Playlists are not supported with this command.' }; + } + } catch (err) { + return message.reply(`there was an error while searching: ${err}`); + } + + if (res.loadType === 'error') { + return message.reply('there was no tracks found with that query.'); + } + + // Create the player + const player = manager.create({ + guild: message.guild?.id as string, + voiceChannel: message.member?.voice.channel.id, + textChannel: message.channel.id, + volume: 100, + }); + + // Connect to the voice channel and add the track to the queue + player.connect(); + console.log(res) + await player.queue.add(res.tracks[0]); + // Checks if the client should play the track if it's the first one added + if (!player.playing && !player.paused && !player.queue.size) player.play(); + + return message.reply(`enqueuing ${res.tracks[0].title}.`); + } +}); client.on("raw", (data) => manager.updateVoiceState(data)); -client.login(""); +client.login(process.env.TOKEN); ``` ## ⛏️ Built Using @@ -115,5 +161,6 @@ client.login(""); ## Credits - [Erela.Js](https://github.com/MenuDocs/erela.js) +- [MagmaStream](https://github.com/Blackfort-Hosting/magmastream/) 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/example/src/index.ts b/example/src/index.ts index ceb8062..4c4a23f 100644 --- a/example/src/index.ts +++ b/example/src/index.ts @@ -17,7 +17,7 @@ let manager = new Manager({ host: 'localhost', port: 2333, password: 'youshallnotpass', - version: "v4", + resumeStatus: true, }, ], clientId: "1234567890", @@ -44,15 +44,47 @@ manager.on("NodeError" , (node, error) => { }); client.on("messageCreate", async (message) => { console.log(message.content) - if (message.content === "message") { - let player = manager.create({ - voiceChannel: message.member?.voice.channel?.id as string, - textChannel: message.channel.id, + const [command, ...args] = message.content.slice(0).split(/\s+/g); + console.log(command) + console.log(command === 'play') + if (command === 'play') { + if (!message.member?.voice.channel) return message.reply('you need to join a voice channel.'); + if (!args.length) return message.reply('you need to give me a URL or a search term.'); + + const search = args.join(' '); + let res; + try { + // Search for tracks using a query or url, using a query searches youtube automatically and the track requester object + res = await manager.search(search, message.author); + // Check the load type as this command is not that advanced for basics + if (res.loadType === 'empty') throw res; + if (res.loadType === 'playlist') { + throw { message: 'Playlists are not supported with this command.' }; + } + } catch (err) { + return message.reply(`there was an error while searching: ${err}`); + } + + if (res.loadType === 'error') { + return message.reply('there was no tracks found with that query.'); + } + + // Create the player + const player = manager.create({ guild: message.guild?.id as string, - selfDeafen: true, - selfMute: false, - }) - if (player.state !== "CONNECTED") await player.connect(); + voiceChannel: message.member?.voice.channel.id, + textChannel: message.channel.id, + volume: 100, + }); + + // Connect to the voice channel and add the track to the queue + player.connect(); + console.log(res) + await player.queue.add(res.tracks[0]); + // Checks if the client should play the track if it's the first one added + if (!player.playing && !player.paused && !player.queue.size) player.play(); + + return message.reply(`enqueuing ${res.tracks[0].title}.`); } }); client.on("raw", (data) => manager.updateVoiceState(data)); diff --git a/package.json b/package.json index 8f05cea..1c3fd3e 100644 --- a/package.json +++ b/package.json @@ -3,9 +3,10 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "type": "module", - "version": "1.0.4-indev", + "version": "1.0.5-indev", "description": "Sunday a lavalink wrapper", "license": "MIT", + "author": "FAYStarNext", "scripts": { "build:js": "npx babel src --out-dir dist --extensions \".ts,.tsx\" --source-maps inline", "lint": "npx eslint src/**/*.ts", @@ -24,6 +25,10 @@ "registry": "https://registry.npmjs.org/", "access": "public" }, + "bugs": { + "url": "https://github.com/FAYStarNext/Sunday.ts/issues" + }, + "homepage": "https://github.com/FAYStarNext/Sunday.ts#readme", "peerDependencies": { "typescript": "^5.5.3" }, diff --git a/src/index.ts b/src/index.ts index 35536bb..dcd61d6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,3 @@ export * from "./structures/Node"; export * from "./structures/Player"; export * from "./structures/Queue"; export * from "./structures/Utils"; -export * from "./structures/Rest"; -export * from "./types/Manager"; -export * from "./types/Node"; -export * from "./types/Rest"; \ No newline at end of file diff --git a/src/structures/Filters.ts b/src/structures/Filters.ts new file mode 100644 index 0000000..993cdab --- /dev/null +++ b/src/structures/Filters.ts @@ -0,0 +1,265 @@ +import { Band, bassBoostEqualizer, softEqualizer, trebleBassEqualizer, tvEqualizer, vaporwaveEqualizer } from "../utils/filtersEqualizers"; +import { Player } from "./Player"; + +export class Filters { + public distortion: distortionOptions | null; + public equalizer: Band[]; + public karaoke: karaokeOptions | null; + public player: Player; + public rotation: rotationOptions | null; + public timescale: timescaleOptions | null; + public vibrato: vibratoOptions | null; + public volume: number; + + private filterStatus: { + [key: string]: boolean; + }; + + constructor(player: Player) { + this.distortion = null; + this.equalizer = []; + this.karaoke = null; + this.player = player; + this.rotation = null; + this.timescale = null; + this.vibrato = null; + this.volume = 1.0; + // Initialize filter status + this.filterStatus = { + bassboost: false, + distort: false, + eightD: false, + karaoke: false, + nightcore: false, + slowmo: false, + soft: false, + trebleBass: false, + tv: false, + vaporwave: false, + }; + } + + private async updateFilters(): Promise { + const { distortion, equalizer, karaoke, rotation, timescale, vibrato, volume } = this; + + await this.player.node.rest.updatePlayer({ + data: { + filters: { + distortion, + equalizer, + karaoke, + rotation, + timescale, + vibrato, + volume, + }, + }, + guildId: this.player.guild, + }); + + return this; + } + + private applyFilter(filter: { property: T; value: Filters[T] }, updateFilters: boolean = true): this { + this[filter.property] = filter.value as this[T]; + if (updateFilters) { + this.updateFilters(); + } + return this; + } + + private setFilterStatus(filter: keyof availableFilters, status: boolean): this { + this.filterStatus[filter] = status; + return this; + } + + /** + * Sets the equalizer bands and updates the filters. + * @param bands - The equalizer bands. + */ + public setEqualizer(bands?: Band[]): this { + return this.applyFilter({ property: "equalizer", value: bands }); + } + + /** Applies the eight dimension audio effect. */ + public eightD(): this { + return this.setRotation({ rotationHz: 0.2 }).setFilterStatus("eightD", true); + } + + /** Applies the bass boost effect. */ + public bassBoost(): this { + return this.setEqualizer(bassBoostEqualizer).setFilterStatus("bassboost", true); + } + + /** Applies the nightcore effect. */ + public nightcore(): this { + return this.setTimescale({ + speed: 1.1, + pitch: 1.125, + rate: 1.05, + }).setFilterStatus("nightcore", true); + } + + /** Applies the slow motion audio effect. */ + public slowmo(): this { + return this.setTimescale({ + speed: 0.7, + pitch: 1.0, + rate: 0.8, + }).setFilterStatus("slowmo", true); + } + + /** Applies the soft audio effect. */ + public soft(): this { + return this.setEqualizer(softEqualizer).setFilterStatus("soft", true); + } + + /** Applies the television audio effect. */ + public tv(): this { + return this.setEqualizer(tvEqualizer).setFilterStatus("tv", true); + } + + /** Applies the treble bass effect. */ + public trebleBass(): this { + return this.setEqualizer(trebleBassEqualizer).setFilterStatus("trebleBass", true); + } + + /** Applies the vaporwave effect. */ + public vaporwave(): this { + return this.setEqualizer(vaporwaveEqualizer).setTimescale({ pitch: 0.55 }).setFilterStatus("vaporwave", true); + } + + /** Applies the distortion audio effect. */ + public distort(): this { + return this.setDistortion({ + sinOffset: 0, + sinScale: 0.2, + cosOffset: 0, + cosScale: 0.2, + tanOffset: 0, + tanScale: 0.2, + offset: 0, + scale: 1.2, + }).setFilterStatus("distort", true); + } + + /** Applies the karaoke options specified by the filter. */ + public setKaraoke(karaoke?: karaokeOptions): this { + return this.applyFilter({ + property: "karaoke", + value: karaoke, + }).setFilterStatus("karaoke", true); + } + + /** Applies the timescale options specified by the filter. */ + public setTimescale(timescale?: timescaleOptions): this { + return this.applyFilter({ property: "timescale", value: timescale }); + } + + /** Applies the vibrato options specified by the filter. */ + public setVibrato(vibrato?: vibratoOptions): this { + return this.applyFilter({ property: "vibrato", value: vibrato }); + } + + /** Applies the rotation options specified by the filter. */ + public setRotation(rotation?: rotationOptions): this { + return this.applyFilter({ property: "rotation", value: rotation }); + } + + /** Applies the distortion options specified by the filter. */ + public setDistortion(distortion?: distortionOptions): this { + return this.applyFilter({ property: "distortion", value: distortion }); + } + + /** Removes the audio effects and resets the filter status. */ + public async clearFilters(): Promise { + this.filterStatus = { + bassboost: false, + distort: false, + eightD: false, + karaoke: false, + nightcore: false, + slowmo: false, + soft: false, + trebleBass: false, + tv: false, + vaporwave: false, + }; + + this.player.filters = new Filters(this.player); + this.setEqualizer([]); + this.setDistortion(null); + this.setKaraoke(null); + this.setRotation(null); + this.setTimescale(null); + this.setVibrato(null); + + await this.updateFilters(); + return this; + } + + /** Returns the status of the specified filter . */ + public getFilterStatus(filter: keyof availableFilters): boolean { + return this.filterStatus[filter]; + } +} + +/** Options for adjusting the timescale of audio. */ +interface timescaleOptions { + /** The speed factor for the timescale. */ + speed?: number; + /** The pitch factor for the timescale. */ + pitch?: number; + /** The rate factor for the timescale. */ + rate?: number; +} + +/** Options for applying vibrato effect to audio. */ +interface vibratoOptions { + /** The frequency of the vibrato effect. */ + frequency: number; + /** * The depth of the vibrato effect.*/ + depth: number; +} + +/** Options for applying rotation effect to audio. */ +interface rotationOptions { + /** The rotation speed in Hertz (Hz). */ + rotationHz: number; +} + +/** Options for applying karaoke effect to audio. */ +interface karaokeOptions { + /** The level of karaoke effect. */ + level?: number; + /** The mono level of karaoke effect. */ + monoLevel?: number; + /** The filter band of karaoke effect. */ + filterBand?: number; + /** The filter width of karaoke effect. */ + filterWidth?: number; +} + +interface distortionOptions { + sinOffset?: number; + sinScale?: number; + cosOffset?: number; + cosScale?: number; + tanOffset?: number; + tanScale?: number; + offset?: number; + scale?: number; +} + +interface availableFilters { + bassboost: boolean; + distort: boolean; + eightD: boolean; + karaoke: boolean; + nightcore: boolean; + slowmo: boolean; + soft: boolean; + trebleBass: boolean; + tv: boolean; + vaporwave: boolean; +} diff --git a/src/structures/Manager.ts b/src/structures/Manager.ts index 1fd441e..f1f2a16 100644 --- a/src/structures/Manager.ts +++ b/src/structures/Manager.ts @@ -1,320 +1,339 @@ /* eslint-disable no-async-promise-executor */ -import { Collection } from "@discordjs/collection"; -import { VoiceState } from ".."; -import { Node } from "./Node"; -import { Player, PlayerOptions, Track } from "./Player"; import { - LoadTypeV3, - LoadTypeV4, - Plugin, - Structure, - TrackData, - TrackUtils, - VoicePacket, - VoiceServer, + LoadType, + Plugin, + Structure, + TrackData, + TrackEndEvent, + TrackExceptionEvent, + TrackStartEvent, + TrackStuckEvent, + TrackUtils, + VoicePacket, + VoiceServer, + WebSocketClosedEvent, } from "./Utils"; +import { Collection } from "@discordjs/collection"; +import { Node, NodeOptions } from "./Node"; +import { Player, PlayerOptions, Track, UnresolvedTrack } from "./Player"; +import { VoiceState } from ".."; +import managerCheck from "../utils/managerCheck"; +import { ClientUser, User } from "discord.js"; import { TypedEmitter } from "tiny-typed-emitter"; -import { ManagerEventEmitter } from "../types/Manager"; -import { NodeOptions } from "../types/Node"; - -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 { - public static readonly DEFAULT_SOURCES: Record = { - "youtube music": "ytmsearch", - "youtube": "ytsearch", - "soundcloud": "scsearch" - } - - /** 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; - - /** Returns the least used Nodes. */ - public get leastUsedNodes(): Collection { - return this.nodes - .filter((node) => node.connected) - .sort((a, b) => b.calls - a.calls); - } - - /** 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.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; - }); - } - - /** - * Initiates the Manager class. - * @param options - */ - constructor(options: ManagerOptions) { - super(); - 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, - }; - - 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); - } - } - - if (this.options.nodes) { - this.options.nodes.forEach((nodeOptions) => { - return new (Structure.get("Node"))(nodeOptions); - }); - } - } - - /** - * 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.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 async search( - query: string | SearchQuery, - requester?: unknown - ): Promise { - 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}`; - } - - try { - const res = await node.rest.get(`/loadtracks?identifier=${encodeURIComponent(search)}`); - - if (!res) { - throw 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 result; - } catch (error) { - throw new Error(`Search failed: ${error.message}`); - } - } - /** - * 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.")); - } - 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 { +/** + * The main hub for interacting with Lavalink and using Magmastream, + */ +export class Manager extends TypedEmitter { + public static readonly DEFAULT_SOURCES: Record = { + "youtube music": "ytmsearch", + youtube: "ytsearch", + soundcloud: "scsearch", + deezer: "dzsearch", + }; + + /** 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; + + /** Returns the nodes that has the least load. */ + private get leastLoadNode(): 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; + return aload - bload; + }); + } + + /** Returns the nodes that has the least amount of players. */ + private get leastPlayersNode(): Collection { + return this.nodes.filter((node) => node.connected).sort((a, b) => a.stats.players - b.stats.players); + } + + /** Returns a node based on priority. */ + 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; + } + } + + return this.options.useNode === "leastLoad" ? this.leastLoadNode.first() : this.leastPlayersNode.first(); + } + + /** Returns the node to use. */ + public get useableNodes(): Node { + return this.options.usePriority ? this.priorityNode : this.options.useNode === "leastLoad" ? this.leastLoadNode.first() : this.leastPlayersNode.first(); + } + + /** + * Initiates the Manager class. + * @param options + */ + constructor(options: ManagerOptions) { + super(); + + managerCheck(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", + resumeStatus: false, + resumeTimeout: 1000, + }, + ], + shards: 1, + autoPlay: true, + usePriority: false, + clientName: "Magmastream", + defaultSearchPlatform: "youtube", + useNode: "leastPlayers", + ...options, + }; + + 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); + } + } + + if (this.options.nodes) { + for (const nodeOptions of this.options.nodes) new (Structure.get("Node"))(nodeOptions); + } + } + + /** + * 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.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 async search(query: string | SearchQuery, requester?: User | ClientUser): Promise { + const node = this.useableNodes; + + 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}`; + } + + try { + const res = (await node.rest.get(`/v4/loadtracks?identifier=${encodeURIComponent(search)}`)) as LavalinkResponse; + + if (!res) { + throw new Error("Query not found."); + } + + let searchData = []; + let playlistData: PlaylistRawData | undefined; + + switch (res.loadType) { + case "search": + searchData = res.data as TrackData[]; + break; + + case "track": + searchData = [res.data as TrackData[]]; + break; + + case "playlist": + playlistData = res.data as PlaylistRawData; + break; + } + + const tracks = searchData.map((track) => TrackUtils.build(track, requester)); + let playlist = null; + + if (res.loadType === "playlist") { + playlist = { + name: playlistData!.info.name, + tracks: playlistData!.tracks.map((track) => TrackUtils.build(track, requester)), + duration: playlistData!.tracks.reduce((acc, cur) => acc + (cur.info.length || 0), 0), + }; + } + + const result: SearchResult = { + loadType: res.loadType, + tracks, + playlist, + }; + + if (this.options.replaceYouTubeCredentials) { + let tracksToReplace: Track[] = []; + if (result.loadType === "playlist") { + tracksToReplace = result.playlist.tracks; + } else { + tracksToReplace = result.tracks; + } + + for (const track of tracksToReplace) { + if (isYouTubeURL(track.uri)) { + track.author = track.author.replace("- Topic", ""); + track.title = track.title.replace("Topic -", ""); + } + if (track.title.includes("-")) { + const [author, title] = track.title.split("-").map((str: string) => str.trim()); + track.author = author; + track.title = title; + } + } + } + + return result; + } catch (err) { + throw new Error(err); + } + + function isYouTubeURL(uri: string): boolean { + return uri.includes("youtube.com") || uri.includes("youtu.be"); + } + } + + /** + * 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("/v4/decodetracks", JSON.stringify(tracks)).catch((err) => reject(err))) as TrackData[]; + + 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; @@ -328,12 +347,13 @@ export class Manager extends TypedEmitter { player.voiceState.event = update; const { + sessionId, event: { token, endpoint }, } = player.voiceState; await player.node.rest.updatePlayer({ guildId: player.guild, - data: { voice: { token, endpoint, sessionId: player.sessionId } }, + data: { voice: { token, endpoint, sessionId } }, }); return; @@ -359,86 +379,107 @@ export class Manager extends TypedEmitter { } export interface Payload { - /** The OP code */ - op: number; - d: { - guild_id: string; - channel_id: string | null; - self_mute: boolean; - self_deaf: boolean; - }; + /** 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; + /** Use priority mode over least amount of player or load? */ + usePriority?: boolean; + /** Use the least amount of players or least load? */ + useNode?: "leastLoad" | "leastPlayers"; + /** 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", "soundcloud" or deezer. */ + defaultSearchPlatform?: SearchPlatform; + /** Whether the YouTube video titles should be replaced if the Author does not exactly match. */ + replaceYouTubeCredentials?: boolean; + /** + * 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 type SearchPlatform = "deezer" | "soundcloud" | "youtube music" | "youtube"; export interface SearchQuery { - /** The source to search from. */ - source?: SearchPlatform; - /** The query to search for. */ - query: string; + /** The source to search from. */ + source?: SearchPlatform | string; + /** The query to search for. */ + query: string; +} + +export interface LavalinkResponse { + loadType: LoadType; + data: TrackData[] | PlaylistRawData; } 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; - }; + /** The load type of the result. */ + loadType: LoadType; + /** The array of tracks from the result. */ + tracks: Track[]; + /** The playlist info if the load type is 'playlist'. */ + playlist?: PlaylistData; +} + +export interface PlaylistRawData { + info: { + /** The playlist name. */ + name: string; + }; + /** Addition info provided by plugins. */ + pluginInfo: object; + /** The tracks of the playlist */ + tracks: TrackData[]; } -export interface PlaylistInfo { - /** The playlist name. */ - name: string; - /** The playlist selected track. */ - selectedTrack?: Track; - /** The duration of the playlist. */ - duration: number; +export interface PlaylistData { + /** The playlist name. */ + name: string; + /** The length of the playlist. */ + duration: number; + /** The songs of the playlist. */ + tracks: Track[]; } -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; - }; +export interface ManagerEvents { + NodeCreate: (node: Node) => void; + NodeDestroy: (node: Node) => void; + NodeConnect: (node: Node) => void; + NodeReconnect: (node: Node) => void; + NodeDisconnect: (node: Node, reason: { code?: number; reason?: string }) => void; + NodeError: (node: Node, error: Error) => void; + NodeRaw: (payload: unknown) => void; + PlayerCreate: (player: Player) => void; + PlayerDestroy: (player: Player) => void; + PlayerStateUpdate: (oldPlayer: Player, newPlayer: Player) => void; + PlayerMove: (player: Player, initChannel: string, newChannel: string) => void; + PlayerDisconnect: (player: Player, oldChannel: string) => void; + QueueEnd: (player: Player, track: Track | UnresolvedTrack, payload: TrackEndEvent) => void; + SocketClosed: (player: Player, payload: WebSocketClosedEvent) => void; + TrackStart: (player: Player, track: Track, payload: TrackStartEvent) => void; + TrackEnd: (player: Player, track: Track, payload: TrackEndEvent) => void; + TrackStuck: (player: Player, track: Track, payload: TrackStuckEvent) => void; + TrackError: (player: Player, track: Track | UnresolvedTrack, payload: TrackExceptionEvent) => void; } diff --git a/src/structures/Node.ts b/src/structures/Node.ts index 1c331df..d237247 100644 --- a/src/structures/Node.ts +++ b/src/structures/Node.ts @@ -1,404 +1,462 @@ -/* eslint-disable no-case-declarations */ -import WebSocket from "ws"; +import { PlayerEvent, PlayerEvents, Structure, TrackEndEvent, TrackExceptionEvent, TrackStartEvent, TrackStuckEvent, WebSocketClosedEvent } from "./Utils"; 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 } 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.'); -} +import nodeCheck from "../utils/nodeCheck"; +import WebSocket from "ws"; 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); - } - - check(options); - - this.options = { - port: 2333, - password: "youshallnotpass", - secure: false, - retryAmount: 5, - retryDelay: 30e3, - ...options, - }; - - if (this.options.secure) { - this.options.port = 443; - } - - 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, - }; - this.socket = new WebSocket(`ws${this.options.secure ? "s" : ""}://${this.address}${this.options.version === "v4" ? "/v4/websocket" : "/"}`, { headers }); - 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); + /** The socket for the node. */ + public socket: WebSocket | null = null; + /** The stats for the node. */ + public stats: NodeStats; + public manager: Manager; + /** The node's session ID. */ + public sessionId: string | null; + /** The REST instance. */ + public readonly rest: Rest; + + private static _manager: Manager; + private reconnectTimeout?: NodeJS.Timeout; + private reconnectAttempts = 1; + + /** 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); + } + + nodeCheck(options); + + this.options = { + port: 2333, + password: "youshallnotpass", + secure: false, + retryAmount: 30, + retryDelay: 60000, + priority: 0, + ...options, + }; + + if (this.options.secure) { + this.options.port = 443; + } + + 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); + this.rest = new Rest(this); + } + + /** Connects to the Node. */ + public connect(): void { + if (this.connected) return; + + const headers = Object.assign({ + Authorization: this.options.password, + "Num-Shards": String(this.manager.options.shards), + "User-Id": this.manager.options.clientId, + "Client-Name": this.manager.options.clientName, + }); + + this.socket = new WebSocket(`ws${this.options.secure ? "s" : ""}://${this.address}/v4/websocket`, { headers }); + 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.removeListener("close", this.close.bind(this)); + this.socket = null; + + this.reconnectAttempts = 1; + clearTimeout(this.reconnectTimeout); + + this.manager.emit("NodeDestroy", this); + this.manager.destroyNode(this.options.identifier); + } + + 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.removeListener("close", this.close.bind(this)); + this.socket = null; + this.manager.emit("NodeReconnect", this); + this.connect(); + this.reconnectAttempts++; + }, this.options.retryDelay) 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); + + let player: Player; + + switch (payload.op) { + case "stats": + delete payload.op; + this.stats = { ...payload } as unknown as NodeStats; + break; + case "playerUpdate": + player = this.manager.players.get(payload.guildId); + if (player) player.position = payload.state.position || 0; + break; + case "event": + this.handleEvent(payload); + break; + case "ready": + this.rest.setSessionId(payload.sessionId); this.sessionId = payload.sessionId; if (this.options.resumeStatus) { this.rest.patch(`/v4/sessions/${this.sessionId}`, { resuming: this.options.resumeStatus, timeout: this.options.resumeTimeout, - }).then((data) => { console.log(data) }); + }); + } + break; + default: + this.manager.emit("NodeError", this, new Error(`Unexpected op "${payload.op}" with data: ${payload.message}`)); + return; + } + } + + protected async handleEvent(payload: PlayerEvent & PlayerEvents): Promise { + if (!payload.guildId) return; + + const player = this.manager.players.get(payload.guildId); + if (!player) return; + + const track = player.queue.current; + const type = payload.type; + + let error: Error; + switch (type) { + case "TrackStartEvent": + this.trackStart(player, track as Track, payload); + break; + + case "TrackEndEvent": + if (player?.nowPlayingMessage && player?.nowPlayingMessage.deletable) { + await player?.nowPlayingMessage?.delete().catch(() => {}); } - 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; - } - } - - 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 + + this.trackEnd(player, track as Track, payload); + break; + + case "TrackStuckEvent": + this.trackStuck(player, track as Track, payload); + break; + + case "TrackExceptionEvent": + this.trackError(player, track, payload); + break; + + case "WebSocketClosedEvent": + this.socketClosed(player, payload); + break; + + default: + error = new Error(`Node#event unknown event '${type}'.`); + this.manager.emit("NodeError", this, error); + break; + } + } + + protected trackStart(player: Player, track: Track, payload: TrackStartEvent): void { + player.playing = true; + player.paused = false; + this.manager.emit("TrackStart", player, track, payload); + } + + protected async trackEnd(player: Player, track: Track, payload: TrackEndEvent): Promise { + const { reason } = payload; + + // If the track failed to load or was cleaned up + if (["loadFailed", "cleanup"].includes(reason)) { + this.handleFailedTrack(player, track, payload); + } + // If the track was forcibly replaced + else if (reason === "replaced") { + this.manager.emit("TrackEnd", player, track, payload); + player.queue.previous = player.queue.current; + } + // If the track ended and it's set to repeat (track or queue) + else if (track && (player.trackRepeat || player.queueRepeat)) { + this.handleRepeatedTrack(player, track, payload); + } + // If there's another track in the queue + else if (player.queue.length) { + this.playNextTrack(player, track, payload); + } + // If there are no more tracks in the queue + else { + await this.queueEnd(player, track, payload); + } + } + + // Handle autoplay + private async handleAutoplay(player: Player, track: Track) { + const previousTrack = player.queue.previous; + + if (!player.isAutoplay || !previousTrack) return; + + const hasYouTubeURL = ["youtube.com", "youtu.be"].some((url) => previousTrack.uri.includes(url)); + + let videoID = previousTrack.uri.substring(previousTrack.uri.indexOf("=") + 1); + + if (!hasYouTubeURL) { + const res = await player.search(`${previousTrack.author} - ${previousTrack.title}`); + + videoID = res.tracks[0].uri.substring(res.tracks[0].uri.indexOf("=") + 1); + } + + let randomIndex: number; + let searchURI: string; + + do { + randomIndex = Math.floor(Math.random() * 23) + 2; + searchURI = `https://www.youtube.com/watch?v=${videoID}&list=RD${videoID}&index=${randomIndex}`; + } while (track.uri.includes(searchURI)); + + const res = await player.search(searchURI, player.get("Internal_BotUser")); + + if (res.loadType === "empty" || res.loadType === "error") return; + + let tracks = res.tracks; + + if (res.loadType === "playlist") { + tracks = res.playlist.tracks; + } + + const foundTrack = tracks.sort(() => Math.random() - 0.5).find((shuffledTrack) => shuffledTrack.uri !== track.uri); + + if (foundTrack) { + player.queue.add(foundTrack); + player.play(); + } + } + + // Handle the case when a track failed to load or was cleaned up + private handleFailedTrack(player: Player, track: Track, payload: TrackEndEvent): void { + player.queue.previous = player.queue.current; + player.queue.current = player.queue.shift(); + + if (!player.queue.current) { + this.queueEnd(player, track, payload); + return; + } + + this.manager.emit("TrackEnd", player, track, payload); + if (this.manager.options.autoPlay) player.play(); + } + + // Handle the case when a track ended and it's set to repeat (track or queue) + private handleRepeatedTrack(player: Player, track: Track, payload: TrackEndEvent): void { + const { queue, trackRepeat, queueRepeat } = player; + const { autoPlay } = this.manager.options; + + if (trackRepeat) { + queue.unshift(queue.current); + } else if (queueRepeat) { + queue.add(queue.current); + } + + queue.previous = queue.current; + queue.current = queue.shift(); + + this.manager.emit("TrackEnd", player, track, payload); + + if (payload.reason === "stopped" && !(queue.current = queue.shift())) { + this.queueEnd(player, track, payload); + return; + } + + if (autoPlay) player.play(); + } + + // Handle the case when there's another track in the queue + private playNextTrack(player: Player, track: Track, payload: TrackEndEvent): void { + 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(); + } + + protected async queueEnd(player: Player, track: Track, payload: TrackEndEvent): Promise { + player.queue.previous = player.queue.current; + player.queue.current = null; + + if (!player.isAutoplay) { + player.queue.previous = player.queue.current; + player.queue.current = null; + player.playing = false; + this.manager.emit("QueueEnd", player, track, payload); + return; + } + + await this.handleAutoplay(player, track); + } + + 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); + } +} + +export interface NodeOptions { + /** The host for the node. */ + host: string; + /** The port for the node. */ + port?: number; + /** The password for the node. */ + password?: string; + /** Whether the host uses SSL. */ + secure?: boolean; + /** The identifier for the node. */ + identifier?: string; + /** The retryAmount for the node. */ + retryAmount?: number; + /** The retryDelay for the node. */ + retryDelay?: number; + /** Whether to resume the previous session. */ + resumeStatus?: boolean; + /** The time the manager will wait before trying to resume the previous session. */ + resumeTimeout?: number; + /** The timeout used for api calls. */ + requestTimeout?: number; + /** Priority of the node. */ + priority?: 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; +} + +export interface CPUStats { + /** The core amount the host machine has. */ + cores: number; + /** The system load. */ + systemLoad: number; + /** The lavalink load. */ + lavalinkLoad: number; +} + +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/structures/Player.ts b/src/structures/Player.ts index da9f933..7c12eaf 100644 --- a/src/structures/Player.ts +++ b/src/structures/Player.ts @@ -1,55 +1,23 @@ +import { Filters } from "./Filters"; 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.'); -} +import { Sizes, State, Structure, TrackSourceName, TrackUtils, VoiceState } from "./Utils"; +import * as _ from "lodash"; +import playerCheck from "../utils/playerCheck"; +import { ClientUser, Message, User } from "discord.js"; export class Player { /** The Queue for the Player. */ public readonly queue = new (Structure.get("Queue"))() as Queue; + /** The filters applied to the audio. */ + public filters: Filters; /** Whether the queue repeats the track. */ public trackRepeat = false; /** Whether the queue repeats the queue. */ public queueRepeat = false; + /**Whether the queue repeats and shuffles after each song. */ + public dynamicRepeat = false; /** The time the player is in the track. */ public position = 0; /** Whether the player is playing. */ @@ -66,17 +34,22 @@ export class Player { public voiceChannel: string | null = null; /** The text channel for the player. */ public textChannel: string | null = null; + /**The now playing message. */ + public nowPlayingMessage?: Message; /** 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; + /** The autoplay state of the player. */ + public isAutoplay: boolean = false; + private static _manager: Manager; private readonly data: Record = {}; + private dynamicLoopInterval: NodeJS.Timeout | null = null; /** * Set custom data. @@ -107,21 +80,31 @@ export class Player { 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); + + playerCheck(options); + this.guild = options.guild; - this.voiceState = Object.assign({ op: "voiceUpdate", guildId: options.guild }); + this.voiceState = Object.assign({ + op: "voiceUpdate", + guild_id: 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.leastLoadNodes.first(); + this.node = node || this.manager.useableNodes; + if (!this.node) throw new RangeError("No available nodes."); - this.sessionId = this.node.sessionId; + this.manager.players.set(options.guild, this); this.manager.emit("PlayerCreate", this); this.setVolume(options.volume ?? 100); + this.filters = new Filters(this); } /** @@ -129,51 +112,13 @@ export class Player { * @param query * @param requester */ - public search( - query: string | SearchQuery, - requester?: unknown - ): Promise { + public search(query: string | SearchQuery, requester?: User | ClientUser): Promise { return this.manager.search(query, requester); } - /** - * 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((a, b) => a.localeCompare(b))) === '["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; - if (this.node.options.version === "v3") { - 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); - if (this.node.options.version === "v3") { - 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."); + if (!this.voiceChannel) throw new RangeError("No voice channel has been set."); this.state = "CONNECTING"; this.manager.options.send(this.guild, { @@ -214,16 +159,12 @@ export class Player { /** Destroys the player. */ public destroy(disconnect = true): void { this.state = "DESTROYING"; + if (disconnect) { this.disconnect(); } - 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); } @@ -233,8 +174,7 @@ export class Player { * @param channel */ public setVoiceChannel(channel: string): this { - if (typeof channel !== "string") - throw new TypeError("Channel must be a non-empty string."); + if (typeof channel !== "string") throw new TypeError("Channel must be a non-empty string."); this.voiceChannel = channel; this.connect(); @@ -246,13 +186,20 @@ export class Player { * @param channel */ public setTextChannel(channel: string): this { - if (typeof channel !== "string") - throw new TypeError("Channel must be a non-empty string."); + if (typeof channel !== "string") throw new TypeError("Channel must be a non-empty string."); this.textChannel = channel; return this; } + /** Sets the now playing message. */ + public setNowPlayingMessage(message: Message): Message { + if (!message) { + throw new TypeError("You must provide the message of the now playing message."); + } + return (this.nowPlayingMessage = message); + } + /** Plays the next track. */ public async play(): Promise; @@ -273,14 +220,9 @@ export class Player { * @param track * @param options */ - public async play( - optionsOrTrack?: PlayOptions | Track | UnresolvedTrack, - playOptions?: PlayOptions - ): Promise { - if ( - typeof optionsOrTrack !== "undefined" && - TrackUtils.validate(optionsOrTrack) - ) { + public async play(track: Track | UnresolvedTrack, options: PlayOptions): Promise; + 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; } @@ -289,11 +231,9 @@ export class Player { const finalOptions = playOptions ? playOptions - : ["startTime", "endTime", "noReplace"].every((v) => - Object.keys(optionsOrTrack || {}).includes(v) - ) - ? (optionsOrTrack as PlayOptions) - : {}; + : ["startTime", "endTime", "noReplace"].every((v) => Object.keys(optionsOrTrack || {}).includes(v)) + ? (optionsOrTrack as PlayOptions) + : {}; if (TrackUtils.isUnresolvedTrack(this.queue.current)) { try { @@ -305,28 +245,35 @@ export class Player { } } - const options = { - op: "play", + await this.node.rest.updatePlayer({ guildId: this.guild, - track: this.queue.current.track, - ...finalOptions, - }; + data: { + encodedTrack: this.queue.current?.track, + ...finalOptions, + }, + }); - if (typeof options.track !== "string") { - options.track = (options.track as Track).track; + Object.assign(this, { position: 0, playing: true }); + } + + /** + * Sets the autoplay-state of the player. + * @param autoplayState + * @param botUser + */ + public setAutoplay(autoplayState: boolean, botUser: object) { + if (typeof autoplayState !== "boolean") { + throw new TypeError("autoplayState must be a boolean."); } - if (this.node.options.version === "v4") { - await this.node.rest.updatePlayer({ - guildId: this.guild, - data: { - encodedTrack: this.queue.current?.track, - ...finalOptions, - }, - }); - } else { - await this.node.send(options); + if (typeof botUser !== "object") { + throw new TypeError("botUser must be a user-object."); } + + this.isAutoplay = autoplayState; + this.set("Internal_BotUser", botUser); + + return this; } /** @@ -334,17 +281,17 @@ export class Player { * @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, + this.node.rest.updatePlayer({ + guildId: this.options.guild, + data: { + volume, + }, }); + this.volume = volume; + return this; } @@ -353,17 +300,21 @@ export class Player { * @param repeat */ public setTrackRepeat(repeat: boolean): this { - if (typeof repeat !== "boolean") - throw new TypeError('Repeat can only be "true" or "false".'); + if (typeof repeat !== "boolean") throw new TypeError('Repeat can only be "true" or "false".'); + + const oldPlayer = { ...this }; if (repeat) { this.trackRepeat = true; this.queueRepeat = false; + this.dynamicRepeat = false; } else { this.trackRepeat = false; this.queueRepeat = false; + this.dynamicRepeat = false; } + this.manager.emit("PlayerStateUpdate", oldPlayer, this); return this; } @@ -372,20 +323,80 @@ export class Player { * @param repeat */ public setQueueRepeat(repeat: boolean): this { - if (typeof repeat !== "boolean") - throw new TypeError('Repeat can only be "true" or "false".'); + if (typeof repeat !== "boolean") throw new TypeError('Repeat can only be "true" or "false".'); + + const oldPlayer = { ...this }; if (repeat) { this.trackRepeat = false; this.queueRepeat = true; + this.dynamicRepeat = false; + } else { + this.trackRepeat = false; + this.queueRepeat = false; + this.dynamicRepeat = false; + } + + this.manager.emit("PlayerStateUpdate", oldPlayer, this); + return this; + } + + /** + * Sets the queue to repeat and shuffles the queue after each song. + * @param repeat "true" or "false". + * @param ms After how many milliseconds to trigger dynamic repeat. + */ + public setDynamicRepeat(repeat: boolean, ms: number): this { + if (typeof repeat !== "boolean") { + throw new TypeError('Repeat can only be "true" or "false".'); + } + + if (this.queue.size <= 1) { + throw new RangeError("The queue size must be greater than 1."); + } + + const oldPlayer = { ...this }; + + if (repeat) { + this.trackRepeat = false; + this.queueRepeat = false; + this.dynamicRepeat = true; + + this.dynamicLoopInterval = setInterval(() => { + if (!this.dynamicRepeat) return; + const shuffled = _.shuffle(this.queue); + this.queue.clear(); + shuffled.forEach((track) => { + this.queue.add(track); + }); + }, ms) as NodeJS.Timeout; } else { + clearInterval(this.dynamicLoopInterval); this.trackRepeat = false; this.queueRepeat = false; + this.dynamicRepeat = false; } + this.manager.emit("PlayerStateUpdate", oldPlayer, this); return this; } + /** Restarts the current track to the start. */ + public restart(): void { + if (!this.queue.current?.track) { + if (this.queue.length) this.play(); + return; + } + + this.node.rest.updatePlayer({ + guildId: this.guild, + data: { + position: 0, + encodedTrack: this.queue.current?.track, + }, + }); + } + /** 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) { @@ -393,9 +404,11 @@ export class Player { this.queue.splice(0, amount - 1); } - this.node.send({ - op: "stop", + this.node.rest.updatePlayer({ guildId: this.guild, + data: { + encodedTrack: null, + }, }); return this; @@ -406,19 +419,31 @@ export class Player { * @param pause */ public pause(pause: boolean): this { - if (typeof pause !== "boolean") - throw new RangeError('Pause can only be "true" or "false".'); + if (typeof pause !== "boolean") throw new RangeError('Pause can only be "true" or "false".'); + if (this.paused === pause || !this.queue.totalSize) return this; + const oldPlayer = { ...this }; + this.playing = !pause; this.paused = pause; - this.node.send({ - op: "pause", + this.node.rest.updatePlayer({ guildId: this.guild, - pause, + data: { + paused: pause, + }, }); + this.manager.emit("PlayerStateUpdate", oldPlayer, this); + return this; + } + + /** Go back to the previous song. */ + public previous(): this { + this.queue.unshift(this.queue.previous); + this.stop(); + return this; } @@ -433,14 +458,15 @@ export class Player { 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); + 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", + + this.node.rest.updatePlayer({ guildId: this.guild, - position, + data: { + position: position, + }, }); return this; @@ -468,14 +494,20 @@ export interface PlayerOptions { export interface Track { /** The base64 encoded track. */ readonly track: string; + /** The artwork url of the track. */ + readonly artworkUrl: string; + /** The track source name. */ + readonly sourceName: TrackSourceName; /** The title of the track. */ - readonly title: string; + title: string; /** The identifier of the track. */ readonly identifier: string; /** The author of the track. */ - readonly author: string; + author: string; /** The duration of the track. */ readonly duration: number; + /** The ISRC of the track. */ + readonly isrc: string; /** If the track is seekable. */ readonly isSeekable: boolean; /** If the track is a stream.. */ @@ -485,9 +517,22 @@ export interface Track { /** 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; + readonly requester: User | ClientUser | null; /** Displays the track thumbnail with optional size or null if it's a unsupported source. */ displayThumbnail(size?: Sizes): string; + /** Additional track info provided by plugins. */ + pluginInfo: TrackPluginInfo; + /** Add your own data to the track. */ + customData: Record; +} + +export interface TrackPluginInfo { + albumName?: string; + albumUrl?: string; + artistArtworkUrl?: string; + artistUrl?: string; + isPreview?: string; + previewUrl?: string; } /** Unresolved tracks can't be played normally, they will resolve before playing into a Track. */ diff --git a/src/structures/Queue.ts b/src/structures/Queue.ts index 7d272eb..d5358b2 100644 --- a/src/structures/Queue.ts +++ b/src/structures/Queue.ts @@ -3,116 +3,111 @@ 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[]".'); - } - - 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]]; - } - } + /** The total duration of the queue. */ + public get duration(): number { + const current = this.current?.duration ?? 0; + return this.reduce((acc, cur) => 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[]".'); + } + + if (!this.current) { + if (Array.isArray(track)) { + this.current = track.shift() || null; + this.push(...track); + } else { + this.current = track; + } + } else { + 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 between 0 and ${this.length}.`); + } + + if (Array.isArray(track)) { + this.splice(offset, 0, ...track); + } else { + this.splice(offset, 0, track); + } + } else { + if (Array.isArray(track)) { + this.push(...track); + } else { + this.push(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 | UnresolvedTrack)[]; + + /** + * 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)) || isNaN(Number(end))) { + throw new RangeError(`Missing "start" or "end" parameter.`); + } + + if (startOrPosition >= end || startOrPosition >= this.length) { + throw new RangeError("Invalid start or end values."); + } + + 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 14db6ae..294ebf0 100644 --- a/src/structures/Rest.ts +++ b/src/structures/Rest.ts @@ -1,45 +1,120 @@ -import { Dispatcher } from "undici"; -import { NodeOptions } from "../types/Node"; -import axios, { AxiosInstance } from "axios"; -import { PlayOptionsData } from "../types/Rest"; +import { Node } from "./Node"; +import axios, { AxiosRequestConfig } from "axios"; +/** Handles the requests sent to the Lavalink REST API. */ 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: options.password - } - }); - } - - public async get(path: string) { - return (await this.rest.get(path)).data; - } - public async post(path: string, data: unknown) { - return (await this.rest.post(path, data)).data; - } - 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}`); - } - public async updatePlayer(options: PlayOptionsData): Promise { - return await this.patch(`/sessions/${this.sessionId}/players/${options.guildId}?noReplace=false`, options.data); + /** 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. */ + private readonly url: string; + + constructor(node: Node) { + this.node = node; + this.url = `http${node.options.secure ? "s" : ""}://${node.options.host}:${node.options.port}`; + this.sessionId = node.sessionId; + this.password = node.options.password; + } + + /** + * Sets the session ID. + * @returns {string} Returns the session ID. + */ + public setSessionId(sessionId: string): string { + this.sessionId = sessionId; + return this.sessionId; + } + + /** Retrieves all the players that are currently running on the node. */ + public async getAllPlayers(): Promise { + return await this.get(`/v4/sessions/${this.sessionId}/players`); + } + + /** Sends a PATCH request to update player related data. */ + public async updatePlayer(options: playOptions): Promise { + return await this.patch(`/v4/sessions/${this.sessionId}/players/${options.guildId}?noReplace=false`, options.data); + } + + /** Sends a DELETE request to the server to destroy the player. */ + public async destroyPlayer(guildId: string): Promise { + return await this.delete(`/v4/sessions/${this.sessionId}/players/${guildId}`); + } + + /* Sends a GET request to the specified endpoint and returns the response data. */ + private async request(method: string, endpoint: string, body?: unknown): Promise { + const config: AxiosRequestConfig = { + method, + url: this.url + endpoint, + headers: { + "Content-Type": "application/json", + Authorization: this.password, + }, + data: body, + }; + + try { + const response = await axios(config); + return response.data; + } catch(error) { + if (error?.response?.status === 404) { + this.node.destroy(); + this.node.manager.createNode(this.node.options).connect(); + } + + return null; + } + } + + /* Sends a GET request to the specified endpoint and returns the response data. */ + public async get(endpoint: string): Promise { + return await this.request("GET", endpoint); + } + + /* Sends a PATCH request to the specified endpoint and returns the response data. */ + public async patch(endpoint: string, body: unknown): Promise { + return await this.request("PATCH", endpoint, body); + } + + /* Sends a POST request to the specified endpoint and returns the response data. */ + public async post(endpoint: string, body: unknown): Promise { + return await this.request("POST", endpoint, body); + } + + /* Sends a DELETE request to the specified endpoint and returns the response data. */ + public async delete(endpoint: string): Promise { + return await this.request("DELETE", endpoint); } } -export type ModifyRequest = (options: Dispatcher.RequestOptions) => void; +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; + }; +} diff --git a/src/structures/Utils.ts b/src/structures/Utils.ts index 53c51c8..d3797f0 100644 --- a/src/structures/Utils.ts +++ b/src/structures/Utils.ts @@ -1,403 +1,363 @@ /* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-var-requires*/ -import { NodeStats } from "../types/Node"; +import { ClientUser, User } from "discord.js"; import { Manager } from "./Manager"; -import { Node } from "./Node"; +import { Node, NodeStats } 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 */ + 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]; - } + 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?: User | ClientUser): Track { + if (typeof data === "undefined") throw new RangeError('Argument "data" must be present.'); + + try { + const track: Track = { + track: data.encoded, + title: data.info.title, + identifier: data.info.identifier, + author: data.info.author, + duration: data.info.length, + isrc: data.info?.isrc, + isSeekable: data.info.isSeekable, + isStream: data.info.isStream, + uri: data.info.uri, + artworkUrl: data.info?.artworkUrl, + sourceName: data.info?.sourceName, + 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, + pluginInfo: { + albumName: data.pluginInfo?.albumName, + albumUrl: data.pluginInfo?.albumUrl, + artistArtworkUrl: data.pluginInfo?.artistArtworkUrl, + artistUrl: data.pluginInfo?.artistUrl, + isPreview: data.pluginInfo?.isPreview, + previewUrl: data.pluginInfo?.previewUrl, + }, + customData: {}, + }; + + 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?: User | ClientUser): 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.uri ? unresolvedTrack.uri : [unresolvedTrack.author, unresolvedTrack.title].filter(Boolean).join(" - "); + const res = await TrackUtils.manager.search(query, unresolvedTrack.requester); + + 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; + } + + const finalTrack = res.tracks[0]; + finalTrack.customData = unresolvedTrack.customData; + return finalTrack; + } } /** 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; - } + /** + * 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 load(manager: Manager): void {} - public unload(manager: Manager): void {} + public unload(manager: Manager): void {} } const structures = { - Player: require("./Player").Player, - Queue: require("./Queue").Queue, - Node: require("./Node").Node, + 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; + /** 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 type Sizes = "0" | "1" | "2" | "3" | "default" | "mqdefault" | "hqdefault" | "maxresdefault"; + +export type LoadType = "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" | "loadFailed" | "stopped" | "replaced" | "cleanup"; + +export type Severity = "common" | "suspicious" | "fault"; export interface TrackData { - track: string; - info: TrackDataInfo; + /** The track information. */ + encoded: string; + /** The detailed information of the track. */ + info: TrackDataInfo; + /** Additional track info provided by plugins. */ + pluginInfo: Record; } export interface TrackDataInfo { - title: string; - identifier: string; - author: string; - length: number; - isSeekable: boolean; - isStream: boolean; - uri: string; + identifier: string; + isSeekable: boolean; + author: string; + length: number; + isrc?: string; + isStream: boolean; + title: string; + uri?: string; + artworkUrl?: string; + sourceName?: TrackSourceName; } +export type TrackSourceName = "deezer" | "spotify" | "soundcloud" | "youtube"; + export interface Extendable { - Player: typeof Player; - Queue: typeof Queue; - Node: typeof Node; + Player: typeof Player; + Queue: typeof Queue; + Node: typeof Node; } export interface VoiceState { - op: "voiceUpdate"; - guildId: string; - event: VoiceServer; - sessionId?: string; + op: "voiceUpdate"; + guildId: string; + event: VoiceServer; + sessionId?: string; } export interface VoiceServer { - token: string; - guild_id: string; - endpoint: string; + token: string; + guild_id: string; + endpoint: string; } export interface VoiceState { - guild_id: string; - user_id: string; - session_id: string; - channel_id: string; + 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; + t?: "VOICE_SERVER_UPDATE" | "VOICE_STATE_UPDATE"; + d: VoiceState | VoiceServer; } export interface NodeMessage extends NodeStats { - type: PlayerEventType; - op: "stats" | "playerUpdate" | "event"; - guildId: string; + type: PlayerEventType; + op: "stats" | "playerUpdate" | "event"; + guildId: string; } export interface PlayerEvent { - op: "event"; - type: PlayerEventType; - guildId: string; + op: "event"; + type: PlayerEventType; + guildId: string; } export interface Exception { - severity: Severity; - message: string; - cause: string; + message: string; + severity: Severity; + cause: string; } export interface TrackStartEvent extends PlayerEvent { - type: "TrackStartEvent"; - track: string; + type: "TrackStartEvent"; + track: TrackData; } export interface TrackEndEvent extends PlayerEvent { - type: "TrackEndEvent"; - track: string; - reason: TrackEndReason; + type: "TrackEndEvent"; + track: TrackData; + reason: TrackEndReason; } export interface TrackExceptionEvent extends PlayerEvent { - type: "TrackExceptionEvent"; - exception?: Exception; - error: string; + exception?: Exception; + guildId: string; + type: "TrackExceptionEvent"; } export interface TrackStuckEvent extends PlayerEvent { - type: "TrackStuckEvent"; - thresholdMs: number; + type: "TrackStuckEvent"; + thresholdMs: number; } export interface WebSocketClosedEvent extends PlayerEvent { - type: "WebSocketClosedEvent"; - code: number; - byRemote: boolean; - reason: string; + type: "WebSocketClosedEvent"; + code: number; + reason: string; + byRemote: boolean; } export interface PlayerUpdate { - op: "playerUpdate"; - state: { - position: number; - time: number; - }; - guildId: string; -} \ No newline at end of file + op: "playerUpdate"; + /** The guild id of the player. */ + guildId: string; + state: { + /** Unix timestamp in milliseconds. */ + time: number; + /** The position of the track in milliseconds. */ + position: number; + /** Whether Lavalink is connected to the voice gateway. */ + connected: boolean; + /** The ping of the node to the Discord voice server in milliseconds (-1 if not connected). */ + ping: number; + }; +} diff --git a/src/types/Manager.ts b/src/types/Manager.ts deleted file mode 100644 index 263eb32..0000000 --- a/src/types/Manager.ts +++ /dev/null @@ -1,116 +0,0 @@ -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; - - /** - * 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; - - /** - * 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; - - /** - * Emitted when a player queue ends. - * @event Manager#queueEnd - */ - QueueEnd: ( - player: Player, - track: Track | UnresolvedTrack, - payload: TrackEndEvent - ) => 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; - - /** - * 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 deleted file mode 100644 index edb35d1..0000000 --- a/src/types/Node.ts +++ /dev/null @@ -1,67 +0,0 @@ -export interface NodeOptions { - /** The host for the node. */ - host: string; - /** The port for the node. */ - port?: number; - /** The password for the node. */ - password?: string; - /** Whether the host uses SSL. */ - secure?: boolean; - /** 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; -} - -export interface CPUStats { - /** The core amount the host machine has. */ - cores: number; - /** The system load. */ - systemLoad: number; - /** The lavalink load. */ - lavalinkLoad: number; -} - -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/Rest.ts b/src/types/Rest.ts deleted file mode 100644 index 54a1c0d..0000000 --- a/src/types/Rest.ts +++ /dev/null @@ -1,29 +0,0 @@ -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/src/utils/filtersEqualizers.ts b/src/utils/filtersEqualizers.ts new file mode 100644 index 0000000..938666e --- /dev/null +++ b/src/utils/filtersEqualizers.ts @@ -0,0 +1,93 @@ +/** Represents an equalizer band. */ +export interface Band { + /** The index of the equalizer band. */ + band: number; + /** The gain value of the equalizer band. */ + gain: number; +} + +export const bassBoostEqualizer: Band[] = [ + { band: 0, gain: 0.2 }, + { band: 1, gain: 0.15 }, + { band: 2, gain: 0.1 }, + { band: 3, gain: 0.05 }, + { band: 4, gain: 0.0 }, + { band: 5, gain: -0.05 }, + { band: 6, gain: -0.1 }, + { band: 7, gain: -0.1 }, + { band: 8, gain: -0.1 }, + { band: 9, gain: -0.1 }, + { band: 10, gain: -0.1 }, + { band: 11, gain: -0.1 }, + { band: 12, gain: -0.1 }, + { band: 13, gain: -0.1 }, + { band: 14, gain: -0.1 }, +]; + +export const softEqualizer: Band[] = [ + { band: 0, gain: 0 }, + { band: 1, gain: 0 }, + { band: 2, gain: 0 }, + { band: 3, gain: 0 }, + { band: 4, gain: 0 }, + { band: 5, gain: 0 }, + { band: 6, gain: 0 }, + { band: 7, gain: 0 }, + { band: 8, gain: -0.25 }, + { band: 9, gain: -0.25 }, + { band: 10, gain: -0.25 }, + { band: 11, gain: -0.25 }, + { band: 12, gain: -0.25 }, + { band: 13, gain: -0.25 }, +]; + +export const tvEqualizer: Band[] = [ + { band: 0, gain: 0 }, + { band: 1, gain: 0 }, + { band: 2, gain: 0 }, + { band: 3, gain: 0 }, + { band: 4, gain: 0 }, + { band: 5, gain: 0 }, + { band: 6, gain: 0 }, + { band: 7, gain: 0.65 }, + { band: 8, gain: 0.65 }, + { band: 9, gain: 0.65 }, + { band: 10, gain: 0.65 }, + { band: 11, gain: 0.65 }, + { band: 12, gain: 0.65 }, + { band: 13, gain: 0.65 }, +]; + +export const trebleBassEqualizer: Band[] = [ + { band: 0, gain: 0.6 }, + { band: 1, gain: 0.67 }, + { band: 2, gain: 0.67 }, + { band: 3, gain: 0 }, + { band: 4, gain: -0.5 }, + { band: 5, gain: 0.15 }, + { band: 6, gain: -0.45 }, + { band: 7, gain: 0.23 }, + { band: 8, gain: 0.35 }, + { band: 9, gain: 0.45 }, + { band: 10, gain: 0.55 }, + { band: 11, gain: 0.6 }, + { band: 12, gain: 0.55 }, + { band: 13, gain: 0 }, +]; + +export const vaporwaveEqualizer: Band[] = [ + { band: 0, gain: 0 }, + { band: 1, gain: 0 }, + { band: 2, gain: 0 }, + { band: 3, gain: 0 }, + { band: 4, gain: 0 }, + { band: 5, gain: 0 }, + { band: 6, gain: 0 }, + { band: 7, gain: 0 }, + { band: 8, gain: 0.15 }, + { band: 9, gain: 0.15 }, + { band: 10, gain: 0.15 }, + { band: 11, gain: 0.15 }, + { band: 12, gain: 0.15 }, + { band: 13, gain: 0.15 }, +]; diff --git a/src/utils/managerCheck.ts b/src/utils/managerCheck.ts new file mode 100644 index 0000000..98f95cf --- /dev/null +++ b/src/utils/managerCheck.ts @@ -0,0 +1,70 @@ +import { ManagerOptions } from "../structures/Manager"; + +export default function managerCheck(options: ManagerOptions) { + if (!options) throw new TypeError("ManagerOptions must not be empty."); + + const { autoPlay, clientId, clientName, defaultSearchPlatform, nodes, plugins, send, shards, trackPartial, usePriority, useNode, replaceYouTubeCredentials } = + options; + + if (typeof autoPlay !== "undefined" && typeof autoPlay !== "boolean") { + throw new TypeError('Manager option "autoPlay" must be a boolean.'); + } + + if (typeof clientId !== "undefined" && !/^\d+$/.test(clientId)) { + throw new TypeError('Manager option "clientId" must be a non-empty string.'); + } + + if (typeof clientName !== "undefined" && typeof clientName !== "string") { + throw new TypeError('Manager option "clientName" must be a string.'); + } + + if (typeof defaultSearchPlatform !== "undefined" && typeof defaultSearchPlatform !== "string") { + throw new TypeError('Manager option "defaultSearchPlatform" must be a string.'); + } + + if (typeof nodes !== "undefined" && !Array.isArray(nodes)) { + throw new TypeError('Manager option "nodes" must be an array.'); + } + + if (typeof plugins !== "undefined" && !Array.isArray(plugins)) { + throw new TypeError('Manager option "plugins" must be a Plugin array.'); + } + + if (typeof send !== "function") { + throw new TypeError('Manager option "send" must be present and a function.'); + } + + if (typeof shards !== "undefined" && typeof shards !== "number") { + throw new TypeError('Manager option "shards" must be a number.'); + } + + if (typeof trackPartial !== "undefined" && !Array.isArray(trackPartial)) { + throw new TypeError('Manager option "trackPartial" must be a string array.'); + } + + if (typeof usePriority !== "undefined" && typeof usePriority !== "boolean") { + throw new TypeError('Manager option "usePriority" must be a boolean.'); + } + + if (usePriority) { + for (let index = 0; index < nodes.length; index++) { + if (!nodes[index].priority) { + throw new TypeError(`Missing node option "priority" at position ${index}`); + } + } + } + + if (typeof useNode !== "undefined") { + if (typeof useNode !== "string") { + throw new TypeError('Manager option "useNode" must be a string "leastLoad" or "leastPlayers".'); + } + + if (useNode !== "leastLoad" && useNode !== "leastPlayers") { + throw new TypeError('Manager option must be either "leastLoad" or "leastPlayers".'); + } + } + + if (typeof replaceYouTubeCredentials !== "undefined" && typeof replaceYouTubeCredentials !== "boolean") { + throw new TypeError('Manager option "replaceYouTubeCredentials" must be a boolean.'); + } +} diff --git a/src/utils/nodeCheck.ts b/src/utils/nodeCheck.ts new file mode 100644 index 0000000..f7c63fb --- /dev/null +++ b/src/utils/nodeCheck.ts @@ -0,0 +1,51 @@ +import { NodeOptions } from "../structures/Node"; + +export default function nodeCheck(options: NodeOptions) { + if (!options) throw new TypeError("NodeOptions must not be empty."); + + const { host, identifier, password, port, requestTimeout, resumeStatus, resumeTimeout, retryAmount, retryDelay, secure, priority } = options; + + if (typeof host !== "string" || !/.+/.test(host)) { + throw new TypeError('Node option "host" must be present and be a non-empty string.'); + } + + if (typeof identifier !== "undefined" && typeof identifier !== "string") { + throw new TypeError('Node option "identifier" must be a non-empty string.'); + } + + if (typeof password !== "undefined" && (typeof password !== "string" || !/.+/.test(password))) { + throw new TypeError('Node option "password" must be a non-empty string.'); + } + + if (typeof port !== "undefined" && typeof port !== "number") { + throw new TypeError('Node option "port" must be a number.'); + } + + if (typeof requestTimeout !== "undefined" && typeof requestTimeout !== "number") { + throw new TypeError('Node option "requestTimeout" must be a number.'); + } + + if (typeof resumeStatus !== "undefined" && typeof resumeStatus !== "boolean") { + throw new TypeError('Node option "resumeStatus" must be a boolean.'); + } + + if (typeof resumeTimeout !== "undefined" && typeof resumeTimeout !== "number") { + throw new TypeError('Node option "resumeTimeout" must be a number.'); + } + + if (typeof retryAmount !== "undefined" && typeof retryAmount !== "number") { + throw new TypeError('Node option "retryAmount" must be a number.'); + } + + if (typeof retryDelay !== "undefined" && typeof retryDelay !== "number") { + throw new TypeError('Node option "retryDelay" must be a number.'); + } + + if (typeof secure !== "undefined" && typeof secure !== "boolean") { + throw new TypeError('Node option "secure" must be a boolean.'); + } + + if (typeof priority !== "undefined" && typeof priority !== "number") { + throw new TypeError('Node option "priority" must be a number.'); + } +} diff --git a/src/utils/playerCheck.ts b/src/utils/playerCheck.ts new file mode 100644 index 0000000..5cf5c47 --- /dev/null +++ b/src/utils/playerCheck.ts @@ -0,0 +1,35 @@ +import { PlayerOptions } from "../structures/Player"; + +export default function playerCheck(options: PlayerOptions) { + if (!options) throw new TypeError("PlayerOptions must not be empty."); + + const { guild, node, selfDeafen, selfMute, textChannel, voiceChannel, volume } = options; + + if (!/^\d+$/.test(guild)) { + throw new TypeError('Player option "guild" must be present and be a non-empty string.'); + } + + if (node && typeof node !== "string") { + throw new TypeError('Player option "node" must be a non-empty string.'); + } + + if (typeof selfDeafen !== "undefined" && typeof selfDeafen !== "boolean") { + throw new TypeError('Player option "selfDeafen" must be a boolean.'); + } + + if (typeof selfMute !== "undefined" && typeof selfMute !== "boolean") { + throw new TypeError('Player option "selfMute" must be a boolean.'); + } + + if (textChannel && !/^\d+$/.test(textChannel)) { + throw new TypeError('Player option "textChannel" must be a non-empty string.'); + } + + if (voiceChannel && !/^\d+$/.test(voiceChannel)) { + throw new TypeError('Player option "voiceChannel" must be a non-empty string.'); + } + + if (typeof volume !== "undefined" && typeof volume !== "number") { + throw new TypeError('Player option "volume" must be a number.'); + } +} diff --git a/test/player.ts b/test/player.ts index 1dc1e2b..7f76cb8 100644 --- a/test/player.ts +++ b/test/player.ts @@ -16,7 +16,6 @@ let manager = new Manager({ host: 'localhost', port: 2333, password: 'youshallnotpass', - version: "v4", }, ], clientId: "1234567890",