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 0d03db5..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",
@@ -42,5 +42,50 @@ 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(process.env.TOKEN);
\ No newline at end of file
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/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 5b2a2aa..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."));
- }
- return 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 4c016d2..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;
-
- this.node.send({
- op: "equalizer",
- guildId: this.guild,
- bands: this.bands.map((gain, band) => ({ band, gain })),
- });
-
- return this;
- }
-
- /** Clears the equalizer bands. */
- public clearEQ(): this {
- this.bands = new Array(15).fill(0.0);
-
- this.node.send({
- op: "equalizer",
- guildId: this.guild,
- bands: this.bands.map((gain, band) => ({ band, gain })),
- });
-
- return this;
- }
-
/** Connect to the voice channel. */
public connect(): this {
- if (!this.voiceChannel)
- throw new RangeError("No voice channel has been set.");
+ 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,18 +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,
+ },
+ });
+
+ Object.assign(this, { position: 0, playing: true });
+ }
- if (typeof options.track !== "string") {
- options.track = (options.track as Track).track;
+ /**
+ * 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.");
}
- 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;
}
/**
@@ -324,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;
}
@@ -343,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;
}
@@ -362,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) {
@@ -383,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;
@@ -396,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;
}
@@ -423,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;
@@ -458,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.. */
@@ -475,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