From 380d6f0e4c32792ba6330fa7f259ce16f6896ed0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?FAY=E3=82=B7?=
<103030954+FAYStarNext@users.noreply.github.com>
Date: Sun, 21 Jul 2024 18:28:39 +0700
Subject: [PATCH 1/3] chore: Update package.json license field to MIT
---
package.json | 1 +
1 file changed, 1 insertion(+)
diff --git a/package.json b/package.json
index 63175c3..af6d109 100644
--- a/package.json
+++ b/package.json
@@ -5,6 +5,7 @@
"type": "module",
"version": "1.0.1-indev",
"description": "Sunday a lavalink wrapper",
+ "license": "MIT",
"scripts": {
"build:js": "npx babel src --out-dir dist --extensions \".ts,.tsx\" --source-maps inline",
"lint": "npx x eslint src/**/*.ts",
From 062d335cca18414b4c2860b7ce19f185d695c3ff Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?FAY=E3=82=B7?=
<103030954+FAYStarNext@users.noreply.github.com>
Date: Sun, 21 Jul 2024 22:31:31 +0700
Subject: [PATCH 2/3] chore: Remove unused connection details in
test/manager.ts and test/player.ts
---
README.md | 27 +-
src/index.ts | 9 +-
src/structures/Manager.ts | 471 +++++++++++++++++++++++++++++------
src/structures/Node.ts | 507 +++++++++++++++++++++++++++++--------
src/structures/Player.ts | 510 ++++++++++++++++++++++++++++++++++----
src/structures/Queue.ts | 122 ++++++++-
src/structures/Rest.ts | 123 +++------
src/structures/Utils.ts | 403 ++++++++++++++++++++++++++++++
src/types/Discord.ts | 24 --
src/types/Manager.ts | 154 ++++++++----
src/types/Node.ts | 111 +++++----
src/types/Player.ts | 44 ----
src/types/Rest.ts | 53 ++--
test/connect.ts | 19 --
test/manager.ts | 7 +-
test/player.ts | 32 +--
tsconfig.json | 1 -
17 files changed, 2031 insertions(+), 586 deletions(-)
create mode 100644 src/structures/Utils.ts
delete mode 100644 src/types/Discord.ts
delete mode 100644 src/types/Player.ts
delete mode 100644 test/connect.ts
diff --git a/README.md b/README.md
index 6038865..d87da6d 100644
--- a/README.md
+++ b/README.md
@@ -29,7 +29,7 @@
- [🎈 Usage ](#-usage-)
- [🚀 Deployment ](#-deployment-)
- [⛏️ Built Using ](#️-built-using-)
-- [✍️ Authors ](#️-authors-)
+- [Credits](#credits)
## 🧐 About
@@ -62,25 +62,7 @@ That's it! You have successfully installed Sunday.ts and are ready to start usin
## 🎈 Usage
```ts
-import { Node } from "sunday.ts"
-const node = new Node({
- host: 'localhost',
- port: 2333,
- password: 'youshallnotpass',
-});
-
-node.on("ready", () => {
- console.log("Ready");
-});
-node.on("stats", () => {
- console.log(node.stats);
-});
-node.on("raw", (data) => {
- console.log(data);
-});
-node.connect();
-node.rest?.on("get", (data) => console.log(data));
-node.rest?.get("/loadtracks?identifier=dQw4w9WgXcQ")
+
```
## 🚀 Deployment
@@ -91,9 +73,8 @@ Add additional notes about how to deploy this on a live system.
- [WebSocket](https://github.com/websockets/ws) - WebSocket Client
- [Axios](https://github.com/axios/axios) - HTTP Request
+## Credits
-## ✍️ Authors
-
-- [FAYStarNext](https://github.com/FAYStarNext) - Idea & Initial work
+- [Erela.Js](https://github.com/MenuDocs/erela.js)
See also the list of [contributors](https://github.com/FAYStarNext/Sunday.ts/contributors) who participated in this project.
\ No newline at end of file
diff --git a/src/index.ts b/src/index.ts
index 1d1e768..dcd61d6 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,8 +1,5 @@
-export * from "./structures/Node";
-export * from "./types/Node";
export * from "./structures/Manager";
-export * from "./types/Manager";
+export * from "./structures/Node";
export * from "./structures/Player";
-export * from "./types/Player";
-export * from "./structures/Rest";
-export * from "./types/Rest";
+export * from "./structures/Queue";
+export * from "./structures/Utils";
diff --git a/src/structures/Manager.ts b/src/structures/Manager.ts
index b696f8a..abef611 100644
--- a/src/structures/Manager.ts
+++ b/src/structures/Manager.ts
@@ -1,117 +1,345 @@
-import { TypedEmitter } from "tiny-typed-emitter";
-import type { ManagerEventEmitter, ManagerOptions } from "../types/Manager";
-import { Node } from "./Node";
-import type { PlayerCreate } from "../types/Player";
-import { Player } from "./Player";
+/* eslint-disable no-async-promise-executor */
import { Collection } from "@discordjs/collection";
-import type { VoiceState } from "discord.js";
-import type { VoicePacket, VoiceServer } from "../types/Discord";
+import { VoiceState } from "..";
+import { Node } from "./Node";
+import { Player, PlayerOptions, Track } from "./Player";
+import {
+ LoadTypeV3,
+ LoadTypeV4,
+ Plugin,
+ Structure,
+ TrackData,
+ TrackUtils,
+ VoicePacket,
+ VoiceServer,
+} from "./Utils";
+import { TypedEmitter } from "tiny-typed-emitter";
+import { ManagerEventEmitter } from "../types/Manager";
+import { NodeOptions } from "../types/Node";
+
+const REQUIRED_KEYS = ["event", "guildId", "op", "sessionId"];
+
+function check(options: ManagerOptions) {
+ if (!options) throw new TypeError("ManagerOptions must not be empty.");
+
+ if (typeof options.send !== "function")
+ throw new TypeError('Manager option "send" must be present and a function.');
+
+ if (
+ typeof options.clientId !== "undefined" &&
+ !/^\d+$/.test(options.clientId)
+ )
+ throw new TypeError('Manager option "clientId" must be a non-empty string.');
+
+ if (
+ typeof options.nodes !== "undefined" &&
+ !Array.isArray(options.nodes)
+ )
+ throw new TypeError('Manager option "nodes" must be a array.');
+
+ if (
+ typeof options.shards !== "undefined" &&
+ typeof options.shards !== "number"
+ )
+ throw new TypeError('Manager option "shards" must be a number.');
+
+ if (
+ typeof options.plugins !== "undefined" &&
+ !Array.isArray(options.plugins)
+ )
+ throw new TypeError('Manager option "plugins" must be a Plugin array.');
+
+ if (
+ typeof options.autoPlay !== "undefined" &&
+ typeof options.autoPlay !== "boolean"
+ )
+ throw new TypeError('Manager option "autoPlay" must be a boolean.');
+
+ if (
+ typeof options.trackPartial !== "undefined" &&
+ !Array.isArray(options.trackPartial)
+ )
+ throw new TypeError('Manager option "trackPartial" must be a string array.');
+
+ if (
+ typeof options.clientName !== "undefined" &&
+ typeof options.clientName !== "string"
+ )
+ throw new TypeError('Manager option "clientName" must be a string.');
+
+ if (
+ typeof options.defaultSearchPlatform !== "undefined" &&
+ typeof options.defaultSearchPlatform !== "string"
+ )
+ throw new TypeError('Manager option "defaultSearchPlatform" must be a string.');
+}
export class Manager extends TypedEmitter {
- options: ManagerOptions;
- public readonly players = new Collection();
- public readonly nodes = new Collection();
- constructor(options: ManagerOptions) {
- super();
- this.options = options;
- for (const node of options.nodes) {
- this.nodes.set(node.host, new Node(node));
- }
+ public static readonly DEFAULT_SOURCES: Record = {
+ "youtube music": "ytmsearch",
+ "youtube": "ytsearch",
+ "soundcloud": "scsearch"
}
- private get priorityNode(): Node {
- const filteredNodes = this.nodes.filter((node) => node.connected && node.options.priority > 0);
- const totalWeight = filteredNodes.reduce((total, node) => total + node.options.priority, 0);
- const weightedNodes = filteredNodes.map((node) => ({
- node,
- weight: node.options.priority / totalWeight,
- }));
- const randomNumber = Math.random();
-
- let cumulativeWeight = 0;
-
- for (const { node, weight } of weightedNodes) {
- cumulativeWeight += weight;
- if (randomNumber <= cumulativeWeight) {
- return node;
- }
- }
+ /** The map of players. */
+ public readonly players = new Collection();
+ /** The map of nodes. */
+ public readonly nodes = new Collection();
+ /** The options that were set. */
+ public readonly options: ManagerOptions;
+ private initiated = false;
- return this.options.useNode === "leastLoad" ? this.leastLoadNode.first() : this.leastPlayersNode.first();
+ /** Returns the least used Nodes. */
+ public get leastUsedNodes(): Collection {
+ return this.nodes
+ .filter((node) => node.connected)
+ .sort((a, b) => b.calls - a.calls);
}
- private get leastLoadNode(): Collection {
+ /** Returns the least system load Nodes. */
+ public get leastLoadNodes(): Collection {
return this.nodes
.filter((node) => node.connected)
.sort((a, b) => {
- const aload = a.stats.cpu ? (a.stats.cpu.lavalinkLoad / a.stats.cpu.cores) * 100 : 0;
- const bload = b.stats.cpu ? (b.stats.cpu.lavalinkLoad / b.stats.cpu.cores) * 100 : 0;
+ const aload = a.stats.cpu
+ ? (a.stats.cpu.systemLoad / a.stats.cpu.cores) * 100
+ : 0;
+ const bload = b.stats.cpu
+ ? (b.stats.cpu.systemLoad / b.stats.cpu.cores) * 100
+ : 0;
return aload - bload;
});
}
- private get leastPlayersNode(): Collection {
- return this.nodes.filter((node) => node.connected).sort((a, b) => a.stats.players - b.stats.players);
- }
+ /**
+ * Initiates the Manager class.
+ * @param options
+ */
+ constructor(options: ManagerOptions) {
+ super();
- public get useableNodes(): Node {
- return this.options.usePriority ? this.priorityNode : this.options.useNode === "leastLoad" ? this.leastLoadNode.first() : this.leastPlayersNode.first();
- }
+ check(options);
+
+ Structure.get("Player").init(this);
+ Structure.get("Node").init(this);
+ TrackUtils.init(this);
+
+ if (options.trackPartial) {
+ TrackUtils.setTrackPartial(options.trackPartial);
+ delete options.trackPartial;
+ }
+
+ this.options = {
+ plugins: [],
+ nodes: [
+ { identifier: "default", host: "localhost" }
+ ],
+ shards: 1,
+ autoPlay: true,
+ clientName: "Sunday.ts",
+ defaultSearchPlatform: "youtube",
+ ...options,
+ };
- public create(options: PlayerCreate): Player {
- if (this.players.has(options.guild_id)) {
- return this.players.get(options.guild_id);
+ if (this.options.plugins) {
+ for (const [index, plugin] of this.options.plugins.entries()) {
+ if (!(plugin instanceof Plugin))
+ throw new RangeError(`Plugin at index ${index} does not extend Plugin.`);
+ plugin.load(this);
+ }
}
- return new Player(this, options);
+ if (this.options.nodes) {
+ for (const nodeOptions of this.options.nodes)
+ new (Structure.get("Node"))(nodeOptions);
+ }
}
- public init() {
- this.emit("raw", "Manager initialized");
+ /**
+ * Initiates the Manager.
+ * @param clientId
+ */
+ public init(clientId?: string): this {
+ if (this.initiated) return this;
+ if (typeof clientId !== "undefined") this.options.clientId = clientId;
+
+ if (typeof this.options.clientId !== "string")
+ throw new Error('"clientId" set is not type of "string"');
+
+ if (!this.options.clientId)
+ throw new Error(
+ '"clientId" is not set. Pass it in Manager#init() or as a option in the constructor.'
+ );
+
for (const node of this.nodes.values()) {
try {
- node.on("connect", () => {
- this.emit("NodeConnect", node);
- }).on("disconnect", () => {
- this.emit("NodeDisconnect", node);
- }).on("error", (err) => {
- this.emit("NodeError", node, err);
- }).on("stats", (stats) => {
- this.emit("NodeStats", node, stats);
- }).on("ready", () => {
- this.emit("NodeReady", node);
- }).on("raw", (data) => {
- this.emit("NodeRaw", node, data);
- });
node.connect();
} catch (err) {
this.emit("NodeError", node, err);
}
}
+
+ this.initiated = true;
+ return this;
+ }
+
+ /**
+ * Searches the enabled sources based off the URL or the `source` property.
+ * @param query
+ * @param requester
+ * @returns The search result.
+ */
+ public search(
+ query: string | SearchQuery,
+ requester?: unknown
+ ): Promise {
+ return new Promise(async (resolve, reject) => {
+ const node = this.leastUsedNodes.first();
+ if (!node) throw new Error("No available nodes.");
+
+ const _query: SearchQuery = typeof query === "string" ? { query } : query;
+ const _source = Manager.DEFAULT_SOURCES[_query.source ?? this.options.defaultSearchPlatform] ?? _query.source;
+
+ let search = _query.query;
+ if (!/^https?:\/\//.test(search)) {
+ search = `${_source}:${search}`;
+ }
+
+ const res = await node.rest.get(`/loadtracks?identifier=${encodeURIComponent(search)}`)
+
+ if (!res) {
+ return reject(new Error("Query not found."));
+ }
+
+ const result: SearchResult = {
+ loadType: res.loadType,
+ exception: res.exception ?? null,
+ tracks: res.tracks?.map((track: TrackData) =>
+ TrackUtils.build(track, requester)
+ ) ?? [],
+ };
+
+ if (result.loadType === "PLAYLIST_LOADED") {
+ result.playlist = {
+ name: res.playlistInfo.name,
+ selectedTrack: res.playlistInfo.selectedTrack === -1 ? null :
+ TrackUtils.build(
+ res.tracks[res.playlistInfo.selectedTrack],
+ requester
+ ),
+ duration: result.tracks
+ .reduce((acc: number, cur: Track) => acc + (cur.duration || 0), 0),
+ };
+ }
+
+ return resolve(result);
+ });
+ }
+
+ /**
+ * Decodes the base64 encoded tracks and returns a TrackData array.
+ * @param tracks
+ */
+ public decodeTracks(tracks: string[]): Promise {
+ return new Promise(async (resolve, reject) => {
+ const node = this.nodes.first();
+ if (!node) throw new Error("No available nodes.");
+ const res = await node.rest.post(`/decodetracks`, JSON.stringify(tracks))
+ if (!res) {
+ return reject(new Error("No data returned from query."));
+ }
+ return resolve(res);
+ });
+ }
+
+ /**
+ * Decodes the base64 encoded track and returns a TrackData.
+ * @param track
+ */
+ public async decodeTrack(track: string): Promise {
+ const res = await this.decodeTracks([track]);
+ return res[0];
+ }
+
+ /**
+ * Creates a player or returns one if it already exists.
+ * @param options
+ */
+ public create(options: PlayerOptions): Player {
+ if (this.players.has(options.guild)) {
+ return this.players.get(options.guild);
+ }
+
+ return new (Structure.get("Player"))(options);
+ }
+
+ /**
+ * Returns a player or undefined if it does not exist.
+ * @param guild
+ */
+ public get(guild: string): Player | undefined {
+ return this.players.get(guild);
}
+
+ /**
+ * Destroys a player if it exists.
+ * @param guild
+ */
+ public destroy(guild: string): void {
+ this.players.delete(guild);
+ }
+
+ /**
+ * Creates a node or returns one if it already exists.
+ * @param options
+ */
+ public createNode(options: NodeOptions): Node {
+ if (this.nodes.has(options.identifier || options.host)) {
+ return this.nodes.get(options.identifier || options.host);
+ }
+
+ return new (Structure.get("Node"))(options);
+ }
+
+ /**
+ * Destroys a node if it exists.
+ * @param identifier
+ */
+ public destroyNode(identifier: string): void {
+ const node = this.nodes.get(identifier);
+ if (!node) return;
+ node.destroy()
+ this.nodes.delete(identifier)
+ }
+
+ /**
+ * Sends voice data to the Lavalink server.
+ * @param data
+ */
public async updateVoiceState(data: VoicePacket | VoiceServer | VoiceState): Promise {
if ("t" in data && !["VOICE_STATE_UPDATE", "VOICE_SERVER_UPDATE"].includes(data.t)) return;
+
const update = "d" in data ? data.d : data;
+
if (!update || (!("token" in update) && !("session_id" in update))) return;
+
const player = this.players.get(update.guild_id);
+
if (!player) return;
if ("token" in update) {
- // @ts-ignore
- if (!player.voiceState) player.voiceState = { event: {} };
- player.voiceState.event = update;
-
- const {
- sessionId,
- event: { token, endpoint },
- } = player.voiceState;
- console.log(player.voiceState)
- await player.node.rest.updatePlayer({
- guildId: player.guild_id,
- data: { voice: { token, endpoint, sessionId } },
- });
-
- return;
- }
+ player.voiceState.event = update;
+
+ const {
+ event: { token, endpoint },
+ } = player.voiceState;
+
+ await player.node.rest.updatePlayer({
+ guildId: player.guild,
+ data: { voice: { token, endpoint, sessionId: player.sessionId } },
+ });
+
+ return;
+ }
if (update.user_id !== this.options.clientId) return;
if (update.channel_id) {
@@ -130,4 +358,89 @@ export class Manager extends TypedEmitter {
player.destroy();
return;
}
-}
\ No newline at end of file
+}
+
+export interface Payload {
+ /** The OP code */
+ op: number;
+ d: {
+ guild_id: string;
+ channel_id: string | null;
+ self_mute: boolean;
+ self_deaf: boolean;
+ };
+}
+
+export interface ManagerOptions {
+ /** The array of nodes to connect to. */
+ nodes?: NodeOptions[];
+ /** The client ID to use. */
+ clientId?: string;
+ /** Value to use for the `Client-Name` header. */
+ clientName?: string;
+ /** The shard count. */
+ shards?: number;
+ /** A array of plugins to use. */
+ plugins?: Plugin[];
+ /** Whether players should automatically play the next song. */
+ autoPlay?: boolean;
+ /** An array of track properties to keep. `track` will always be present. */
+ trackPartial?: string[];
+ /** The default search platform to use, can be "youtube", "youtube music", or "soundcloud". */
+ defaultSearchPlatform?: SearchPlatform;
+ /**
+ * Function to send data to the websocket.
+ * @param id
+ * @param payload
+ */
+ send(id: string, payload: Payload): void;
+}
+
+export type SearchPlatform = "youtube" | "youtube music" | "soundcloud";
+
+export interface SearchQuery {
+ /** The source to search from. */
+ source?: SearchPlatform | string;
+ /** The query to search for. */
+ query: string;
+}
+
+export interface SearchResult {
+ /** The load type of the result. */
+ loadType: LoadTypeV3 | LoadTypeV4;
+ /** The array of tracks from the result. */
+ tracks: Track[];
+ /** The playlist info if the load type is PLAYLIST_LOADED. */
+ playlist?: PlaylistInfo;
+ /** The exception when searching if one. */
+ exception?: {
+ /** The message for the exception. */
+ message: string;
+ /** The severity of exception. */
+ severity: string;
+ };
+}
+
+export interface PlaylistInfo {
+ /** The playlist name. */
+ name: string;
+ /** The playlist selected track. */
+ selectedTrack?: Track;
+ /** The duration of the playlist. */
+ duration: number;
+}
+
+export interface LavalinkResult {
+ tracks: TrackData[];
+ loadType: LoadTypeV3 | LoadTypeV4;
+ exception?: {
+ /** The message for the exception. */
+ message: string;
+ /** The severity of exception. */
+ severity: string;
+ };
+ playlistInfo: {
+ name: string;
+ selectedTrack?: number;
+ };
+}
diff --git a/src/structures/Node.ts b/src/structures/Node.ts
index ba4ac80..b465728 100644
--- a/src/structures/Node.ts
+++ b/src/structures/Node.ts
@@ -1,119 +1,410 @@
-import { TypedEmitter } from "tiny-typed-emitter";
-import { WebSocket } from "ws";
-import type { NodeConfig, NodeEventEmitter, NodeStats } from "../types/Node";
-import { Rest } from "./Rest";
-import type { Manager } from "./Manager";
-
-/* This TypeScript class extends TypedEmitter with a generic type of NodeEventEmitter. */
-export class Node extends TypedEmitter {
- /* The line `public socket: WebSocket | null = null;` in the Node class is declaring a public
- property named `socket` with a type of `WebSocket` or `null`. This means that the `socket`
- property can hold a value of type `WebSocket` (imported from the "ws" module) representing a
- WebSocket connection, or it can be `null` if no value is assigned to it. This property is
- initialized with a default value of `null` when a new instance of the Node class is created. */
- public socket: WebSocket | null = null;
-
- /* The line `public options: NodeConfig;` in the Node class is declaring a public property named
- `options` with a type of `NodeConfig`. This means that the `options` property can hold values
- that conform to the structure defined by the `NodeConfig` type. The `NodeConfig` type likely
- represents a configuration object containing various settings or parameters related to a Node
- instance, such as `host`, `password`, and `port`. */
- public options: NodeConfig;
- /* The line `public stats: NodeStats | null = null;` in the Node class is declaring a public
- property named `stats` with a type of `NodeStats` or `null`. This means that the `stats`
- property can hold a value of type `NodeStats` (defined elsewhere in the codebase) or it can be
- `null` if no value is assigned to it. */
- public stats: NodeStats | null = null;
- /* The line `session_id: string | null = null;` in the Node class is declaring a public property
- named `session_id` with a type of `string` or `null`. This means that the `session_id` property
- can hold a value of type `string`, representing a session identifier, or it can be `null` if no
- value is assigned to it. */
- session_id: string | null = null;
- /* The line `public rest: Rest | null = null;` in the Node class is declaring a public property
- named `rest` of type `Rest` or `null`. */
- public rest: Rest | null = null;
- /**
- * The function checks if a WebSocket connection is open and returns a boolean value accordingly.
- * @returns The `get connected` method returns a boolean value indicating whether the WebSocket
- * connection is open or not. It returns `true` if the socket is open and `false` if the socket is
- * either closed or not initialized.
- */
- public get connected(): boolean {
- if (!this.socket) return false;
- return this.socket.readyState === WebSocket.OPEN;
+/* eslint-disable no-case-declarations */
+import WebSocket from "ws";
+import { Manager } from "./Manager";
+import { Player, Track, UnresolvedTrack } from "./Player";
+import {
+ PlayerEvent,
+ PlayerEvents,
+ Structure,
+ TrackEndEvent,
+ TrackExceptionEvent,
+ TrackStartEvent,
+ TrackStuckEvent,
+ WebSocketClosedEvent,
+} from "./Utils";
+import { NodeOptions, NodeStats } from "../types/Node";
+import { Rest, ModifyRequest } from "./Rest";
+
+function check(options: NodeOptions) {
+ if (!options) throw new TypeError("NodeOptions must not be empty.");
+
+ if (
+ typeof options.host !== "string" ||
+ !/.+/.test(options.host)
+ )
+ throw new TypeError('Node option "host" must be present and be a non-empty string.');
+
+ if (
+ typeof options.port !== "undefined" &&
+ typeof options.port !== "number"
+ )
+ throw new TypeError('Node option "port" must be a number.');
+
+ if (
+ typeof options.password !== "undefined" &&
+ (typeof options.password !== "string" ||
+ !/.+/.test(options.password))
+ )
+ throw new TypeError('Node option "password" must be a non-empty string.');
+
+ if (
+ typeof options.secure !== "undefined" &&
+ typeof options.secure !== "boolean"
+ )
+ throw new TypeError('Node option "secure" must be a boolean.');
+
+ if (
+ typeof options.identifier !== "undefined" &&
+ typeof options.identifier !== "string"
+ )
+ throw new TypeError('Node option "identifier" must be a non-empty string.');
+
+ if (
+ typeof options.retryAmount !== "undefined" &&
+ typeof options.retryAmount !== "number"
+ )
+ throw new TypeError('Node option "retryAmount" must be a number.');
+
+ if (
+ typeof options.retryDelay !== "undefined" &&
+ typeof options.retryDelay !== "number"
+ )
+ throw new TypeError('Node option "retryDelay" must be a number.');
+
+ if (
+ typeof options.requestTimeout !== "undefined" &&
+ typeof options.requestTimeout !== "number"
+ )
+ throw new TypeError('Node option "requestTimeout" must be a number.');
+}
+
+export class Node {
+ /** The socket for the node. */
+ public socket: WebSocket | null = null;
+ /** The HTTP rest client. */
+ public rest: Rest;
+ /** The stats for the node. */
+ public stats: NodeStats;
+ public manager: Manager
+
+ private static _manager: Manager;
+ private reconnectTimeout: NodeJS.Timeout | null = null;
+ private reconnectAttempts = 1;
+ public calls: number = 0;
+ sessionId: string;
+
+ /** Returns if connected to the Node. */
+ public get connected(): boolean {
+ if (!this.socket) return false;
+ return this.socket.readyState === WebSocket.OPEN;
+ }
+
+ /** Returns the address for this node. */
+ public get address(): string {
+ return `${this.options.host}:${this.options.port}`;
+ }
+
+ /** @hidden */
+ public static init(manager: Manager): void {
+ this._manager = manager;
+ }
+
+ /**
+ * Creates an instance of Node.
+ * @param options
+ */
+ constructor(public options: NodeOptions) {
+ if (!this.manager) this.manager = Structure.get("Node")._manager;
+ if (!this.manager) throw new RangeError("Manager has not been initiated.");
+
+ if (this.manager.nodes.has(options.identifier || options.host)) {
+ return this.manager.nodes.get(options.identifier || options.host);
}
- /* The line `private static _manager: Manager;` in the Node class is declaring a private static
- property named `_manager` with a type of `Manager`. */
- /**
- * The constructor initializes a Rest object with the provided NodeConfig options.
- * @param {NodeConfig} options - The `options` parameter is an object that contains configuration
- * settings for a Node instance. It typically includes properties such as `host`, `password`, and
- * `port` that are used to establish a connection to a server or service.
- */
- constructor(options: NodeConfig) {
- super();
- this.options = options;
- this.rest = new Rest(this);
+
+ check(options);
+
+ this.options = {
+ port: 2333,
+ password: "youshallnotpass",
+ secure: false,
+ retryAmount: 5,
+ retryDelay: 30e3,
+ ...options,
+ };
+
+ if (this.options.secure) {
+ this.options.port = 443;
}
- /**
- * The `connect` function establishes a WebSocket connection with specified headers and event
- * handlers for connection, closure, error, and message reception.
- * @returns If the `connect()` method is called when `this.connected` is already `true`, the method
- * will return early without establishing a new connection.
- */
- public connect() {
- if (this.connected) return;
- const headers = Object.assign({
- "Authorization": this.options.password,
- "Client-Name": this.options.clientName || `Sunday.ts/${require("../../package.json").version}`,
- "User-Id": "213",
- })
- this.socket = new WebSocket(`ws${this.options.secure ? "s" : ""}://${this.options.host}:${this.options.port}/v4/websocket`, { headers });
- this.socket.on("open", () => {
- this.emit("connect");
- });
- this.socket.on("close", (data) => {
- this.emit("disconnect", data);
- });
- this.socket.on("error", (error) => {
- this.emit("error", error);
- });
- this.socket.on("message", this.onMessage.bind(this));
+
+ this.rest = new Rest(this.options);
+
+ this.options.identifier = options.identifier || options.host;
+ this.stats = {
+ players: 0,
+ playingPlayers: 0,
+ uptime: 0,
+ memory: {
+ free: 0,
+ used: 0,
+ allocated: 0,
+ reservable: 0,
+ },
+ cpu: {
+ cores: 0,
+ systemLoad: 0,
+ lavalinkLoad: 0,
+ },
+ frameStats: {
+ sent: 0,
+ nulled: 0,
+ deficit: 0,
+ },
+ };
+
+ this.manager.nodes.set(this.options.identifier, this);
+ this.manager.emit("NodeCreate", this);
+ }
+
+ /** Connects to the Node. */
+ public connect(): void {
+ if (this.connected) return;
+
+ const headers = {
+ Authorization: this.options.password,
+ "Num-Shards": String(this.manager.options.shards),
+ "User-Id": this.manager.options.clientId,
+ "Client-Name": this.manager.options.clientName,
+ };
+ console.log(headers);
+ if (this.options.version === "v4") {
+ this.socket = new WebSocket(`ws${this.options.secure ? "s" : ""}://${this.address}/v4/websocket`, { headers });
+ } else {
+ this.socket = new WebSocket(`ws${this.options.secure ? "s" : ""}://${this.address}/websocket`, { headers });
}
- /**
- * The function `onMessage` processes incoming data, parses it as JSON, and emits events based on
- * the payload's operation type.
- * @param {Buffer | string} data - The `data` parameter in the `onMessage` function can be either a
- * Buffer or a string. If it is an array, the function concatenates the buffers in the array. If it
- * is an ArrayBuffer, it converts it to a Buffer. Then it parses the data as JSON and emits a
- */
- private onMessage(data: Buffer | string) {
- if (Array.isArray(data)) data = Buffer.concat(data);
- else if (data instanceof ArrayBuffer) data = Buffer.from(data);
- const payload = JSON.parse(data.toString());
- this.emit("raw", payload);
- switch (payload?.op) {
- case "ready": {
- this.rest.setSessionId(payload.sessionId);
- this.session_id = payload.sessionId;
+ this.socket.on("open", this.open.bind(this));
+ this.socket.on("close", this.close.bind(this));
+ this.socket.on("message", this.message.bind(this));
+ this.socket.on("error", this.error.bind(this));
+ }
+
+ /** Destroys the Node and all players connected with it. */
+ public destroy(): void {
+ if (!this.connected) return;
+
+ const players = this.manager.players.filter(p => p.node == this);
+ if (players.size) players.forEach(p => p.destroy());
+
+ this.socket.close(1000, "destroy");
+ this.socket.removeAllListeners();
+ this.socket = null;
+
+ this.reconnectAttempts = 1;
+ clearTimeout(this.reconnectTimeout);
+
+ this.manager.emit("NodeDestroy", this);
+ this.manager.destroyNode(this.options.identifier);
+ }
+
+ /**
+ * Sends data to the Node.
+ * @param data
+ */
+ public send(data: unknown): Promise {
+ return new Promise((resolve, reject) => {
+ if (!this.connected) return resolve(false);
+ if (!data || !JSON.stringify(data).startsWith("{")) {
+ return reject(false);
+ }
+ this.socket.send(JSON.stringify(data), (error: Error) => {
+ if (error) reject(error);
+ else resolve(true);
+ });
+ });
+ }
+
+ private reconnect(): void {
+ this.reconnectTimeout = setTimeout(() => {
+ if (this.reconnectAttempts >= this.options.retryAmount) {
+ const error = new Error(
+ `Unable to connect after ${this.options.retryAmount} attempts.`
+ );
+
+ this.manager.emit("NodeError", this, error);
+ return this.destroy();
+ }
+ this.socket.removeAllListeners();
+ this.socket = null;
+ this.manager.emit("NodeReconnect", this);
+ this.connect();
+ this.reconnectAttempts++;
+ }, this.options.retryDelay) as unknown as NodeJS.Timeout;
+ }
+
+ protected open(): void {
+ if (this.reconnectTimeout) clearTimeout(this.reconnectTimeout);
+ this.manager.emit("NodeConnect", this);
+ }
+
+ protected close(code: number, reason: string): void {
+ this.manager.emit("NodeDisconnect", this, { code, reason });
+ if (code !== 1000 || reason !== "destroy") this.reconnect();
+ }
+
+ protected error(error: Error): void {
+ if (!error) return;
+ this.manager.emit("NodeError", this, error);
+ }
+
+ protected message(d: Buffer | string): void {
+ if (Array.isArray(d)) d = Buffer.concat(d);
+ else if (d instanceof ArrayBuffer) d = Buffer.from(d);
+
+ const payload = JSON.parse(d.toString());
+
+ if (!payload.op) return;
+ this.manager.emit("NodeRaw", payload);
+
+ switch (payload.op) {
+ case "stats":
+ delete payload.op;
+ this.stats = ({ ...payload } as unknown) as NodeStats;
+ break;
+ case "ready": {
+ this.rest.setSessionId(payload.sessionId);
+ this.sessionId = payload.sessionId;
if (this.options.resumeStatus) {
- this.rest.patch(`/v4/sessions/${this.session_id}`, {
+ this.rest.patch(`/v4/sessions/${this.sessionId}`, {
resuming: this.options.resumeStatus,
timeout: this.options.resumeTimeout,
- });
+ }).then((data) => { console.log(data) });
}
- this.emit("ready")
- break;
- }
- case "stats": {
- this.stats = payload;
- this.emit("stats", payload);
- break;
- }
- }
+ this.manager.emit("NodeReady", this);
+ break;
+ }
+ case "playerUpdate":
+ const player = this.manager.players.get(payload.guildId);
+ if (player) player.position = payload.state.position || 0;
+ break;
+ case "event":
+ this.handleEvent(payload);
+ break;
+ default:
+ this.manager.emit(
+ "NodeError",
+ this,
+ new Error(`Unexpected op "${payload.op}" with data: ${payload}`)
+ );
+ return;
}
- private debug(message: string) {
- return this.emit("raw", message);
+ }
+
+ protected handleEvent(payload: PlayerEvent & PlayerEvents): void {
+ if (!payload.guildId) return;
+
+ const player = this.manager.players.get(payload.guildId);
+ if (!player) return;
+
+ const track = player.queue.current;
+ const type = payload.type;
+
+ if (payload.type === "TrackStartEvent") {
+ this.trackStart(player, track as Track, payload);
+ } else if (payload.type === "TrackEndEvent") {
+ this.trackEnd(player, track as Track, payload);
+ } else if (payload.type === "TrackStuckEvent") {
+ this.trackStuck(player, track as Track, payload);
+ } else if (payload.type === "TrackExceptionEvent") {
+ this.trackError(player, track, payload);
+ } else if (payload.type === "WebSocketClosedEvent") {
+ this.socketClosed(player, payload);
+ } else {
+ const error = new Error(`Node#event unknown event '${type}'.`);
+ this.manager.emit("NodeError", this, error);
+ }
+ }
+
+ protected trackStart(player: Player, track: Track, payload: TrackStartEvent): void {
+ player.playing = true;
+ player.paused = false;
+ this.manager.emit("TrackStart", player, track, payload);
+ }
+
+ protected trackEnd(player: Player, track: Track, payload: TrackEndEvent): void {
+ // If a track had an error while starting
+ if (["LOAD_FAILED", "CLEAN_UP"].includes(payload.reason)) {
+ player.queue.previous = player.queue.current;
+ player.queue.current = player.queue.shift();
+
+ if (!player.queue.current) return this.queueEnd(player, track, payload);
+
+ this.manager.emit("TrackEnd", player, track, payload);
+ if (this.manager.options.autoPlay) player.play();
+ return;
+ }
+
+ // If a track was forcibly played
+ if (payload.reason === "REPLACED") {
+ this.manager.emit("TrackEnd", player, track, payload);
+ return;
}
+
+ // If a track ended and is track repeating
+ if (track && player.trackRepeat) {
+ if (payload.reason === "STOPPED") {
+ player.queue.previous = player.queue.current;
+ player.queue.current = player.queue.shift();
+ }
+
+ if (!player.queue.current) return this.queueEnd(player, track, payload);
+
+ this.manager.emit("TrackEnd", player, track, payload);
+ if (this.manager.options.autoPlay) player.play();
+ return;
+ }
+
+ // If a track ended and is track repeating
+ if (track && player.queueRepeat) {
+ player.queue.previous = player.queue.current;
+
+ if (payload.reason === "STOPPED") {
+ player.queue.current = player.queue.shift();
+ if (!player.queue.current) return this.queueEnd(player, track, payload);
+ } else {
+ player.queue.add(player.queue.current);
+ player.queue.current = player.queue.shift();
+ }
+
+ this.manager.emit("TrackEnd", player, track, payload);
+ if (this.manager.options.autoPlay) player.play();
+ return;
+ }
+
+ // If there is another song in the queue
+ if (player.queue.length) {
+ player.queue.previous = player.queue.current;
+ player.queue.current = player.queue.shift();
+
+ this.manager.emit("TrackEnd", player, track, payload);
+ if (this.manager.options.autoPlay) player.play();
+ return;
+ }
+
+ // If there are no songs in the queue
+ if (!player.queue.length) return this.queueEnd(player, track, payload);
+ }
+
+ protected queueEnd(player: Player, track: Track, payload: TrackEndEvent): void {
+ player.queue.current = null;
+ player.playing = false;
+ this.manager.emit("QueueEnd", player, track, payload);
+ }
+
+ protected trackStuck(player: Player, track: Track, payload: TrackStuckEvent): void {
+ player.stop();
+ this.manager.emit("TrackStuck", player, track, payload);
+ }
+
+ protected trackError(
+ player: Player,
+ track: Track | UnresolvedTrack,
+ payload: TrackExceptionEvent
+ ): void {
+ player.stop();
+ this.manager.emit("TrackError", player, track, payload);
+ }
+
+ protected socketClosed(player: Player, payload: WebSocketClosedEvent): void {
+ this.manager.emit("SocketClosed", player, payload);
+ }
}
\ No newline at end of file
diff --git a/src/structures/Player.ts b/src/structures/Player.ts
index 0c8edfe..5de877d 100644
--- a/src/structures/Player.ts
+++ b/src/structures/Player.ts
@@ -1,48 +1,189 @@
-import type { VoiceState } from "../types/Discord";
-import type { PlayerCreate } from "../types/Player";
-import type { Manager } from "./Manager";
-import type { Node } from "./Node";
+import { Manager, SearchQuery, SearchResult } from "./Manager";
+import { Node } from "./Node";
import { Queue } from "./Queue";
+import { Sizes, State, Structure, TrackUtils, VoiceState } from "./Utils";
+
+function check(options: PlayerOptions) {
+ if (!options) throw new TypeError("PlayerOptions must not be empty.");
+
+ if (!/^\d+$/.test(options.guild))
+ throw new TypeError(
+ 'Player option "guild" must be present and be a non-empty string.'
+ );
+
+ if (options.textChannel && !/^\d+$/.test(options.textChannel))
+ throw new TypeError(
+ 'Player option "textChannel" must be a non-empty string.'
+ );
+
+ if (options.voiceChannel && !/^\d+$/.test(options.voiceChannel))
+ throw new TypeError(
+ 'Player option "voiceChannel" must be a non-empty string.'
+ );
+
+ if (options.node && typeof options.node !== "string")
+ throw new TypeError('Player option "node" must be a non-empty string.');
+
+ if (
+ typeof options.volume !== "undefined" &&
+ typeof options.volume !== "number"
+ )
+ throw new TypeError('Player option "volume" must be a number.');
+
+ if (
+ typeof options.selfMute !== "undefined" &&
+ typeof options.selfMute !== "boolean"
+ )
+ throw new TypeError('Player option "selfMute" must be a boolean.');
+
+ if (
+ typeof options.selfDeafen !== "undefined" &&
+ typeof options.selfDeafen !== "boolean"
+ )
+ throw new TypeError('Player option "selfDeafen" must be a boolean.');
+}
export class Player {
- manager: Manager;
- options: PlayerCreate;
- node: Node;
- voiceChannel: string;
- state: "CONNECTING" | "CONNECTED" | "DISCONNECTED" | "DISCONNECTING" | "DESTROYING" = "DISCONNECTED";
- guild_id: string;
- public voiceState: VoiceState;
- paused: boolean = false;
- playing: boolean = false;
- textChannel: string
- public readonly queue = new Queue();
- constructor(manager: Manager, options: PlayerCreate) {
- this.manager = manager;
- if (!this.manager) throw new RangeError("Manager has not been initiated.");
- this.options = options;
- this.voiceChannel = options.voiceChannel;
- this.voiceState = Object.assign({
- op: "voiceUpdate",
- guild_id: options.guild_id,
- });
- if (options.voiceChannel) this.voiceChannel = options.voiceChannel;
+ /** The Queue for the Player. */
+ public readonly queue = new (Structure.get("Queue"))() as Queue;
+ /** Whether the queue repeats the track. */
+ public trackRepeat = false;
+ /** Whether the queue repeats the queue. */
+ public queueRepeat = false;
+ /** The time the player is in the track. */
+ public position = 0;
+ /** Whether the player is playing. */
+ public playing = false;
+ /** Whether the player is paused. */
+ public paused = false;
+ /** The volume for the player */
+ public volume: number;
+ /** The Node for the Player. */
+ public node: Node;
+ /** The guild for the player. */
+ public guild: string;
+ /** The voice channel for the player. */
+ public voiceChannel: string | null = null;
+ /** The text channel for the player. */
+ public textChannel: string | null = null;
+ /** The current state of the player. */
+ public state: State = "DISCONNECTED";
+ /** The equalizer bands array. */
+ public bands = new Array(15).fill(0.0);
+ /** The voice state object from Discord. */
+ public voiceState: VoiceState;
+ public sessionId: string;
+ /** The Manager. */
+ public manager: Manager;
+ private static _manager: Manager;
+ private readonly data: Record = {};
+
+ /**
+ * Set custom data.
+ * @param key
+ * @param value
+ */
+ public set(key: string, value: unknown): void {
+ this.data[key] = value;
+ }
+
+ /**
+ * Get custom data.
+ * @param key
+ */
+ public get(key: string): T {
+ return this.data[key] as T;
+ }
+
+ /** @hidden */
+ public static init(manager: Manager): void {
+ this._manager = manager;
+ }
+
+ /**
+ * Creates a new player, returns one if it already exists.
+ * @param options
+ */
+ constructor(public options: PlayerOptions) {
+ if (!this.manager) this.manager = Structure.get("Player")._manager;
+ if (!this.manager) throw new RangeError("Manager has not been initiated.");
+ if (this.manager.players.has(options.guild)) {
+ return this.manager.players.get(options.guild);
+ }
+ check(options);
+ this.guild = options.guild;
+ this.voiceState = Object.assign({ op: "voiceUpdate", guildId: options.guild });
+ if (options.voiceChannel) this.voiceChannel = options.voiceChannel;
if (options.textChannel) this.textChannel = options.textChannel;
- const node = this.manager.nodes.get(options.node);
- this.node = node || this.manager.useableNodes;
+ const node = this.manager.nodes.get(options.node);
+ this.node = node || this.manager.leastLoadNodes.first();
if (!this.node) throw new RangeError("No available nodes.");
- this.guild_id = options.guild_id;
- this.manager.players.set(options.guild_id, this);
- this.manager.emit("PlayerCreate", this);
- }
+ this.sessionId = this.node.sessionId;
+ this.manager.players.set(options.guild, this);
+ this.manager.emit("PlayerCreate", this);
+ this.setVolume(options.volume ?? 100);
+ }
+
+ /**
+ * Same as Manager#search() but a shortcut on the player itself.
+ * @param query
+ * @param requester
+ */
+ public search(
+ query: string | SearchQuery,
+ requester?: unknown
+ ): Promise {
+ return this.manager.search(query, requester);
+ }
- public connect(): this {
- if (!this.voiceChannel) throw new RangeError("No voice channel has been set.");
+ /**
+ * Sets the players equalizer band on-top of the existing ones.
+ * @param bands
+ */
+ public setEQ(...bands: EqualizerBand[]): this {
+ // Hacky support for providing an array
+ if (Array.isArray(bands[0])) bands = bands[0] as unknown as EqualizerBand[]
+
+ if (!bands.length || !bands.every(
+ (band) => JSON.stringify(Object.keys(band).sort()) === '["band","gain"]'
+ )
+ )
+ throw new TypeError("Bands must be a non-empty object array containing 'band' and 'gain' properties.");
+
+ for (const { band, gain } of bands) this.bands[band] = gain;
+
+ this.node.send({
+ op: "equalizer",
+ guildId: this.guild,
+ bands: this.bands.map((gain, band) => ({ band, gain })),
+ });
+
+ return this;
+ }
+
+ /** Clears the equalizer bands. */
+ public clearEQ(): this {
+ this.bands = new Array(15).fill(0.0);
+
+ this.node.send({
+ op: "equalizer",
+ guildId: this.guild,
+ bands: this.bands.map((gain, band) => ({ band, gain })),
+ });
+
+ return this;
+ }
+
+ /** Connect to the voice channel. */
+ public connect(): this {
+ if (!this.voiceChannel)
+ throw new RangeError("No voice channel has been set.");
this.state = "CONNECTING";
- this.manager.options.send(this.guild_id, {
+ this.manager.options.send(this.guild, {
op: 4,
d: {
- guild_id: this.guild_id,
+ guild_id: this.guild,
channel_id: this.voiceChannel,
self_mute: this.options.selfMute || false,
self_deaf: this.options.selfDeafen || false,
@@ -52,15 +193,17 @@ export class Player {
this.state = "CONNECTED";
return this;
}
- public disconnect(): this {
+
+ /** Disconnect from the voice channel. */
+ public disconnect(): this {
if (this.voiceChannel === null) return this;
this.state = "DISCONNECTING";
this.pause(true);
- this.manager.options.send(this.guild_id, {
+ this.manager.options.send(this.guild, {
op: 4,
d: {
- guild_id: this.guild_id,
+ guild_id: this.guild,
channel_id: null,
self_mute: false,
self_deaf: false,
@@ -75,33 +218,296 @@ export class Player {
/** Destroys the player. */
public destroy(disconnect = true): void {
this.state = "DESTROYING";
-
if (disconnect) {
this.disconnect();
}
- //this.node.rest.destroyPlayer(this.guild);
+ this.node.send({
+ op: "destroy",
+ guildId: this.guild,
+ });
+ this.node.rest.destroyPlayer(this.guild);
+
this.manager.emit("PlayerDestroy", this);
- this.manager.players.delete(this.guild_id);
+ this.manager.players.delete(this.guild);
}
- public pause(pause: boolean): this {
- if (typeof pause !== "boolean") throw new RangeError('Pause can only be "true" or "false".');
- if (this.paused === pause || !this.queue.totalSize) return this;
+ /**
+ * Sets the player voice channel.
+ * @param channel
+ */
+ public setVoiceChannel(channel: string): this {
+ if (typeof channel !== "string")
+ throw new TypeError("Channel must be a non-empty string.");
+
+ this.voiceChannel = channel;
+ this.connect();
+ return this;
+ }
+
+ /**
+ * Sets the player text channel.
+ * @param channel
+ */
+ public setTextChannel(channel: string): this {
+ if (typeof channel !== "string")
+ throw new TypeError("Channel must be a non-empty string.");
+
+ this.textChannel = channel;
+ return this;
+ }
+
+ /** Plays the next track. */
+ public async play(): Promise;
+
+ /**
+ * Plays the specified track.
+ * @param track
+ */
+ public async play(track: Track | UnresolvedTrack): Promise;
+
+ /**
+ * Plays the next track with some options.
+ * @param options
+ */
+ public async play(options: PlayOptions): Promise;
+
+ /**
+ * Plays the specified track with some options.
+ * @param track
+ * @param options
+ */
+ public async play(
+ optionsOrTrack?: PlayOptions | Track | UnresolvedTrack,
+ playOptions?: PlayOptions
+ ): Promise {
+ if (
+ typeof optionsOrTrack !== "undefined" &&
+ TrackUtils.validate(optionsOrTrack)
+ ) {
+ if (this.queue.current) this.queue.previous = this.queue.current;
+ this.queue.current = optionsOrTrack as Track;
+ }
- const oldPlayer = { ...this };
+ if (!this.queue.current) throw new RangeError("No current track.");
+
+ const finalOptions = playOptions
+ ? playOptions
+ : ["startTime", "endTime", "noReplace"].every((v) =>
+ Object.keys(optionsOrTrack || {}).includes(v)
+ )
+ ? (optionsOrTrack as PlayOptions)
+ : {};
+
+ if (TrackUtils.isUnresolvedTrack(this.queue.current)) {
+ try {
+ this.queue.current = await TrackUtils.getClosestTrack(this.queue.current as UnresolvedTrack);
+ } catch (error) {
+ this.manager.emit("TrackError", this, this.queue.current, error);
+ if (this.queue[0]) return this.play(this.queue[0]);
+ return;
+ }
+ }
+
+ const options = {
+ op: "play",
+ guildId: this.guild,
+ track: this.queue.current.track,
+ ...finalOptions,
+ };
+
+ if (typeof options.track !== "string") {
+ options.track = (options.track as Track).track;
+ }
+
+ await this.node.send(options);
+ }
+
+ /**
+ * Sets the player volume.
+ * @param volume
+ */
+ public setVolume(volume: number): this {
+ volume = Number(volume);
+
+ if (isNaN(volume)) throw new TypeError("Volume must be a number.");
+ this.volume = Math.max(Math.min(volume, 1000), 0);
+
+ this.node.send({
+ op: "volume",
+ guildId: this.guild,
+ volume: this.volume,
+ });
+
+ return this;
+ }
+
+ /**
+ * Sets the track repeat.
+ * @param repeat
+ */
+ public setTrackRepeat(repeat: boolean): this {
+ if (typeof repeat !== "boolean")
+ throw new TypeError('Repeat can only be "true" or "false".');
+
+ if (repeat) {
+ this.trackRepeat = true;
+ this.queueRepeat = false;
+ } else {
+ this.trackRepeat = false;
+ this.queueRepeat = false;
+ }
+
+ return this;
+ }
+
+ /**
+ * Sets the queue repeat.
+ * @param repeat
+ */
+ public setQueueRepeat(repeat: boolean): this {
+ if (typeof repeat !== "boolean")
+ throw new TypeError('Repeat can only be "true" or "false".');
+
+ if (repeat) {
+ this.trackRepeat = false;
+ this.queueRepeat = true;
+ } else {
+ this.trackRepeat = false;
+ this.queueRepeat = false;
+ }
+
+ return this;
+ }
+
+ /** Stops the current track, optionally give an amount to skip to, e.g 5 would play the 5th song. */
+ public stop(amount?: number): this {
+ if (typeof amount === "number" && amount > 1) {
+ if (amount > this.queue.length) throw new RangeError("Cannot skip more than the queue length.");
+ this.queue.splice(0, amount - 1);
+ }
+
+ this.node.send({
+ op: "stop",
+ guildId: this.guild,
+ });
+
+ return this;
+ }
+
+ /**
+ * Pauses the current track.
+ * @param pause
+ */
+ public pause(pause: boolean): this {
+ if (typeof pause !== "boolean")
+ throw new RangeError('Pause can only be "true" or "false".');
+ if (this.paused === pause || !this.queue.totalSize) return this;
this.playing = !pause;
this.paused = pause;
- this.node.rest.updatePlayer({
- guildId: this.guild_id,
- data: {
- paused: pause,
- },
+ this.node.send({
+ op: "pause",
+ guildId: this.guild,
+ pause,
});
- this.manager.emit("PlayerStateUpdate", oldPlayer, this);
return this;
}
-}
\ No newline at end of file
+
+ /**
+ * Seeks to the position in the current track.
+ * @param position
+ */
+ public seek(position: number): this {
+ if (!this.queue.current) return undefined;
+ position = Number(position);
+
+ if (isNaN(position)) {
+ throw new RangeError("Position must be a number.");
+ }
+ if (position < 0 || position > this.queue.current.duration)
+ position = Math.max(Math.min(position, this.queue.current.duration), 0);
+
+ this.position = position;
+ this.node.send({
+ op: "seek",
+ guildId: this.guild,
+ position,
+ });
+
+ return this;
+ }
+}
+
+export interface PlayerOptions {
+ /** The guild the Player belongs to. */
+ guild: string;
+ /** The text channel the Player belongs to. */
+ textChannel: string;
+ /** The voice channel the Player belongs to. */
+ voiceChannel?: string;
+ /** The node the Player uses. */
+ node?: string;
+ /** The initial volume the Player will use. */
+ volume?: number;
+ /** If the player should mute itself. */
+ selfMute?: boolean;
+ /** If the player should deaf itself. */
+ selfDeafen?: boolean;
+}
+
+/** If track partials are set some of these will be `undefined` as they were removed. */
+export interface Track {
+ /** The base64 encoded track. */
+ readonly track: string;
+ /** The title of the track. */
+ readonly title: string;
+ /** The identifier of the track. */
+ readonly identifier: string;
+ /** The author of the track. */
+ readonly author: string;
+ /** The duration of the track. */
+ readonly duration: number;
+ /** If the track is seekable. */
+ readonly isSeekable: boolean;
+ /** If the track is a stream.. */
+ readonly isStream: boolean;
+ /** The uri of the track. */
+ readonly uri: string;
+ /** The thumbnail of the track or null if it's a unsupported source. */
+ readonly thumbnail: string | null;
+ /** The user that requested the track. */
+ readonly requester: unknown | null;
+ /** Displays the track thumbnail with optional size or null if it's a unsupported source. */
+ displayThumbnail(size?: Sizes): string;
+}
+
+/** Unresolved tracks can't be played normally, they will resolve before playing into a Track. */
+export interface UnresolvedTrack extends Partial