├── .gitignore ├── .github ├── FUNDING.yml └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── src ├── index.ts ├── builders │ └── FilterBuilder.ts ├── structures │ ├── Queue.ts │ ├── Utils.ts │ ├── Node.ts │ ├── Player.ts │ └── Manager.ts └── data │ └── filters.ts ├── tsconfig.json ├── package.json ├── README.md └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | custom: ['https://qiwi.com/n/BLEACHNODES'] 2 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./structures/Manager"; 2 | export * from "./structures/Node"; 3 | export * from "./structures/Player"; 4 | export * from "./structures/Queue"; 5 | export * from "./structures/Utils"; 6 | export * from "./data/filters"; 7 | export * from "./builders/FilterBuilder"; -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "ES2022", 5 | "module": "CommonJS", 6 | "declaration": true, 7 | "outDir": "./dist", 8 | "rootDir": "./src", 9 | "skipLibCheck": true, 10 | "importHelpers": true, 11 | 12 | /* Strict Type-Checking Options */ 13 | // "strict": true, 14 | // "noImplicitAny": true, 15 | // "strictNullChecks": true, 16 | // "strictFunctionTypes": true, 17 | // "strictBindCallApply": true, 18 | // "strictPropertyInitialization": true, 19 | // "noImplicitThis": true, 20 | // "alwaysStrict": true, 21 | 22 | /* Additional Checks */ 23 | // "noUnusedLocals": true, 24 | // "noUnusedParameters": true, 25 | // "noImplicitReturns": true, 26 | // "noFallthroughCasesInSwitch": true, 27 | 28 | /* Module Resolution Options */ 29 | "moduleResolution": "node", 30 | "esModuleInterop": true 31 | }, 32 | "exclude": ["node_modules", "dist"] 33 | } 34 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lavacat", 3 | "version": "0.0.1", 4 | "description": "An easy-to-use Lavalink client for NodeJS.", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "files": [ "dist" ], 8 | "scripts": { 9 | "build": "tsc", 10 | "types": "rtb --dist dist", 11 | "lint": "eslint --ext .ts ./src", 12 | "docs": "typedoc --json ./docs.json --mode file --excludeProtected --excludePrivate --excludeExternals src/structures", 13 | "publish:stable": "yarn build && yarn types && yarn publish --access=public", 14 | "publish:beta": "yarn build && yarn types && yarn publish --tag beta --access=public", 15 | "ci": "run-s lint build types" 16 | }, 17 | "keywords": [ 18 | "lavalink", 19 | "discord", 20 | "music", 21 | "bot", 22 | "discord.js", 23 | "eris", 24 | "lavacat" 25 | ], 26 | "author": "CatLegend", 27 | "contributors": [], 28 | "license": "Apache-2.0", 29 | "repository": "BleachStudio/LavaCat", 30 | "bugs": "https://github.com/BleachStudio/LavaCat", 31 | "devDependencies": { 32 | "@favware/rollup-type-bundler": "^1.0.11", 33 | "@types/node": "v16", 34 | "@types/ws": "^8.5.3", 35 | "@typescript-eslint/eslint-plugin": "^5.37.0", 36 | "@typescript-eslint/parser": "^5.37.0", 37 | "eslint": "^8.23.1", 38 | "npm-run-all": "^4.1.5", 39 | "typedoc": "^0.23.14", 40 | "typedoc-plugin-no-inherit": "^1.4.0", 41 | "typescript": "^4.8.3" 42 | }, 43 | "dependencies": { 44 | "@discordjs/collection": "^1.1.0", 45 | "tslib": "^2.4.0", 46 | "undici": "^5.10.0", 47 | "ws": "^8.8.1" 48 | }, 49 | "engines": { 50 | "node": ">=16.0.0" 51 | }, 52 | "eslintConfig": { 53 | "root": true, 54 | "parser": "@typescript-eslint/parser", 55 | "plugins": [ 56 | "@typescript-eslint" 57 | ], 58 | "rules": { 59 | "object-curly-spacing": [ 60 | "error", 61 | "always" 62 | ] 63 | }, 64 | "extends": [ 65 | "eslint:recommended", 66 | "plugin:@typescript-eslint/recommended" 67 | ] 68 | }, 69 | "homepage": "https://github.com/BleachStudio/LavaCat#readme" 70 | } 71 | -------------------------------------------------------------------------------- /src/builders/FilterBuilder.ts: -------------------------------------------------------------------------------- 1 | import { Filter } from "../data/filters"; 2 | 3 | export class FilterBuilder { 4 | public name: string = 'CUSTOM'; 5 | public data: Filter = {}; 6 | 7 | constructor(options?: Filter) { 8 | if (options) { 9 | this.data = options 10 | } 11 | }; 12 | 13 | /** 14 | * setName 15 | */ 16 | public setName(name: string) { 17 | this.name = name; 18 | 19 | return this; 20 | }; 21 | 22 | /** 23 | * setEQ 24 | */ 25 | public setEQ(options: Filter['equalizer']) { 26 | this.data['equalizer'] = options; 27 | 28 | return this; 29 | }; 30 | 31 | /** 32 | * setKaraoke 33 | */ 34 | public setKaraoke(options: Filter['karaoke']) { 35 | this.data['karaoke'] = options; 36 | 37 | return this; 38 | }; 39 | 40 | /** 41 | * setTimescale 42 | */ 43 | public setTimescale(options: Filter['timescale']) { 44 | this.data['timescale'] = options; 45 | 46 | return this; 47 | }; 48 | 49 | /** 50 | * setTremolo 51 | */ 52 | public setTremolo(options: Filter['tremolo']) { 53 | this.data['tremolo'] = options; 54 | 55 | return this; 56 | }; 57 | 58 | /** 59 | * setVibrato 60 | */ 61 | public setVibrato(options: Filter['vibrato']) { 62 | this.data['vibrato'] = options; 63 | 64 | return this; 65 | }; 66 | 67 | /** 68 | * setRotation 69 | */ 70 | public setRotation(options: Filter['rotation']) { 71 | this.data['rotation'] = options; 72 | 73 | return this; 74 | }; 75 | 76 | /** 77 | * setDistortion 78 | */ 79 | public setDistortion(options: Filter['distortion']) { 80 | this.data['distortion'] = options; 81 | 82 | return this; 83 | }; 84 | 85 | /** 86 | * setChannelMix 87 | */ 88 | public setChannelMix(options: Filter['channelMix']) { 89 | this.data['channelMix'] = options; 90 | 91 | return this; 92 | }; 93 | 94 | /** 95 | * setLowPass 96 | */ 97 | public setLowPass(options: Filter['lowPass']) { 98 | this.data['lowPass'] = options; 99 | 100 | return this; 101 | }; 102 | }; -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |
5 | 6 | Discord 7 | 8 | 9 | Downloads 10 | 11 | 12 | Npm version 13 | 14 |
15 | 16 | Github stars 17 | 18 | 19 | License 20 | 21 |
22 |
23 | 24 | ## Documentation & Guides 25 | 26 | - [Documentation](https://lavacat.xyz "LavaCat Documentation") 27 | 28 | - [Guides](https://lavacat.xyz/guides "LavaCat Guides") 29 | 30 | ## Prerequisites 31 | 32 | - Java - [Azul](https://www.azul.com/downloads/zulu-community/?architecture=x86-64-bit&package=jdk "Download Azul OpenJDK"), [Adopt](https://adoptopenjdk.net/ "Download Adopt OpenJDK") or [sdkman](https://sdkman.io/install "Download sdkman") 33 | 34 | - [Lavalink](https://ci.fredboat.com/viewLog.html?buildId=lastSuccessful&buildTypeId=Lavalink_Build&tab=artifacts&guest=1 "Download Lavalink") 35 | 36 | - ### Lavalink plugins: 37 | - [LavaSRC](https://ci.fredboat.com/viewLog.html?buildId=lastSuccessful&buildTypeId=Lavalink_Build&tab=artifacts&guest=1 "Install plugin") 38 | - [Lavalink](https://ci.fredboat.com/viewLog.html?buildId=lastSuccessful&buildTypeId=Lavalink_Build&tab=artifacts&guest=1 "Download Lavalink") 39 | 40 | **Note**: _Java v11 or newer is required to run the Lavalink.jar. Java v13 is recommended._ If you are using **sdkman** then _its a manager, not Java, you have to install sdkman and use sdkman to install Java_ 41 | 42 | **Warning**: Java v14 has issues with Lavalink. 43 | 44 | ## Installation 45 | 46 | ##### **NPM** 47 | 48 | ```bash 49 | npm install lavacat 50 | ``` 51 | 52 | ##### **Yarn** 53 | 54 | ```bash 55 | yarn add lavacat 56 | ``` 57 | 58 | **Note**: _Node **v16** is required!_ 59 | 60 | ## Getting Started 61 | 62 | - Create an application.yml file in your working directory and copy the [example](https://github.com/freyacodes/Lavalink/blob/master/LavalinkServer/application.yml.example "application.yml file") into the created file and edit it with your configuration. 63 | 64 | - Run the jar file by running `java -jar Lavalink.jar` in a Terminal window. 65 | 66 | ## Example usage 67 | 68 | Please read the guides to start: 69 | -------------------------------------------------------------------------------- /src/structures/Queue.ts: -------------------------------------------------------------------------------- 1 | import { Track, UnresolvedTrack } from "./Player"; 2 | import { TrackUtils } from "./Utils"; 3 | 4 | /** 5 | * The player's queue, the `current` property is the currently playing track, think of the rest as the up-coming tracks. 6 | * @noInheritDoc 7 | */ 8 | export class Queue extends Array { 9 | /** The total duration of the queue. */ 10 | public get duration(): number { 11 | const current = this.current?.duration ?? 0; 12 | return this 13 | .reduce( 14 | (acc: number, cur: Track) => acc + (cur.duration || 0), 15 | current 16 | ); 17 | } 18 | 19 | /** The total size of tracks in the queue including the current track. */ 20 | public get totalSize(): number { 21 | return this.length + (this.current ? 1 : 0); 22 | } 23 | 24 | /** The size of tracks in the queue. */ 25 | public get size(): number { 26 | return this.length 27 | } 28 | 29 | /** The current track */ 30 | public current: Track | UnresolvedTrack | null = null; 31 | 32 | /** The previous tracks */ 33 | public previous: Array | null = []; 34 | 35 | /** 36 | * Adds a track to the queue. 37 | * @param track 38 | * @param [offset=null] 39 | */ 40 | public add( 41 | track: (Track | UnresolvedTrack) | (Track | UnresolvedTrack)[], 42 | offset?: number 43 | ): void { 44 | if (!TrackUtils.validate(track)) { 45 | throw new RangeError('Track must be a "Track" or "Track[]".'); 46 | } 47 | 48 | if (!this.current) { 49 | if (!Array.isArray(track)) { 50 | this.current = track; 51 | return; 52 | } else { 53 | this.current = (track = [...track]).shift(); 54 | } 55 | } 56 | 57 | if (typeof offset !== "undefined" && typeof offset === "number") { 58 | if (isNaN(offset)) { 59 | throw new RangeError("Offset must be a number."); 60 | } 61 | 62 | if (offset < 0 || offset > this.length) { 63 | throw new RangeError(`Offset must be or between 0 and ${this.length}.`); 64 | } 65 | } 66 | 67 | if (typeof offset === "undefined" && typeof offset !== "number") { 68 | if (track instanceof Array) this.push(...track); 69 | else this.push(track); 70 | } else { 71 | if (track instanceof Array) this.splice(offset, 0, ...track); 72 | else this.splice(offset, 0, track); 73 | } 74 | } 75 | 76 | /** 77 | * Removes a track from the queue. Defaults to the first track, returning the removed track, EXCLUDING THE `current` TRACK. 78 | * @param [position=0] 79 | */ 80 | public remove(position?: number): Track[]; 81 | 82 | /** 83 | * Removes an amount of tracks using a exclusive start and end exclusive index, returning the removed tracks, EXCLUDING THE `current` TRACK. 84 | * @param start 85 | * @param end 86 | */ 87 | public remove(start: number, end: number): (Track | UnresolvedTrack)[]; 88 | public remove(startOrPosition = 0, end?: number): (Track | UnresolvedTrack)[] { 89 | if (typeof end !== "undefined") { 90 | if (isNaN(Number(startOrPosition))) { 91 | throw new RangeError(`Missing "start" parameter.`); 92 | } else if (isNaN(Number(end))) { 93 | throw new RangeError(`Missing "end" parameter.`); 94 | } else if (startOrPosition >= end) { 95 | throw new RangeError("Start can not be bigger than end."); 96 | } else if (startOrPosition >= this.length) { 97 | throw new RangeError(`Start can not be bigger than ${this.length}.`); 98 | } 99 | 100 | return this.splice(startOrPosition, end - startOrPosition); 101 | } 102 | 103 | return this.splice(startOrPosition, 1); 104 | } 105 | 106 | /** Clears the queue. */ 107 | public clear(): void { 108 | this.splice(0); 109 | } 110 | 111 | /** Shuffles the queue. */ 112 | public shuffle(): void { 113 | for (let i = this.length - 1; i > 0; i--) { 114 | const j = Math.floor(Math.random() * (i + 1)); 115 | [this[i], this[j]] = [this[j], this[i]]; 116 | } 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/data/filters.ts: -------------------------------------------------------------------------------- 1 | import { EqualizerBand } from "../"; 2 | 3 | export const nightcoreData = { 4 | timescale: { 5 | speed: 1.2999999523162842, 6 | pitch: 1.2999999523162842, 7 | rate: 1, 8 | } 9 | }; 10 | 11 | export const daycoreData = { 12 | timescale: { 13 | speed: 0.855, 14 | pitch: 0.855, 15 | rate: 1, 16 | } 17 | }; 18 | 19 | export const vaporwaveData = { 20 | equalizer: [ 21 | {band: 1, gain: 0.3}, 22 | {band: 0, gain: 0.3}, 23 | ], 24 | timescale: {pitch: 0.5}, 25 | tremolo: {depth: 0.3, frequency: 14} 26 | }; 27 | 28 | export const bassboostData = { 29 | equalizer: [ 30 | {band: 0, gain: 0.6}, 31 | {band: 1, gain: 0.67}, 32 | {band: 2, gain: 0.67}, 33 | {band: 3, gain: 0}, 34 | {band: 4, gain: -0.5}, 35 | {band: 5, gain: 0.15}, 36 | {band: 6, gain: -0.45}, 37 | {band: 7, gain: 0.23}, 38 | {band: 8, gain: 0.35}, 39 | {band: 9, gain: 0.45}, 40 | {band: 10, gain: 0.55}, 41 | {band: 11, gain: 0.6}, 42 | {band: 12, gain: 0.55}, 43 | {band: 13, gain: 0}, 44 | ] 45 | }; 46 | 47 | export const popData = { 48 | equalizer: [ 49 | {band: 0, gain: 0.65}, 50 | {band: 1, gain: 0.45}, 51 | {band: 2, gain: -0.45}, 52 | {band: 3, gain: -0.65}, 53 | {band: 4, gain: -0.35}, 54 | {band: 5, gain: 0.45}, 55 | {band: 6, gain: 0.55}, 56 | {band: 7, gain: 0.6}, 57 | {band: 8, gain: 0.6}, 58 | {band: 9, gain: 0.6}, 59 | {band: 10, gain: 0}, 60 | {band: 11, gain: 0}, 61 | {band: 12, gain: 0}, 62 | {band: 13, gain: 0}, 63 | ] 64 | }; 65 | 66 | export const softData = { 67 | lowPass: { 68 | smoothing: 20.0 69 | } 70 | }; 71 | 72 | export const treblebassData = { 73 | equalizer: [ 74 | {band: 0, gain: 0.6}, 75 | {band: 1, gain: 0.67}, 76 | {band: 2, gain: 0.67}, 77 | {band: 3, gain: 0}, 78 | {band: 4, gain: -0.5}, 79 | {band: 5, gain: 0.15}, 80 | {band: 6, gain: -0.45}, 81 | {band: 7, gain: 0.23}, 82 | {band: 8, gain: 0.35}, 83 | {band: 9, gain: 0.45}, 84 | {band: 10, gain: 0.55}, 85 | {band: 11, gain: 0.6}, 86 | {band: 12, gain: 0.55}, 87 | {band: 13, gain: 0}, 88 | ] 89 | }; 90 | 91 | export const eightDData = { 92 | rotation: { 93 | rotationHz: 0.2 94 | } 95 | }; 96 | 97 | export const karaokeData = { 98 | karaoke: { 99 | level: 1.0, 100 | monoLevel: 1.0, 101 | filterBand: 220.0, 102 | filterWidth: 100.0 103 | } 104 | }; 105 | 106 | export const vibratoData = { 107 | vibrato: { 108 | frequency: 10, 109 | depth: 0.9 110 | } 111 | }; 112 | 113 | export const tremoloData = { 114 | tremolo: { 115 | frequency: 10, 116 | depth: 0.5 117 | } 118 | }; 119 | 120 | export interface Filter { 121 | equalizer?: Array, 122 | karaoke?: { 123 | level?: number, 124 | monoLevel?: number, 125 | filterBand?: number, 126 | filterWidth?: number 127 | }, 128 | timescale?: { 129 | speed?: number, // 0 ≤ x 130 | pitch?: number, // 0 ≤ x 131 | rate?: number // 0 ≤ x 132 | }, 133 | tremolo?: { 134 | frequency?: number, // 0 < x 135 | depth?: number // 0 < x ≤ 1 136 | }, 137 | vibrato?: { 138 | frequency?: number, // 0 < x ≤ 14 139 | depth?: number // 0 < x ≤ 1 140 | }, 141 | rotation?: { 142 | rotationHz?: number // The frequency of the audio rotating around the listener in Hz. 0.2 is similar to the example video above. 143 | }, 144 | distortion?: { 145 | sinOffset?: number, 146 | sinScale?: number, 147 | cosOffset?: number, 148 | cosScale?: number, 149 | tanOffset?: number, 150 | tanScale?: number, 151 | offset?: number, 152 | scale?: number 153 | }, 154 | channelMix?: { 155 | leftToLeft?: number, 156 | leftToRight?: number, 157 | rightToLeft?: number, 158 | rightToRight?: number, 159 | }, 160 | lowPass?: { 161 | smoothing?: number 162 | } 163 | }; 164 | 165 | export const filtersKeys = { 166 | nightcore: nightcoreData, 167 | daycore: daycoreData, 168 | vaporwave: vaporwaveData, 169 | bassboost: bassboostData, 170 | pop: popData, 171 | soft: softData, 172 | treblebass: treblebassData, 173 | eightD: eightDData, 174 | karaoke: karaokeData, 175 | vibrato: vibratoData, 176 | tremolo: tremoloData 177 | }; 178 | 179 | export type filters = 'nightcore' | 'vaporwave' | 'bassboost' | 'pop' | 'soft' | 'treblebass' | 'eightD' | 'karaoke' | 'vibrato' | 'tremolo' | 'daycore' | string; -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2021 MenuDocs 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /src/structures/Utils.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars, @typescript-eslint/no-var-requires*/ 2 | import { Manager } from "./Manager"; 3 | import { Node, NodeStats } from "./Node"; 4 | import { Player, Track, UnresolvedTrack } from "./Player"; 5 | import { Queue } from "./Queue"; 6 | 7 | /** @hidden */ 8 | const TRACK_SYMBOL = Symbol("track"), 9 | /** @hidden */ 10 | UNRESOLVED_TRACK_SYMBOL = Symbol("unresolved"), 11 | SIZES = [ 12 | "0", 13 | "1", 14 | "2", 15 | "3", 16 | "default", 17 | "mqdefault", 18 | "hqdefault", 19 | "maxresdefault", 20 | ]; 21 | 22 | /** @hidden */ 23 | const escapeRegExp = (str: string): string => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); 24 | 25 | export abstract class TrackUtils { 26 | static trackPartial: string[] | null = null; 27 | private static manager: Manager; 28 | 29 | /** @hidden */ 30 | public static init(manager: Manager): void { 31 | this.manager = manager; 32 | } 33 | 34 | static setTrackPartial(partial: string[]): void { 35 | if (!Array.isArray(partial) || !partial.every(str => typeof str === "string")) 36 | throw new Error("Provided partial is not an array or not a string array."); 37 | if (!partial.includes("track")) partial.unshift("track"); 38 | 39 | this.trackPartial = partial; 40 | } 41 | 42 | /** 43 | * Checks if the provided argument is a valid Track or UnresolvedTrack, if provided an array then every element will be checked. 44 | * @param trackOrTracks 45 | */ 46 | static validate(trackOrTracks: unknown): boolean { 47 | if (typeof trackOrTracks === "undefined") 48 | throw new RangeError("Provided argument must be present."); 49 | 50 | if (Array.isArray(trackOrTracks) && trackOrTracks.length) { 51 | for (const track of trackOrTracks) { 52 | if (!(track[TRACK_SYMBOL] || track[UNRESOLVED_TRACK_SYMBOL])) return false 53 | } 54 | return true; 55 | } 56 | 57 | return ( 58 | trackOrTracks[TRACK_SYMBOL] || 59 | trackOrTracks[UNRESOLVED_TRACK_SYMBOL] 60 | ) === true; 61 | } 62 | 63 | /** 64 | * Checks if the provided argument is a valid UnresolvedTrack. 65 | * @param track 66 | */ 67 | static isUnresolvedTrack(track: unknown): boolean { 68 | if (typeof track === "undefined") 69 | throw new RangeError("Provided argument must be present."); 70 | return track[UNRESOLVED_TRACK_SYMBOL] === true; 71 | } 72 | 73 | /** 74 | * Checks if the provided argument is a valid Track. 75 | * @param track 76 | */ 77 | static isTrack(track: unknown): boolean { 78 | if (typeof track === "undefined") 79 | throw new RangeError("Provided argument must be present."); 80 | return track[TRACK_SYMBOL] === true; 81 | } 82 | 83 | /** 84 | * Builds a Track from the raw data from Lavalink and a optional requester. 85 | * @param data 86 | * @param requester 87 | */ 88 | static build(data: TrackData, requester?: unknown): Track { 89 | if (typeof data === "undefined") 90 | throw new RangeError('Argument "data" must be present.'); 91 | 92 | try { 93 | const track: Track = { 94 | track: data.track, 95 | title: data.info.title, 96 | identifier: data.info.identifier, 97 | author: data.info.author, 98 | duration: data.info.length, 99 | isSeekable: data.info.isSeekable, 100 | isStream: data.info.isStream, 101 | uri: data.info.uri, 102 | thumbnail: data.info.uri.includes("youtube") 103 | ? `https://img.youtube.com/vi/${data.info.identifier}/default.jpg` 104 | : null, 105 | displayThumbnail(size = "default"): string | null { 106 | const finalSize = SIZES.find((s) => s === size) ?? "default"; 107 | return this.uri.includes("youtube") 108 | ? `https://img.youtube.com/vi/${data.info.identifier}/${finalSize}.jpg` 109 | : null; 110 | }, 111 | requester, 112 | }; 113 | 114 | track.displayThumbnail = track.displayThumbnail.bind(track); 115 | 116 | if (this.trackPartial) { 117 | for (const key of Object.keys(track)) { 118 | if (this.trackPartial.includes(key)) continue; 119 | delete track[key]; 120 | } 121 | } 122 | 123 | Object.defineProperty(track, TRACK_SYMBOL, { 124 | configurable: true, 125 | value: true 126 | }); 127 | 128 | return track; 129 | } catch (error) { 130 | throw new RangeError(`Argument "data" is not a valid track: ${error.message}`); 131 | } 132 | } 133 | 134 | /** 135 | * Builds a UnresolvedTrack to be resolved before being played . 136 | * @param query 137 | * @param requester 138 | */ 139 | static buildUnresolved(query: string | UnresolvedQuery, requester?: unknown): UnresolvedTrack { 140 | if (typeof query === "undefined") 141 | throw new RangeError('Argument "query" must be present.'); 142 | 143 | let unresolvedTrack: Partial = { 144 | requester, 145 | async resolve(): Promise { 146 | const resolved = await TrackUtils.getClosestTrack(this) 147 | Object.getOwnPropertyNames(this).forEach(prop => delete this[prop]); 148 | Object.assign(this, resolved); 149 | } 150 | }; 151 | 152 | if (typeof query === "string") unresolvedTrack.title = query; 153 | else unresolvedTrack = { ...unresolvedTrack, ...query } 154 | 155 | Object.defineProperty(unresolvedTrack, UNRESOLVED_TRACK_SYMBOL, { 156 | configurable: true, 157 | value: true 158 | }); 159 | 160 | return unresolvedTrack as UnresolvedTrack; 161 | } 162 | 163 | static async getClosestTrack( 164 | unresolvedTrack: UnresolvedTrack 165 | ): Promise { 166 | if (!TrackUtils.manager) throw new RangeError("Manager has not been initiated."); 167 | 168 | if (!TrackUtils.isUnresolvedTrack(unresolvedTrack)) 169 | throw new RangeError("Provided track is not a UnresolvedTrack."); 170 | 171 | const query = [unresolvedTrack.author, unresolvedTrack.title].filter(str => !!str).join(" - "); 172 | const res = await TrackUtils.manager.search(query, unresolvedTrack.requester); 173 | 174 | if (res.loadType !== "SEARCH_RESULT") throw res.exception ?? { 175 | message: "No tracks found.", 176 | severity: "COMMON", 177 | }; 178 | 179 | if (unresolvedTrack.author) { 180 | const channelNames = [unresolvedTrack.author, `${unresolvedTrack.author} - Topic`]; 181 | 182 | const originalAudio = res.tracks.find(track => { 183 | return ( 184 | channelNames.some(name => new RegExp(`^${escapeRegExp(name)}$`, "i").test(track.author)) || 185 | new RegExp(`^${escapeRegExp(unresolvedTrack.title)}$`, "i").test(track.title) 186 | ); 187 | }); 188 | 189 | if (originalAudio) return originalAudio; 190 | } 191 | 192 | if (unresolvedTrack.duration) { 193 | const sameDuration = res.tracks.find(track => 194 | (track.duration >= (unresolvedTrack.duration - 1500)) && 195 | (track.duration <= (unresolvedTrack.duration + 1500)) 196 | ); 197 | 198 | if (sameDuration) return sameDuration; 199 | } 200 | 201 | return res.tracks[0]; 202 | } 203 | } 204 | 205 | /** Gets or extends structures to extend the built in, or already extended, classes to add more functionality. */ 206 | export abstract class Structure { 207 | /** 208 | * Extends a class. 209 | * @param name 210 | * @param extender 211 | */ 212 | public static extend( 213 | name: K, 214 | extender: (target: Extendable[K]) => T 215 | ): T { 216 | if (!structures[name]) throw new TypeError(`"${name} is not a valid structure`); 217 | const extended = extender(structures[name]); 218 | structures[name] = extended; 219 | return extended; 220 | } 221 | 222 | /** 223 | * Get a structure from available structures by name. 224 | * @param name 225 | */ 226 | public static get(name: K): Extendable[K] { 227 | const structure = structures[name]; 228 | if (!structure) throw new TypeError('"structure" must be provided.'); 229 | return structure; 230 | } 231 | } 232 | 233 | export class Plugin { 234 | public load(manager: Manager): void {} 235 | 236 | public unload(manager: Manager): void {} 237 | } 238 | 239 | const structures = { 240 | Player: require("./Player").Player, 241 | Queue: require("./Queue").Queue, 242 | Node: require("./Node").Node, 243 | }; 244 | 245 | export interface UnresolvedQuery { 246 | /** The title of the unresolved track. */ 247 | title: string; 248 | /** The author of the unresolved track. If provided it will have a more precise search. */ 249 | author?: string; 250 | /** The duration of the unresolved track. If provided it will have a more precise search. */ 251 | duration?: number; 252 | } 253 | 254 | export type Sizes = 255 | | "0" 256 | | "1" 257 | | "2" 258 | | "3" 259 | | "default" 260 | | "mqdefault" 261 | | "hqdefault" 262 | | "maxresdefault"; 263 | 264 | export type LoadType = 265 | | "TRACK_LOADED" 266 | | "PLAYLIST_LOADED" 267 | | "SEARCH_RESULT" 268 | | "LOAD_FAILED" 269 | | "NO_MATCHES"; 270 | 271 | export type State = 272 | | "CONNECTED" 273 | | "CONNECTING" 274 | | "DISCONNECTED" 275 | | "DISCONNECTING" 276 | | "DESTROYING"; 277 | 278 | export type PlayerEvents = 279 | | TrackStartEvent 280 | | TrackEndEvent 281 | | TrackStuckEvent 282 | | TrackExceptionEvent 283 | | WebSocketClosedEvent; 284 | 285 | export type PlayerEventType = 286 | | "TrackStartEvent" 287 | | "TrackEndEvent" 288 | | "TrackExceptionEvent" 289 | | "TrackStuckEvent" 290 | | "WebSocketClosedEvent"; 291 | 292 | export type TrackEndReason = 293 | | "FINISHED" 294 | | "LOAD_FAILED" 295 | | "STOPPED" 296 | | "REPLACED" 297 | | "CLEANUP"; 298 | 299 | export type Severity = "COMMON" | "SUSPICIOUS" | "FAULT"; 300 | 301 | export interface TrackData { 302 | track: string; 303 | info: TrackDataInfo; 304 | } 305 | 306 | export interface TrackDataInfo { 307 | title: string; 308 | identifier: string; 309 | author: string; 310 | length: number; 311 | isSeekable: boolean; 312 | isStream: boolean; 313 | uri: string; 314 | } 315 | 316 | export interface Extendable { 317 | Player: typeof Player; 318 | Queue: typeof Queue; 319 | Node: typeof Node; 320 | } 321 | 322 | export interface VoiceState { 323 | op: "voiceUpdate"; 324 | guildId: string; 325 | event: VoiceServer; 326 | sessionId?: string; 327 | } 328 | 329 | export interface VoiceServer { 330 | token: string; 331 | guild_id: string; 332 | endpoint: string; 333 | } 334 | 335 | export interface VoiceState { 336 | guild_id: string; 337 | user_id: string; 338 | session_id: string; 339 | channel_id: string; 340 | } 341 | 342 | export interface VoicePacket { 343 | t?: "VOICE_SERVER_UPDATE" | "VOICE_STATE_UPDATE"; 344 | d: VoiceState | VoiceServer; 345 | } 346 | 347 | export interface NodeMessage extends NodeStats { 348 | type: PlayerEventType; 349 | op: "stats" | "playerUpdate" | "event"; 350 | guildId: string; 351 | } 352 | 353 | export interface PlayerEvent { 354 | op: "event"; 355 | type: PlayerEventType; 356 | guildId: string; 357 | } 358 | 359 | export interface Exception { 360 | severity: Severity; 361 | message: string; 362 | cause: string; 363 | } 364 | 365 | export interface TrackStartEvent extends PlayerEvent { 366 | type: "TrackStartEvent"; 367 | track: string; 368 | } 369 | 370 | export interface TrackEndEvent extends PlayerEvent { 371 | type: "TrackEndEvent"; 372 | track: string; 373 | reason: TrackEndReason; 374 | } 375 | 376 | export interface TrackExceptionEvent extends PlayerEvent { 377 | type: "TrackExceptionEvent"; 378 | exception?: Exception; 379 | error: string; 380 | } 381 | 382 | export interface TrackStuckEvent extends PlayerEvent { 383 | type: "TrackStuckEvent"; 384 | thresholdMs: number; 385 | } 386 | 387 | export interface WebSocketClosedEvent extends PlayerEvent { 388 | type: "WebSocketClosedEvent"; 389 | code: number; 390 | byRemote: boolean; 391 | reason: string; 392 | } 393 | 394 | export interface PlayerUpdate { 395 | op: "playerUpdate"; 396 | state: { 397 | position: number; 398 | time: number; 399 | }; 400 | guildId: string; 401 | } -------------------------------------------------------------------------------- /src/structures/Node.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-case-declarations */ 2 | import WebSocket from "ws"; 3 | import { Dispatcher, Pool } from "undici"; 4 | import { Manager } from "./Manager"; 5 | import { Player, Track, UnresolvedTrack } from "./Player"; 6 | import { 7 | PlayerEvent, 8 | PlayerEvents, 9 | Structure, 10 | TrackEndEvent, 11 | TrackExceptionEvent, 12 | TrackStartEvent, 13 | TrackStuckEvent, 14 | WebSocketClosedEvent, 15 | } from "./Utils"; 16 | 17 | function check(options: NodeOptions) { 18 | if (!options) throw new TypeError("NodeOptions must not be empty."); 19 | 20 | if ( 21 | typeof options.host !== "string" || 22 | !/.+/.test(options.host) 23 | ) 24 | throw new TypeError('Node option "host" must be present and be a non-empty string.'); 25 | 26 | if ( 27 | typeof options.port !== "undefined" && 28 | typeof options.port !== "number" 29 | ) 30 | throw new TypeError('Node option "port" must be a number.'); 31 | 32 | if ( 33 | typeof options.password !== "undefined" && 34 | (typeof options.password !== "string" || 35 | !/.+/.test(options.password)) 36 | ) 37 | throw new TypeError('Node option "password" must be a non-empty string.'); 38 | 39 | if ( 40 | typeof options.secure !== "undefined" && 41 | typeof options.secure !== "boolean" 42 | ) 43 | throw new TypeError('Node option "secure" must be a boolean.'); 44 | 45 | if ( 46 | typeof options.identifier !== "undefined" && 47 | typeof options.identifier !== "string" 48 | ) 49 | throw new TypeError('Node option "identifier" must be a non-empty string.'); 50 | 51 | if ( 52 | typeof options.retryAmount !== "undefined" && 53 | typeof options.retryAmount !== "number" 54 | ) 55 | throw new TypeError('Node option "retryAmount" must be a number.'); 56 | 57 | if ( 58 | typeof options.retryDelay !== "undefined" && 59 | typeof options.retryDelay !== "number" 60 | ) 61 | throw new TypeError('Node option "retryDelay" must be a number.'); 62 | 63 | if ( 64 | typeof options.requestTimeout !== "undefined" && 65 | typeof options.requestTimeout !== "number" 66 | ) 67 | throw new TypeError('Node option "requestTimeout" must be a number.'); 68 | } 69 | 70 | export class Node { 71 | /** The socket for the node. */ 72 | public socket: WebSocket | null = null; 73 | /** The HTTP pool used for rest calls. */ 74 | public http: Pool; 75 | /** The amount of rest calls the node has made. */ 76 | public calls = 0; 77 | /** The stats for the node. */ 78 | public stats: NodeStats; 79 | public manager: Manager 80 | 81 | private static _manager: Manager; 82 | private reconnectTimeout?: NodeJS.Timeout; 83 | private reconnectAttempts = 1; 84 | 85 | /** Returns if connected to the Node. */ 86 | public get connected(): boolean { 87 | if (!this.socket) return false; 88 | return this.socket.readyState === WebSocket.OPEN; 89 | } 90 | 91 | /** Returns the address for this node. */ 92 | public get address(): string { 93 | return `${this.options.host}:${this.options.port}`; 94 | } 95 | 96 | /** @hidden */ 97 | public static init(manager: Manager): void { 98 | this._manager = manager; 99 | } 100 | 101 | /** 102 | * Creates an instance of Node. 103 | * @param options 104 | */ 105 | constructor(public options: NodeOptions) { 106 | if (!this.manager) this.manager = Structure.get("Node")._manager; 107 | if (!this.manager) throw new RangeError("Manager has not been initiated."); 108 | 109 | if (this.manager.nodes.has(options.identifier || options.host)) { 110 | return this.manager.nodes.get(options.identifier || options.host); 111 | } 112 | 113 | check(options); 114 | 115 | this.options = { 116 | port: 2333, 117 | password: "youshallnotpass", 118 | secure: false, 119 | retryAmount: 5, 120 | retryDelay: 30e3, 121 | ...options, 122 | }; 123 | 124 | if (this.options.secure) { 125 | this.options.port = 443; 126 | } 127 | 128 | this.http = new Pool(`http${this.options.secure ? "s" : ""}://${this.address}`, this.options.poolOptions); 129 | 130 | this.options.identifier = options.identifier || options.host; 131 | this.stats = { 132 | players: 0, 133 | playingPlayers: 0, 134 | uptime: 0, 135 | memory: { 136 | free: 0, 137 | used: 0, 138 | allocated: 0, 139 | reservable: 0, 140 | }, 141 | cpu: { 142 | cores: 0, 143 | systemLoad: 0, 144 | lavalinkLoad: 0, 145 | }, 146 | frameStats: { 147 | sent: 0, 148 | nulled: 0, 149 | deficit: 0, 150 | }, 151 | }; 152 | 153 | this.manager.nodes.set(this.options.identifier, this); 154 | this.manager.emit("nodeCreate", this); 155 | } 156 | 157 | /** Connects to the Node. */ 158 | public connect(): void { 159 | if (this.connected) return; 160 | 161 | const headers = { 162 | Authorization: this.options.password, 163 | "Num-Shards": String(this.manager.options.shards), 164 | "User-Id": this.manager.options.clientId, 165 | "Client-Name": this.manager.options.clientName, 166 | }; 167 | 168 | this.socket = new WebSocket(`ws${this.options.secure ? "s" : ""}://${this.address}`, { headers }); 169 | this.socket.on("open", this.open.bind(this)); 170 | this.socket.on("close", this.close.bind(this)); 171 | this.socket.on("message", this.message.bind(this)); 172 | this.socket.on("error", this.error.bind(this)); 173 | } 174 | 175 | /** Destroys the Node and all players connected with it. */ 176 | public destroy(): void { 177 | if (!this.connected) return; 178 | 179 | const players = this.manager.players.filter(p => p.node == this); 180 | if (players.size) players.forEach(p => p.destroy()); 181 | 182 | this.socket.close(1000, "destroy"); 183 | this.socket.removeAllListeners(); 184 | this.socket = null; 185 | 186 | this.reconnectAttempts = 1; 187 | clearTimeout(this.reconnectTimeout); 188 | 189 | this.manager.emit("nodeDestroy", this); 190 | this.manager.destroyNode(this.options.identifier); 191 | } 192 | 193 | /** 194 | * Makes an API call to the Node 195 | * @param endpoint The endpoint that we will make the call to 196 | * @param modify Used to modify the request before being sent 197 | * @returns The returned data 198 | */ 199 | public async makeRequest(endpoint: string, modify?: ModifyRequest): Promise { 200 | const options: Dispatcher.RequestOptions = { 201 | path: `/${endpoint.replace(/^\//gm, "")}`, 202 | method: "GET", 203 | headers: { 204 | Authorization: this.options.password 205 | }, 206 | headersTimeout: this.options.requestTimeout, 207 | } 208 | 209 | modify?.(options); 210 | 211 | const request = await this.http.request(options); 212 | this.calls++; 213 | 214 | return await request.body.json(); 215 | } 216 | 217 | /** 218 | * Sends data to the Node. 219 | * @param data 220 | */ 221 | public send(data: unknown): Promise { 222 | return new Promise((resolve, reject) => { 223 | if (!this.connected) return resolve(false); 224 | if (!data || !JSON.stringify(data).startsWith("{")) { 225 | return reject(false); 226 | } 227 | this.socket.send(JSON.stringify(data), (error: Error) => { 228 | if (error) reject(error); 229 | else resolve(true); 230 | }); 231 | }); 232 | } 233 | 234 | private reconnect(): void { 235 | this.reconnectTimeout = setTimeout(() => { 236 | if (this.reconnectAttempts >= this.options.retryAmount) { 237 | const error = new Error( 238 | `Unable to connect after ${this.options.retryAmount} attempts.` 239 | ) 240 | 241 | this.manager.emit("nodeError", this, error); 242 | return this.destroy(); 243 | } 244 | this.socket.removeAllListeners(); 245 | this.socket = null; 246 | this.manager.emit("nodeReconnect", this); 247 | this.connect(); 248 | this.reconnectAttempts++; 249 | }, this.options.retryDelay); 250 | } 251 | 252 | protected open(): void { 253 | if (this.reconnectTimeout) clearTimeout(this.reconnectTimeout); 254 | this.manager.emit("nodeConnect", this); 255 | } 256 | 257 | protected close(code: number, reason: string): void { 258 | this.manager.emit("nodeDisconnect", this, { code, reason }); 259 | if (code !== 1000 || reason !== "destroy") this.reconnect(); 260 | } 261 | 262 | protected error(error: Error): void { 263 | if (!error) return; 264 | this.manager.emit("nodeError", this, error); 265 | } 266 | 267 | protected message(d: Buffer | string): void { 268 | if (Array.isArray(d)) d = Buffer.concat(d); 269 | else if (d instanceof ArrayBuffer) d = Buffer.from(d); 270 | 271 | const payload = JSON.parse(d.toString()); 272 | 273 | if (!payload.op) return; 274 | this.manager.emit("nodeRaw", payload); 275 | 276 | switch (payload.op) { 277 | case "stats": 278 | delete payload.op; 279 | this.stats = ({ ...payload } as unknown) as NodeStats; 280 | break; 281 | case "playerUpdate": 282 | const player = this.manager.players.get(payload.guildId); 283 | if (player) player.position = payload.state.position || 0; 284 | break; 285 | case "event": 286 | this.handleEvent(payload); 287 | break; 288 | default: 289 | this.manager.emit( 290 | "nodeError", 291 | this, 292 | new Error(`Unexpected op "${payload.op}" with data: ${payload}`) 293 | ); 294 | return; 295 | } 296 | } 297 | 298 | protected handleEvent(payload: PlayerEvent & PlayerEvents): void { 299 | if (!payload.guildId) return; 300 | 301 | const player = this.manager.players.get(payload.guildId); 302 | if (!player) return; 303 | 304 | const track = player.queue.current; 305 | const type = payload.type; 306 | 307 | if (payload.type === "TrackStartEvent") { 308 | this.trackStart(player, track as Track, payload); 309 | } else if (payload.type === "TrackEndEvent") { 310 | this.trackEnd(player, track as Track, payload); 311 | } else if (payload.type === "TrackStuckEvent") { 312 | this.trackStuck(player, track as Track, payload); 313 | } else if (payload.type === "TrackExceptionEvent") { 314 | this.trackError(player, track, payload); 315 | } else if (payload.type === "WebSocketClosedEvent") { 316 | this.socketClosed(player, payload); 317 | } else { 318 | const error = new Error(`Node#event unknown event '${type}'.`); 319 | this.manager.emit("nodeError", this, error); 320 | } 321 | } 322 | 323 | protected trackStart(player: Player, track: Track, payload: TrackStartEvent): void { 324 | player.playing = true; 325 | player.paused = false; 326 | this.manager.emit("trackStart", player, track, payload); 327 | } 328 | 329 | protected trackEnd(player: Player, track: Track, payload: TrackEndEvent): void { 330 | // If a track had an error while starting 331 | if (["LOAD_FAILED", "CLEAN_UP"].includes(payload.reason)) { 332 | if (player.track_stop_reason == 'SKIPPED') { 333 | player.queue.previous.push(player.queue.current); 334 | player.track_stop_reason = null; 335 | }; 336 | player.queue.current = player.queue.shift(); 337 | 338 | if (!player.queue.current) return this.queueEnd(player, track, payload); 339 | 340 | this.manager.emit("trackEnd", player, track, payload); 341 | if (this.manager.options.autoPlay) player.play(); 342 | return; 343 | } 344 | 345 | // If a track was forcibly played 346 | if (payload.reason === "REPLACED") { 347 | this.manager.emit("trackEnd", player, track, payload); 348 | return; 349 | } 350 | 351 | // If a track ended and is track repeating 352 | if (track && player.trackRepeat) { 353 | if (payload.reason === "STOPPED") { 354 | if (player.track_stop_reason == 'SKIPPED') { 355 | player.queue.previous.push(player.queue.current); 356 | player.track_stop_reason = null; 357 | }; 358 | player.queue.current = player.queue.shift(); 359 | } 360 | 361 | if (!player.queue.current) return this.queueEnd(player, track, payload); 362 | 363 | this.manager.emit("trackEnd", player, track, payload); 364 | if (this.manager.options.autoPlay) player.play(); 365 | return; 366 | } 367 | 368 | // If a track ended and is track repeating 369 | if (track && player.queueRepeat) { 370 | if (player.track_stop_reason == 'SKIPPED') { 371 | player.queue.previous.push(player.queue.current); 372 | player.track_stop_reason = null; 373 | }; 374 | 375 | if (payload.reason === "STOPPED") { 376 | player.queue.current = player.queue.shift(); 377 | if (!player.queue.current) return this.queueEnd(player, track, payload); 378 | } else { 379 | player.queue.add(player.queue.current); 380 | player.queue.current = player.queue.shift(); 381 | } 382 | 383 | this.manager.emit("trackEnd", player, track, payload); 384 | if (this.manager.options.autoPlay) player.play(); 385 | return; 386 | } 387 | 388 | // If there is another song in the queue 389 | if (player.queue.length) { 390 | if (player.track_stop_reason == 'SKIPPED') { 391 | player.queue.previous.push(player.queue.current); 392 | player.track_stop_reason = null; 393 | }; 394 | player.queue.current = player.queue.shift(); 395 | 396 | this.manager.emit("trackEnd", player, track, payload); 397 | if (this.manager.options.autoPlay) player.play(); 398 | return; 399 | } 400 | 401 | // If there are no songs in the queue 402 | if (!player.queue.length) return this.queueEnd(player, track, payload); 403 | } 404 | 405 | 406 | protected queueEnd(player: Player, track: Track, payload: TrackEndEvent): void { 407 | player.queue.current = null; 408 | player.playing = false; 409 | this.manager.emit("queueEnd", player, track, payload); 410 | } 411 | 412 | protected trackStuck(player: Player, track: Track, payload: TrackStuckEvent): void { 413 | player.stop(); 414 | this.manager.emit("trackStuck", player, track, payload); 415 | } 416 | 417 | protected trackError( 418 | player: Player, 419 | track: Track | UnresolvedTrack, 420 | payload: TrackExceptionEvent 421 | ): void { 422 | player.stop(); 423 | this.manager.emit("trackError", player, track, payload); 424 | } 425 | 426 | protected socketClosed(player: Player, payload: WebSocketClosedEvent): void { 427 | this.manager.emit("socketClosed", player, payload); 428 | } 429 | } 430 | 431 | /** Modifies any outgoing REST requests. */ 432 | export type ModifyRequest = (options: Dispatcher.RequestOptions) => void; 433 | 434 | export interface NodeOptions { 435 | /** The host for the node. */ 436 | host: string; 437 | /** The port for the node. */ 438 | port?: number; 439 | /** The password for the node. */ 440 | password?: string; 441 | /** Whether the host uses SSL. */ 442 | secure?: boolean; 443 | /** The identifier for the node. */ 444 | identifier?: string; 445 | /** The retryAmount for the node. */ 446 | retryAmount?: number; 447 | /** The retryDelay for the node. */ 448 | retryDelay?: number; 449 | /** The timeout used for api calls */ 450 | requestTimeout?: number; 451 | /** Options for the undici http pool used for http requests */ 452 | poolOptions?: Pool.Options; 453 | } 454 | 455 | export interface NodeStats { 456 | /** The amount of players on the node. */ 457 | players: number; 458 | /** The amount of playing players on the node. */ 459 | playingPlayers: number; 460 | /** The uptime for the node. */ 461 | uptime: number; 462 | /** The memory stats for the node. */ 463 | memory: MemoryStats; 464 | /** The cpu stats for the node. */ 465 | cpu: CPUStats; 466 | /** The frame stats for the node. */ 467 | frameStats: FrameStats; 468 | } 469 | 470 | export interface MemoryStats { 471 | /** The free memory of the allocated amount. */ 472 | free: number; 473 | /** The used memory of the allocated amount. */ 474 | used: number; 475 | /** The total allocated memory. */ 476 | allocated: number; 477 | /** The reservable memory. */ 478 | reservable: number; 479 | } 480 | 481 | export interface CPUStats { 482 | /** The core amount the host machine has. */ 483 | cores: number; 484 | /** The system load. */ 485 | systemLoad: number; 486 | /** The lavalink load. */ 487 | lavalinkLoad: number; 488 | } 489 | 490 | export interface FrameStats { 491 | /** The amount of sent frames. */ 492 | sent?: number; 493 | /** The amount of nulled frames. */ 494 | nulled?: number; 495 | /** The amount of deficit frames. */ 496 | deficit?: number; 497 | } 498 | -------------------------------------------------------------------------------- /src/structures/Player.ts: -------------------------------------------------------------------------------- 1 | import { Manager, SearchQuery, SearchResult } from "./Manager"; 2 | import { Node } from "./Node"; 3 | import { Queue } from "./Queue"; 4 | import { Sizes, State, Structure, TrackUtils, VoiceState } from "./Utils"; 5 | import { filters, filtersKeys } from "../data/filters"; 6 | import { FilterBuilder } from "../builders/FilterBuilder"; 7 | 8 | function check(options: PlayerOptions) { 9 | if (!options) throw new TypeError("PlayerOptions must not be empty."); 10 | 11 | if (!/^\d+$/.test(options.guild)) 12 | throw new TypeError( 13 | 'Player option "guild" must be present and be a non-empty string.' 14 | ); 15 | 16 | if (options.textChannel && !/^\d+$/.test(options.textChannel)) 17 | throw new TypeError( 18 | 'Player option "textChannel" must be a non-empty string.' 19 | ); 20 | 21 | if (options.voiceChannel && !/^\d+$/.test(options.voiceChannel)) 22 | throw new TypeError( 23 | 'Player option "voiceChannel" must be a non-empty string.' 24 | ); 25 | 26 | if (options.node && typeof options.node !== "string") 27 | throw new TypeError('Player option "node" must be a non-empty string.'); 28 | 29 | if ( 30 | typeof options.volume !== "undefined" && 31 | typeof options.volume !== "number" 32 | ) 33 | throw new TypeError('Player option "volume" must be a number.'); 34 | 35 | if ( 36 | typeof options.selfMute !== "undefined" && 37 | typeof options.selfMute !== "boolean" 38 | ) 39 | throw new TypeError('Player option "selfMute" must be a boolean.'); 40 | 41 | if ( 42 | typeof options.selfDeafen !== "undefined" && 43 | typeof options.selfDeafen !== "boolean" 44 | ) 45 | throw new TypeError('Player option "selfDeafen" must be a boolean.'); 46 | } 47 | 48 | export class Player { 49 | /** The Queue for the Player. */ 50 | public readonly queue = new (Structure.get("Queue"))() as Queue; 51 | /** Whether the queue repeats the track. */ 52 | public trackRepeat = false; 53 | /** Whether the queue repeats the queue. */ 54 | public queueRepeat = false; 55 | /** The time the player is in the track. */ 56 | public position = 0; 57 | /** Whether the player is playing. */ 58 | public playing = false; 59 | /** Whether the player is paused. */ 60 | public paused = false; 61 | /** The volume for the player */ 62 | public volume: number; 63 | /** The Node for the Player. */ 64 | public node: Node; 65 | /** The guild for the player. */ 66 | public guild: string; 67 | /** The voice channel for the player. */ 68 | public voiceChannel: string | null = null; 69 | /** The text channel for the player. */ 70 | public textChannel: string | null = null; 71 | /** The current state of the player. */ 72 | public state: State = "DISCONNECTED"; 73 | /** The equalizer bands array. */ 74 | public bands = new Array(15).fill(0.0); 75 | /** The voice state object from Discord. */ 76 | public voiceState: VoiceState; 77 | /** The Manager. */ 78 | public manager: Manager; 79 | public track_stop_reason: string | null = null; 80 | public filter: filters | null = null; 81 | private static _manager: Manager; 82 | private readonly data: Record = {}; 83 | 84 | /** 85 | * Set custom data. 86 | * @param key 87 | * @param value 88 | */ 89 | public set(key: string, value: unknown): void { 90 | this.data[key] = value; 91 | } 92 | 93 | /** 94 | * Get custom data. 95 | * @param key 96 | */ 97 | public get(key: string): T { 98 | return this.data[key] as T; 99 | } 100 | 101 | /** 102 | * delete custom data 103 | * @param key 104 | */ 105 | public delete(key: string): T { 106 | return delete this.data[key] as T; 107 | } 108 | 109 | /** @hidden */ 110 | public static init(manager: Manager): void { 111 | this._manager = manager; 112 | } 113 | 114 | /** 115 | * Creates a new player, returns one if it already exists. 116 | * @param options 117 | */ 118 | constructor(public options: PlayerOptions) { 119 | if (!this.manager) this.manager = Structure.get("Player")._manager; 120 | if (!this.manager) throw new RangeError("Manager has not been initiated."); 121 | 122 | if (this.manager.players.has(options.guild)) { 123 | return this.manager.players.get(options.guild); 124 | } 125 | 126 | check(options); 127 | 128 | this.guild = options.guild; 129 | this.voiceState = Object.assign({ op: "voiceUpdate", guildId: options.guild }); 130 | 131 | if (options.voiceChannel) this.voiceChannel = options.voiceChannel; 132 | if (options.textChannel) this.textChannel = options.textChannel; 133 | 134 | const node = this.manager.nodes.get(options.node); 135 | this.node = node || this.manager.leastLoadNodes.first(); 136 | 137 | if (!this.node) throw new RangeError("No available nodes."); 138 | 139 | this.manager.players.set(options.guild, this); 140 | this.manager.emit("playerCreate", this); 141 | this.setVolume(options.volume ?? 100); 142 | } 143 | 144 | /** 145 | * Same as Manager#search() but a shortcut on the player itself. 146 | * @param query 147 | * @param requester 148 | */ 149 | public search( 150 | query: string | SearchQuery, 151 | requester?: unknown 152 | ): Promise { 153 | return this.manager.search(query, requester); 154 | } 155 | 156 | /** 157 | * Sets the players equalizer band on-top of the existing ones. 158 | * @param bands 159 | */ 160 | public setEQ(...bands: EqualizerBand[]): this { 161 | // Hacky support for providing an array 162 | if (Array.isArray(bands[0])) bands = bands[0] as unknown as EqualizerBand[] 163 | 164 | if (!bands.length || !bands.every( 165 | (band) => JSON.stringify(Object.keys(band).sort()) === '["band","gain"]' 166 | ) 167 | ) 168 | throw new TypeError("Bands must be a non-empty object array containing 'band' and 'gain' properties."); 169 | 170 | for (const { band, gain } of bands) this.bands[band] = gain; 171 | 172 | this.node.send({ 173 | op: "equalizer", 174 | guildId: this.guild, 175 | bands: this.bands.map((gain, band) => ({ band, gain })), 176 | }); 177 | 178 | return this; 179 | } 180 | 181 | /** Clears the equalizer bands. */ 182 | public clearEQ(): this { 183 | this.bands = new Array(15).fill(0.0); 184 | 185 | this.node.send({ 186 | op: "equalizer", 187 | guildId: this.guild, 188 | bands: this.bands.map((gain, band) => ({ band, gain })), 189 | }); 190 | 191 | return this; 192 | } 193 | 194 | /** Connect to the voice channel. */ 195 | public connect(): this { 196 | if (!this.voiceChannel) 197 | throw new RangeError("No voice channel has been set."); 198 | this.state = "CONNECTING"; 199 | 200 | this.manager.options.send(this.guild, { 201 | op: 4, 202 | d: { 203 | guild_id: this.guild, 204 | channel_id: this.voiceChannel, 205 | self_mute: this.options.selfMute || false, 206 | self_deaf: this.options.selfDeafen || false, 207 | }, 208 | }); 209 | 210 | this.state = "CONNECTED"; 211 | return this; 212 | } 213 | 214 | /** Disconnect from the voice channel. */ 215 | public disconnect(): this { 216 | if (this.voiceChannel === null) return this; 217 | this.state = "DISCONNECTING"; 218 | 219 | this.pause(true); 220 | this.manager.options.send(this.guild, { 221 | op: 4, 222 | d: { 223 | guild_id: this.guild, 224 | channel_id: null, 225 | self_mute: false, 226 | self_deaf: false, 227 | }, 228 | }); 229 | 230 | this.voiceChannel = null; 231 | this.state = "DISCONNECTED"; 232 | return this; 233 | } 234 | 235 | /** Destroys the player. */ 236 | public destroy(disconnect = true): void { 237 | this.state = "DESTROYING"; 238 | if (disconnect) { 239 | this.disconnect(); 240 | } 241 | 242 | this.node.send({ 243 | op: "destroy", 244 | guildId: this.guild, 245 | }); 246 | 247 | this.manager.emit("playerDestroy", this); 248 | this.manager.players.delete(this.guild); 249 | } 250 | 251 | /** 252 | * Sets the player voice channel. 253 | * @param channel 254 | */ 255 | public setVoiceChannel(channel: string): this { 256 | if (typeof channel !== "string") 257 | throw new TypeError("Channel must be a non-empty string."); 258 | 259 | this.voiceChannel = channel; 260 | this.connect(); 261 | return this; 262 | } 263 | 264 | /** 265 | * Sets the player text channel. 266 | * @param channel 267 | */ 268 | public setTextChannel(channel: string): this { 269 | if (typeof channel !== "string") 270 | throw new TypeError("Channel must be a non-empty string."); 271 | 272 | this.textChannel = channel; 273 | return this; 274 | } 275 | 276 | /** Plays the next track. */ 277 | public async play(): Promise; 278 | 279 | /** 280 | * Plays the specified track. 281 | * @param track 282 | */ 283 | public async play(track: Track | UnresolvedTrack): Promise; 284 | 285 | /** 286 | * Plays the next track with some options. 287 | * @param options 288 | */ 289 | public async play(options: PlayOptions): Promise; 290 | 291 | /** 292 | * Plays the specified track with some options. 293 | * @param track 294 | * @param options 295 | */ 296 | public async play(track: Track | UnresolvedTrack, options: PlayOptions): Promise; 297 | public async play( 298 | optionsOrTrack?: PlayOptions | Track | UnresolvedTrack, 299 | playOptions?: PlayOptions 300 | ): Promise { 301 | if ( 302 | typeof optionsOrTrack !== "undefined" && 303 | TrackUtils.validate(optionsOrTrack) 304 | ) { 305 | if (this.queue.current) this.queue.previous.push(this.queue.current); 306 | this.queue.current = optionsOrTrack as Track; 307 | } 308 | 309 | if (!this.queue.current) throw new RangeError("No current track."); 310 | 311 | const finalOptions = playOptions 312 | ? playOptions 313 | : ["startTime", "endTime", "noReplace"].every((v) => 314 | Object.keys(optionsOrTrack || {}).includes(v) 315 | ) 316 | ? (optionsOrTrack as PlayOptions) 317 | : {}; 318 | 319 | if (TrackUtils.isUnresolvedTrack(this.queue.current)) { 320 | try { 321 | this.queue.current = await TrackUtils.getClosestTrack(this.queue.current as UnresolvedTrack); 322 | } catch (error) { 323 | this.manager.emit("trackError", this, this.queue.current, error); 324 | if (this.queue[0]) return this.play(this.queue[0]); 325 | return; 326 | } 327 | } 328 | 329 | const options = { 330 | op: "play", 331 | guildId: this.guild, 332 | track: this.queue.current.track, 333 | ...finalOptions, 334 | }; 335 | 336 | if (typeof options.track !== "string") { 337 | options.track = (options.track as Track).track; 338 | } 339 | 340 | await this.node.send(options); 341 | } 342 | 343 | /** 344 | * Sets the player volume. 345 | * @param volume 346 | */ 347 | public setVolume(volume: number): this { 348 | volume = Number(volume); 349 | 350 | if (isNaN(volume)) throw new TypeError("Volume must be a number."); 351 | this.volume = Math.max(Math.min(volume, 1000), 0); 352 | 353 | this.node.send({ 354 | op: "volume", 355 | guildId: this.guild, 356 | volume: this.volume, 357 | }); 358 | 359 | return this; 360 | } 361 | 362 | /** 363 | * Sets the track repeat. 364 | * @param repeat 365 | */ 366 | public setTrackRepeat(repeat: boolean): this { 367 | if (typeof repeat !== "boolean") 368 | throw new TypeError('Repeat can only be "true" or "false".'); 369 | 370 | if (repeat) { 371 | this.trackRepeat = true; 372 | this.queueRepeat = false; 373 | } else { 374 | this.trackRepeat = false; 375 | this.queueRepeat = false; 376 | } 377 | 378 | return this; 379 | } 380 | 381 | /** 382 | * Sets the queue repeat. 383 | * @param repeat 384 | */ 385 | public setQueueRepeat(repeat: boolean): this { 386 | if (typeof repeat !== "boolean") 387 | throw new TypeError('Repeat can only be "true" or "false".'); 388 | 389 | if (repeat) { 390 | this.trackRepeat = false; 391 | this.queueRepeat = true; 392 | } else { 393 | this.trackRepeat = false; 394 | this.queueRepeat = false; 395 | } 396 | 397 | return this; 398 | } 399 | 400 | /** Stops the current track, optionally give an amount to skip to, e.g 5 would play the 5th song. */ 401 | public stop(amount?: number, reason?: string): this { 402 | if (typeof amount === "number" && amount > 1) { 403 | if (amount > this.queue.length) throw new RangeError("Cannot skip more than the queue length."); 404 | this.queue.splice(0, amount - 1); 405 | }; 406 | this.track_stop_reason = reason ?? 'SKIPPED'; 407 | 408 | this.node.send({ 409 | op: "stop", 410 | guildId: this.guild, 411 | }); 412 | 413 | return this; 414 | } 415 | 416 | public previous() { 417 | if (this.queue.previous.length != 0) { 418 | this.queue.unshift(this.queue.current); 419 | this.queue.unshift(this.queue.previous[this.queue.previous.length - 1]); 420 | this.queue.previous.pop(); 421 | this.stop(undefined, 'PREVIOUS'); 422 | }; 423 | 424 | return this; 425 | }; 426 | 427 | /** 428 | * Pauses the current track. 429 | * @param pause 430 | */ 431 | public pause(pause: boolean): this { 432 | if (typeof pause !== "boolean") 433 | throw new RangeError('Pause can only be "true" or "false".'); 434 | 435 | // If already paused or the queue is empty do nothing https://github.com/MenuDocs/erela.js/issues/58 436 | if (this.paused === pause || !this.queue.totalSize) return this; 437 | 438 | this.playing = !pause; 439 | this.paused = pause; 440 | 441 | this.node.send({ 442 | op: "pause", 443 | guildId: this.guild, 444 | pause, 445 | }); 446 | 447 | return this; 448 | } 449 | 450 | /** 451 | * Seeks to the position in the current track. 452 | * @param position 453 | */ 454 | public seek(position: number): this { 455 | if (!this.queue.current) return undefined; 456 | position = Number(position); 457 | 458 | if (isNaN(position)) { 459 | throw new RangeError("Position must be a number."); 460 | } 461 | if (position < 0 || position > this.queue.current.duration) 462 | position = Math.max(Math.min(position, this.queue.current.duration), 0); 463 | 464 | this.position = position; 465 | this.node.send({ 466 | op: "seek", 467 | guildId: this.guild, 468 | position, 469 | }); 470 | 471 | return this; 472 | }; 473 | 474 | /** 475 | * setFilter 476 | */ 477 | public setFilter(filter: filters | FilterBuilder | null) { 478 | if (!filter) { 479 | this.node.send({ 480 | op: 'filters', 481 | guildId: this.guild 482 | }); 483 | this.filter = null; 484 | } else if (typeof filter == 'string') { 485 | if (!filtersKeys[filter]) throw new Error('Unknown filter.'); 486 | this.node.send({ 487 | op: 'filters', 488 | guildId: this.guild, 489 | ...filtersKeys[filter] 490 | }); 491 | this.filter = filter; 492 | } else { 493 | this.node.send({ 494 | op: 'filters', 495 | guildId: this.guild, 496 | ...filter.data 497 | }); 498 | this.filter = filter.name; 499 | } 500 | 501 | return this; 502 | } 503 | } 504 | 505 | export interface PlayerOptions { 506 | /** The guild the Player belongs to. */ 507 | guild: string; 508 | /** The text channel the Player belongs to. */ 509 | textChannel: string; 510 | /** The voice channel the Player belongs to. */ 511 | voiceChannel?: string; 512 | /** The node the Player uses. */ 513 | node?: string; 514 | /** The initial volume the Player will use. */ 515 | volume?: number; 516 | /** If the player should mute itself. */ 517 | selfMute?: boolean; 518 | /** If the player should deaf itself. */ 519 | selfDeafen?: boolean; 520 | } 521 | 522 | /** If track partials are set some of these will be `undefined` as they were removed. */ 523 | export interface Track { 524 | /** The base64 encoded track. */ 525 | readonly track: string; 526 | /** The title of the track. */ 527 | readonly title: string; 528 | /** The identifier of the track. */ 529 | readonly identifier: string; 530 | /** The author of the track. */ 531 | readonly author: string; 532 | /** The duration of the track. */ 533 | readonly duration: number; 534 | /** If the track is seekable. */ 535 | readonly isSeekable: boolean; 536 | /** If the track is a stream.. */ 537 | readonly isStream: boolean; 538 | /** The uri of the track. */ 539 | readonly uri: string; 540 | /** The thumbnail of the track or null if it's a unsupported source. */ 541 | readonly thumbnail: string | null; 542 | /** The user that requested the track. */ 543 | readonly requester: unknown | null; 544 | /** Displays the track thumbnail with optional size or null if it's a unsupported source. */ 545 | displayThumbnail(size?: Sizes): string; 546 | } 547 | 548 | /** Unresolved tracks can't be played normally, they will resolve before playing into a Track. */ 549 | export interface UnresolvedTrack extends Partial { 550 | /** The title to search against. */ 551 | title: string; 552 | /** The author to search against. */ 553 | author?: string; 554 | /** The duration to search within 1500 milliseconds of the results from YouTube. */ 555 | duration?: number; 556 | /** Resolves into a Track. */ 557 | resolve(): Promise; 558 | } 559 | 560 | export interface PlayOptions { 561 | /** The position to start the track. */ 562 | readonly startTime?: number; 563 | /** The position to end the track. */ 564 | readonly endTime?: number; 565 | /** Whether to not replace the track if a play payload is sent. */ 566 | readonly noReplace?: boolean; 567 | } 568 | 569 | export interface EqualizerBand { 570 | /** The band number being 0 to 14. */ 571 | band: number; 572 | /** The gain amount being -0.25 to 1.00, 0.25 being double. */ 573 | gain: number; 574 | } 575 | -------------------------------------------------------------------------------- /src/structures/Manager.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-async-promise-executor */ 2 | import { Collection } from "@discordjs/collection"; 3 | import { EventEmitter } from "events"; 4 | import { VoiceState } from ".."; 5 | import { Node, NodeOptions } from "./Node"; 6 | import { Player, PlayerOptions, Track, UnresolvedTrack } from "./Player"; 7 | import { 8 | LoadType, 9 | Plugin, 10 | Structure, 11 | TrackData, 12 | TrackDataInfo, 13 | TrackEndEvent, 14 | TrackExceptionEvent, 15 | TrackStartEvent, 16 | TrackStuckEvent, 17 | TrackUtils, 18 | VoicePacket, 19 | VoiceServer, 20 | WebSocketClosedEvent, 21 | } from "./Utils"; 22 | 23 | const REQUIRED_KEYS = ["event", "guildId", "op", "sessionId"]; 24 | 25 | function check(options: ManagerOptions) { 26 | if (!options) throw new TypeError("ManagerOptions must not be empty."); 27 | 28 | if (typeof options.send !== "function") 29 | throw new TypeError('Manager option "send" must be present and a function.'); 30 | 31 | if ( 32 | typeof options.clientId !== "undefined" && 33 | !/^\d+$/.test(options.clientId) 34 | ) 35 | throw new TypeError('Manager option "clientId" must be a non-empty string.'); 36 | 37 | if ( 38 | typeof options.nodes !== "undefined" && 39 | !Array.isArray(options.nodes) 40 | ) 41 | throw new TypeError('Manager option "nodes" must be a array.'); 42 | 43 | if ( 44 | typeof options.shards !== "undefined" && 45 | typeof options.shards !== "number" 46 | ) 47 | throw new TypeError('Manager option "shards" must be a number.'); 48 | 49 | if ( 50 | typeof options.plugins !== "undefined" && 51 | !Array.isArray(options.plugins) 52 | ) 53 | throw new TypeError('Manager option "plugins" must be a Plugin array.'); 54 | 55 | if ( 56 | typeof options.autoPlay !== "undefined" && 57 | typeof options.autoPlay !== "boolean" 58 | ) 59 | throw new TypeError('Manager option "autoPlay" must be a boolean.'); 60 | 61 | if ( 62 | typeof options.trackPartial !== "undefined" && 63 | !Array.isArray(options.trackPartial) 64 | ) 65 | throw new TypeError('Manager option "trackPartial" must be a string array.'); 66 | 67 | if ( 68 | typeof options.clientName !== "undefined" && 69 | typeof options.clientName !== "string" 70 | ) 71 | throw new TypeError('Manager option "clientName" must be a string.'); 72 | 73 | if ( 74 | typeof options.defaultSearchPlatform !== "undefined" && 75 | typeof options.defaultSearchPlatform !== "string" 76 | ) 77 | throw new TypeError('Manager option "defaultSearchPlatform" must be a string.'); 78 | } 79 | 80 | export interface Manager { 81 | /** 82 | * Emitted when a Node is created. 83 | * @event Manager#nodeCreate 84 | */ 85 | on(event: "nodeCreate", listener: (node: Node) => void): this; 86 | 87 | /** 88 | * Emitted when a Node is destroyed. 89 | * @event Manager#nodeDestroy 90 | */ 91 | on(event: "nodeDestroy", listener: (node: Node) => void): this; 92 | 93 | /** 94 | * Emitted when a Node connects. 95 | * @event Manager#nodeConnect 96 | */ 97 | on(event: "nodeConnect", listener: (node: Node) => void): this; 98 | 99 | /** 100 | * Emitted when a Node reconnects. 101 | * @event Manager#nodeReconnect 102 | */ 103 | on(event: "nodeReconnect", listener: (node: Node) => void): this; 104 | 105 | /** 106 | * Emitted when a Node disconnects. 107 | * @event Manager#nodeDisconnect 108 | */ 109 | on( 110 | event: "nodeDisconnect", 111 | listener: (node: Node, reason: { code?: number; reason?: string }) => void 112 | ): this; 113 | 114 | /** 115 | * Emitted when a Node has an error. 116 | * @event Manager#nodeError 117 | */ 118 | on(event: "nodeError", listener: (node: Node, error: Error) => void): this; 119 | 120 | /** 121 | * Emitted whenever any Lavalink event is received. 122 | * @event Manager#nodeRaw 123 | */ 124 | on(event: "nodeRaw", listener: (payload: unknown) => void): this; 125 | 126 | /** 127 | * Emitted when a player is created. 128 | * @event Manager#playerCreate 129 | */ 130 | on(event: "playerCreate", listener: (player: Player) => void): this; 131 | 132 | /** 133 | * Emitted when a player is destroyed. 134 | * @event Manager#playerDestroy 135 | */ 136 | on(event: "playerDestroy", listener: (player: Player) => void): this; 137 | 138 | /** 139 | * Emitted when a player queue ends. 140 | * @event Manager#queueEnd 141 | */ 142 | on( 143 | event: "queueEnd", 144 | listener: ( 145 | player: Player, 146 | track: Track | UnresolvedTrack, 147 | payload: TrackEndEvent 148 | ) => void 149 | ): this; 150 | 151 | /** 152 | * Emitted when a player is moved to a new voice channel. 153 | * @event Manager#playerMove 154 | */ 155 | on( 156 | event: "playerMove", 157 | listener: (player: Player, initChannel: string, newChannel: string) => void 158 | ): this; 159 | 160 | /** 161 | * Emitted when a player is disconnected from it's current voice channel. 162 | * @event Manager#playerDisconnect 163 | */ 164 | on( 165 | event: "playerDisconnect", 166 | listener: (player: Player, oldChannel: string) => void 167 | ): this; 168 | 169 | /** 170 | * Emitted when a track starts. 171 | * @event Manager#trackStart 172 | */ 173 | on( 174 | event: "trackStart", 175 | listener: (player: Player, track: Track, payload: TrackStartEvent) => void 176 | ): this; 177 | 178 | /** 179 | * Emitted when a track ends. 180 | * @event Manager#trackEnd 181 | */ 182 | on( 183 | event: "trackEnd", 184 | listener: (player: Player, track: Track, payload: TrackEndEvent) => void 185 | ): this; 186 | 187 | /** 188 | * Emitted when a track gets stuck during playback. 189 | * @event Manager#trackStuck 190 | */ 191 | on( 192 | event: "trackStuck", 193 | listener: (player: Player, track: Track, payload: TrackStuckEvent) => void 194 | ): this; 195 | 196 | /** 197 | * Emitted when a track has an error during playback. 198 | * @event Manager#trackError 199 | */ 200 | on( 201 | event: "trackError", 202 | listener: ( 203 | player: Player, 204 | track: Track | UnresolvedTrack, 205 | payload: TrackExceptionEvent 206 | ) => void 207 | ): this; 208 | 209 | /** 210 | * Emitted when a voice connection is closed. 211 | * @event Manager#socketClosed 212 | */ 213 | on( 214 | event: "socketClosed", 215 | listener: (player: Player, payload: WebSocketClosedEvent) => void 216 | ): this; 217 | } 218 | 219 | /** 220 | * The main hub for interacting with Lavalink and using Erela.JS, 221 | * @noInheritDoc 222 | */ 223 | export class Manager extends EventEmitter { 224 | public static readonly DEFAULT_SOURCES: Record = { 225 | "youtube music": "ytmsearch", 226 | "youtube": "ytsearch", 227 | "soundcloud": "scsearch", 228 | "spotify": "spsearch", 229 | "apple music": "amsearch", 230 | "deezer": "dzsearch", 231 | "yandex music": "ymsearch" 232 | } 233 | 234 | /** The map of players. */ 235 | public readonly players = new Collection(); 236 | /** The map of nodes. */ 237 | public readonly nodes = new Collection(); 238 | /** The options that were set. */ 239 | public readonly options: ManagerOptions; 240 | private initiated = false; 241 | 242 | /** Returns the least used Nodes. */ 243 | public get leastUsedNodes(): Collection { 244 | return this.nodes 245 | .filter((node) => node.connected) 246 | .sort((a, b) => b.calls - a.calls); 247 | } 248 | 249 | /** Returns the least system load Nodes. */ 250 | public get leastLoadNodes(): Collection { 251 | return this.nodes 252 | .filter((node) => node.connected) 253 | .sort((a, b) => { 254 | const aload = a.stats.cpu 255 | ? (a.stats.cpu.systemLoad / a.stats.cpu.cores) * 100 256 | : 0; 257 | const bload = b.stats.cpu 258 | ? (b.stats.cpu.systemLoad / b.stats.cpu.cores) * 100 259 | : 0; 260 | return aload - bload; 261 | }); 262 | } 263 | 264 | /** 265 | * Initiates the Manager class. 266 | * @param options 267 | */ 268 | constructor(options: ManagerOptions) { 269 | super(); 270 | 271 | check(options); 272 | 273 | Structure.get("Player").init(this); 274 | Structure.get("Node").init(this); 275 | TrackUtils.init(this); 276 | 277 | if (options.trackPartial) { 278 | TrackUtils.setTrackPartial(options.trackPartial); 279 | delete options.trackPartial; 280 | } 281 | 282 | this.options = { 283 | plugins: [], 284 | nodes: [{ identifier: "default", host: "localhost" }], 285 | shards: 1, 286 | autoPlay: true, 287 | clientName: "LavaCat", 288 | defaultSearchPlatform: "youtube", 289 | ...options, 290 | }; 291 | 292 | if (this.options.plugins) { 293 | for (const [index, plugin] of this.options.plugins.entries()) { 294 | if (!(plugin instanceof Plugin)) 295 | throw new RangeError(`Plugin at index ${index} does not extend Plugin.`); 296 | plugin.load(this); 297 | } 298 | } 299 | 300 | if (this.options.nodes) { 301 | for (const nodeOptions of this.options.nodes) 302 | new (Structure.get("Node"))(nodeOptions); 303 | } 304 | } 305 | 306 | /** 307 | * Initiates the Manager. 308 | * @param clientId 309 | */ 310 | public init(clientId?: string): this { 311 | if (this.initiated) return this; 312 | if (typeof clientId !== "undefined") this.options.clientId = clientId; 313 | 314 | if (typeof this.options.clientId !== "string") 315 | throw new Error('"clientId" set is not type of "string"'); 316 | 317 | if (!this.options.clientId) 318 | throw new Error( 319 | '"clientId" is not set. Pass it in Manager#init() or as a option in the constructor.' 320 | ); 321 | 322 | for (const node of this.nodes.values()) { 323 | try { 324 | node.connect(); 325 | } catch (err) { 326 | this.emit("nodeError", node, err); 327 | } 328 | } 329 | 330 | this.initiated = true; 331 | return this; 332 | } 333 | 334 | /** 335 | * Searches the enabled sources based off the URL or the `source` property. 336 | * @param query 337 | * @param requester 338 | * @returns The search result. 339 | */ 340 | public search( 341 | query: string | SearchQuery, 342 | requester?: unknown 343 | ): Promise { 344 | return new Promise(async (resolve, reject) => { 345 | const node = this.leastUsedNodes.first(); 346 | if (!node) throw new Error("No available nodes."); 347 | 348 | const _query: SearchQuery = typeof query === "string" ? { query } : query; 349 | const _source = Manager.DEFAULT_SOURCES[_query.source ?? this.options.defaultSearchPlatform] ?? _query.source; 350 | 351 | let search = _query.query; 352 | if (!/^https?:\/\//.test(search)) { 353 | search = `${_source}:${search}`; 354 | } 355 | 356 | const res = await node 357 | .makeRequest(`/loadtracks?identifier=${encodeURIComponent(search)}`) 358 | .catch(err => reject(err)); 359 | 360 | if (!res) { 361 | return reject(new Error("Query not found.")); 362 | } 363 | 364 | const result: SearchResult = { 365 | loadType: res.loadType, 366 | exception: res.exception ?? null, 367 | tracks: res.tracks?.map((track: TrackData) => 368 | TrackUtils.build(track, requester) 369 | ) ?? [], 370 | }; 371 | 372 | if (result.loadType === "PLAYLIST_LOADED") { 373 | result.playlist = { 374 | name: res.playlistInfo.name, 375 | selectedTrack: res.playlistInfo.selectedTrack === -1 ? null : 376 | TrackUtils.build( 377 | res.tracks[res.playlistInfo.selectedTrack], 378 | requester 379 | ), 380 | duration: result.tracks 381 | .reduce((acc: number, cur: Track) => acc + (cur.duration || 0), 0), 382 | }; 383 | } 384 | 385 | return resolve(result); 386 | }); 387 | } 388 | 389 | /** 390 | * Decodes the base64 encoded tracks and returns a TrackData array. 391 | * @param tracks 392 | */ 393 | public decodeTracks(tracks: string[]): Promise { 394 | return new Promise(async (resolve, reject) => { 395 | const node = this.nodes.first(); 396 | if (!node) throw new Error("No available nodes."); 397 | 398 | const res = await node.makeRequest(`/decodetracks`, r => { 399 | r.method = "POST"; 400 | r.body = JSON.stringify(tracks); 401 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 402 | r.headers!["Content-Type"] = "application/json"; 403 | }) 404 | .catch(err => reject(err)); 405 | 406 | if (!res) { 407 | return reject(new Error("No data returned from query.")); 408 | } 409 | 410 | return resolve(res); 411 | }); 412 | } 413 | 414 | /** 415 | * Decodes the base64 encoded track and returns a TrackData. 416 | * @param track 417 | */ 418 | public async decodeTrack(track: string): Promise { 419 | const res = await this.decodeTracks([ track ]); 420 | return res[0]; 421 | } 422 | 423 | /** 424 | * Creates a player or returns one if it already exists. 425 | * @param options 426 | */ 427 | public create(options: PlayerOptions): Player { 428 | if (this.players.has(options.guild)) { 429 | return this.players.get(options.guild); 430 | } 431 | 432 | return new (Structure.get("Player"))(options); 433 | } 434 | 435 | /** 436 | * Returns a player or undefined if it does not exist. 437 | * @param guild 438 | */ 439 | public get(guild: string): Player | undefined { 440 | return this.players.get(guild); 441 | } 442 | 443 | /** 444 | * Destroys a player if it exists. 445 | * @param guild 446 | */ 447 | public destroy(guild: string): void { 448 | this.players.delete(guild); 449 | } 450 | 451 | /** 452 | * Creates a node or returns one if it already exists. 453 | * @param options 454 | */ 455 | public createNode(options: NodeOptions): Node { 456 | if (this.nodes.has(options.identifier || options.host)) { 457 | return this.nodes.get(options.identifier || options.host); 458 | } 459 | 460 | return new (Structure.get("Node"))(options); 461 | } 462 | 463 | /** 464 | * Destroys a node if it exists. 465 | * @param identifier 466 | */ 467 | public destroyNode(identifier: string): void { 468 | const node = this.nodes.get(identifier); 469 | if (!node) return; 470 | node.destroy() 471 | this.nodes.delete(identifier) 472 | } 473 | 474 | /** 475 | * Sends voice data to the Lavalink server. 476 | * @param data 477 | */ 478 | public updateVoiceState(data: VoicePacket | VoiceServer | VoiceState): void { 479 | if ("t" in data && !["VOICE_STATE_UPDATE", "VOICE_SERVER_UPDATE"].includes(data.t)) return; 480 | 481 | const update: VoiceServer | VoiceState = "d" in data ? data.d : data; 482 | if (!update || !("token" in update) && !("session_id" in update)) return; 483 | 484 | const player = this.players.get(update.guild_id) as Player; 485 | if (!player) return; 486 | 487 | if ("token" in update) { 488 | /* voice server update */ 489 | player.voiceState.event = update; 490 | } else { 491 | /* voice state update */ 492 | if (update.user_id !== this.options.clientId) { 493 | return; 494 | } 495 | 496 | if (update.channel_id) { 497 | if (player.voiceChannel !== update.channel_id) { 498 | /* we moved voice channels. */ 499 | this.emit("playerMove", player, player.voiceChannel, update.channel_id); 500 | } 501 | 502 | player.voiceState.sessionId = update.session_id; 503 | player.voiceChannel = update.channel_id; 504 | } else { 505 | /* player got disconnected. */ 506 | this.emit("playerDisconnect", player, player.voiceChannel); 507 | player.voiceChannel = null; 508 | player.voiceState = Object.assign({}); 509 | player.pause(true); 510 | } 511 | } 512 | 513 | if (REQUIRED_KEYS.every(key => key in player.voiceState)) { 514 | player.node.send(player.voiceState); 515 | } 516 | } 517 | } 518 | 519 | export interface Payload { 520 | /** The OP code */ 521 | op: number; 522 | d: { 523 | guild_id: string; 524 | channel_id: string | null; 525 | self_mute: boolean; 526 | self_deaf: boolean; 527 | }; 528 | } 529 | 530 | export interface ManagerOptions { 531 | /** The array of nodes to connect to. */ 532 | nodes?: NodeOptions[]; 533 | /** The client ID to use. */ 534 | clientId?: string; 535 | /** Value to use for the `Client-Name` header. */ 536 | clientName?: string; 537 | /** The shard count. */ 538 | shards?: number; 539 | /** A array of plugins to use. */ 540 | plugins?: Plugin[]; 541 | /** Whether players should automatically play the next song. */ 542 | autoPlay?: boolean; 543 | /** An array of track properties to keep. `track` will always be present. */ 544 | trackPartial?: string[]; 545 | /** The default search platform to use, can be "youtube", "youtube music", or "soundcloud". */ 546 | defaultSearchPlatform?: SearchPlatform; 547 | /** 548 | * Function to send data to the websocket. 549 | * @param id 550 | * @param payload 551 | */ 552 | send(id: string, payload: Payload): void; 553 | } 554 | 555 | export type SearchPlatform = "youtube" | "youtube music" | "soundcloud" | "spotify" | "apple music" | "deezer" | "yandex music"; 556 | 557 | export interface SearchQuery { 558 | /** The source to search from. */ 559 | source?: SearchPlatform | string; 560 | /** The query to search for. */ 561 | query: string; 562 | } 563 | 564 | export interface SearchResult { 565 | /** The load type of the result. */ 566 | loadType: LoadType; 567 | /** The array of tracks from the result. */ 568 | tracks: Track[]; 569 | /** The playlist info if the load type is PLAYLIST_LOADED. */ 570 | playlist?: PlaylistInfo; 571 | /** The exception when searching if one. */ 572 | exception?: { 573 | /** The message for the exception. */ 574 | message: string; 575 | /** The severity of exception. */ 576 | severity: string; 577 | }; 578 | } 579 | 580 | export interface PlaylistInfo { 581 | /** The playlist name. */ 582 | name: string; 583 | /** The playlist selected track. */ 584 | selectedTrack?: Track; 585 | /** The duration of the playlist. */ 586 | duration: number; 587 | } 588 | 589 | export interface LavalinkResult { 590 | tracks: TrackData[]; 591 | loadType: LoadType; 592 | exception?: { 593 | /** The message for the exception. */ 594 | message: string; 595 | /** The severity of exception. */ 596 | severity: string; 597 | }; 598 | playlistInfo: { 599 | name: string; 600 | selectedTrack?: number; 601 | }; 602 | } 603 | --------------------------------------------------------------------------------