├── src ├── sessionData │ └── sessionIds.json ├── structures │ ├── MagmastreamError.ts │ ├── Plugin.ts │ ├── Rest.ts │ ├── Enums.ts │ ├── Filters.ts │ └── Utils.ts ├── index.ts ├── wrappers │ ├── oceanic.ts │ ├── eris.ts │ ├── discord.js.ts │ ├── detritus.ts │ └── seyfert.ts ├── utils │ ├── playerCheck.ts │ ├── filtersEqualizers.ts │ ├── nodeCheck.ts │ └── managerCheck.ts ├── config │ └── blockedWords.ts └── statestorage │ ├── MemoryQueue.ts │ ├── JsonQueue.ts │ └── RedisQueue.ts ├── .gitignore ├── .npmignore ├── .gitattributes ├── .vscode └── settings.json ├── .prettierrc ├── .prettierignore ├── .github ├── ISSUE_TEMPLATE │ ├── question.md │ ├── feature_request.md │ └── bug_report.md ├── dependabot.yml └── workflows │ ├── build.yml │ └── npm-publish.yml ├── tsconfig.json ├── eslint.config.js ├── NOTICE.md ├── .all-contributorsrc ├── package.json ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── README.md └── LICENSE /src/sessionData/sessionIds.json: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | /.idea/ 4 | /.vscode/ -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | node_modules 3 | package-lock.json 4 | 5 | .nuxt 6 | .env -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules\\typescript\\lib" 3 | } 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 1, 3 | "semi": true, 4 | "singleQuote": false, 5 | "printWidth": 160, 6 | "useTabs": true 7 | } 8 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | LICENSE 2 | package.json 3 | package-lock.json 4 | NOTICE.md 5 | CODE_OF_CONDUCT.md 6 | .npmignore 7 | .gitignore 8 | .gitattributes 9 | node_modules 10 | dist 11 | README.md -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question 3 | about: Ask a question or seek clarification 4 | --- 5 | 6 | **Your Question** 7 | Write your question or request for clarification here. 8 | 9 | **Additional Context** 10 | Add any other context or information that may help in answering your question. 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "ES2022", 5 | "module": "NodeNext", 6 | "declaration": true, 7 | "outDir": "./dist", 8 | "rootDir": "./src", 9 | "skipLibCheck": true, 10 | "importHelpers": true, 11 | /* Restrict DOM globals */ 12 | "lib": ["ES2022"], 13 | /* Module Resolution Options */ 14 | "moduleResolution": "NodeNext", 15 | "esModuleInterop": true, 16 | "resolveJsonModule": true 17 | }, 18 | "exclude": ["node_modules", "dist"] 19 | } 20 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | const eslintPlugin = require("@typescript-eslint/eslint-plugin"); 2 | const eslintParser = require("@typescript-eslint/parser"); 3 | 4 | module.exports = [ 5 | { 6 | ignores: ["node_modules/", "dist/"], 7 | }, 8 | { 9 | files: ["**/*.ts"], 10 | languageOptions: { 11 | parser: eslintParser, 12 | sourceType: "module", 13 | }, 14 | plugins: { 15 | "@typescript-eslint": eslintPlugin, 16 | }, 17 | rules: { 18 | ...eslintPlugin.configs.recommended.rules, 19 | }, 20 | }, 21 | ]; 22 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "daily" 12 | -------------------------------------------------------------------------------- /NOTICE.md: -------------------------------------------------------------------------------- 1 | Copyright 2023 Abel Purnwasy 2 | Copyright 2023 Nick Brouwer 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Suggest an idea or enhancement for the project 4 | --- 5 | 6 | **Is your feature request related to a problem? Please describe.** 7 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 8 | 9 | **Describe the Solution You'd Like** 10 | A clear and concise description of what you want to happen. 11 | 12 | **Describe Alternatives You've Considered** 13 | A clear and concise description of any alternative solutions or features you've considered. 14 | 15 | **Additional Context** 16 | Add any other context or screenshots about the feature request here. 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Create a report to help us improve 4 | --- 5 | 6 | NodeJs Version: x.x.x 7 | Lavalink Version: x.x.x 8 | Magmastream NPM Package Version: x.x.x 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 | 16 | 1. .... 17 | 2. .... 18 | 3. .... 19 | 4. .... 20 | 21 | **Expected Behavior** 22 | A clear and concise description of what you expected to happen. 23 | 24 | **Screenshots** 25 | If applicable, add screenshots to help explain your problem. 26 | 27 | **Additional Context** 28 | Add any other context about the problem here. 29 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v2 18 | 19 | - name: Set up Node.js 20 | uses: actions/setup-node@v2 21 | with: 22 | node-version: "18" 23 | 24 | - name: Remove node_modules and package-lock.json 25 | run: | 26 | rm -rf node_modules 27 | rm -f package-lock.json 28 | 29 | - name: Install dependencies 30 | run: npm install 31 | 32 | - name: Run lint, build, and types 33 | run: npm run ci 34 | -------------------------------------------------------------------------------- /src/structures/MagmastreamError.ts: -------------------------------------------------------------------------------- 1 | import { MagmaStreamErrorCode, MagmaStreamErrorNumbers } from "./Enums"; 2 | 3 | interface MagmaStreamErrorOptions { 4 | code: MagmaStreamErrorCode; 5 | message?: string; 6 | cause?: Error; 7 | context?: T; 8 | } 9 | 10 | export class MagmaStreamError extends Error { 11 | public readonly code: MagmaStreamErrorCode; 12 | public readonly number: number; 13 | public readonly context?: T; 14 | 15 | constructor({ code, message, cause, context }: MagmaStreamErrorOptions) { 16 | super(message || code); 17 | this.name = "MagmaStreamError"; 18 | this.code = code; 19 | this.number = MagmaStreamErrorNumbers[code]; // auto-lookup 20 | this.context = context; 21 | if (cause) this.cause = cause; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/structures/Plugin.ts: -------------------------------------------------------------------------------- 1 | import { Manager } from "./Manager"; 2 | 3 | /** 4 | * Base abstract class for all plugins. 5 | * Users must extend this and implement load and unload methods. 6 | */ 7 | export abstract class Plugin { 8 | public readonly name: string; 9 | 10 | /** 11 | * @param name The name of the plugin 12 | */ 13 | constructor(name: string) { 14 | this.name = name; 15 | } 16 | 17 | /** 18 | * Load the plugin. 19 | * @param manager The MagmaStream Manager instance 20 | */ 21 | abstract load(manager: Manager): void; 22 | 23 | /** 24 | * Unload the plugin. 25 | * Called on shutdown to gracefully cleanup resources or detach listeners. 26 | * @param manager The MagmaStream Manager instance 27 | */ 28 | abstract unload(manager: Manager): void; 29 | } 30 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Node.js Package 3 | on: 4 | release: 5 | types: 6 | - created 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: actions/setup-node@v4 13 | with: 14 | node-version: 20 15 | - run: npm ci 16 | - run: npm install @rollup/rollup-linux-x64-gnu 17 | - run: npm update rollup 18 | - run: npm run ci 19 | publish-npm: 20 | needs: build 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/checkout@v4 24 | - uses: actions/setup-node@v4 25 | with: 26 | node-version: 20 27 | registry-url: https://registry.npmjs.org/ 28 | - run: npm ci 29 | - run: npm run ci 30 | - run: npm publish 31 | env: 32 | NODE_AUTH_TOKEN: ${{secrets.npm_token}} 33 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./structures/Filters"; 2 | export * from "./structures/Manager"; 3 | export * from "./structures/Node"; 4 | export * from "./structures/Player"; 5 | export * from "./structures/Plugin"; 6 | export * from "./statestorage/MemoryQueue"; 7 | export * from "./structures/Rest"; 8 | export * from "./structures/Utils"; 9 | export * from "./structures/MagmastreamError"; 10 | // wrappers 11 | export * from "./wrappers/discord.js"; 12 | export * from "./wrappers/eris"; 13 | export * from "./wrappers/detritus"; 14 | export * from "./wrappers/oceanic"; 15 | export * from "./wrappers/seyfert"; 16 | // types 17 | export * from "./structures/Types"; 18 | export * from "./structures/Enums"; 19 | // state storage 20 | export * from "./statestorage/MemoryQueue"; 21 | export * from "./statestorage/JsonQueue"; 22 | export * from "./statestorage/RedisQueue"; 23 | -------------------------------------------------------------------------------- /src/wrappers/oceanic.ts: -------------------------------------------------------------------------------- 1 | import { GatewayVoiceStateUpdate } from "discord-api-types/v10"; 2 | import { Manager as BaseManager } from "../structures/Manager"; 3 | import { AnyUser, ManagerOptions, VoicePacket } from "../structures/Types"; 4 | import type { Client, User } from "oceanic.js"; 5 | 6 | export * from "../index"; 7 | 8 | /** 9 | * Oceanic wrapper for Magmastream. 10 | */ 11 | export class OceanicManager extends BaseManager { 12 | constructor(public readonly client: Client, options?: ManagerOptions) { 13 | super(options); 14 | 15 | client.once("ready", () => { 16 | if (!this.options.clientId) this.options.clientId = client.user.id; 17 | }); 18 | 19 | client.on("packet", async (packet) => { 20 | await this.updateVoiceState(packet as unknown as VoicePacket); 21 | }); 22 | } 23 | 24 | protected override send(packet: GatewayVoiceStateUpdate) { 25 | const guild = this.client.guilds.get(packet.d.guild_id); 26 | if (guild) guild.shard.send(packet.op as number, packet.d); 27 | } 28 | 29 | public override async resolveUser(user: AnyUser | string): Promise { 30 | const id = typeof user === "string" ? user : user.id; 31 | const cached = this.client.users.get(id); 32 | if (cached) return cached; 33 | 34 | return { 35 | id, 36 | username: typeof user === "string" ? undefined : user.username, 37 | }; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/wrappers/eris.ts: -------------------------------------------------------------------------------- 1 | import { GatewayReceivePayload, GatewayVoiceStateUpdate } from "discord-api-types/v10"; 2 | import { Manager as BaseManager } from "../structures/Manager"; 3 | import type { Client, User } from "eris"; 4 | import { AnyUser, ManagerOptions, VoicePacket } from "../structures/Types"; 5 | 6 | export * from "../index"; 7 | 8 | /** 9 | * Eris wrapper for Magmastream. 10 | */ 11 | export class ErisManager extends BaseManager { 12 | public constructor(public readonly client: Client, options?: ManagerOptions) { 13 | super(options); 14 | 15 | client.once("ready", () => { 16 | if (!this.options.clientId) this.options.clientId = client.user.id; 17 | }); 18 | 19 | client.on("rawWS", async (packet: GatewayReceivePayload) => { 20 | await this.updateVoiceState(packet as unknown as VoicePacket); 21 | }); 22 | } 23 | 24 | protected override send(packet: GatewayVoiceStateUpdate) { 25 | const guild = this.client.guilds.get(packet.d.guild_id); 26 | if (guild) guild.shard.sendWS(packet.op, packet.d as unknown as Record); 27 | } 28 | 29 | public override async resolveUser(user: AnyUser | string): Promise { 30 | const id = typeof user === "string" ? user : user.id; 31 | const cached = this.client.users.get(id); 32 | if (cached) return cached; 33 | 34 | return { 35 | id, 36 | username: typeof user === "string" ? undefined : user.username, 37 | }; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "magmastream", 3 | "projectOwner": "Magmastream-NPM", 4 | "files": [ 5 | "README.md" 6 | ], 7 | "commitType": "docs", 8 | "commitConvention": "angular", 9 | "contributorsPerLine": 7, 10 | "contributors": [ 11 | { 12 | "login": "realdarek", 13 | "name": "Darek", 14 | "avatar_url": "https://avatars.githubusercontent.com/u/58607083?v=4", 15 | "profile": "https://discord.gg/JCaTDJRz7P", 16 | "contributions": [ 17 | "doc" 18 | ] 19 | }, 20 | { 21 | "login": "Vexify4103", 22 | "name": "Vexify4103", 23 | "avatar_url": "https://avatars.githubusercontent.com/u/47192617?v=4", 24 | "profile": "https://github.com/Vexify4103", 25 | "contributions": [ 26 | "code", 27 | "doc" 28 | ] 29 | }, 30 | { 31 | "login": "ItzRandom23", 32 | "name": "Itz Random", 33 | "avatar_url": "https://avatars.githubusercontent.com/u/100831398?v=4", 34 | "profile": "https://github.com/ItzRandom23", 35 | "contributions": [ 36 | "code" 37 | ] 38 | }, 39 | { 40 | "login": "Yanishamburger", 41 | "name": "Yanis_Hamburger", 42 | "avatar_url": "https://avatars.githubusercontent.com/u/121449519?v=4", 43 | "profile": "https://github.com/Yanishamburger", 44 | "contributions": [ 45 | "bug" 46 | ] 47 | }, 48 | { 49 | "login": "Kenver123", 50 | "name": "Kenver", 51 | "avatar_url": "https://avatars.githubusercontent.com/u/165576302?v=4", 52 | "profile": "https://github.com/Kenver123", 53 | "contributions": [ 54 | "platform" 55 | ] 56 | } 57 | ] 58 | } 59 | -------------------------------------------------------------------------------- /src/wrappers/discord.js.ts: -------------------------------------------------------------------------------- 1 | import { Manager as BaseManager } from "../structures/Manager"; 2 | import type { GatewayVoiceStateUpdate } from "discord-api-types/v10"; 3 | import { Client, User } from "discord.js"; 4 | import { AnyUser, ManagerOptions, VoicePacket } from "../structures/Types"; 5 | import { version as djsVersion } from "discord.js"; 6 | const [major, minor] = djsVersion.split(".").map(Number); 7 | 8 | export * from "../index"; 9 | 10 | /** 11 | * Discord.js wrapper for Magmastream. 12 | */ 13 | export class DiscordJSManager extends BaseManager { 14 | public constructor(public readonly client: Client, options?: ManagerOptions) { 15 | super(options); 16 | 17 | const attachReadyHandler = () => { 18 | const handler = () => { 19 | if (!this.options.clientId) this.options.clientId = this.client.user!.id; 20 | }; 21 | 22 | // Only attach clientReady if Discord.js >= 14.22.0 23 | if (major > 14 || (major === 14 && minor >= 22)) { 24 | client.once("clientReady", handler); 25 | } 26 | 27 | // Only attach ready if Discord.js < 14.22.0 28 | if (major < 14 || (major === 14 && minor < 22)) { 29 | client.once("ready", handler); 30 | } 31 | }; 32 | 33 | attachReadyHandler(); 34 | 35 | client.on("raw", async (data) => { 36 | await this.updateVoiceState(data as unknown as VoicePacket); 37 | }); 38 | } 39 | 40 | protected override send(packet: GatewayVoiceStateUpdate) { 41 | const guild = this.client.guilds.cache.get(packet.d.guild_id); 42 | if (guild) guild.shard.send(packet); 43 | } 44 | 45 | public override async resolveUser(user: AnyUser | string): Promise { 46 | const id = typeof user === "string" ? user : user.id; 47 | const cached = this.client.users.cache.get(id); 48 | if (cached) return cached; 49 | try { 50 | const fetched = await this.client.users.fetch(id); 51 | return fetched; 52 | } catch { 53 | return { id, username: typeof user === "string" ? undefined : user.username }; 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/wrappers/detritus.ts: -------------------------------------------------------------------------------- 1 | import { GatewayReceivePayload, GatewayVoiceStateUpdate } from "discord-api-types/v10"; 2 | import { Manager as BaseManager } from "../structures/Manager"; 3 | import { AnyUser, ManagerOptions, VoicePacket } from "../structures/Types"; 4 | 5 | import { ClusterClient, ShardClient } from "detritus-client"; 6 | 7 | export * from "../index"; 8 | 9 | /** 10 | * Detritus wrapper for Magmastream. 11 | */ 12 | export class DetritusManager extends BaseManager { 13 | public constructor(public readonly client: ClusterClient | ShardClient, options?: ManagerOptions) { 14 | super(options); 15 | 16 | client.once("ready", () => { 17 | if (!this.options.clientId) this.options.clientId = client instanceof ClusterClient ? client.applicationId : client.clientId; 18 | }); 19 | 20 | client.on("raw", async (packet: GatewayReceivePayload) => { 21 | await this.updateVoiceState(packet as unknown as VoicePacket); 22 | }); 23 | } 24 | 25 | protected override send(packet: GatewayVoiceStateUpdate) { 26 | const asCluster = this.client as ClusterClient; 27 | const asShard = this.client as ShardClient; 28 | 29 | if (asShard.guilds) return asShard.gateway.send(packet.op, packet.d); 30 | if (asCluster.shards) { 31 | const shard = asCluster.shards.find((c) => c.guilds.has(packet.d.guild_id)); 32 | if (shard) shard.gateway.send(packet.op, packet.d); 33 | } 34 | } 35 | 36 | public override async resolveUser(user: AnyUser | string): Promise { 37 | const id = typeof user === "string" ? user : user.id; 38 | 39 | if (this.client instanceof ShardClient) { 40 | const cached = this.client.users.get(id); 41 | if (cached) return { id: cached.id, username: cached.username }; 42 | } else if (this.client instanceof ClusterClient) { 43 | for (const [, shard] of this.client.shards) { 44 | const cached = shard.users.get(id); 45 | if (cached) return { id: cached.id, username: cached.username }; 46 | } 47 | } 48 | 49 | return typeof user === "string" ? { id: user } : user; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/wrappers/seyfert.ts: -------------------------------------------------------------------------------- 1 | import { Manager as BaseManager } from "../structures/Manager"; 2 | import type { GatewayVoiceStateUpdate } from "discord-api-types/v10"; 3 | import { Client, User, WorkerClient } from "seyfert"; 4 | import { AnyUser, ManagerOptions } from "../structures/Types"; 5 | import { calculateShardId } from "seyfert/lib/common"; 6 | 7 | export * from "../index"; 8 | 9 | /** 10 | * Seyfert wrapper for Magmastream. 11 | * 12 | * @note This wrapper does require the manual implementation of the "raw" and "ready" events, to call the `updateVoiceState` and `init` methods respectively. 13 | * 14 | * @example 15 | * ```typescript 16 | * const client = new Client(); 17 | * const manager = new SeyfertManager(client, options); 18 | * 19 | * client.events.values.RAW = { 20 | * data: { name: "raw" }, 21 | * run: async (data) => { 22 | * await manager.updateVoiceState(data); 23 | * } 24 | * } 25 | * 26 | * client.events.values.READY = { 27 | * data: { name: "ready" }, 28 | * run: async (user, client) => { 29 | * await manager.init({ clientId: client.botId }); 30 | * } 31 | * } 32 | * ``` 33 | */ 34 | export class SeyfertManager extends BaseManager { 35 | public constructor(public readonly client: Client | WorkerClient, options?: ManagerOptions) { 36 | super(options); 37 | } 38 | 39 | protected override send(packet: GatewayVoiceStateUpdate) { 40 | if (this.client instanceof Client) { 41 | this.client.gateway.send(calculateShardId(packet.d.guild_id), packet); 42 | } else { 43 | this.client.shards.get(calculateShardId(packet.d.guild_id))?.send(true, packet); 44 | } 45 | } 46 | 47 | public override async resolveUser(user: AnyUser | string): Promise { 48 | const id = typeof user === "string" ? user : user.id; 49 | const cached = this.client.cache.users?.get(id); 50 | if (cached) return cached; 51 | 52 | try { 53 | return await this.client.users.fetch(id); 54 | } catch { 55 | return { id, username: typeof user === "string" ? undefined : user.username }; 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "magmastream", 3 | "version": "2.9.2", 4 | "description": "A user-friendly Lavalink client designed for NodeJS.", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "files": [ 8 | "dist" 9 | ], 10 | "scripts": { 11 | "build": "tsc", 12 | "types": "rtb --dist dist", 13 | "lint": "eslint src", 14 | "lint:fix": "eslint --fix src", 15 | "ci": "run-s lint:fix lint build types", 16 | "release:dev": "npm run ci && npm version prerelease --preid=dev && npm run ci && npm publish --tag dev" 17 | }, 18 | "devDependencies": { 19 | "@favware/rollup-type-bundler": "^4.0.0", 20 | "@types/jsdom": "^21.1.7", 21 | "@types/lodash": "^4.17.20", 22 | "@types/node": "^22.16.5", 23 | "@types/ws": "^8.18.1", 24 | "@typescript-eslint/eslint-plugin": "^8.37.0", 25 | "@typescript-eslint/parser": "^8.37.0", 26 | "eslint": "^9.31.0", 27 | "npm-run-all": "^4.1.5", 28 | "typedoc": "^0.27.9", 29 | "typedoc-plugin-no-inherit": "^1.6.1", 30 | "typescript": "^5.8.3" 31 | }, 32 | "dependencies": { 33 | "@discordjs/collection": "^2.1.1", 34 | "axios": "^1.10.0", 35 | "events": "^3.3.0", 36 | "ioredis": "^5.6.1", 37 | "jsdom": "^26.1.0", 38 | "lodash": "^4.17.21", 39 | "safe-stable-stringify": "^2.5.0", 40 | "tslib": "^2.8.1", 41 | "ws": "^8.18.3" 42 | }, 43 | "optionalDependencies": { 44 | "detritus-client": "0.16.x", 45 | "discord.js": "14.x", 46 | "eris": "0.18.x", 47 | "oceanic.js": "1.12.0", 48 | "seyfert": "3.2.x" 49 | }, 50 | "engines": { 51 | "node": ">=16.0.0" 52 | }, 53 | "eslintConfig": { 54 | "root": true, 55 | "parser": "@typescript-eslint/parser", 56 | "plugins": [ 57 | "@typescript-eslint" 58 | ], 59 | "rules": { 60 | "object-curly-spacing": [ 61 | "error", 62 | "always" 63 | ] 64 | }, 65 | "extends": [ 66 | "eslint:recommended", 67 | "plugin:@typescript-eslint/recommended" 68 | ] 69 | }, 70 | "keywords": [ 71 | "lavalink client", 72 | "wrapper", 73 | "typescript", 74 | "discord.js", 75 | "node.js", 76 | "java", 77 | "javascript", 78 | "audio streaming", 79 | "music bot", 80 | "voice chat", 81 | "discord integration", 82 | "high performance", 83 | "scalable", 84 | "easy-to-use", 85 | "feature-rich", 86 | "cross-platform", 87 | "seamless integration", 88 | "community support", 89 | "documentation", 90 | "open-source", 91 | "lavalink", 92 | "magmastream" 93 | ], 94 | "repository": { 95 | "url": "git+https://github.com/Blackfort-Hosting/magmastream.git#main" 96 | }, 97 | "homepage": "https://docs.magmastream.com", 98 | "author": "Abel Purnwasy", 99 | "license": "Apache-2.0" 100 | } 101 | -------------------------------------------------------------------------------- /src/utils/playerCheck.ts: -------------------------------------------------------------------------------- 1 | import { MagmaStreamErrorCode } from "../structures/Enums"; 2 | import { MagmaStreamError } from "../structures/MagmastreamError"; 3 | import { PlayerOptions } from "../structures/Types"; 4 | 5 | /** 6 | * Validates the provided PlayerOptions object. 7 | * @param options - The options to validate. 8 | * @throws {MagmaStreamError} Throws if any required option is missing or invalid. 9 | */ 10 | export default function playerCheck(options: PlayerOptions) { 11 | if (!options) { 12 | throw new MagmaStreamError({ 13 | code: MagmaStreamErrorCode.PLAYER_INVALID_CONFIG, 14 | message: "PlayerOptions must not be empty.", 15 | }); 16 | } 17 | 18 | const { guildId, nodeIdentifier, selfDeafen, selfMute, textChannelId, voiceChannelId, volume, applyVolumeAsFilter } = options; 19 | 20 | if (!/^\d+$/.test(guildId)) { 21 | throw new MagmaStreamError({ 22 | code: MagmaStreamErrorCode.PLAYER_INVALID_CONFIG, 23 | message: 'Player option "guildId" must be present and a non-empty string.', 24 | context: { guildId }, 25 | }); 26 | } 27 | 28 | if (nodeIdentifier && typeof nodeIdentifier !== "string") { 29 | throw new MagmaStreamError({ 30 | code: MagmaStreamErrorCode.PLAYER_INVALID_CONFIG, 31 | message: 'Player option "nodeIdentifier" must be a non-empty string.', 32 | context: { nodeIdentifier }, 33 | }); 34 | } 35 | 36 | if (typeof selfDeafen !== "undefined" && typeof selfDeafen !== "boolean") { 37 | throw new MagmaStreamError({ 38 | code: MagmaStreamErrorCode.PLAYER_INVALID_CONFIG, 39 | message: 'Player option "selfDeafen" must be a boolean.', 40 | context: { selfDeafen }, 41 | }); 42 | } 43 | 44 | if (typeof selfMute !== "undefined" && typeof selfMute !== "boolean") { 45 | throw new MagmaStreamError({ 46 | code: MagmaStreamErrorCode.PLAYER_INVALID_CONFIG, 47 | message: 'Player option "selfMute" must be a boolean.', 48 | context: { selfMute }, 49 | }); 50 | } 51 | 52 | if (textChannelId && !/^\d+$/.test(textChannelId)) { 53 | throw new MagmaStreamError({ 54 | code: MagmaStreamErrorCode.PLAYER_INVALID_CONFIG, 55 | message: 'Player option "textChannelId" must be a non-empty string.', 56 | context: { textChannelId }, 57 | }); 58 | } 59 | 60 | if (voiceChannelId && !/^\d+$/.test(voiceChannelId)) { 61 | throw new MagmaStreamError({ 62 | code: MagmaStreamErrorCode.PLAYER_INVALID_CONFIG, 63 | message: 'Player option "voiceChannelId" must be a non-empty string.', 64 | context: { voiceChannelId }, 65 | }); 66 | } 67 | 68 | if (typeof volume !== "undefined" && typeof volume !== "number") { 69 | throw new MagmaStreamError({ 70 | code: MagmaStreamErrorCode.PLAYER_INVALID_CONFIG, 71 | message: 'Player option "volume" must be a number.', 72 | context: { volume }, 73 | }); 74 | } 75 | 76 | if (typeof applyVolumeAsFilter !== "undefined" && typeof applyVolumeAsFilter !== "boolean") { 77 | throw new MagmaStreamError({ 78 | code: MagmaStreamErrorCode.PLAYER_INVALID_CONFIG, 79 | message: 'Player option "applyVolumeAsFilter" must be a boolean.', 80 | context: { applyVolumeAsFilter }, 81 | }); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/utils/filtersEqualizers.ts: -------------------------------------------------------------------------------- 1 | /** Represents an equalizer band. */ 2 | export interface Band { 3 | /** The index of the equalizer band (0-12). */ 4 | band: number; 5 | /** The gain value of the equalizer band (in decibels). */ 6 | gain: number; 7 | } 8 | 9 | export const bassBoostEqualizer: Band[] = [ 10 | { band: 0, gain: 0.2 }, 11 | { band: 1, gain: 0.15 }, 12 | { band: 2, gain: 0.1 }, 13 | { band: 3, gain: 0.05 }, 14 | { band: 4, gain: 0.0 }, 15 | { band: 5, gain: -0.05 }, 16 | { band: 6, gain: -0.1 }, 17 | { band: 7, gain: -0.1 }, 18 | { band: 8, gain: -0.1 }, 19 | { band: 9, gain: -0.1 }, 20 | { band: 10, gain: -0.1 }, 21 | { band: 11, gain: -0.1 }, 22 | { band: 12, gain: -0.1 }, 23 | { band: 13, gain: -0.1 }, 24 | { band: 14, gain: -0.1 }, 25 | ]; 26 | 27 | export const softEqualizer: Band[] = [ 28 | { band: 0, gain: 0 }, 29 | { band: 1, gain: 0 }, 30 | { band: 2, gain: 0 }, 31 | { band: 3, gain: 0 }, 32 | { band: 4, gain: 0 }, 33 | { band: 5, gain: 0 }, 34 | { band: 6, gain: 0 }, 35 | { band: 7, gain: 0 }, 36 | { band: 8, gain: -0.25 }, 37 | { band: 9, gain: -0.25 }, 38 | { band: 10, gain: -0.25 }, 39 | { band: 11, gain: -0.25 }, 40 | { band: 12, gain: -0.25 }, 41 | { band: 13, gain: -0.25 }, 42 | ]; 43 | 44 | export const tvEqualizer: Band[] = [ 45 | { band: 0, gain: 0 }, 46 | { band: 1, gain: 0 }, 47 | { band: 2, gain: 0 }, 48 | { band: 3, gain: 0 }, 49 | { band: 4, gain: 0 }, 50 | { band: 5, gain: 0 }, 51 | { band: 6, gain: 0 }, 52 | { band: 7, gain: 0.65 }, 53 | { band: 8, gain: 0.65 }, 54 | { band: 9, gain: 0.65 }, 55 | { band: 10, gain: 0.65 }, 56 | { band: 11, gain: 0.65 }, 57 | { band: 12, gain: 0.65 }, 58 | { band: 13, gain: 0.65 }, 59 | ]; 60 | 61 | export const trebleBassEqualizer: Band[] = [ 62 | { band: 0, gain: 0.6 }, 63 | { band: 1, gain: 0.67 }, 64 | { band: 2, gain: 0.67 }, 65 | { band: 3, gain: 0 }, 66 | { band: 4, gain: -0.5 }, 67 | { band: 5, gain: 0.15 }, 68 | { band: 6, gain: -0.45 }, 69 | { band: 7, gain: 0.23 }, 70 | { band: 8, gain: 0.35 }, 71 | { band: 9, gain: 0.45 }, 72 | { band: 10, gain: 0.55 }, 73 | { band: 11, gain: 0.6 }, 74 | { band: 12, gain: 0.55 }, 75 | { band: 13, gain: 0 }, 76 | ]; 77 | 78 | export const vaporwaveEqualizer: Band[] = [ 79 | { band: 0, gain: 0 }, 80 | { band: 1, gain: 0 }, 81 | { band: 2, gain: 0 }, 82 | { band: 3, gain: 0 }, 83 | { band: 4, gain: 0 }, 84 | { band: 5, gain: 0 }, 85 | { band: 6, gain: 0 }, 86 | { band: 7, gain: 0 }, 87 | { band: 8, gain: 0.15 }, 88 | { band: 9, gain: 0.15 }, 89 | { band: 10, gain: 0.15 }, 90 | { band: 11, gain: 0.15 }, 91 | { band: 12, gain: 0.15 }, 92 | { band: 13, gain: 0.15 }, 93 | ]; 94 | 95 | export const popEqualizer: Band[] = [ 96 | { band: 0, gain: 0.5 }, 97 | { band: 1, gain: 1.5 }, 98 | { band: 2, gain: 2 }, 99 | { band: 3, gain: 1.5 }, 100 | ]; 101 | 102 | export const electronicEqualizer: Band[] = [ 103 | { band: 0, gain: 1.0 }, 104 | { band: 1, gain: 2.0 }, 105 | { band: 2, gain: 3.0 }, 106 | { band: 3, gain: 2.5 }, 107 | ]; 108 | 109 | export const radioEqualizer: Band[] = [ 110 | { band: 0, gain: 3.0 }, 111 | { band: 1, gain: 3.0 }, 112 | { band: 2, gain: 1.0 }, 113 | { band: 3, gain: 0.5 }, 114 | ]; 115 | 116 | export const demonEqualizer: Band[] = [ 117 | { band: 1, gain: -0.6 }, 118 | { band: 3, gain: -0.6 }, 119 | { band: 5, gain: -0.6 }, 120 | ]; 121 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Magmastream Code of Conduct 2 | 3 | ## Introduction 4 | 5 | We are committed to providing a friendly, safe, and welcoming environment for all contributors and users participating in the Magmastream community, regardless of their level of experience, background, or identity. This Code of Conduct outlines our expectations for behavior and sets the standards for a positive and inclusive community. 6 | 7 | ## Our Values 8 | 9 | 1. **Respect**: Treat everyone with respect, kindness, and empathy. Be considerate of different perspectives and experiences. 10 | 2. **Inclusivity**: Foster an inclusive environment where all individuals feel welcome, regardless of their race, ethnicity, gender identity, sexual orientation, religion, disability, or any other personal attribute. 11 | 3. **Collaboration**: Encourage a collaborative and supportive atmosphere. Value teamwork and cooperation. 12 | 4. **Open Communication**: Strive for open and constructive communication. Be receptive to feedback and ideas from others. 13 | 14 | ## Expected Behavior 15 | 16 | We expect all participants in the Magmastream community to: 17 | 18 | - Be respectful and inclusive towards others. 19 | - Use welcoming and inclusive language. 20 | - Be considerate of different opinions, experiences, and backgrounds. 21 | - Accept constructive criticism and provide feedback in a constructive manner. 22 | - Be open to learning from others and sharing knowledge. 23 | - Be mindful of the impact of your words and actions on others. 24 | - Be supportive and considerate in your interactions. 25 | 26 | ## Unacceptable Behavior 27 | 28 | The following behaviors are considered unacceptable within the Magmastream community: 29 | 30 | - Harassment, discrimination, or offensive behavior based on race, ethnicity, gender identity, sexual orientation, religion, disability, or any other personal attribute. 31 | - Trolling, insulting or derogatory comments, personal attacks, or any form of disruptive behavior that hinders productive discussions. 32 | - Spamming, advertising, or self-promotion without prior approval. 33 | - Any form of unethical or dishonest behavior. 34 | - Any behavior that violates or encourages others to violate applicable laws or regulations. 35 | 36 | ## Reporting Issues 37 | 38 | If you experience or witness any behavior that violates the Code of Conduct, please report it promptly to the project maintainers. You can contact us via our discord server. All reports will be kept confidential, and appropriate actions will be taken to address the issue. 39 | 40 | ## Consequences of Unacceptable Behavior 41 | 42 | Unacceptable behavior will not be tolerated within the Magmastream community. Consequences for violating the Code of Conduct may include: 43 | 44 | - A private or public warning from the project maintainers. 45 | - Temporary or permanent exclusion from participating in community activities. 46 | - Temporary or permanent bans from the Magmastream project and associated resources. 47 | 48 | ## Scope 49 | 50 | This Code of Conduct applies to all participants in the Magmastream community, including contributors, maintainers, and users, both online and offline. It also applies to all project-related platforms, such as GitHub repositories, issue trackers, forums, and community events. 51 | 52 | ## Acknowledgment 53 | 54 | This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.0. 55 | 56 | --- 57 | 58 | By participating in the Magmastream community, you are expected to adhere to this Code of Conduct. We thank you for helping us create a welcoming and inclusive community environment. 59 | -------------------------------------------------------------------------------- /src/utils/nodeCheck.ts: -------------------------------------------------------------------------------- 1 | import { NodeOptions } from "../structures/Types"; 2 | import { MagmaStreamError } from "../structures/MagmastreamError"; 3 | import { MagmaStreamErrorCode } from "../structures/Enums"; 4 | 5 | /** 6 | * Validates the provided NodeOptions object. 7 | * @param options - The options to validate. 8 | * @throws {MagmaStreamError} Throws if any required option is missing or invalid. 9 | */ 10 | export default function nodeCheck(options: NodeOptions) { 11 | if (!options) { 12 | throw new MagmaStreamError({ 13 | code: MagmaStreamErrorCode.NODE_INVALID_CONFIG, 14 | message: "NodeOptions must not be empty.", 15 | }); 16 | } 17 | 18 | const { host, identifier, password, port, enableSessionResumeOption, sessionTimeoutSeconds, maxRetryAttempts, retryDelayMs, useSSL, nodePriority } = options; 19 | 20 | if (typeof host !== "string" || !/.+/.test(host)) { 21 | throw new MagmaStreamError({ 22 | code: MagmaStreamErrorCode.NODE_INVALID_CONFIG, 23 | message: 'Node option "host" must be present and be a non-empty string.', 24 | context: { host }, 25 | }); 26 | } 27 | 28 | if (typeof identifier !== "undefined" && typeof identifier !== "string") { 29 | throw new MagmaStreamError({ 30 | code: MagmaStreamErrorCode.NODE_INVALID_CONFIG, 31 | message: 'Node option "identifier" must be a non-empty string.', 32 | context: { identifier }, 33 | }); 34 | } 35 | 36 | if (typeof password !== "undefined" && (typeof password !== "string" || !/.+/.test(password))) { 37 | throw new MagmaStreamError({ 38 | code: MagmaStreamErrorCode.NODE_INVALID_CONFIG, 39 | message: 'Node option "password" must be a non-empty string.', 40 | context: { password }, 41 | }); 42 | } 43 | 44 | if (typeof port !== "undefined" && typeof port !== "number") { 45 | throw new MagmaStreamError({ 46 | code: MagmaStreamErrorCode.NODE_INVALID_CONFIG, 47 | message: 'Node option "port" must be a number.', 48 | context: { port }, 49 | }); 50 | } 51 | 52 | if (typeof enableSessionResumeOption !== "undefined" && typeof enableSessionResumeOption !== "boolean") { 53 | throw new MagmaStreamError({ 54 | code: MagmaStreamErrorCode.NODE_INVALID_CONFIG, 55 | message: 'Node option "enableSessionResumeOption" must be a boolean.', 56 | context: { enableSessionResumeOption }, 57 | }); 58 | } 59 | 60 | if (typeof sessionTimeoutSeconds !== "undefined" && typeof sessionTimeoutSeconds !== "number") { 61 | throw new MagmaStreamError({ 62 | code: MagmaStreamErrorCode.NODE_INVALID_CONFIG, 63 | message: 'Node option "sessionTimeoutSeconds" must be a number.', 64 | context: { sessionTimeoutSeconds }, 65 | }); 66 | } 67 | 68 | if (typeof maxRetryAttempts !== "undefined" && typeof maxRetryAttempts !== "number") { 69 | throw new MagmaStreamError({ 70 | code: MagmaStreamErrorCode.NODE_INVALID_CONFIG, 71 | message: 'Node option "maxRetryAttempts" must be a number.', 72 | context: { maxRetryAttempts }, 73 | }); 74 | } 75 | 76 | if (typeof retryDelayMs !== "undefined" && typeof retryDelayMs !== "number") { 77 | throw new MagmaStreamError({ 78 | code: MagmaStreamErrorCode.NODE_INVALID_CONFIG, 79 | message: 'Node option "retryDelayMs" must be a number.', 80 | context: { retryDelayMs }, 81 | }); 82 | } 83 | 84 | if (typeof useSSL !== "undefined" && typeof useSSL !== "boolean") { 85 | throw new MagmaStreamError({ 86 | code: MagmaStreamErrorCode.NODE_INVALID_CONFIG, 87 | message: 'Node option "useSSL" must be a boolean.', 88 | context: { useSSL }, 89 | }); 90 | } 91 | 92 | if (typeof nodePriority !== "undefined" && typeof nodePriority !== "number") { 93 | throw new MagmaStreamError({ 94 | code: MagmaStreamErrorCode.NODE_INVALID_CONFIG, 95 | message: 'Node option "nodePriority" must be a number.', 96 | context: { nodePriority }, 97 | }); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/config/blockedWords.ts: -------------------------------------------------------------------------------- 1 | export const blockedWords: string[] = [ 2 | // Music-related terms 3 | "lyrics", 4 | "lyric", 5 | "+LYRICS", 6 | "audio", 7 | "music", 8 | "musicvideo", 9 | "music-video", 10 | "music video", 11 | "vevo", 12 | "original mix", 13 | "mix", 14 | "bass boosted", 15 | "boosted", 16 | "bass", 17 | "acoustic", 18 | "EP", 19 | "karaoke", 20 | "lofi", 21 | "lo-fi", 22 | "lo fi", 23 | "lofibeats", 24 | "lo-fi hip hop", 25 | 26 | // Video-related terms 27 | "video", 28 | "official-musicvideo", 29 | "official-audio", 30 | "official", 31 | "official mv", 32 | "official-mv", 33 | "official visualizer", 34 | "official-visualizer", 35 | "visualizer", 36 | "official video", 37 | "lyric video", 38 | "animated", 39 | "m/v", 40 | "MV", 41 | "Official HD Music Video", 42 | 43 | // Live and performance-related terms 44 | "live", 45 | "live performance", 46 | "vod", 47 | 48 | // Duration-related terms 49 | "1 hour", 50 | "2 hour", 51 | "3 hour", 52 | "4 hour", 53 | "5 hour", 54 | "6 hour", 55 | "7 hour", 56 | "8 hour", 57 | "9 hour", 58 | "10 hour", 59 | "11 hour", 60 | "12 hour", 61 | "13 hour", 62 | "15 hour", 63 | "16 hour", 64 | "17 hour", 65 | "18 hour", 66 | "19 hour", 67 | "20 hour", 68 | "21 hour", 69 | "23 hour", 70 | "24 hour", 71 | "48 hour", 72 | "full version", 73 | 74 | // Music labels and channels 75 | "free music", 76 | "ncs release", 77 | "- Topic - ", 78 | "GRM Daily", 79 | "Monstercat Release", 80 | "trapnation", 81 | "trapcity", 82 | "proximity", 83 | "majesticcasual", 84 | "suicide sheep", 85 | "mrrevillz", 86 | "taz network", 87 | "cloudkid", 88 | "xkito", 89 | "dynmk", 90 | "mrsuicidesheep", 91 | "chill nation", 92 | "indie nation", 93 | "thegrimeviolinist", 94 | "liquicity", 95 | "epic network", 96 | "pandora journey", 97 | "edm.com", 98 | "house nation", 99 | "monstercat", 100 | "nocopyrightsounds", 101 | "ncs", 102 | "nghbrs", 103 | "chillhop music", 104 | "chilledcow", 105 | 106 | // Social media and engagement terms 107 | "Tik Tok", 108 | "TikTok", 109 | "FREE DOWNLOAD", 110 | "stream", 111 | "download", 112 | "subscribe", 113 | "comment", 114 | "like", 115 | "share", 116 | "follow", 117 | 118 | // Mood and genre-related terms 119 | "ambient music", 120 | "relaxing music", 121 | "study music", 122 | "focus music", 123 | "calming music", 124 | "sleep music", 125 | "reverb", 126 | 127 | // Symbols and formatting characters 128 | "♪", 129 | "|", 130 | "~", 131 | "『", 132 | "』", 133 | 134 | // Non-English terms 135 | "clip officiel", 136 | "Audio oficial", 137 | "Áudio oficial", 138 | "Offizielles Audio", 139 | "Audio resmi", 140 | "Audio officiel", 141 | "Resmi ses dosyası", 142 | "Videoclipe Oficial", 143 | "Clipe Oficial", 144 | "video officiale", 145 | "offizielles video", 146 | // Additional non-English terms 147 | "Vídeo oficial", 148 | "Видео официальное", 149 | "Offizielle Musik", 150 | "Musique officielle", 151 | "Música oficial", 152 | "Ufficiale musica", 153 | "公式ミュージックビデオ", 154 | "官方音乐视频", 155 | "공식 뮤직비디오", 156 | "Vídeo musical oficial", 157 | "Videoclip ufficiale", 158 | "Clip de musique officiel", 159 | "Offizielles Musikvideo", 160 | "Официальный клип", 161 | "Resmi müzik videosu", 162 | "Oficjalne wideo", 163 | "Officiële videoclip", 164 | "Επίσημο βίντεο κλιπ", 165 | "Resmi video klip", 166 | "Offisiell musikkvideo", 167 | "Officiell musikvideo", 168 | "Virallinen musiikkivideo", 169 | "Oficialus muzikinis vaizdo klipas", 170 | "Zyrtare video muzikore", 171 | "Rasmi muzik video", 172 | "Chính thức video âm nhạc", 173 | "Resmi müzik klibi", 174 | "Oficjalne audio", 175 | "Officiële audio", 176 | "Аудио официальное", 177 | "音声公式", 178 | "官方音频", 179 | "공식 오디오", 180 | 181 | // Additional terms 182 | "remix", 183 | "cover", 184 | "instrumental", 185 | "extended mix", 186 | "radio edit", 187 | "feat.", 188 | "ft.", 189 | "featuring", 190 | "prod. by", 191 | "produced by", 192 | "official lyric video", 193 | "official audio", 194 | "official music video", 195 | "behind the scenes", 196 | "making of", 197 | "teaser", 198 | "trailer", 199 | "preview", 200 | "snippet", 201 | "exclusive", 202 | "premiere", 203 | "remastered", 204 | "remaster", 205 | "high quality", 206 | "HQ", 207 | "HD", 208 | "4K", 209 | "8K", 210 | "surround sound", 211 | "dolby atmos", 212 | ]; 213 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Magmastream 2 | 3 | Thank you for your interest in contributing to Magmastream! We welcome contributions from everyone, whether you're fixing a typo, improving documentation, adding a new feature, or reporting a bug. 4 | 5 | This document provides guidelines and steps for contributing to Magmastream. By following these guidelines, you help maintain the quality and consistency of the project. 6 | 7 | ## Table of Contents 8 | 9 | - [Code of Conduct](#code-of-conduct) 10 | - [Getting Started](#getting-started) 11 | - [Development Workflow](#development-workflow) 12 | - [Pull Request Process](#pull-request-process) 13 | - [Coding Standards](#coding-standards) 14 | - [Testing](#testing) 15 | - [Documentation](#documentation) 16 | - [Community](#community) 17 | 18 | ## Code of Conduct 19 | 20 | We expect all contributors to adhere to our Code of Conduct. Please read it before participating. 21 | 22 | - Be respectful and inclusive 23 | - Be patient and welcoming 24 | - Be thoughtful 25 | - Be collaborative 26 | - When disagreeing, try to understand why 27 | 28 | ## Getting Started 29 | 30 | ### Prerequisites 31 | 32 | - Node.js (v16.x or higher) 33 | - npm (v7.x or higher) 34 | - A running [Lavalink](https://github.com/freyacodes/Lavalink) server for testing 35 | 36 | ### Setup 37 | 38 | 1. Fork the repository on GitHub 39 | 2. Clone your fork locally: 40 | ```bash 41 | git clone https://github.com/YOUR-USERNAME/magmastream.git 42 | cd magmastream 43 | ``` 44 | 3. Install dependencies: 45 | ```bash 46 | npm install 47 | ``` 48 | 4. Add the original repository as a remote: 49 | ```bash 50 | git remote add upstream https://github.com/Magmastream-NPM/magmastream.git 51 | ``` 52 | 53 | ## Development Workflow 54 | 55 | 1. Create a new branch for your feature/fix: 56 | ```bash 57 | git checkout -b feature/your-feature-name 58 | ``` 59 | or 60 | ```bash 61 | git checkout -b fix/issue-you-are-fixing 62 | ``` 63 | 64 | 2. Make your changes 65 | 66 | 3. Run tests to ensure your changes don't break existing functionality: 67 | ```bash 68 | npm test 69 | ``` 70 | 71 | 4. Commit your changes with a descriptive message: 72 | ```bash 73 | git commit -m "feat: add new audio filter functionality" 74 | ``` 75 | We follow [Conventional Commits](https://www.conventionalcommits.org/) for commit messages. 76 | Also take a look at [this](https://www.freecodecamp.org/news/how-to-write-better-git-commit-messages/) tutorial for more information. 77 | 78 | 5. Push to your fork: 79 | ```bash 80 | git push origin feature/your-feature-name 81 | ``` 82 | 83 | 6. Create a Pull Request from your fork to the [dev](https://github.com/Magmastream-NPM/magmastream/tree/dev) branch 84 | 85 | ## Pull Request Process 86 | 87 | 1. Ensure your PR addresses a specific issue. If an issue doesn't exist, create one first. 88 | 2. Update documentation if necessary. 89 | 3. Include tests for new features. 90 | 4. Make sure all tests pass. 91 | 5. Wait for code review and address any requested changes. 92 | 6. Once approved, a maintainer will merge your PR. 93 | 94 | ## Coding Standards 95 | 96 | We follow a set of coding standards to maintain consistency across the codebase: 97 | 98 | - Use ESLint for code linting 99 | - Follow the existing code style 100 | - Write clear, descriptive variable and function names 101 | - Comment complex code sections 102 | - Keep functions small and focused on a single task 103 | - Use TypeScript for type safety 104 | 105 | ### TypeScript Guidelines 106 | 107 | - Use proper typing instead of `any` wherever possible 108 | - Make interfaces for complex objects 109 | - Use enums for predefined values 110 | - Leverage union types and generics when appropriate 111 | 112 | ## Testing 113 | 114 | All new features and bug fixes should include tests. We use Jest for testing. 115 | 116 | - Unit tests for individual functions and methods 117 | - Integration tests for API endpoints and complex interactions 118 | - Make sure your tests are meaningful and cover edge cases 119 | 120 | To run tests: 121 | ```bash 122 | npm test 123 | ``` 124 | 125 | ## Documentation 126 | 127 | Good documentation is crucial for the project: 128 | 129 | - Update the README.md if necessary 130 | - Document all public methods and classes with JSDoc comments 131 | - Include examples for non-trivial functionality 132 | - Update the changelog for significant changes 133 | 134 | ## Community 135 | 136 | Join our community to discuss development, get help, or chat: 137 | 138 | - [Discord Server](https://discord.gg/wrydAnP3M6) 139 | - [GitHub Discussions](https://github.com/Magmastream-NPM/magmastream/discussions) 140 | 141 | ## Plugin Development 142 | 143 | If you're creating a plugin for Magmastream: 144 | 145 | 1. Use the official plugin template 146 | 2. Follow the plugin development guidelines 147 | 3. Make sure your plugin is well-documented 148 | 4. Include tests for your plugin functionality 149 | 150 | ## Releasing 151 | 152 | Only maintainers can release new versions. The process is: 153 | 154 | 1. Update version in package.json 155 | 2. Update CHANGELOG.md 156 | 3. Create a GitHub release 157 | 4. Publish to npm 158 | 159 | ## Questions? 160 | 161 | If you have any questions about contributing, please reach out on our Discord server or open an issue on GitHub. 162 | 163 | Thank you for contributing to Magmastream! 164 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |

4 | 5 |

6 | 7 | [![NPM Version](https://img.shields.io/npm/v/magmastream?color=00DDB3&label=Magmastream&style=for-the-badge&logo=npm)](https://www.npmjs.com/package/magmastream) 8 | [![GitHub Stars](https://img.shields.io/github/stars/Magmastream-NPM/magmastream?color=yellow&style=for-the-badge&logo=github)](https://github.com/Magmastream-NPM/magmastream/stargazers) 9 | [![Downloads](https://img.shields.io/npm/dt/magmastream.svg?style=for-the-badge&color=FF6B6B)](https://www.npmjs.com/package/magmastream) 10 | 11 |
12 | 13 |

14 | 15 |

16 | 17 |
18 | 19 |
20 |

🎵 The Most Advanced Lavalink Wrapper for Node.js 🚀

21 |

Powering the next generation of Discord music bots

22 |
23 | 24 |
25 | 26 | ## ✨ Features 27 | 28 |
29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 |
🎯 Simple API⚡ High Performance🛠️ Rich Features
🔌 Plugin Support📊 Advanced Analytics🎚️ Audio Filters
41 |
42 | 43 | ## 📦 Resources 44 | 45 | 56 | 57 | ## 🌟 Featured Projects 58 | 59 |
60 | 61 | 62 | 66 | 70 | 74 | 75 |
63 |
64 | by Abel Purnwasy 65 |
67 |
68 | by memte 69 |
71 |
72 | by vexi 73 |
76 | 77 | [View All Projects →](https://github.com/Magmastream-NPM/magmastream#used-by) 78 |
79 | 80 | ## 📊 Project Statistics 81 | 82 |
83 | 84 | ![Stats](https://repobeats.axiom.co/api/embed/e46896cea6c7ad6648effe4d7868ffa3fef0151b.svg "Repobeats analytics image") 85 | 86 |
87 | 88 |
89 | 90 | ## Used By 91 | 92 | The "Used By" section can be found [here](https://magmastream.com/usedby). 93 | 94 | Want to showcase your bot? Feel free to create a pull request in [the docs repo](https://github.com/Magmastream-NPM/magmastream_documentation) and add it to our growing list of amazing bots powered by Magmastream! 95 | 96 | ## 👥 Contributors 97 | 98 |
99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 |
Darek
Darek

📖
Vexify4103
Vexify4103

💻 📖
Itz Random
Itz Random

💻
Yanis_Hamburger
Yanis_Hamburger

🐛
Kenver
Kenver

📦
114 | 115 | 116 | 117 | 118 | 119 | 120 | Contributors 121 | 122 |
123 | 124 | ## 🤝 Contributing 125 | 126 |
127 | 128 | We welcome contributions! Check out our [Contributing Guide](CONTRIBUTING.md) to get started. 129 | 130 | [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=for-the-badge)](CONTRIBUTING.md) 131 | 132 |
133 | 134 |
135 | 136 |
137 | 138 |

139 | 140 |

141 | 142 | Built with ❤️ by the Magmastream Team 143 | 144 |
145 | -------------------------------------------------------------------------------- /src/utils/managerCheck.ts: -------------------------------------------------------------------------------- 1 | import { AutoPlayPlatform, MagmaStreamErrorCode, SearchPlatform, TrackPartial, UseNodeOptions } from "../structures/Enums"; 2 | import { MagmaStreamError } from "../structures/MagmastreamError"; 3 | import { ManagerOptions } from "../structures/Types"; 4 | 5 | /** 6 | * Validates the provided ManagerOptions object. 7 | * @param options - The options to validate. 8 | * @throws {MagmaStreamError} Throws if any required option is missing or invalid. 9 | */ 10 | export default function managerCheck(options: ManagerOptions) { 11 | if (!options) { 12 | throw new MagmaStreamError({ 13 | code: MagmaStreamErrorCode.MANAGER_INVALID_CONFIG, 14 | message: "ManagerOptions must not be empty.", 15 | context: { option: "options" }, 16 | }); 17 | } 18 | const { 19 | playNextOnEnd, 20 | clientName, 21 | defaultSearchPlatform, 22 | autoPlaySearchPlatforms, 23 | nodes, 24 | enabledPlugins, 25 | send, 26 | trackPartial, 27 | enablePriorityMode, 28 | useNode, 29 | normalizeYouTubeTitles, 30 | lastFmApiKey, 31 | maxPreviousTracks, 32 | } = options; 33 | 34 | // Validate playNextOnEnd option 35 | if (typeof playNextOnEnd !== "boolean") { 36 | throw new MagmaStreamError({ 37 | code: MagmaStreamErrorCode.MANAGER_INVALID_CONFIG, 38 | message: 'Manager option "playNextOnEnd" must be a boolean.', 39 | context: { option: "playNextOnEnd", value: playNextOnEnd }, 40 | }); 41 | } 42 | 43 | // Validate clientName option 44 | if (typeof clientName !== "undefined") { 45 | if (typeof clientName !== "string" || clientName.trim().length === 0) { 46 | throw new MagmaStreamError({ 47 | code: MagmaStreamErrorCode.MANAGER_INVALID_CONFIG, 48 | message: 'Manager option "clientName" must be a non-empty string.', 49 | context: { option: "clientName", value: clientName }, 50 | }); 51 | } 52 | } 53 | 54 | // Validate defaultSearchPlatform option 55 | if (typeof defaultSearchPlatform !== "undefined") { 56 | if (!Object.values(SearchPlatform).includes(defaultSearchPlatform)) { 57 | throw new MagmaStreamError({ 58 | code: MagmaStreamErrorCode.MANAGER_INVALID_CONFIG, 59 | message: `Manager option "defaultSearchPlatform" must be one of: ${Object.values(SearchPlatform).join(", ")}.`, 60 | context: { option: "defaultSearchPlatform", value: defaultSearchPlatform }, 61 | }); 62 | } 63 | } 64 | 65 | // Validate autoPlaySearchPlatforms 66 | if (typeof autoPlaySearchPlatforms !== "undefined") { 67 | if (!Array.isArray(autoPlaySearchPlatforms)) { 68 | throw new MagmaStreamError({ 69 | code: MagmaStreamErrorCode.MANAGER_INVALID_CONFIG, 70 | message: 'Manager option "autoPlaySearchPlatforms" must be an array.', 71 | context: { option: "autoPlaySearchPlatforms", value: autoPlaySearchPlatforms }, 72 | }); 73 | } 74 | 75 | if (!autoPlaySearchPlatforms.every((platform) => Object.values(AutoPlayPlatform).includes(platform))) { 76 | throw new MagmaStreamError({ 77 | code: MagmaStreamErrorCode.MANAGER_INVALID_CONFIG, 78 | message: 'Manager option "autoPlaySearchPlatforms" must be an array of valid AutoPlayPlatform values.', 79 | context: { option: "autoPlaySearchPlatforms", value: autoPlaySearchPlatforms }, 80 | }); 81 | } 82 | } 83 | 84 | // Validate nodes option 85 | if (typeof nodes === "undefined" || !Array.isArray(nodes)) { 86 | throw new MagmaStreamError({ 87 | code: MagmaStreamErrorCode.MANAGER_INVALID_CONFIG, 88 | message: 'Manager option "nodes" must be an array.', 89 | context: { option: "nodes", value: nodes }, 90 | }); 91 | } 92 | 93 | // Validate enabledPlugins option 94 | if (typeof enabledPlugins !== "undefined" && !Array.isArray(enabledPlugins)) { 95 | throw new MagmaStreamError({ 96 | code: MagmaStreamErrorCode.MANAGER_INVALID_CONFIG, 97 | message: 'Manager option "enabledPlugins" must be a Plugin array.', 98 | context: { option: "enabledPlugins", value: enabledPlugins }, 99 | }); 100 | } 101 | 102 | // Validate send option 103 | if (typeof send !== "undefined" && typeof send !== "function") { 104 | throw new MagmaStreamError({ 105 | code: MagmaStreamErrorCode.MANAGER_INVALID_CONFIG, 106 | message: 'Manager option "send" must be a function.', 107 | context: { option: "send", value: send }, 108 | }); 109 | } 110 | 111 | // Validate trackPartial option 112 | if (typeof trackPartial !== "undefined") { 113 | if (!Array.isArray(trackPartial)) { 114 | throw new MagmaStreamError({ 115 | code: MagmaStreamErrorCode.MANAGER_INVALID_CONFIG, 116 | message: 'Manager option "trackPartial" must be an array.', 117 | context: { option: "trackPartial", value: trackPartial }, 118 | }); 119 | } 120 | if (!trackPartial.every((item) => Object.values(TrackPartial).includes(item))) { 121 | throw new MagmaStreamError({ 122 | code: MagmaStreamErrorCode.MANAGER_INVALID_CONFIG, 123 | message: 'Manager option "trackPartial" must be an array of valid TrackPartial values.', 124 | context: { option: "trackPartial", value: trackPartial }, 125 | }); 126 | } 127 | } 128 | 129 | // Validate enablePriorityMode option 130 | if (typeof enablePriorityMode !== "undefined" && typeof enablePriorityMode !== "boolean") { 131 | throw new MagmaStreamError({ 132 | code: MagmaStreamErrorCode.MANAGER_INVALID_CONFIG, 133 | message: 'Manager option "enablePriorityMode" must be a boolean.', 134 | context: { option: "enablePriorityMode", value: enablePriorityMode }, 135 | }); 136 | } 137 | 138 | // Validate node priority if enablePriorityMode is enabled 139 | if (enablePriorityMode) { 140 | for (let index = 0; index < nodes.length; index++) { 141 | if (typeof nodes[index].nodePriority !== "number" || isNaN(nodes[index].nodePriority)) { 142 | throw new MagmaStreamError({ 143 | code: MagmaStreamErrorCode.MANAGER_INVALID_CONFIG, 144 | message: `Missing or invalid node option "nodePriority" at position ${index}.`, 145 | context: { option: "nodePriority", index, value: nodes[index].nodePriority }, 146 | }); 147 | } 148 | } 149 | } 150 | 151 | // Validate useNode option 152 | if (typeof useNode !== "undefined") { 153 | if (typeof useNode !== "string") { 154 | throw new MagmaStreamError({ 155 | code: MagmaStreamErrorCode.MANAGER_INVALID_CONFIG, 156 | message: 'Manager option "useNode" must be a string "leastLoad" or "leastPlayers".', 157 | context: { option: "useNode", value: useNode }, 158 | }); 159 | } 160 | 161 | if (!Object.values(UseNodeOptions).includes(useNode as UseNodeOptions)) { 162 | throw new MagmaStreamError({ 163 | code: MagmaStreamErrorCode.MANAGER_INVALID_CONFIG, 164 | message: 'Manager option "useNode" must be either "leastLoad" or "leastPlayers".', 165 | context: { option: "useNode", value: useNode }, 166 | }); 167 | } 168 | } 169 | 170 | // Validate normalizeYouTubeTitles option 171 | if (typeof normalizeYouTubeTitles !== "undefined" && typeof normalizeYouTubeTitles !== "boolean") { 172 | throw new MagmaStreamError({ 173 | code: MagmaStreamErrorCode.MANAGER_INVALID_CONFIG, 174 | message: 'Manager option "normalizeYouTubeTitles" must be a boolean.', 175 | context: { option: "normalizeYouTubeTitles", value: normalizeYouTubeTitles }, 176 | }); 177 | } 178 | 179 | // Validate lastFmApiKey option 180 | if (typeof lastFmApiKey !== "undefined" && (typeof lastFmApiKey !== "string" || lastFmApiKey.trim().length === 0)) { 181 | throw new MagmaStreamError({ 182 | code: MagmaStreamErrorCode.MANAGER_INVALID_CONFIG, 183 | message: 'Manager option "lastFmApiKey" must be a non-empty string.', 184 | context: { option: "lastFmApiKey", value: lastFmApiKey }, 185 | }); 186 | } 187 | 188 | // Validate maxPreviousTracks option 189 | if (typeof maxPreviousTracks !== "undefined") { 190 | if (typeof maxPreviousTracks !== "number" || isNaN(maxPreviousTracks)) { 191 | throw new MagmaStreamError({ 192 | code: MagmaStreamErrorCode.MANAGER_INVALID_CONFIG, 193 | message: 'Manager option "maxPreviousTracks" must be a number.', 194 | context: { option: "maxPreviousTracks", value: maxPreviousTracks }, 195 | }); 196 | } 197 | if (maxPreviousTracks <= 0) { 198 | throw new MagmaStreamError({ 199 | code: MagmaStreamErrorCode.MANAGER_INVALID_CONFIG, 200 | message: 'Manager option "maxPreviousTracks" must be a positive number.', 201 | context: { option: "maxPreviousTracks", value: maxPreviousTracks }, 202 | }); 203 | } 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /src/structures/Rest.ts: -------------------------------------------------------------------------------- 1 | import { Node } from "./Node"; 2 | import axios, { AxiosError, AxiosRequestConfig } from "axios"; 3 | import { Manager } from "./Manager"; 4 | import { MagmaStreamErrorCode, ManagerEventTypes } from "./Enums"; 5 | import { LavaPlayer, RestPlayOptions } from "./Types"; 6 | import { JSONUtils } from "./Utils"; 7 | import { MagmaStreamError } from "./MagmastreamError"; 8 | 9 | /** Handles the requests sent to the Lavalink REST API. */ 10 | export class Rest { 11 | /** The Node that this Rest instance is connected to. */ 12 | private node: Node; 13 | /** The ID of the current session. */ 14 | private sessionId: string; 15 | /** The password for the Node. */ 16 | private readonly password: string; 17 | /** The URL of the Node. */ 18 | private readonly url: string; 19 | /** The Manager instance. */ 20 | public manager: Manager; 21 | /** Whether the node is a NodeLink. */ 22 | public isNodeLink: boolean = false; 23 | 24 | constructor(node: Node, manager: Manager) { 25 | this.node = node; 26 | this.url = `http${node.options.useSSL ? "s" : ""}://${node.options.host}:${node.options.port}`; 27 | this.sessionId = node.sessionId; 28 | this.password = node.options.password; 29 | this.manager = manager; 30 | this.isNodeLink = node.isNodeLink; 31 | } 32 | 33 | /** 34 | * Sets the session ID. 35 | * This method is used to set the session ID after a resume operation is done. 36 | * @param {string} sessionId The session ID to set. 37 | * @returns {string} Returns the set session ID. 38 | */ 39 | public setSessionId(sessionId: string): string { 40 | this.sessionId = sessionId; 41 | return this.sessionId; 42 | } 43 | 44 | /** 45 | * Retrieves one the player that is currently running on the node. 46 | * @returns {Promise} Returns the result of the GET request. 47 | */ 48 | public async getPlayer(guildId: string): Promise { 49 | // Send a GET request to the Lavalink Node to retrieve all the players. 50 | const result = (await this.get(`/v4/sessions/${this.sessionId}/players/${guildId}`)) as LavaPlayer; 51 | 52 | // Log the result of the request. 53 | this.manager.emit(ManagerEventTypes.Debug, `[REST] Getting all players on node: ${this.node.options.identifier} : ${JSONUtils.safe(result, 2)}`); 54 | 55 | // Return the result of the request. 56 | return result; 57 | } 58 | 59 | /** 60 | * Sends a PATCH request to update player related data. 61 | * @param {RestPlayOptions} options The options to update the player with. 62 | * @returns {Promise} Returns the result of the PATCH request. 63 | */ 64 | public async updatePlayer(options: RestPlayOptions): Promise { 65 | // Log the request. 66 | this.manager.emit(ManagerEventTypes.Debug, `[REST] Updating player: ${options.guildId}: ${JSONUtils.safe(options, 2)}`); 67 | 68 | // Send the PATCH request. 69 | return await this.patch(`/v4/sessions/${this.sessionId}/players/${options.guildId}?noReplace=false`, options.data); 70 | } 71 | 72 | /** 73 | * Sends a DELETE request to the server to destroy the player. 74 | * @param {string} guildId The guild ID of the player to destroy. 75 | * @returns {Promise} Returns the result of the DELETE request. 76 | */ 77 | public async destroyPlayer(guildId: string): Promise { 78 | // Log the request. 79 | this.manager.emit(ManagerEventTypes.Debug, `[REST] Destroying player: ${guildId}`); 80 | // Send the DELETE request. 81 | return await this.delete(`/v4/sessions/${this.sessionId}/players/${guildId}`); 82 | } 83 | 84 | /** 85 | * Updates the session status for resuming. 86 | * This method sends a PATCH request to update the session's resuming status and timeout. 87 | * 88 | * @param {boolean} resuming - Indicates whether the session should be set to resuming. 89 | * @param {number} timeout - The timeout duration for the session resume. 90 | * @returns {Promise} The result of the PATCH request. 91 | */ 92 | public async updateSession(resuming: boolean, timeout: number): Promise { 93 | // Emit a debug event with information about the session being updated 94 | this.manager.emit(ManagerEventTypes.Debug, `[REST] Updating session: ${this.sessionId}`); 95 | 96 | // Send a PATCH request to update the session with the provided resuming status and timeout 97 | return await this.patch(`/v4/sessions/${this.sessionId}`, { resuming, timeout }); 98 | } 99 | 100 | /** 101 | * Sends a request to the specified endpoint and returns the response data. 102 | * @param {string} method The HTTP method to use for the request. 103 | * @param {string} endpoint The endpoint to send the request to. 104 | * @param {unknown} [body] The data to send in the request body. 105 | * @returns {Promise} The response data of the request. 106 | */ 107 | private async request(method: string, endpoint: string, body?: unknown): Promise { 108 | this.manager.emit(ManagerEventTypes.Debug, `[REST] ${method} request to ${endpoint} with body: ${JSONUtils.safe(body, 2)}`); 109 | 110 | const config: AxiosRequestConfig = { 111 | method, 112 | url: this.url + endpoint, 113 | headers: { 114 | "Content-Type": "application/json", 115 | Authorization: this.password, 116 | }, 117 | data: body, 118 | timeout: this.node.options.apiRequestTimeoutMs, 119 | }; 120 | 121 | try { 122 | const response = await axios(config); 123 | return response.data; 124 | } catch (err: unknown) { 125 | const error = err as AxiosError; 126 | 127 | if (!error.response) { 128 | throw new MagmaStreamError({ 129 | code: MagmaStreamErrorCode.REST_REQUEST_FAILED, 130 | message: `No response from node ${this.node.options.identifier}: ${error.message}`, 131 | }); 132 | } 133 | 134 | const data = error.response.data as { message?: string }; 135 | 136 | if (data?.message === "Guild not found") { 137 | return []; 138 | } 139 | 140 | if (error.response.status === 401) { 141 | throw new MagmaStreamError({ 142 | code: MagmaStreamErrorCode.REST_UNAUTHORIZED, 143 | message: `Unauthorized access to node ${this.node.options.identifier}`, 144 | }); 145 | } 146 | 147 | const dataMessage = typeof data === "string" ? data : data?.message ? data.message : JSONUtils.safe(data, 2); 148 | 149 | throw new MagmaStreamError({ 150 | code: MagmaStreamErrorCode.REST_REQUEST_FAILED, 151 | message: `Request to node ${this.node.options.identifier} failed with status ${error.response.status}: ${dataMessage}`, 152 | }); 153 | } 154 | } 155 | 156 | /** 157 | * Sends a GET request to the specified endpoint and returns the response data. 158 | * @param {string} endpoint The endpoint to send the GET request to. 159 | * @returns {Promise} The response data of the GET request. 160 | */ 161 | public async get(endpoint: string): Promise { 162 | // Send a GET request to the specified endpoint and return the response data. 163 | return await this.request("GET", endpoint); 164 | } 165 | 166 | /** 167 | * Sends a PATCH request to the specified endpoint and returns the response data. 168 | * @param {string} endpoint The endpoint to send the PATCH request to. 169 | * @param {unknown} body The data to send in the request body. 170 | * @returns {Promise} The response data of the PATCH request. 171 | */ 172 | public async patch(endpoint: string, body: unknown): Promise { 173 | // Send a PATCH request to the specified endpoint and return the response data. 174 | return await this.request("PATCH", endpoint, body); 175 | } 176 | 177 | /** 178 | * Sends a POST request to the specified endpoint and returns the response data. 179 | * @param {string} endpoint The endpoint to send the POST request to. 180 | * @param {unknown} body The data to send in the request body. 181 | * @returns {Promise} The response data of the POST request. 182 | */ 183 | public async post(endpoint: string, body: unknown): Promise { 184 | return await this.request("POST", endpoint, body); 185 | } 186 | 187 | /** 188 | * Sends a PUT request to the specified endpoint and returns the response data. 189 | * @param {string} endpoint The endpoint to send the PUT request to. 190 | * @param {unknown} body The data to send in the request body. 191 | * @returns {Promise} The response data of the PUT request. 192 | */ 193 | public async put(endpoint: string, body: unknown): Promise { 194 | // Send a PUT request to the specified endpoint and return the response data. 195 | return await this.request("PUT", endpoint, body); 196 | } 197 | 198 | /** 199 | * Sends a DELETE request to the specified endpoint. 200 | * @param {string} endpoint - The endpoint to send the DELETE request to. 201 | * @returns {Promise} The response data of the DELETE request. 202 | */ 203 | public async delete(endpoint: string): Promise { 204 | // Send a DELETE request using the request method and return the response data. 205 | return await this.request("DELETE", endpoint); 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/structures/Enums.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * State Storage Enum 3 | */ 4 | export enum StateStorageType { 5 | Memory = "memory", 6 | Redis = "redis", 7 | JSON = "json", 8 | } 9 | 10 | /** 11 | * AutoPlay Platform Enum 12 | */ 13 | export enum AutoPlayPlatform { 14 | Spotify = "spotify", 15 | Deezer = "deezer", 16 | SoundCloud = "soundcloud", 17 | Tidal = "tidal", 18 | VKMusic = "vkmusic", 19 | Qobuz = "qobuz", 20 | YouTube = "youtube", 21 | } 22 | 23 | /** 24 | * State Types Enum 25 | */ 26 | export enum StateTypes { 27 | Connected = "CONNECTED", 28 | Connecting = "CONNECTING", 29 | Disconnected = "DISCONNECTED", 30 | Disconnecting = "DISCONNECTING", 31 | Destroying = "DESTROYING", 32 | } 33 | 34 | /** 35 | * Load Types Enum 36 | */ 37 | export enum LoadTypes { 38 | Track = "track", 39 | Playlist = "playlist", 40 | Search = "search", 41 | Empty = "empty", 42 | Error = "error", 43 | /** Nodelink */ 44 | Album = "album", 45 | /** Nodelink */ 46 | Artist = "artist", 47 | /** Nodelink */ 48 | Station = "station", 49 | /** Nodelink */ 50 | Podcast = "podcast", 51 | /** Nodelink */ 52 | Show = "show", 53 | /** Nodelink */ 54 | Short = "short", 55 | } 56 | 57 | /** 58 | * Search Platform Enum 59 | */ 60 | export enum SearchPlatform { 61 | AppleMusic = "amsearch", 62 | Audius = "audsearch", 63 | Bandcamp = "bcsearch", 64 | Deezer = "dzsearch", 65 | Jiosaavn = "jssearch", 66 | Qobuz = "qbsearch", 67 | SoundCloud = "scsearch", 68 | Spotify = "spsearch", 69 | Tidal = "tdsearch", 70 | TTS = "speak", 71 | VKMusic = "vksearch", 72 | YouTube = "ytsearch", 73 | YouTubeMusic = "ytmsearch", 74 | } 75 | 76 | /** 77 | * Player State Event Types Enum 78 | */ 79 | export enum PlayerStateEventTypes { 80 | AutoPlayChange = "playerAutoplay", 81 | ConnectionChange = "playerConnection", 82 | RepeatChange = "playerRepeat", 83 | PauseChange = "playerPause", 84 | QueueChange = "queueChange", 85 | TrackChange = "trackChange", 86 | VolumeChange = "volumeChange", 87 | ChannelChange = "channelChange", 88 | PlayerCreate = "playerCreate", 89 | PlayerDestroy = "playerDestroy", 90 | FilterChange = "filterChange", 91 | } 92 | 93 | /** 94 | * Track Source Types Enum 95 | */ 96 | export enum TrackSourceTypes { 97 | AppleMusic = "AppleMusic", 98 | Audius = "Audius", 99 | Bandcamp = "Bandcamp", 100 | Deezer = "Deezer", 101 | Jiosaavn = "Jiosaavn", 102 | Qobuz = "Qobuz", 103 | SoundCloud = "SoundCloud", 104 | Spotify = "Spotify", 105 | Tidal = "Tidal", 106 | VKMusic = "VKMusic", 107 | YouTube = "YouTube", 108 | Pornhub = "Pornub", 109 | TikTok = "TikTok", 110 | Flowertts = "Flowertts", 111 | Ocremix = "Ocremix", 112 | Mixcloud = "Mixcloud", 113 | Soundgasm = "Soundgasm", 114 | Reddit = "Reddit", 115 | Clypit = "Clypit", 116 | Http = "Http", 117 | Tts = "Tts", 118 | } 119 | 120 | /** 121 | * Use Node Options Enum 122 | */ 123 | export enum UseNodeOptions { 124 | LeastLoad = "leastLoad", 125 | LeastPlayers = "leastPlayers", 126 | } 127 | 128 | /** 129 | * Track Partial Enum 130 | */ 131 | export enum TrackPartial { 132 | /** The base64 encoded string of the track */ 133 | Track = "track", 134 | /** The title of the track */ 135 | Title = "title", 136 | /** The track identifier */ 137 | Identifier = "identifier", 138 | /** The author of the track */ 139 | Author = "author", 140 | /** The length of the track in milliseconds */ 141 | Duration = "duration", 142 | /** The ISRC of the track */ 143 | Isrc = "isrc", 144 | /** Whether the track is seekable */ 145 | IsSeekable = "isSeekable", 146 | /** Whether the track is a stream */ 147 | IsStream = "isStream", 148 | /** The URI of the track */ 149 | Uri = "uri", 150 | /** The artwork URL of the track */ 151 | ArtworkUrl = "artworkUrl", 152 | /** The source name of the track */ 153 | SourceName = "sourceName", 154 | /** The thumbnail of the track */ 155 | ThumbNail = "thumbnail", 156 | /** The requester of the track */ 157 | Requester = "requester", 158 | /** The plugin info of the track */ 159 | PluginInfo = "pluginInfo", 160 | /** The custom data of the track */ 161 | CustomData = "customData", 162 | } 163 | 164 | /** 165 | * Manager Event Types Enum 166 | */ 167 | export enum ManagerEventTypes { 168 | ChapterStarted = "chapterStarted", 169 | ChaptersLoaded = "chaptersLoaded", 170 | Debug = "debug", 171 | LyricsFound = "lyricsFound", 172 | LyricsLine = "lyricsLine", 173 | LyricsNotFound = "lyricsNotFound", 174 | NodeConnect = "nodeConnect", 175 | NodeCreate = "nodeCreate", 176 | NodeDestroy = "nodeDestroy", 177 | NodeDisconnect = "nodeDisconnect", 178 | NodeError = "nodeError", 179 | NodeRaw = "nodeRaw", 180 | NodeReconnect = "nodeReconnect", 181 | PlayerCreate = "playerCreate", 182 | PlayerDestroy = "playerDestroy", 183 | PlayerDisconnect = "playerDisconnect", 184 | PlayerMove = "playerMove", 185 | PlayerRestored = "playerRestored", 186 | PlayerStateUpdate = "playerStateUpdate", 187 | QueueEnd = "queueEnd", 188 | RestoreComplete = "restoreComplete", 189 | SegmentSkipped = "segmentSkipped", 190 | SegmentsLoaded = "segmentsLoaded", 191 | SocketClosed = "socketClosed", 192 | TrackEnd = "trackEnd", 193 | TrackError = "trackError", 194 | TrackStart = "trackStart", 195 | TrackStuck = "trackStuck", 196 | /** Nodelink */ 197 | VoiceReceiverDisconnect = "voiceReceiverDisconnect", 198 | /** Nodelink */ 199 | VoiceReceiverConnect = "voiceReceiverConnect", 200 | /** Nodelink */ 201 | VoiceReceiverError = "voiceReceiverError", 202 | /** Nodelink */ 203 | VoiceReceiverStartSpeaking = "voiceReceiverStartSpeaking", 204 | /** Nodelink */ 205 | VoiceReceiverEndSpeaking = "voiceReceiverEndSpeaking", 206 | } 207 | 208 | /** 209 | * Track End Reason Enum 210 | */ 211 | export enum TrackEndReasonTypes { 212 | Finished = "finished", 213 | LoadFailed = "loadFailed", 214 | Stopped = "stopped", 215 | Replaced = "replaced", 216 | Cleanup = "cleanup", 217 | } 218 | 219 | /** 220 | * Severity Types Enum 221 | */ 222 | export enum SeverityTypes { 223 | Common = "common", 224 | Suspicious = "suspicious", 225 | Fault = "fault", 226 | } 227 | 228 | /** 229 | * SponsorBlock Segment Enum 230 | */ 231 | export enum SponsorBlockSegment { 232 | Filler = "filler", 233 | Interaction = "interaction", 234 | Intro = "intro", 235 | MusicOfftopic = "music_offtopic", 236 | Outro = "outro", 237 | Preview = "preview", 238 | SelfPromo = "selfpromo", 239 | Sponsor = "sponsor", 240 | } 241 | 242 | /** 243 | * Available Filters Enum 244 | */ 245 | export enum AvailableFilters { 246 | BassBoost = "bassboost", 247 | China = "china", 248 | Chipmunk = "chipmunk", 249 | Darthvader = "darthvader", 250 | Daycore = "daycore", 251 | Demon = "demon", 252 | Distort = "distort", 253 | Doubletime = "doubletime", 254 | Earrape = "earrape", 255 | EightD = "eightD", 256 | Electronic = "electronic", 257 | Nightcore = "nightcore", 258 | Party = "party", 259 | Pop = "pop", 260 | Radio = "radio", 261 | SetDistortion = "setDistortion", 262 | SetKaraoke = "setKaraoke", 263 | SetRotation = "setRotation", 264 | SetTimescale = "setTimescale", 265 | Slowmo = "slowmo", 266 | Soft = "soft", 267 | TrebleBass = "trebleBass", 268 | Tremolo = "tremolo", 269 | TV = "tv", 270 | Vaporwave = "vaporwave", 271 | Vibrato = "vibrato", 272 | } 273 | 274 | /** 275 | * MagmaStream Error Codes Enum 276 | */ 277 | export enum MagmaStreamErrorCode { 278 | // GENERAL (1000) 279 | GENERAL_UNKNOWN = "MS_GENERAL_UNKNOWN", 280 | GENERAL_TIMEOUT = "MS_GENERAL_TIMEOUT", 281 | GENERAL_INVALID_MANAGER = "MS_GENERAL_INVALID_MANAGER", 282 | 283 | // MANAGER (1100) 284 | MANAGER_INIT_FAILED = "MS_MANAGER_INIT_FAILED", 285 | MANAGER_INVALID_CONFIG = "MS_MANAGER_INVALID_CONFIG", 286 | MANAGER_SHUTDOWN_FAILED = "MS_MANAGER_SHUTDOWN_FAILED", 287 | MANAGER_NO_NODES = "MS_MANAGER_NO_NODES", 288 | MANAGER_NODE_NOT_FOUND = "MS_MANAGER_NODE_NOT_FOUND", 289 | MANAGER_SEARCH_FAILED = "MS_MANAGER_SEARCH_FAILED", 290 | MANAGER_CLEANUP_INACTIVE_PLAYERS_FAILED = "MS_MANAGER_CLEANUP_INACTIVE_PLAYERS_FAILED", 291 | 292 | // NODE (1200) 293 | NODE_INVALID_CONFIG = "MS_NODE_INVALID_CONFIG", 294 | NODE_CONNECT_FAILED = "MS_NODE_CONNECT_FAILED", 295 | NODE_RECONNECT_FAILED = "MS_NODE_RECONNECT_FAILED", 296 | NODE_DISCONNECTED = "MS_NODE_DISCONNECTED", 297 | NODE_PROTOCOL_ERROR = "MS_NODE_PROTOCOL_ERROR", 298 | NODE_SESSION_IDS_LOAD_FAILED = "MS_NODE_SESSION_IDS_LOAD_FAILED", 299 | NODE_SESSION_IDS_UPDATE_FAILED = "MS_NODE_SESSION_IDS_UPDATE_FAILED", 300 | NODE_PLUGIN_ERROR = "MS_NODE_PLUGIN_ERROR", 301 | 302 | // PLAYER (1300) 303 | PLAYER_INVALID_CONFIG = "MS_PLAYER_INVALID_CONFIG", 304 | PLAYER_STATE_INVALID = "MS_PLAYER_STATE_INVALID", 305 | PLAYER_QUEUE_EMPTY = "MS_PLAYER_QUEUE_EMPTY", 306 | PLAYER_PREVIOUS_EMPTY = "MS_PLAYER_PREVIOUS_EMPTY", 307 | PLAYER_INVALID_NOW_PLAYING_MESSAGE = "MS_PLAYER_INVALID_NOW_PLAYING_MESSAGE", 308 | PLAYER_INVALID_AUTOPLAY = "MS_PLAYER_INVALID_AUTOPLAY", 309 | PLAYER_INVALID_VOLUME = "MS_PLAYER_INVALID_VOLUME", 310 | PLAYER_INVALID_REPEAT = "MS_PLAYER_INVALID_REPEAT", 311 | PLAYER_INVALID_PAUSE = "MS_PLAYER_INVALID_PAUSE", 312 | PLAYER_INVALID_SEEK = "MS_PLAYER_INVALID_SEEK", 313 | PLAYER_MOVE_FAILED = "MS_PLAYER_MOVE_FAILED", 314 | PLAYER_VOICE_RECEIVER_ERROR = "MS_PLAYER_VOICE_RECEIVER_ERROR", 315 | 316 | // QUEUE (1400) 317 | QUEUE_REDIS_ERROR = "MS_QUEUE_REDIS_ERROR", 318 | QUEUE_JSON_ERROR = "MS_QUEUE_JSON_ERROR", 319 | QUEUE_MEMORY_ERROR = "MS_QUEUE_MEMORY_ERROR", 320 | 321 | // FILTERS (1500) 322 | FILTER_APPLY_FAILED = "MS_FILTER_APPLY_FAILED", 323 | 324 | // REST (1600) 325 | REST_REQUEST_FAILED = "MS_REST_REQUEST_FAILED", 326 | REST_UNAUTHORIZED = "MS_REST_UNAUTHORIZED", 327 | 328 | // UTILS (1700) 329 | UTILS_TRACK_PARTIAL_INVALID = "MS_UTILS_TRACK_PARTIAL_INVALID", 330 | UTILS_TRACK_BUILD_FAILED = "MS_UTILS_TRACK_BUILD_FAILED", 331 | UTILS_AUTOPLAY_BUILD_FAILED = "MS_UTILS_AUTOPLAY_BUILD_FAILED", 332 | 333 | // PLUGIN (1800) 334 | PLUGIN_LOAD_FAILED = "MS_PLUGIN_LOAD_FAILED", 335 | PLUGIN_RUNTIME_ERROR = "MS_PLUGIN_RUNTIME_ERROR", 336 | } 337 | 338 | // Numeric mappings (secondary, machine-friendly) 339 | export const MagmaStreamErrorNumbers: Record = { 340 | // GENERAL 341 | [MagmaStreamErrorCode.GENERAL_UNKNOWN]: 1000, 342 | [MagmaStreamErrorCode.GENERAL_TIMEOUT]: 1001, 343 | [MagmaStreamErrorCode.GENERAL_INVALID_MANAGER]: 1002, 344 | 345 | // MANAGER 346 | [MagmaStreamErrorCode.MANAGER_INIT_FAILED]: 1100, 347 | [MagmaStreamErrorCode.MANAGER_INVALID_CONFIG]: 1101, 348 | [MagmaStreamErrorCode.MANAGER_SHUTDOWN_FAILED]: 1102, 349 | [MagmaStreamErrorCode.MANAGER_NO_NODES]: 1103, 350 | [MagmaStreamErrorCode.MANAGER_NODE_NOT_FOUND]: 1104, 351 | [MagmaStreamErrorCode.MANAGER_SEARCH_FAILED]: 1105, 352 | [MagmaStreamErrorCode.MANAGER_CLEANUP_INACTIVE_PLAYERS_FAILED]: 1106, 353 | 354 | // NODE 355 | [MagmaStreamErrorCode.NODE_INVALID_CONFIG]: 1200, 356 | [MagmaStreamErrorCode.NODE_CONNECT_FAILED]: 1201, 357 | [MagmaStreamErrorCode.NODE_RECONNECT_FAILED]: 1202, 358 | [MagmaStreamErrorCode.NODE_DISCONNECTED]: 1203, 359 | [MagmaStreamErrorCode.NODE_PROTOCOL_ERROR]: 1204, 360 | [MagmaStreamErrorCode.NODE_SESSION_IDS_LOAD_FAILED]: 1205, 361 | [MagmaStreamErrorCode.NODE_SESSION_IDS_UPDATE_FAILED]: 1206, 362 | [MagmaStreamErrorCode.NODE_PLUGIN_ERROR]: 1207, 363 | 364 | // PLAYER 365 | [MagmaStreamErrorCode.PLAYER_INVALID_CONFIG]: 1300, 366 | [MagmaStreamErrorCode.PLAYER_STATE_INVALID]: 1301, 367 | [MagmaStreamErrorCode.PLAYER_QUEUE_EMPTY]: 1302, 368 | [MagmaStreamErrorCode.PLAYER_PREVIOUS_EMPTY]: 1303, 369 | [MagmaStreamErrorCode.PLAYER_INVALID_NOW_PLAYING_MESSAGE]: 1304, 370 | [MagmaStreamErrorCode.PLAYER_INVALID_AUTOPLAY]: 1305, 371 | [MagmaStreamErrorCode.PLAYER_INVALID_VOLUME]: 1306, 372 | [MagmaStreamErrorCode.PLAYER_INVALID_REPEAT]: 1307, 373 | [MagmaStreamErrorCode.PLAYER_INVALID_PAUSE]: 1308, 374 | [MagmaStreamErrorCode.PLAYER_INVALID_SEEK]: 1309, 375 | [MagmaStreamErrorCode.PLAYER_MOVE_FAILED]: 1310, 376 | [MagmaStreamErrorCode.PLAYER_VOICE_RECEIVER_ERROR]: 1311, 377 | 378 | // QUEUE 379 | [MagmaStreamErrorCode.QUEUE_REDIS_ERROR]: 1400, 380 | [MagmaStreamErrorCode.QUEUE_JSON_ERROR]: 1401, 381 | [MagmaStreamErrorCode.QUEUE_MEMORY_ERROR]: 1402, 382 | 383 | // FILTERS 384 | [MagmaStreamErrorCode.FILTER_APPLY_FAILED]: 1500, 385 | 386 | // REST 387 | [MagmaStreamErrorCode.REST_REQUEST_FAILED]: 1600, 388 | [MagmaStreamErrorCode.REST_UNAUTHORIZED]: 1601, 389 | 390 | // UTILS 391 | [MagmaStreamErrorCode.UTILS_TRACK_PARTIAL_INVALID]: 1700, 392 | [MagmaStreamErrorCode.UTILS_TRACK_BUILD_FAILED]: 1701, 393 | [MagmaStreamErrorCode.UTILS_AUTOPLAY_BUILD_FAILED]: 1702, 394 | 395 | // PLUGIN 396 | [MagmaStreamErrorCode.PLUGIN_LOAD_FAILED]: 1800, 397 | [MagmaStreamErrorCode.PLUGIN_RUNTIME_ERROR]: 1801, 398 | }; 399 | -------------------------------------------------------------------------------- /src/statestorage/MemoryQueue.ts: -------------------------------------------------------------------------------- 1 | import { Manager } from "../structures/Manager"; 2 | import { MagmaStreamErrorCode, ManagerEventTypes, PlayerStateEventTypes } from "../structures/Enums"; 3 | import { AnyUser, IQueue, PlayerStateUpdateEvent, Track } from "../structures/Types"; 4 | import { JSONUtils } from "../structures/Utils"; 5 | import { MagmaStreamError } from "../structures/MagmastreamError"; 6 | 7 | /** 8 | * The player's queue, the `current` property is the currently playing track, think of the rest as the up-coming tracks. 9 | */ 10 | export class MemoryQueue extends Array implements IQueue { 11 | /** The current track */ 12 | public current: Track | null = null; 13 | 14 | /** The previous tracks */ 15 | public previous: Track[] = []; 16 | 17 | /** The Manager instance. */ 18 | public manager: Manager; 19 | 20 | /** The guild ID property. */ 21 | guildId: string; 22 | 23 | /** 24 | * Constructs a new Queue. 25 | * @param guildId The guild ID. 26 | * @param manager The Manager instance. 27 | */ 28 | constructor(guildId: string, manager: Manager) { 29 | super(); 30 | /** The Manager instance. */ 31 | this.manager = manager; 32 | /** The guild property. */ 33 | this.guildId = guildId; 34 | } 35 | 36 | // #region Public 37 | /** 38 | * Adds a track to the queue. 39 | * @param track The track or tracks to add. Can be a single `Track` or an array of `Track`s. 40 | * @param [offset=null] The position to add the track(s) at. If not provided, the track(s) will be added at the end of the queue. 41 | */ 42 | public async add(track: Track | Track[], offset?: number): Promise { 43 | try { 44 | const isArray = Array.isArray(track); 45 | const tracks = isArray ? [...track] : [track]; 46 | 47 | // Get the track info as a string 48 | const trackInfo = isArray ? tracks.map((t) => JSONUtils.safe(t, 2)).join(", ") : JSONUtils.safe(track, 2); 49 | 50 | // Emit a debug message 51 | this.manager.emit(ManagerEventTypes.Debug, `[MEMORYQUEUE] Added ${tracks.length} track(s) to queue: ${trackInfo}`); 52 | 53 | const oldPlayer = this.manager.players.get(this.guildId) ? { ...this.manager.players.get(this.guildId) } : null; 54 | 55 | // If the queue is empty, set the track as the current track 56 | if (!this.current) { 57 | if (isArray) { 58 | this.current = (tracks.shift() as Track) || null; 59 | this.push(...tracks); 60 | } else { 61 | this.current = track; 62 | } 63 | } else { 64 | // If an offset is provided, add the track(s) at that position 65 | if (typeof offset !== "undefined" && typeof offset === "number") { 66 | // Validate the offset 67 | if (isNaN(offset)) { 68 | throw new RangeError("Offset must be a number."); 69 | } 70 | 71 | // Make sure the offset is between 0 and the length of the queue 72 | if (offset < 0 || offset > this.length) { 73 | throw new RangeError(`Offset must be between 0 and ${this.length}.`); 74 | } 75 | 76 | // Add the track(s) at the offset position 77 | if (isArray) { 78 | this.splice(offset, 0, ...tracks); 79 | } else { 80 | this.splice(offset, 0, track); 81 | } 82 | } else { 83 | // If no offset is provided, add the track(s) at the end of the queue 84 | if (isArray) { 85 | this.push(...tracks); 86 | } else { 87 | this.push(track); 88 | } 89 | } 90 | } 91 | 92 | if (this.manager.players.has(this.guildId) && this.manager.players.get(this.guildId).isAutoplay) { 93 | if (!isArray) { 94 | const AutoplayUser = this.manager.players.get(this.guildId).get("Internal_AutoplayUser") as AnyUser; 95 | if (AutoplayUser && AutoplayUser.id === track.requester.id) { 96 | this.manager.emit(ManagerEventTypes.PlayerStateUpdate, oldPlayer, this.manager.players.get(this.guildId), { 97 | changeType: PlayerStateEventTypes.QueueChange, 98 | details: { 99 | type: "queue", 100 | action: "autoPlayAdd", 101 | tracks: [track], 102 | }, 103 | } as PlayerStateUpdateEvent); 104 | 105 | return; 106 | } 107 | } 108 | } 109 | // Emit a player state update event with the added track(s) 110 | this.manager.emit(ManagerEventTypes.PlayerStateUpdate, oldPlayer, this.manager.players.get(this.guildId), { 111 | changeType: PlayerStateEventTypes.QueueChange, 112 | details: { 113 | type: "queue", 114 | action: "add", 115 | tracks: isArray ? tracks : [track], 116 | }, 117 | } as PlayerStateUpdateEvent); 118 | } catch (err) { 119 | const error = 120 | err instanceof MagmaStreamError 121 | ? err 122 | : new MagmaStreamError({ 123 | code: MagmaStreamErrorCode.QUEUE_MEMORY_ERROR, 124 | message: `Failed to add tracks to queue for guild ${this.guildId}: ${(err as Error).message}`, 125 | cause: err, 126 | }); 127 | 128 | console.error(error); 129 | } 130 | } 131 | 132 | /** 133 | * Adds a track to the previous tracks. 134 | * @param track The track or tracks to add. Can be a single `Track` or an array of `Track`s. 135 | */ 136 | public async addPrevious(track: Track | Track[]): Promise { 137 | try { 138 | const max = this.manager.options.maxPreviousTracks; 139 | 140 | if (Array.isArray(track)) { 141 | const newTracks = track.filter((t) => !this.previous.some((p) => p.identifier === t.identifier)); 142 | this.previous.push(...newTracks); 143 | } else { 144 | const exists = this.previous.some((p) => p.identifier === track.identifier); 145 | if (!exists) { 146 | this.previous.push(track); 147 | } 148 | } 149 | 150 | if (this.previous.length > max) { 151 | this.previous = this.previous.slice(-max); 152 | } 153 | } catch (err) { 154 | const error = 155 | err instanceof MagmaStreamError 156 | ? err 157 | : new MagmaStreamError({ 158 | code: MagmaStreamErrorCode.QUEUE_MEMORY_ERROR, 159 | message: `Failed to add tracks to previous tracks for guild ${this.guildId}: ${(err as Error).message}`, 160 | cause: err, 161 | }); 162 | 163 | console.error(error); 164 | } 165 | } 166 | 167 | /** 168 | * Clears the queue. 169 | * This will remove all tracks from the queue and emit a state update event. 170 | */ 171 | public async clear(): Promise { 172 | try { 173 | // Capture the current state of the player for event emission. 174 | const oldPlayer = this.manager.players.get(this.guildId) ? { ...this.manager.players.get(this.guildId) } : null; 175 | 176 | // Remove all items from the queue. 177 | this.splice(0); 178 | 179 | // Emit an event to update the player state indicating the queue has been cleared. 180 | this.manager.emit(ManagerEventTypes.PlayerStateUpdate, oldPlayer, this.manager.players.get(this.guildId), { 181 | changeType: PlayerStateEventTypes.QueueChange, 182 | details: { 183 | type: "queue", 184 | action: "clear", 185 | tracks: [], // No tracks are left after clearing 186 | }, 187 | } as PlayerStateUpdateEvent); 188 | 189 | // Emit a debug message indicating the queue has been cleared for a specific guild ID. 190 | this.manager.emit(ManagerEventTypes.Debug, `[MEMORYQUEUE] Cleared the queue for: ${this.guildId}`); 191 | } catch (err) { 192 | const error = 193 | err instanceof MagmaStreamError 194 | ? err 195 | : new MagmaStreamError({ 196 | code: MagmaStreamErrorCode.QUEUE_MEMORY_ERROR, 197 | message: `Failed to clear queue for guild ${this.guildId}: ${(err as Error).message}`, 198 | cause: err, 199 | }); 200 | 201 | console.error(error); 202 | } 203 | } 204 | 205 | /** 206 | * Clears the previous tracks. 207 | */ 208 | public async clearPrevious(): Promise { 209 | this.previous = []; 210 | } 211 | 212 | /** 213 | * Removes the first element from the queue. 214 | */ 215 | public async dequeue(): Promise { 216 | return super.shift(); 217 | } 218 | 219 | /** 220 | * The total duration of the queue in milliseconds. 221 | * This includes the duration of the currently playing track. 222 | */ 223 | public async duration(): Promise { 224 | const current = this.current?.duration ?? 0; 225 | return this.reduce((acc, cur) => acc + (cur.duration || 0), current); 226 | } 227 | 228 | /** 229 | * Adds the specified track or tracks to the front of the queue. 230 | * @param track The track or tracks to add. 231 | */ 232 | public async enqueueFront(track: Track | Track[]): Promise { 233 | if (Array.isArray(track)) { 234 | this.unshift(...track); 235 | } else { 236 | this.unshift(track); 237 | } 238 | } 239 | 240 | /** 241 | * @returns Whether all elements in the queue satisfy the provided testing function. 242 | */ 243 | public async everyAsync(callback: (track: Track, index: number, array: Track[]) => boolean): Promise { 244 | return this.every(callback); 245 | } 246 | 247 | /** 248 | * @returns A new array with all elements that pass the test implemented by the provided function. 249 | */ 250 | public async filterAsync(callback: (track: Track, index: number, array: Track[]) => boolean): Promise { 251 | return this.filter(callback); 252 | } 253 | 254 | /** 255 | * @returns The first element in the queue that satisfies the provided testing function. 256 | */ 257 | public async findAsync(callback: (track: Track, index: number, array: Track[]) => boolean): Promise { 258 | return this.find(callback); 259 | } 260 | 261 | /** 262 | * @returns The current track. 263 | */ 264 | public async getCurrent(): Promise { 265 | return this.current; 266 | } 267 | 268 | /** 269 | * @returns The previous tracks. 270 | */ 271 | public async getPrevious(): Promise { 272 | return [...this.previous]; 273 | } 274 | 275 | /** 276 | * @returns The tracks in the queue from start to end. 277 | */ 278 | public async getSlice(start?: number, end?: number): Promise { 279 | return this.slice(start, end); // Native sync method, still wrapped in a Promise 280 | } 281 | 282 | /** 283 | * @returns The tracks in the queue. 284 | */ 285 | public async getTracks(): Promise { 286 | return [...this]; // clone to avoid direct mutation 287 | } 288 | 289 | /** 290 | * @returns A new array with the results of calling a provided function on every element in the queue. 291 | */ 292 | public async mapAsync(callback: (track: Track, index: number, array: Track[]) => T): Promise { 293 | return this.map(callback); 294 | } 295 | 296 | /** 297 | * Modifies the queue at the specified index. 298 | * @param start The index at which to start modifying the queue. 299 | * @param deleteCount The number of elements to remove from the queue. 300 | * @param items The elements to add to the queue. 301 | * @returns The modified queue. 302 | */ 303 | public async modifyAt(start: number, deleteCount = 0, ...items: Track[]): Promise { 304 | return super.splice(start, deleteCount, ...items); 305 | } 306 | 307 | /** 308 | * @returns The newest track. 309 | */ 310 | public async popPrevious(): Promise { 311 | return this.previous.pop() || null; // get newest track 312 | } 313 | 314 | /** 315 | * Removes track(s) from the queue. 316 | * @param startOrPosition If a single number is provided, it will be treated as the position of the track to remove. 317 | * If two numbers are provided, they will be used as the start and end of a range of tracks to remove. 318 | * @param end Optional, end of the range of tracks to remove. 319 | * @returns The removed track(s). 320 | */ 321 | public async remove(position?: number): Promise; 322 | public async remove(start: number, end: number): Promise; 323 | public async remove(startOrPosition = 0, end?: number): Promise { 324 | try { 325 | const oldPlayer = this.manager.players.get(this.guildId) ? { ...this.manager.players.get(this.guildId) } : null; 326 | 327 | if (typeof end !== "undefined") { 328 | // Validate input for `start` and `end` 329 | if (isNaN(Number(startOrPosition)) || isNaN(Number(end))) { 330 | throw new RangeError(`Invalid "start" or "end" parameter: start = ${startOrPosition}, end = ${end}`); 331 | } 332 | 333 | if (startOrPosition >= end || startOrPosition >= this.length) { 334 | throw new RangeError("Invalid range: start should be less than end and within queue length."); 335 | } 336 | 337 | const removedTracks = this.splice(startOrPosition, end - startOrPosition); 338 | this.manager.emit( 339 | ManagerEventTypes.Debug, 340 | `[MEMORYQUEUE] Removed ${removedTracks.length} track(s) from player: ${this.guildId} from position ${startOrPosition} to ${end}.` 341 | ); 342 | 343 | this.manager.emit(ManagerEventTypes.PlayerStateUpdate, oldPlayer, this.manager.players.get(this.guildId), { 344 | changeType: PlayerStateEventTypes.QueueChange, 345 | details: { 346 | type: "queue", 347 | action: "remove", 348 | tracks: removedTracks, 349 | }, 350 | } as PlayerStateUpdateEvent); 351 | 352 | return removedTracks; 353 | } 354 | 355 | // Single item removal when no end specified 356 | const removedTrack = this.splice(startOrPosition, 1); 357 | this.manager.emit( 358 | ManagerEventTypes.Debug, 359 | `[MEMORYQUEUE] Removed 1 track from player: ${this.guildId} from position ${startOrPosition}: ${JSONUtils.safe(removedTrack[0], 2)}` 360 | ); 361 | 362 | // Ensure removedTrack is an array for consistency 363 | const tracksToEmit = removedTrack.length > 0 ? removedTrack : []; 364 | 365 | this.manager.emit(ManagerEventTypes.PlayerStateUpdate, oldPlayer, this.manager.players.get(this.guildId), { 366 | changeType: PlayerStateEventTypes.QueueChange, 367 | details: { 368 | type: "queue", 369 | action: "remove", 370 | tracks: tracksToEmit, 371 | }, 372 | } as PlayerStateUpdateEvent); 373 | 374 | return removedTrack; 375 | } catch (err) { 376 | const error = 377 | err instanceof MagmaStreamError 378 | ? err 379 | : new MagmaStreamError({ 380 | code: MagmaStreamErrorCode.QUEUE_MEMORY_ERROR, 381 | message: `Failed to remove track(s) from queue for guild ${this.guildId}: ${(err as Error).message}`, 382 | cause: err, 383 | }); 384 | 385 | console.error(error); 386 | } 387 | } 388 | 389 | /** 390 | * Shuffles the queue to play tracks requested by each user one by one. 391 | */ 392 | public async roundRobinShuffle(): Promise { 393 | try { 394 | // Capture the current state of the player for event emission. 395 | const oldPlayer = this.manager.players.get(this.guildId) ? { ...this.manager.players.get(this.guildId) } : null; 396 | 397 | // Group the tracks in the queue by the user that requested them. 398 | const userTracks = new Map>(); 399 | 400 | // Group the tracks in the queue by the user that requested them. 401 | this.forEach((track) => { 402 | const user = track.requester.id; 403 | 404 | if (!userTracks.has(user)) { 405 | userTracks.set(user, []); 406 | } 407 | 408 | userTracks.get(user).push(track); 409 | }); 410 | 411 | // Shuffle the tracks of each user. 412 | userTracks.forEach((tracks) => { 413 | for (let i = tracks.length - 1; i > 0; i--) { 414 | const j = Math.floor(Math.random() * (i + 1)); 415 | [tracks[i], tracks[j]] = [tracks[j], tracks[i]]; 416 | } 417 | }); 418 | 419 | // Create a new array for the shuffled queue. 420 | const shuffledQueue: Array = []; 421 | 422 | // Add the shuffled tracks to the queue in a round-robin fashion. 423 | const users = Array.from(userTracks.keys()); 424 | const userQueues = users.map((user) => userTracks.get(user)!); 425 | const userCount = users.length; 426 | 427 | while (userQueues.some((queue) => queue.length > 0)) { 428 | for (let i = 0; i < userCount; i++) { 429 | const queue = userQueues[i]; 430 | if (queue.length > 0) { 431 | shuffledQueue.push(queue.shift()!); 432 | } 433 | } 434 | } 435 | 436 | // Clear the queue and add the shuffled tracks. 437 | this.splice(0); 438 | this.add(shuffledQueue); 439 | 440 | // Emit an event to update the player state indicating the queue has been shuffled. 441 | this.manager.emit(ManagerEventTypes.PlayerStateUpdate, oldPlayer, this.manager.players.get(this.guildId), { 442 | changeType: PlayerStateEventTypes.QueueChange, 443 | details: { 444 | type: "queue", 445 | action: "roundRobin", 446 | }, 447 | } as PlayerStateUpdateEvent); 448 | 449 | // Emit a debug message indicating the queue has been shuffled for a specific guild ID. 450 | this.manager.emit(ManagerEventTypes.Debug, `[MEMORYQUEUE] roundRobinShuffled the queue for: ${this.guildId}`); 451 | } catch (err) { 452 | const error = 453 | err instanceof MagmaStreamError 454 | ? err 455 | : new MagmaStreamError({ 456 | code: MagmaStreamErrorCode.QUEUE_MEMORY_ERROR, 457 | message: `Failed to shuffle queue for guild ${this.guildId}: ${(err as Error).message}`, 458 | cause: err, 459 | }); 460 | 461 | console.error(error); 462 | } 463 | } 464 | 465 | /** 466 | * @param track The track to set. 467 | */ 468 | public async setCurrent(track: Track | null): Promise { 469 | this.current = track; 470 | } 471 | 472 | /** 473 | * @param tracks The tracks to set. 474 | */ 475 | public async setPrevious(tracks: Track[]): Promise { 476 | this.previous = [...tracks]; 477 | } 478 | 479 | /** 480 | * Shuffles the queue. 481 | * This will randomize the order of the tracks in the queue and emit a state update event. 482 | */ 483 | public async shuffle(): Promise { 484 | try { 485 | // Capture the current state of the player for event emission. 486 | const oldPlayer = this.manager.players.get(this.guildId) ? { ...this.manager.players.get(this.guildId) } : null; 487 | 488 | // Shuffle the queue. 489 | for (let i = this.length - 1; i > 0; i--) { 490 | const j = Math.floor(Math.random() * (i + 1)); 491 | [this[i], this[j]] = [this[j], this[i]]; 492 | } 493 | 494 | // Emit an event to update the player state indicating the queue has been shuffled. 495 | this.manager.emit(ManagerEventTypes.PlayerStateUpdate, oldPlayer, this.manager.players.get(this.guildId), { 496 | changeType: PlayerStateEventTypes.QueueChange, 497 | details: { 498 | type: "queue", 499 | action: "shuffle", 500 | }, 501 | } as PlayerStateUpdateEvent); 502 | 503 | // Emit a debug message indicating the queue has been shuffled for a specific guild ID. 504 | this.manager.emit(ManagerEventTypes.Debug, `[MEMORYQUEUE] Shuffled the queue for: ${this.guildId}`); 505 | } catch (err) { 506 | const error = 507 | err instanceof MagmaStreamError 508 | ? err 509 | : new MagmaStreamError({ 510 | code: MagmaStreamErrorCode.QUEUE_MEMORY_ERROR, 511 | message: `Failed to shuffle queue for guild ${this.guildId}: ${(err as Error).message}`, 512 | cause: err, 513 | }); 514 | 515 | console.error(error); 516 | } 517 | } 518 | 519 | /** 520 | * The size of tracks in the queue. 521 | * This does not include the currently playing track. 522 | * @returns The size of tracks in the queue. 523 | */ 524 | public async size(): Promise { 525 | return this.length; 526 | } 527 | 528 | /** 529 | * @returns Whether at least one element in the queue satisfies the provided testing function. 530 | */ 531 | public async someAsync(callback: (track: Track, index: number, array: Track[]) => boolean): Promise { 532 | return this.some(callback); 533 | } 534 | 535 | /** 536 | * The total size of tracks in the queue including the current track. 537 | * This includes the current track if it is not null. 538 | * @returns The total size of tracks in the queue including the current track. 539 | */ 540 | public async totalSize(): Promise { 541 | return this.length + (this.current ? 1 : 0); 542 | } 543 | 544 | /** 545 | * Shuffles the queue to play tracks requested by each user one block at a time. 546 | */ 547 | public async userBlockShuffle(): Promise { 548 | try { 549 | // Capture the current state of the player for event emission. 550 | const oldPlayer = this.manager.players.get(this.guildId) ? { ...this.manager.players.get(this.guildId) } : null; 551 | 552 | // Group the tracks in the queue by the user that requested them. 553 | const userTracks = new Map>(); 554 | this.forEach((track) => { 555 | const user = track.requester.id; 556 | 557 | if (!userTracks.has(user)) { 558 | userTracks.set(user, []); 559 | } 560 | 561 | userTracks.get(user).push(track); 562 | }); 563 | 564 | // Create a new array for the shuffled queue. 565 | const shuffledQueue: Array = []; 566 | 567 | // Iterate over the user tracks and add one track from each user to the shuffled queue. 568 | // This will ensure that all the tracks requested by each user are played in a block order. 569 | while (shuffledQueue.length < this.length) { 570 | userTracks.forEach((tracks) => { 571 | const track = tracks.shift(); 572 | if (track) { 573 | shuffledQueue.push(track); 574 | } 575 | }); 576 | } 577 | 578 | // Clear the queue and add the shuffled tracks. 579 | this.splice(0); 580 | this.add(shuffledQueue); 581 | 582 | // Emit an event to update the player state indicating the queue has been shuffled. 583 | this.manager.emit(ManagerEventTypes.PlayerStateUpdate, oldPlayer, this.manager.players.get(this.guildId), { 584 | changeType: PlayerStateEventTypes.QueueChange, 585 | details: { 586 | type: "queue", 587 | action: "userBlock", 588 | }, 589 | } as PlayerStateUpdateEvent); 590 | 591 | // Emit a debug message indicating the queue has been shuffled for a specific guild ID. 592 | this.manager.emit(ManagerEventTypes.Debug, `[MEMORYQUEUE] userBlockShuffled the queue for: ${this.guildId}`); 593 | } catch (err) { 594 | const error = 595 | err instanceof MagmaStreamError 596 | ? err 597 | : new MagmaStreamError({ 598 | code: MagmaStreamErrorCode.QUEUE_MEMORY_ERROR, 599 | message: `Failed to add tracks to queue for guild ${this.guildId}: ${(err as Error).message}`, 600 | cause: err, 601 | }); 602 | 603 | console.error(error); 604 | } 605 | } 606 | // #endregion Public 607 | // #region Private 608 | // #endregion Private 609 | // #region Protected 610 | // #endregion Protected 611 | } 612 | -------------------------------------------------------------------------------- /src/statestorage/JsonQueue.ts: -------------------------------------------------------------------------------- 1 | import { Manager } from "../structures/Manager"; 2 | import { MagmaStreamErrorCode, ManagerEventTypes, PlayerStateEventTypes } from "../structures/Enums"; 3 | import { AnyUser, IQueue, PlayerStateUpdateEvent, Track } from "../structures/Types"; 4 | import path from "path"; 5 | import { promises as fs } from "fs"; 6 | import { JSONUtils, TrackUtils } from "../structures/Utils"; 7 | import { MagmaStreamError } from "../structures/MagmastreamError"; 8 | 9 | /** 10 | * The player's queue, the `current` property is the currently playing track, think of the rest as the up-coming tracks. 11 | */ 12 | export class JsonQueue implements IQueue { 13 | /** 14 | * The base path for the queue files. 15 | */ 16 | private basePath: string; 17 | 18 | /** 19 | * @param guildId The guild ID. 20 | * @param manager The manager. 21 | */ 22 | constructor(public readonly guildId: string, public readonly manager: Manager) { 23 | const base = manager.options.stateStorage?.jsonConfig?.path ?? path.join(process.cwd(), "magmastream", "sessionData", "players"); 24 | 25 | this.basePath = path.join(base, this.guildId); 26 | } 27 | 28 | // #region Public 29 | /** 30 | * @param track The track or tracks to add. Can be a single `Track` or an array of `Track`s. 31 | * @param [offset=null] The position to add the track(s) at. If not provided, the track(s) will be added at the end of the queue. 32 | */ 33 | public async add(track: Track | Track[], offset?: number): Promise { 34 | try { 35 | const isArray = Array.isArray(track); 36 | const inputTracks = isArray ? track : [track]; 37 | const tracks = [...inputTracks]; 38 | 39 | const queue = await this.getQueue(); 40 | 41 | const oldPlayer = this.manager.players.get(this.guildId) ? { ...this.manager.players.get(this.guildId) } : null; 42 | 43 | // Set first track as current if none is active 44 | if (!(await this.getCurrent())) { 45 | const current = tracks.shift(); 46 | if (current) { 47 | await this.setCurrent(current); 48 | } 49 | } 50 | 51 | if (typeof offset === "number" && !isNaN(offset)) { 52 | queue.splice(offset, 0, ...tracks); 53 | } else { 54 | queue.push(...tracks); 55 | } 56 | 57 | await this.setQueue(queue); 58 | 59 | this.manager.emit(ManagerEventTypes.Debug, `[JSONQUEUE] Added ${tracks.length} track(s) to queue`); 60 | 61 | if (this.manager.players.has(this.guildId) && this.manager.players.get(this.guildId).isAutoplay) { 62 | if (!isArray) { 63 | const AutoplayUser = (await this.manager.players.get(this.guildId).get("Internal_AutoplayUser")) as AnyUser; 64 | if (AutoplayUser && AutoplayUser.id === track.requester.id) { 65 | this.manager.emit(ManagerEventTypes.PlayerStateUpdate, oldPlayer, this.manager.players.get(this.guildId), { 66 | changeType: PlayerStateEventTypes.QueueChange, 67 | details: { 68 | type: "queue", 69 | action: "autoPlayAdd", 70 | tracks: [track], 71 | }, 72 | } as PlayerStateUpdateEvent); 73 | return; 74 | } 75 | } 76 | } 77 | 78 | this.manager.emit(ManagerEventTypes.PlayerStateUpdate, oldPlayer, this.manager.players.get(this.guildId), { 79 | changeType: PlayerStateEventTypes.QueueChange, 80 | details: { 81 | type: "queue", 82 | action: "add", 83 | tracks, 84 | }, 85 | } as PlayerStateUpdateEvent); 86 | } catch (err) { 87 | const error = 88 | err instanceof MagmaStreamError 89 | ? err 90 | : new MagmaStreamError({ 91 | code: MagmaStreamErrorCode.QUEUE_JSON_ERROR, 92 | message: `Failed to add tracks to JSON queue for guild ${this.guildId}: ${(err as Error).message}`, 93 | cause: err, 94 | }); 95 | 96 | console.error(error); 97 | } 98 | } 99 | 100 | /** 101 | * @param track The track to add. 102 | */ 103 | public async addPrevious(track: Track | Track[]): Promise { 104 | try { 105 | const max = this.manager.options.maxPreviousTracks; 106 | const tracks = Array.isArray(track) ? track : [track]; 107 | if (!tracks.length) return; 108 | 109 | const current = await this.getPrevious(); 110 | 111 | const newTracks = tracks.filter((t) => !current.some((p) => p.uri === t.uri)); 112 | if (!newTracks.length) return; 113 | 114 | const updated = [...current, ...newTracks]; 115 | 116 | const trimmed = updated.slice(-max); 117 | 118 | await this.writeJSON(this.previousPath, trimmed); 119 | } catch (err) { 120 | const error = 121 | err instanceof MagmaStreamError 122 | ? err 123 | : new MagmaStreamError({ 124 | code: MagmaStreamErrorCode.QUEUE_JSON_ERROR, 125 | message: `Failed to add tracks to JSON queue for guild ${this.guildId}: ${(err as Error).message}`, 126 | cause: err, 127 | }); 128 | 129 | console.error(error); 130 | } 131 | } 132 | 133 | /** 134 | * Clears the queue. 135 | */ 136 | public async clear(): Promise { 137 | try { 138 | const oldPlayer = this.manager.players.get(this.guildId) ? { ...this.manager.players.get(this.guildId) } : null; 139 | await this.deleteFile(this.queuePath); 140 | 141 | this.manager.emit(ManagerEventTypes.PlayerStateUpdate, oldPlayer, this.manager.players.get(this.guildId), { 142 | changeType: PlayerStateEventTypes.QueueChange, 143 | details: { 144 | type: "queue", 145 | action: "clear", 146 | tracks: [], 147 | }, 148 | } as PlayerStateUpdateEvent); 149 | 150 | this.manager.emit(ManagerEventTypes.Debug, `[JSONQUEUE] Cleared the queue for: ${this.guildId}`); 151 | } catch (err) { 152 | const error = 153 | err instanceof MagmaStreamError 154 | ? err 155 | : new MagmaStreamError({ 156 | code: MagmaStreamErrorCode.QUEUE_JSON_ERROR, 157 | message: `Failed to clear JSON queue for guild ${this.guildId}: ${(err as Error).message}`, 158 | cause: err, 159 | }); 160 | 161 | console.error(error); 162 | } 163 | } 164 | 165 | /** 166 | * Clears the previous tracks. 167 | */ 168 | public async clearPrevious(): Promise { 169 | await this.deleteFile(this.previousPath); 170 | } 171 | 172 | /** 173 | * Removes the first track from the queue. 174 | */ 175 | public async dequeue(): Promise { 176 | try { 177 | const queue = await this.getQueue(); 178 | const track = queue.shift(); 179 | await this.setQueue(queue); 180 | return track; 181 | } catch (err) { 182 | const error = 183 | err instanceof MagmaStreamError 184 | ? err 185 | : new MagmaStreamError({ 186 | code: MagmaStreamErrorCode.QUEUE_JSON_ERROR, 187 | message: `Failed to dequeue track for guild ${this.guildId}: ${(err as Error).message}`, 188 | cause: err, 189 | }); 190 | 191 | console.error(error); 192 | } 193 | } 194 | 195 | /** 196 | * @returns The total duration of the queue. 197 | */ 198 | public async duration(): Promise { 199 | try { 200 | const queue = await this.getQueue(); 201 | const current = await this.getCurrent(); 202 | const currentDuration = current?.duration || 0; 203 | 204 | const total = queue.reduce((acc, track) => acc + (track.duration || 0), currentDuration); 205 | return total; 206 | } catch (err) { 207 | const error = 208 | err instanceof MagmaStreamError 209 | ? err 210 | : new MagmaStreamError({ 211 | code: MagmaStreamErrorCode.QUEUE_JSON_ERROR, 212 | message: `Failed to get duration for guild ${this.guildId}: ${(err as Error).message}`, 213 | cause: err, 214 | }); 215 | 216 | console.error(error); 217 | } 218 | } 219 | 220 | /** 221 | * Adds a track to the front of the queue. 222 | */ 223 | public async enqueueFront(track: Track | Track[]): Promise { 224 | try { 225 | const tracks = Array.isArray(track) ? track : [track]; 226 | const queue = await this.getQueue(); 227 | await this.setQueue([...tracks.reverse(), ...queue]); 228 | } catch (err) { 229 | const error = 230 | err instanceof MagmaStreamError 231 | ? err 232 | : new MagmaStreamError({ 233 | code: MagmaStreamErrorCode.QUEUE_JSON_ERROR, 234 | message: `Failed to enqueue front track for guild ${this.guildId}: ${(err as Error).message}`, 235 | cause: err, 236 | }); 237 | 238 | console.error(error); 239 | } 240 | } 241 | 242 | /** 243 | * Tests whether all elements in the queue pass the test implemented by the provided function. 244 | */ 245 | public async everyAsync(callback: (track: Track, index: number, array: Track[]) => boolean): Promise { 246 | const queue = await this.getQueue(); 247 | return queue.every(callback); 248 | } 249 | 250 | /** 251 | * Filters the queue. 252 | */ 253 | public async filterAsync(callback: (track: Track, index: number, array: Track[]) => boolean): Promise { 254 | const queue = await this.getQueue(); 255 | return queue.filter(callback); 256 | } 257 | 258 | /** 259 | * Finds the first track in the queue that satisfies the provided testing function. 260 | */ 261 | public async findAsync(callback: (track: Track, index: number, array: Track[]) => boolean): Promise { 262 | const queue = await this.getQueue(); 263 | return queue.find(callback); 264 | } 265 | 266 | /** 267 | * @returns The current track. 268 | */ 269 | public async getCurrent(): Promise { 270 | const track = await this.readJSON(this.currentPath); 271 | return track ? TrackUtils.revive(track) : null; 272 | } 273 | 274 | /** 275 | * @returns The previous tracks. 276 | */ 277 | public async getPrevious(): Promise { 278 | const data = await this.readJSON(this.previousPath); 279 | return Array.isArray(data) ? data.map(TrackUtils.revive) : []; 280 | } 281 | 282 | /** 283 | * @returns The tracks in the queue from start to end. 284 | */ 285 | public async getSlice(start = 0, end = -1): Promise { 286 | const queue = await this.getQueue(); 287 | if (end === -1) return queue.slice(start); 288 | return queue.slice(start, end); 289 | } 290 | 291 | /** 292 | * @returns The tracks in the queue. 293 | */ 294 | public async getTracks(): Promise { 295 | return await this.getQueue(); 296 | } 297 | 298 | /** 299 | * Maps the queue to a new array. 300 | */ 301 | public async mapAsync(callback: (track: Track, index: number, array: Track[]) => T): Promise { 302 | const queue = await this.getQueue(); 303 | return queue.map(callback); 304 | } 305 | 306 | /** 307 | * Modifies the queue at the specified index. 308 | */ 309 | public async modifyAt(start: number, deleteCount = 0, ...items: Track[]): Promise { 310 | const queue = await this.getQueue(); 311 | 312 | const removed = queue.splice(start, deleteCount, ...items); 313 | 314 | await this.setQueue(queue); 315 | return removed; 316 | } 317 | 318 | /** 319 | * @returns The newest track. 320 | */ 321 | public async popPrevious(): Promise { 322 | try { 323 | const current = await this.getPrevious(); 324 | if (!current.length) return null; 325 | 326 | const popped = current.pop()!; 327 | await this.writeJSON(this.previousPath, current); 328 | return popped; 329 | } catch (err) { 330 | const error = 331 | err instanceof MagmaStreamError 332 | ? err 333 | : new MagmaStreamError({ 334 | code: MagmaStreamErrorCode.QUEUE_JSON_ERROR, 335 | message: `Failed to pop previous track for guild ${this.guildId}: ${(err as Error).message}`, 336 | cause: err, 337 | }); 338 | 339 | console.error(error); 340 | } 341 | } 342 | 343 | /** 344 | * Removes a track from the queue. 345 | * @param position The position to remove the track at. 346 | * @param end The end position to remove the track at. 347 | */ 348 | public async remove(position?: number): Promise; 349 | public async remove(start: number, end: number): Promise; 350 | public async remove(startOrPos = 0, end?: number): Promise { 351 | try { 352 | const oldPlayer = this.manager.players.get(this.guildId) ? { ...this.manager.players.get(this.guildId) } : null; 353 | 354 | const queue = await this.getQueue(); 355 | let removed: Track[] = []; 356 | 357 | if (typeof end === "number") { 358 | if (startOrPos >= end || startOrPos >= queue.length) throw new RangeError("Invalid range."); 359 | removed = queue.splice(startOrPos, end - startOrPos); 360 | } else { 361 | removed = queue.splice(startOrPos, 1); 362 | } 363 | 364 | await this.setQueue(queue); 365 | 366 | this.manager.emit(ManagerEventTypes.Debug, `[JSONQUEUE] Removed ${removed.length} track(s) from position ${startOrPos}${end ? ` to ${end}` : ""}`); 367 | this.manager.emit(ManagerEventTypes.PlayerStateUpdate, oldPlayer, this.manager.players.get(this.guildId), { 368 | changeType: PlayerStateEventTypes.QueueChange, 369 | details: { 370 | type: "queue", 371 | action: "remove", 372 | tracks: removed, 373 | }, 374 | } as PlayerStateUpdateEvent); 375 | 376 | return removed; 377 | } catch (err) { 378 | const error = 379 | err instanceof MagmaStreamError 380 | ? err 381 | : new MagmaStreamError({ 382 | code: MagmaStreamErrorCode.QUEUE_JSON_ERROR, 383 | message: `Failed to remove track for guild ${this.guildId}: ${(err as Error).message}`, 384 | cause: err, 385 | }); 386 | 387 | console.error(error); 388 | } 389 | } 390 | 391 | /** 392 | * Shuffles the queue by round-robin. 393 | */ 394 | public async roundRobinShuffle(): Promise { 395 | try { 396 | const oldPlayer = this.manager.players.get(this.guildId) ? { ...this.manager.players.get(this.guildId) } : null; 397 | 398 | const queue = await this.getQueue(); 399 | 400 | const userMap = new Map(); 401 | for (const track of queue) { 402 | const userId = track.requester.id; 403 | if (!userMap.has(userId)) userMap.set(userId, []); 404 | userMap.get(userId)!.push(track); 405 | } 406 | 407 | // Shuffle each user's tracks 408 | for (const tracks of userMap.values()) { 409 | for (let i = tracks.length - 1; i > 0; i--) { 410 | const j = Math.floor(Math.random() * (i + 1)); 411 | [tracks[i], tracks[j]] = [tracks[j], tracks[i]]; 412 | } 413 | } 414 | 415 | const users = [...userMap.keys()]; 416 | const queues = users.map((id) => userMap.get(id)!); 417 | const shuffledQueue: Track[] = []; 418 | 419 | while (queues.some((q) => q.length > 0)) { 420 | for (const q of queues) { 421 | const track = q.shift(); 422 | if (track) shuffledQueue.push(track); 423 | } 424 | } 425 | 426 | await this.setQueue(shuffledQueue); 427 | 428 | this.manager.emit(ManagerEventTypes.PlayerStateUpdate, oldPlayer, this.manager.players.get(this.guildId), { 429 | changeType: PlayerStateEventTypes.QueueChange, 430 | details: { 431 | type: "queue", 432 | action: "roundRobin", 433 | }, 434 | } as PlayerStateUpdateEvent); 435 | 436 | this.manager.emit(ManagerEventTypes.Debug, `[JSONQUEUE] roundRobinShuffled the queue for: ${this.guildId}`); 437 | } catch (err) { 438 | const error = 439 | err instanceof MagmaStreamError 440 | ? err 441 | : new MagmaStreamError({ 442 | code: MagmaStreamErrorCode.QUEUE_JSON_ERROR, 443 | message: `Failed to round robin shuffle queue for guild ${this.guildId}: ${(err as Error).message}`, 444 | cause: err, 445 | }); 446 | 447 | console.error(error); 448 | } 449 | } 450 | 451 | /** 452 | * @param track The track to set. 453 | */ 454 | public async setCurrent(track: Track | null): Promise { 455 | if (track) { 456 | await this.writeJSON(this.currentPath, track); 457 | } else { 458 | await this.deleteFile(this.currentPath); 459 | } 460 | } 461 | 462 | /** 463 | * @param track The track to set. 464 | */ 465 | public async setPrevious(track: Track | Track[]): Promise { 466 | const tracks = Array.isArray(track) ? track : [track]; 467 | if (!tracks.length) return; 468 | 469 | await this.writeJSON(this.previousPath, tracks); 470 | } 471 | 472 | /** 473 | * Shuffles the queue. 474 | */ 475 | public async shuffle(): Promise { 476 | try { 477 | const oldPlayer = this.manager.players.get(this.guildId) ? { ...this.manager.players.get(this.guildId) } : null; 478 | 479 | const queue = await this.getQueue(); 480 | for (let i = queue.length - 1; i > 0; i--) { 481 | const j = Math.floor(Math.random() * (i + 1)); 482 | [queue[i], queue[j]] = [queue[j], queue[i]]; 483 | } 484 | 485 | await this.setQueue(queue); 486 | 487 | this.manager.emit(ManagerEventTypes.PlayerStateUpdate, oldPlayer, this.manager.players.get(this.guildId), { 488 | changeType: PlayerStateEventTypes.QueueChange, 489 | details: { 490 | type: "queue", 491 | action: "shuffle", 492 | }, 493 | } as PlayerStateUpdateEvent); 494 | 495 | this.manager.emit(ManagerEventTypes.Debug, `[JSONQUEUE] Shuffled the queue for: ${this.guildId}`); 496 | } catch (err) { 497 | const error = 498 | err instanceof MagmaStreamError 499 | ? err 500 | : new MagmaStreamError({ 501 | code: MagmaStreamErrorCode.QUEUE_JSON_ERROR, 502 | message: `Failed to shuffle queue for guild ${this.guildId}: ${(err as Error).message}`, 503 | cause: err, 504 | }); 505 | 506 | console.error(error); 507 | } 508 | } 509 | 510 | /** 511 | * @returns The size of the queue. 512 | */ 513 | public async size(): Promise { 514 | const queue = await this.getQueue(); 515 | return queue.length; 516 | } 517 | 518 | /** 519 | * Tests whether at least one element in the queue passes the test implemented by the provided function. 520 | */ 521 | public async someAsync(callback: (track: Track, index: number, array: Track[]) => boolean): Promise { 522 | const queue = await this.getQueue(); 523 | return queue.some(callback); 524 | } 525 | 526 | /** 527 | * @returns The total size of the queue. 528 | */ 529 | public async totalSize(): Promise { 530 | const size = await this.size(); 531 | return (await this.getCurrent()) ? size + 1 : size; 532 | } 533 | 534 | /** 535 | * Shuffles the queue by user. 536 | */ 537 | public async userBlockShuffle(): Promise { 538 | try { 539 | const oldPlayer = this.manager.players.get(this.guildId) ? { ...this.manager.players.get(this.guildId) } : null; 540 | 541 | const queue = await this.getQueue(); 542 | 543 | const userMap = new Map(); 544 | for (const track of queue) { 545 | const userId = track.requester.id; 546 | if (!userMap.has(userId)) userMap.set(userId, []); 547 | userMap.get(userId)!.push(track); 548 | } 549 | 550 | const shuffledQueue: Track[] = []; 551 | while (shuffledQueue.length < queue.length) { 552 | for (const [, tracks] of userMap) { 553 | const track = tracks.shift(); 554 | if (track) shuffledQueue.push(track); 555 | } 556 | } 557 | 558 | await this.setQueue(shuffledQueue); 559 | 560 | this.manager.emit(ManagerEventTypes.PlayerStateUpdate, oldPlayer, this.manager.players.get(this.guildId), { 561 | changeType: PlayerStateEventTypes.QueueChange, 562 | details: { 563 | type: "queue", 564 | action: "userBlock", 565 | }, 566 | } as PlayerStateUpdateEvent); 567 | 568 | this.manager.emit(ManagerEventTypes.Debug, `[JSONQUEUE] userBlockShuffled the queue for: ${this.guildId}`); 569 | } catch (err) { 570 | const error = 571 | err instanceof MagmaStreamError 572 | ? err 573 | : new MagmaStreamError({ 574 | code: MagmaStreamErrorCode.QUEUE_JSON_ERROR, 575 | message: `Failed to user block shuffle queue for guild ${this.guildId}: ${(err as Error).message}`, 576 | cause: err, 577 | }); 578 | 579 | console.error(error); 580 | } 581 | } 582 | // #endregion Public 583 | // #region Private 584 | /** 585 | * @returns The current path. 586 | */ 587 | private get currentPath(): string { 588 | return path.join(this.basePath, "current.json"); 589 | } 590 | 591 | /** 592 | * @param filePath The file path. 593 | */ 594 | private async deleteFile(filePath: string): Promise { 595 | try { 596 | await fs.unlink(filePath); 597 | } catch (err) { 598 | if (err.code !== "ENOENT") { 599 | const error = 600 | err instanceof MagmaStreamError 601 | ? err 602 | : new MagmaStreamError({ 603 | code: MagmaStreamErrorCode.QUEUE_JSON_ERROR, 604 | message: `Failed to delete file: ${filePath}`, 605 | cause: err, 606 | }); 607 | 608 | console.error(error); 609 | this.manager.emit(ManagerEventTypes.Debug, `[JSONQUEUE] Failed to delete file: ${filePath}`); 610 | } 611 | } 612 | } 613 | 614 | /** 615 | * Ensures the directory exists. 616 | */ 617 | private async ensureDir(): Promise { 618 | await fs.mkdir(this.basePath, { recursive: true }); 619 | } 620 | 621 | /** 622 | * @returns The queue. 623 | */ 624 | private async getQueue(): Promise { 625 | const data = await this.readJSON(this.queuePath); 626 | return Array.isArray(data) ? data.map(TrackUtils.revive) : []; 627 | } 628 | 629 | /** 630 | * @returns The previous path. 631 | */ 632 | private get previousPath(): string { 633 | return path.join(this.basePath, "previous.json"); 634 | } 635 | 636 | /** 637 | * @returns The queue path. 638 | */ 639 | private get queuePath(): string { 640 | return path.join(this.basePath, "queue.json"); 641 | } 642 | 643 | /** 644 | * @param filePath The file path. 645 | * @returns The JSON data. 646 | */ 647 | private async readJSON(filePath: string): Promise { 648 | try { 649 | const raw = await fs.readFile(filePath, "utf-8"); 650 | if (!raw || !raw.trim()) return null; 651 | return JSON.parse(raw); 652 | } catch (err) { 653 | if (err.code !== "ENOENT") { 654 | const error = 655 | err instanceof MagmaStreamError 656 | ? err 657 | : new MagmaStreamError({ 658 | code: MagmaStreamErrorCode.QUEUE_JSON_ERROR, 659 | message: `Failed to read file: ${filePath}`, 660 | cause: err, 661 | }); 662 | 663 | console.error(error); 664 | } 665 | return null; 666 | } 667 | } 668 | 669 | /** 670 | * @param queue The queue. 671 | */ 672 | private async setQueue(queue: Track[]): Promise { 673 | await this.deleteFile(this.queuePath); 674 | await this.writeJSON(this.queuePath, queue); 675 | } 676 | 677 | /** 678 | * @param filePath The file path. 679 | * @param data The data to write. 680 | */ 681 | private async writeJSON(filePath: string, data: T): Promise { 682 | await this.ensureDir(); 683 | await fs.writeFile(filePath, JSONUtils.safe(data, 2), "utf-8"); 684 | } 685 | // #endregion Private 686 | // #region Protected 687 | // #endregion Protected 688 | } 689 | -------------------------------------------------------------------------------- /src/statestorage/RedisQueue.ts: -------------------------------------------------------------------------------- 1 | import { Manager } from "../structures/Manager"; 2 | import { Redis } from "ioredis"; 3 | import { MagmaStreamErrorCode, ManagerEventTypes, PlayerStateEventTypes } from "../structures/Enums"; 4 | import { AnyUser, IQueue, PlayerStateUpdateEvent, Track } from "../structures/Types"; 5 | import { JSONUtils, TrackUtils } from "../structures/Utils"; 6 | import { MagmaStreamError } from "../structures/MagmastreamError"; 7 | 8 | /** 9 | * The player's queue, the `current` property is the currently playing track, think of the rest as the up-coming tracks. 10 | */ 11 | export class RedisQueue implements IQueue { 12 | /** 13 | * The prefix for the Redis keys. 14 | */ 15 | public redisPrefix: string; 16 | /** 17 | * The Redis instance. 18 | */ 19 | private redis: Redis; 20 | 21 | /** 22 | * Constructs a new RedisQueue. 23 | * @param guildId The guild ID. 24 | * @param manager The Manager instance. 25 | */ 26 | constructor(public readonly guildId: string, public readonly manager: Manager) { 27 | this.redis = manager.redis; 28 | const rawPrefix = manager.options.stateStorage.redisConfig.prefix; 29 | let clean = typeof rawPrefix === "string" ? rawPrefix.trim() : ""; 30 | if (!clean.endsWith(":")) clean = clean || "magmastream"; 31 | this.redisPrefix = `${clean}:`; 32 | } 33 | 34 | // #region Public 35 | /** 36 | * Adds a track or tracks to the queue. 37 | * @param track The track or tracks to add. Can be a single `Track` or an array of `Track`s. 38 | * @param [offset=null] The position to add the track(s) at. If not provided, the track(s) will be added at the end of the queue. 39 | */ 40 | public async add(track: Track | Track[], offset?: number): Promise { 41 | try { 42 | const isArray = Array.isArray(track); 43 | const tracks = isArray ? track : [track]; 44 | 45 | // Serialize tracks 46 | const serialized = tracks.map((t) => this.serialize(t)); 47 | 48 | const oldPlayer = this.manager.players.get(this.guildId) ? { ...this.manager.players.get(this.guildId) } : null; 49 | 50 | // Set current track if none exists 51 | if (!(await this.getCurrent())) { 52 | const current = serialized.shift(); 53 | if (current) { 54 | await this.setCurrent(this.deserialize(current)); 55 | } 56 | } 57 | 58 | // Insert at offset or append 59 | try { 60 | if (typeof offset === "number" && !isNaN(offset)) { 61 | const queue = await this.redis.lrange(this.queueKey, 0, -1); 62 | queue.splice(offset, 0, ...serialized); 63 | await this.redis.del(this.queueKey); 64 | if (queue.length > 0) { 65 | await this.redis.rpush(this.queueKey, ...queue); 66 | } 67 | } else if (serialized.length > 0) { 68 | await this.redis.rpush(this.queueKey, ...serialized); 69 | } 70 | } catch (err) { 71 | const error = 72 | err instanceof MagmaStreamError 73 | ? err 74 | : new MagmaStreamError({ 75 | code: MagmaStreamErrorCode.QUEUE_REDIS_ERROR, 76 | message: `Failed to add tracks to Redis queue for guild ${this.guildId}: ${(err as Error).message}`, 77 | cause: err, 78 | }); 79 | 80 | console.error(error); 81 | } 82 | 83 | this.manager.emit(ManagerEventTypes.Debug, `[REDISQUEUE] Added ${tracks.length} track(s) to queue`); 84 | 85 | // Autoplay logic 86 | if (this.manager.players.has(this.guildId) && this.manager.players.get(this.guildId).isAutoplay) { 87 | if (!Array.isArray(track)) { 88 | const AutoplayUser = (await this.manager.players.get(this.guildId).get("Internal_AutoplayUser")) as AnyUser; 89 | if (AutoplayUser && AutoplayUser.id === track.requester.id) { 90 | this.manager.emit(ManagerEventTypes.PlayerStateUpdate, oldPlayer, this.manager.players.get(this.guildId), { 91 | changeType: PlayerStateEventTypes.QueueChange, 92 | details: { 93 | type: "queue", 94 | action: "autoPlayAdd", 95 | tracks: [track], 96 | }, 97 | } as PlayerStateUpdateEvent); 98 | 99 | return; 100 | } 101 | } 102 | } 103 | 104 | this.manager.emit(ManagerEventTypes.PlayerStateUpdate, oldPlayer, this.manager.players.get(this.guildId), { 105 | changeType: PlayerStateEventTypes.QueueChange, 106 | details: { 107 | type: "queue", 108 | action: "add", 109 | tracks, 110 | }, 111 | } as PlayerStateUpdateEvent); 112 | } catch (err) { 113 | const error = 114 | err instanceof MagmaStreamError 115 | ? err 116 | : new MagmaStreamError({ 117 | code: MagmaStreamErrorCode.QUEUE_REDIS_ERROR, 118 | message: `Unexpected error in add() for guild ${this.guildId}: ${(err as Error).message}`, 119 | cause: err, 120 | }); 121 | 122 | console.error(error); 123 | } 124 | } 125 | 126 | /** 127 | * Adds a track or tracks to the previous tracks. 128 | * @param track The track or tracks to add. 129 | */ 130 | public async addPrevious(track: Track | Track[]): Promise { 131 | try { 132 | const tracks = Array.isArray(track) ? track : [track]; 133 | if (!tracks.length) { 134 | throw new MagmaStreamError({ 135 | code: MagmaStreamErrorCode.QUEUE_REDIS_ERROR, 136 | message: `No tracks provided for addPrevious in guild ${this.guildId}`, 137 | }); 138 | } 139 | 140 | const serialized = tracks.map(this.serialize); 141 | 142 | try { 143 | // Push newest to TAIL 144 | await this.redis.rpush(this.previousKey, ...serialized); 145 | 146 | // Keep only the most recent maxPreviousTracks (trim from HEAD) 147 | const max = this.manager.options.maxPreviousTracks; 148 | await this.redis.ltrim(this.previousKey, -max, -1); 149 | } catch (err) { 150 | const error = 151 | err instanceof MagmaStreamError 152 | ? err 153 | : new MagmaStreamError({ 154 | code: MagmaStreamErrorCode.QUEUE_REDIS_ERROR, 155 | message: `Failed to add previous tracks to Redis for guild ${this.guildId}: ${(err as Error).message}`, 156 | cause: err, 157 | }); 158 | 159 | console.error(error); 160 | } 161 | } catch (err) { 162 | const error = 163 | err instanceof MagmaStreamError 164 | ? err 165 | : new MagmaStreamError({ 166 | code: MagmaStreamErrorCode.QUEUE_REDIS_ERROR, 167 | message: `Unexpected error in addPrevious() for guild ${this.guildId}: ${(err as Error).message}`, 168 | cause: err, 169 | }); 170 | 171 | console.error(error); 172 | } 173 | } 174 | 175 | /** 176 | * Clears the queue. 177 | */ 178 | public async clear(): Promise { 179 | const oldPlayer = this.manager.players.get(this.guildId) ? { ...this.manager.players.get(this.guildId) } : null; 180 | 181 | try { 182 | await this.redis.del(this.queueKey); 183 | } catch (err) { 184 | const error = 185 | err instanceof MagmaStreamError 186 | ? err 187 | : new MagmaStreamError({ 188 | code: MagmaStreamErrorCode.QUEUE_REDIS_ERROR, 189 | message: `Failed to clear queue for guild ${this.guildId}: ${(err as Error).message}`, 190 | cause: err, 191 | }); 192 | 193 | console.error(error); 194 | } 195 | 196 | this.manager.emit(ManagerEventTypes.PlayerStateUpdate, oldPlayer, this.manager.players.get(this.guildId), { 197 | changeType: PlayerStateEventTypes.QueueChange, 198 | details: { 199 | type: "queue", 200 | action: "clear", 201 | tracks: [], 202 | }, 203 | } as PlayerStateUpdateEvent); 204 | 205 | this.manager.emit(ManagerEventTypes.Debug, `[REDISQUEUE] Cleared the queue for: ${this.guildId}`); 206 | } 207 | 208 | /** 209 | * Clears the previous tracks. 210 | */ 211 | public async clearPrevious(): Promise { 212 | try { 213 | await this.redis.del(this.previousKey); 214 | } catch (err) { 215 | const error = 216 | err instanceof MagmaStreamError 217 | ? err 218 | : new MagmaStreamError({ 219 | code: MagmaStreamErrorCode.QUEUE_REDIS_ERROR, 220 | message: `Failed to clear previous tracks for guild ${this.guildId}: ${(err as Error).message}`, 221 | cause: err, 222 | }); 223 | 224 | console.error(error); 225 | } 226 | } 227 | 228 | /** 229 | * Removes the first track from the queue. 230 | */ 231 | public async dequeue(): Promise { 232 | try { 233 | const raw = await this.redis.lpop(this.queueKey); 234 | return raw ? this.deserialize(raw) : undefined; 235 | } catch (err) { 236 | const error = 237 | err instanceof MagmaStreamError 238 | ? err 239 | : new MagmaStreamError({ 240 | code: MagmaStreamErrorCode.QUEUE_REDIS_ERROR, 241 | message: `Failed to dequeue track for guild ${this.guildId}: ${(err as Error).message}`, 242 | cause: err, 243 | }); 244 | 245 | console.error(error); 246 | } 247 | } 248 | 249 | /** 250 | * @returns The total duration of the queue in milliseconds. 251 | * This includes the duration of the currently playing track. 252 | */ 253 | public async duration(): Promise { 254 | try { 255 | const tracks = await this.redis.lrange(this.queueKey, 0, -1); 256 | const currentDuration = (await this.getCurrent())?.duration || 0; 257 | 258 | const total = tracks.reduce((acc, raw) => { 259 | try { 260 | const parsed = this.deserialize(raw); 261 | return acc + (parsed.duration || 0); 262 | } catch (err) { 263 | // Skip invalid tracks but log 264 | this.manager.emit( 265 | ManagerEventTypes.Debug, 266 | `[REDISQUEUE] Skipping invalid track during duration calculation for guild ${this.guildId}: ${(err as Error).message}` 267 | ); 268 | return acc; 269 | } 270 | }, currentDuration); 271 | 272 | return total; 273 | } catch (err) { 274 | const error = 275 | err instanceof MagmaStreamError 276 | ? err 277 | : new MagmaStreamError({ 278 | code: MagmaStreamErrorCode.QUEUE_REDIS_ERROR, 279 | message: `Failed to calculate total queue duration for guild ${this.guildId}: ${(err as Error).message}`, 280 | cause: err, 281 | }); 282 | 283 | console.error(error); 284 | } 285 | } 286 | 287 | /** 288 | * Adds a track to the front of the queue. 289 | * @param track The track or tracks to add. 290 | */ 291 | public async enqueueFront(track: Track | Track[]): Promise { 292 | try { 293 | const serialized = Array.isArray(track) ? track.map(this.serialize) : [this.serialize(track)]; 294 | 295 | // Redis: LPUSH adds to front, reverse to maintain order if multiple tracks 296 | await this.redis.lpush(this.queueKey, ...serialized.reverse()); 297 | } catch (err) { 298 | const error = 299 | err instanceof MagmaStreamError 300 | ? err 301 | : new MagmaStreamError({ 302 | code: MagmaStreamErrorCode.QUEUE_REDIS_ERROR, 303 | message: `Failed to enqueue track to front for guild ${this.guildId}: ${(err as Error).message}`, 304 | cause: err, 305 | }); 306 | 307 | console.error(error); 308 | } 309 | } 310 | 311 | /** 312 | * Whether all tracks in the queue match the specified condition. 313 | * @param callback The condition to match. 314 | * @returns Whether all tracks in the queue match the specified condition. 315 | */ 316 | public async everyAsync(callback: (track: Track, index: number, array: Track[]) => boolean): Promise { 317 | const tracks = await this.getTracks(); 318 | return tracks.every(callback); 319 | } 320 | 321 | /** 322 | * Filters the tracks in the queue. 323 | * @param callback The condition to match. 324 | * @returns The tracks that match the condition. 325 | */ 326 | public async filterAsync(callback: (track: Track, index: number, array: Track[]) => boolean): Promise { 327 | const tracks = await this.getTracks(); 328 | return tracks.filter(callback); 329 | } 330 | 331 | /** 332 | * Finds the first track in the queue that matches the specified condition. 333 | * @param callback The condition to match. 334 | * @returns The first track that matches the condition. 335 | */ 336 | public async findAsync(callback: (track: Track, index: number, array: Track[]) => boolean): Promise { 337 | const tracks = await this.getTracks(); 338 | return tracks.find(callback); 339 | } 340 | 341 | /** 342 | * @returns The current track. 343 | */ 344 | public async getCurrent(): Promise { 345 | try { 346 | const raw = await this.redis.get(this.currentKey); 347 | return raw ? this.deserialize(raw) : null; 348 | } catch (err) { 349 | const error = 350 | err instanceof MagmaStreamError 351 | ? err 352 | : new MagmaStreamError({ 353 | code: MagmaStreamErrorCode.QUEUE_REDIS_ERROR, 354 | message: `Failed to get current track for guild ${this.guildId}: ${(err as Error).message}`, 355 | cause: err, 356 | }); 357 | 358 | console.error(error); 359 | } 360 | } 361 | 362 | /** 363 | * @returns The previous tracks. 364 | */ 365 | public async getPrevious(): Promise { 366 | try { 367 | const raw = await this.redis.lrange(this.previousKey, 0, -1); 368 | return raw.map(this.deserialize); 369 | } catch (err) { 370 | const error = 371 | err instanceof MagmaStreamError 372 | ? err 373 | : new MagmaStreamError({ 374 | code: MagmaStreamErrorCode.QUEUE_REDIS_ERROR, 375 | message: `Failed to get previous tracks for guild ${this.guildId}: ${(err as Error).message}`, 376 | cause: err, 377 | }); 378 | 379 | console.error(error); 380 | } 381 | } 382 | 383 | /** 384 | * @returns The tracks in the queue from the start to the end. 385 | */ 386 | public async getSlice(start = 0, end = -1): Promise { 387 | try { 388 | const raw = await this.redis.lrange(this.queueKey, start, end === -1 ? -1 : end - 1); 389 | return raw.map(this.deserialize); 390 | } catch (err) { 391 | const error = 392 | err instanceof MagmaStreamError 393 | ? err 394 | : new MagmaStreamError({ 395 | code: MagmaStreamErrorCode.QUEUE_REDIS_ERROR, 396 | message: `Failed to get slice of queue for guild ${this.guildId}: ${(err as Error).message}`, 397 | cause: err, 398 | }); 399 | 400 | console.error(error); 401 | } 402 | } 403 | 404 | /** 405 | * @returns The tracks in the queue. 406 | */ 407 | public async getTracks(): Promise { 408 | try { 409 | const raw = await this.redis.lrange(this.queueKey, 0, -1); 410 | return raw.map(this.deserialize); 411 | } catch (err) { 412 | const error = 413 | err instanceof MagmaStreamError 414 | ? err 415 | : new MagmaStreamError({ 416 | code: MagmaStreamErrorCode.QUEUE_REDIS_ERROR, 417 | message: `Failed to get tracks for guild ${this.guildId}: ${(err as Error).message}`, 418 | cause: err, 419 | }); 420 | 421 | console.error(error); 422 | } 423 | } 424 | 425 | /** 426 | * Maps the tracks in the queue. 427 | * @returns The tracks in the queue after the specified index. 428 | */ 429 | public async mapAsync(callback: (track: Track, index: number, array: Track[]) => T): Promise { 430 | const tracks = await this.getTracks(); // same as lrange + deserialize 431 | return tracks.map(callback); 432 | } 433 | 434 | /** 435 | * Modifies the queue at the specified index. 436 | * @param start The start index. 437 | * @param deleteCount The number of tracks to delete. 438 | * @param items The tracks to insert. 439 | * @returns The removed tracks. 440 | */ 441 | public async modifyAt(start: number, deleteCount = 0, ...items: Track[]): Promise { 442 | try { 443 | const queue = await this.redis.lrange(this.queueKey, 0, -1); 444 | 445 | const removed = queue.splice(start, deleteCount, ...items.map(this.serialize)); 446 | 447 | await this.redis.del(this.queueKey); 448 | 449 | if (queue.length > 0) { 450 | await this.redis.rpush(this.queueKey, ...queue); 451 | } 452 | return removed.map(this.deserialize); 453 | } catch (err) { 454 | const error = 455 | err instanceof MagmaStreamError 456 | ? err 457 | : new MagmaStreamError({ 458 | code: MagmaStreamErrorCode.QUEUE_REDIS_ERROR, 459 | message: `Failed to modify queue at index ${start} for guild ${this.guildId}: ${(err as Error).message}`, 460 | cause: err, 461 | }); 462 | 463 | console.error(error); 464 | } 465 | } 466 | 467 | /** 468 | * Removes the newest track. 469 | * @returns The newest track. 470 | */ 471 | public async popPrevious(): Promise { 472 | try { 473 | // Pop the newest track from the TAIL 474 | const raw = await this.redis.rpop(this.previousKey); 475 | return raw ? this.deserialize(raw) : null; 476 | } catch (err) { 477 | const error = 478 | err instanceof MagmaStreamError 479 | ? err 480 | : new MagmaStreamError({ 481 | code: MagmaStreamErrorCode.QUEUE_REDIS_ERROR, 482 | message: `Failed to pop previous track for guild ${this.guildId}: ${(err as Error).message}`, 483 | cause: err, 484 | }); 485 | 486 | console.error(error); 487 | } 488 | } 489 | 490 | /** 491 | * Removes the track at the specified index. 492 | * @param position The position to remove the track at. 493 | * @param end The end position to remove the track at. 494 | */ 495 | public async remove(position?: number): Promise; 496 | public async remove(start: number, end: number): Promise; 497 | public async remove(startOrPos = 0, end?: number): Promise { 498 | try { 499 | const oldPlayer = this.manager.players.get(this.guildId) ? { ...this.manager.players.get(this.guildId) } : null; 500 | 501 | const queue = await this.redis.lrange(this.queueKey, 0, -1); 502 | 503 | let removed: string[] = []; 504 | 505 | if (typeof end === "number") { 506 | if (startOrPos >= end || startOrPos >= queue.length) { 507 | throw new RangeError("Invalid range."); 508 | } 509 | removed = queue.slice(startOrPos, end); 510 | queue.splice(startOrPos, end - startOrPos); 511 | } else { 512 | removed = queue.splice(startOrPos, 1); 513 | } 514 | 515 | await this.redis.del(this.queueKey); 516 | if (queue.length > 0) { 517 | await this.redis.rpush(this.queueKey, ...queue); 518 | } 519 | 520 | const deserialized = removed.map(this.deserialize); 521 | 522 | this.manager.emit(ManagerEventTypes.Debug, `[REDISQUEUE] Removed ${removed.length} track(s) from position ${startOrPos}${end ? ` to ${end}` : ""}`); 523 | 524 | this.manager.emit(ManagerEventTypes.PlayerStateUpdate, oldPlayer, this.manager.players.get(this.guildId), { 525 | changeType: PlayerStateEventTypes.QueueChange, 526 | details: { 527 | type: "queue", 528 | action: "remove", 529 | tracks: deserialized, 530 | }, 531 | } as PlayerStateUpdateEvent); 532 | 533 | return deserialized; 534 | } catch (err) { 535 | const error = 536 | err instanceof MagmaStreamError 537 | ? err 538 | : new MagmaStreamError({ 539 | code: MagmaStreamErrorCode.QUEUE_REDIS_ERROR, 540 | message: `Failed to remove track for guild ${this.guildId}: ${(err as Error).message}`, 541 | cause: err, 542 | }); 543 | 544 | console.error(error); 545 | } 546 | } 547 | 548 | /** 549 | * Shuffles the queue round-robin style. 550 | */ 551 | public async roundRobinShuffle(): Promise { 552 | try { 553 | const oldPlayer = this.manager.players.get(this.guildId) ? { ...this.manager.players.get(this.guildId) } : null; 554 | 555 | const rawTracks = await this.redis.lrange(this.queueKey, 0, -1); 556 | const deserialized = rawTracks.map(this.deserialize); 557 | 558 | const userMap = new Map(); 559 | for (const track of deserialized) { 560 | const userId = track.requester.id; 561 | if (!userMap.has(userId)) userMap.set(userId, []); 562 | userMap.get(userId)!.push(track); 563 | } 564 | 565 | // Shuffle each user's tracks 566 | for (const tracks of userMap.values()) { 567 | for (let i = tracks.length - 1; i > 0; i--) { 568 | const j = Math.floor(Math.random() * (i + 1)); 569 | [tracks[i], tracks[j]] = [tracks[j], tracks[i]]; 570 | } 571 | } 572 | 573 | const users = [...userMap.keys()]; 574 | const queues = users.map((id) => userMap.get(id)!); 575 | const shuffledQueue: Track[] = []; 576 | 577 | while (queues.some((q) => q.length > 0)) { 578 | for (const q of queues) { 579 | const track = q.shift(); 580 | if (track) shuffledQueue.push(track); 581 | } 582 | } 583 | 584 | await this.redis.del(this.queueKey); 585 | await this.redis.rpush(this.queueKey, ...shuffledQueue.map(this.serialize)); 586 | 587 | this.manager.emit(ManagerEventTypes.PlayerStateUpdate, oldPlayer, this.manager.players.get(this.guildId), { 588 | changeType: PlayerStateEventTypes.QueueChange, 589 | details: { 590 | type: "queue", 591 | action: "roundRobin", 592 | }, 593 | } as PlayerStateUpdateEvent); 594 | 595 | this.manager.emit(ManagerEventTypes.Debug, `[REDISQUEUE] roundRobinShuffled the queue for: ${this.guildId}`); 596 | } catch (err) { 597 | const error = 598 | err instanceof MagmaStreamError 599 | ? err 600 | : new MagmaStreamError({ 601 | code: MagmaStreamErrorCode.QUEUE_REDIS_ERROR, 602 | message: `Failed to roundRobinShuffle the queue for guild ${this.guildId}: ${(err as Error).message}`, 603 | cause: err, 604 | }); 605 | 606 | console.error(error); 607 | } 608 | } 609 | 610 | /** 611 | * Sets the current track. 612 | * @param track The track to set. 613 | */ 614 | public async setCurrent(track: Track | null): Promise { 615 | try { 616 | if (track) { 617 | await this.redis.set(this.currentKey, this.serialize(track)); 618 | } else { 619 | await this.redis.del(this.currentKey); 620 | } 621 | } catch (err) { 622 | const error = 623 | err instanceof MagmaStreamError 624 | ? err 625 | : new MagmaStreamError({ 626 | code: MagmaStreamErrorCode.QUEUE_REDIS_ERROR, 627 | message: `Failed to setCurrent the queue for guild ${this.guildId}: ${(err as Error).message}`, 628 | cause: err, 629 | }); 630 | 631 | console.error(error); 632 | } 633 | } 634 | 635 | /** 636 | * Sets the previous track(s). 637 | * @param track The track to set. 638 | */ 639 | public async setPrevious(track: Track | Track[]): Promise { 640 | try { 641 | const tracks = Array.isArray(track) ? track : [track]; 642 | if (!tracks.length) return; 643 | 644 | await this.redis 645 | .multi() 646 | .del(this.previousKey) 647 | .rpush(this.previousKey, ...tracks.map(this.serialize)) 648 | .exec(); 649 | } catch (err) { 650 | const error = 651 | err instanceof MagmaStreamError 652 | ? err 653 | : new MagmaStreamError({ 654 | code: MagmaStreamErrorCode.QUEUE_REDIS_ERROR, 655 | message: `Failed to setPrevious the queue for guild ${this.guildId}: ${(err as Error).message}`, 656 | cause: err, 657 | }); 658 | 659 | console.error(error); 660 | } 661 | } 662 | 663 | /** 664 | * Shuffles the queue. 665 | */ 666 | public async shuffle(): Promise { 667 | try { 668 | const oldPlayer = this.manager.players.get(this.guildId) ? { ...this.manager.players.get(this.guildId) } : null; 669 | 670 | const queue = await this.redis.lrange(this.queueKey, 0, -1); 671 | for (let i = queue.length - 1; i > 0; i--) { 672 | const j = Math.floor(Math.random() * (i + 1)); 673 | [queue[i], queue[j]] = [queue[j], queue[i]]; 674 | } 675 | 676 | await this.redis.del(this.queueKey); 677 | if (queue.length > 0) { 678 | await this.redis.rpush(this.queueKey, ...queue); 679 | } 680 | 681 | this.manager.emit(ManagerEventTypes.PlayerStateUpdate, oldPlayer, this.manager.players.get(this.guildId), { 682 | changeType: PlayerStateEventTypes.QueueChange, 683 | details: { 684 | type: "queue", 685 | action: "shuffle", 686 | }, 687 | } as PlayerStateUpdateEvent); 688 | 689 | this.manager.emit(ManagerEventTypes.Debug, `[REDISQUEUE] Shuffled the queue for: ${this.guildId}`); 690 | } catch (err) { 691 | const error = 692 | err instanceof MagmaStreamError 693 | ? err 694 | : new MagmaStreamError({ 695 | code: MagmaStreamErrorCode.QUEUE_REDIS_ERROR, 696 | message: `Failed to shuffle the queue for guild ${this.guildId}: ${(err as Error).message}`, 697 | cause: err, 698 | }); 699 | 700 | console.error(error); 701 | } 702 | } 703 | 704 | /** 705 | * @returns The size of the queue. 706 | */ 707 | public async size(): Promise { 708 | try { 709 | return await this.redis.llen(this.queueKey); 710 | } catch (err) { 711 | const error = 712 | err instanceof MagmaStreamError 713 | ? err 714 | : new MagmaStreamError({ 715 | code: MagmaStreamErrorCode.QUEUE_REDIS_ERROR, 716 | message: `Failed to get the size of the queue for guild ${this.guildId}: ${(err as Error).message}`, 717 | cause: err, 718 | }); 719 | 720 | console.error(error); 721 | } 722 | } 723 | 724 | /** 725 | * @returns Whether any tracks in the queue match the specified condition. 726 | */ 727 | public async someAsync(callback: (track: Track, index: number, array: Track[]) => boolean): Promise { 728 | const tracks = await this.getTracks(); 729 | return tracks.some(callback); 730 | } 731 | 732 | /** 733 | * @returns The total size of tracks in the queue including the current track. 734 | */ 735 | public async totalSize(): Promise { 736 | const size = await this.size(); 737 | return (await this.getCurrent()) ? size + 1 : size; 738 | } 739 | 740 | /** 741 | * Shuffles the queue, but keeps the tracks of the same user together. 742 | */ 743 | public async userBlockShuffle(): Promise { 744 | try { 745 | const oldPlayer = this.manager.players.get(this.guildId) ? { ...this.manager.players.get(this.guildId) } : null; 746 | 747 | const rawTracks = await this.redis.lrange(this.queueKey, 0, -1); 748 | const deserialized = rawTracks.map(this.deserialize); 749 | 750 | const userMap = new Map(); 751 | for (const track of deserialized) { 752 | const userId = track.requester.id; 753 | if (!userMap.has(userId)) userMap.set(userId, []); 754 | userMap.get(userId)!.push(track); 755 | } 756 | 757 | const shuffledQueue: Track[] = []; 758 | while (shuffledQueue.length < deserialized.length) { 759 | for (const [, tracks] of userMap) { 760 | const track = tracks.shift(); 761 | if (track) shuffledQueue.push(track); 762 | } 763 | } 764 | 765 | await this.redis.del(this.queueKey); 766 | await this.redis.rpush(this.queueKey, ...shuffledQueue.map(this.serialize)); 767 | 768 | this.manager.emit(ManagerEventTypes.PlayerStateUpdate, oldPlayer, this.manager.players.get(this.guildId), { 769 | changeType: PlayerStateEventTypes.QueueChange, 770 | details: { 771 | type: "queue", 772 | action: "userBlock", 773 | }, 774 | } as PlayerStateUpdateEvent); 775 | 776 | this.manager.emit(ManagerEventTypes.Debug, `[REDISQUEUE] userBlockShuffled the queue for: ${this.guildId}`); 777 | } catch (err) { 778 | const error = 779 | err instanceof MagmaStreamError 780 | ? err 781 | : new MagmaStreamError({ 782 | code: MagmaStreamErrorCode.QUEUE_REDIS_ERROR, 783 | message: `Failed to userBlockShuffle the queue for guild ${this.guildId}: ${(err as Error).message}`, 784 | cause: err, 785 | }); 786 | 787 | console.error(error); 788 | } 789 | } 790 | // #endregion Public 791 | // #region Private 792 | /** 793 | * @returns The current key. 794 | */ 795 | private get currentKey(): string { 796 | return `${this.redisPrefix}queue:${this.guildId}:current`; 797 | } 798 | 799 | /** 800 | * Deserializes a track from a string. 801 | */ 802 | private deserialize(data: string): Track { 803 | const track = JSON.parse(data) as Track; 804 | return TrackUtils.revive(track); 805 | } 806 | 807 | /** 808 | * @returns The previous key. 809 | */ 810 | private get previousKey(): string { 811 | return `${this.redisPrefix}queue:${this.guildId}:previous`; 812 | } 813 | 814 | /** 815 | * @returns The queue key. 816 | */ 817 | private get queueKey(): string { 818 | return `${this.redisPrefix}queue:${this.guildId}:tracks`; 819 | } 820 | 821 | /** 822 | * Helper to serialize/deserialize Track 823 | */ 824 | private serialize(track: Track): string { 825 | // return JSONUtils.serializeTrack(track); 826 | return JSONUtils.safe(track, 2); 827 | } 828 | // #endregion Private 829 | // #region Protected 830 | // #endregion Protected 831 | } 832 | -------------------------------------------------------------------------------- /src/structures/Filters.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Band, 3 | bassBoostEqualizer, 4 | electronicEqualizer, 5 | popEqualizer, 6 | radioEqualizer, 7 | softEqualizer, 8 | trebleBassEqualizer, 9 | tvEqualizer, 10 | vaporwaveEqualizer, 11 | demonEqualizer, 12 | } from "../utils/filtersEqualizers"; 13 | import { AvailableFilters, MagmaStreamErrorCode, ManagerEventTypes, PlayerStateEventTypes } from "./Enums"; 14 | import { MagmaStreamError } from "./MagmastreamError"; 15 | import { Manager } from "./Manager"; 16 | import { Player } from "./Player"; 17 | import { DistortionOptions, KaraokeOptions, PlayerStateUpdateEvent, ReverbOptions, RotationOptions, TimescaleOptions, VibratoOptions } from "./Types"; 18 | 19 | export class Filters { 20 | public distortion: DistortionOptions | null; 21 | public equalizer: Band[]; 22 | public karaoke: KaraokeOptions | null; 23 | public manager: Manager; 24 | public player: Player; 25 | public rotation: RotationOptions | null; 26 | public timescale: TimescaleOptions | null; 27 | public vibrato: VibratoOptions | null; 28 | public reverb: ReverbOptions | null; 29 | public volume: number; 30 | public bassBoostlevel: number; 31 | public filtersStatus: Record; 32 | 33 | constructor(player: Player, manager: Manager) { 34 | this.distortion = null; 35 | this.equalizer = []; 36 | this.karaoke = null; 37 | this.manager = manager; 38 | this.player = player; 39 | this.rotation = null; 40 | this.timescale = null; 41 | this.vibrato = null; 42 | this.volume = 1.0; 43 | this.bassBoostlevel = 0; 44 | // Initialize filter status 45 | this.filtersStatus = Object.values(AvailableFilters).reduce((acc, filter) => { 46 | acc[filter] = false; 47 | return acc; 48 | }, {} as Record); 49 | } 50 | 51 | /** 52 | * Updates the player's audio filters. 53 | * 54 | * This method sends a request to the player's node to update the filter settings 55 | * based on the current properties of the `Filters` instance. The filters include 56 | * distortion, equalizer, karaoke, rotation, timescale, vibrato, and volume. Once 57 | * the request is sent, it ensures that the player's audio output reflects the 58 | * changes in filter settings. 59 | * 60 | * @returns {Promise} - Returns a promise that resolves to the current instance 61 | * of the Filters class for method chaining. 62 | */ 63 | public async updateFilters(): Promise { 64 | const { distortion, equalizer, karaoke, rotation, timescale, vibrato, volume } = this; 65 | 66 | try { 67 | await this.player.node.rest.updatePlayer({ 68 | data: { 69 | filters: { 70 | distortion, 71 | equalizer, 72 | karaoke, 73 | rotation, 74 | timescale, 75 | vibrato, 76 | volume, 77 | }, 78 | }, 79 | guildId: this.player.guildId, 80 | }); 81 | } catch (err) { 82 | const error = 83 | err instanceof MagmaStreamError 84 | ? err 85 | : new MagmaStreamError({ 86 | code: MagmaStreamErrorCode.FILTER_APPLY_FAILED, 87 | message: `Failed to apply filters to player "${this.player.guildId}".`, 88 | cause: err instanceof Error ? err : undefined, 89 | context: { nodeId: this.player.node.options.identifier }, 90 | }); 91 | 92 | console.log(error); 93 | } 94 | 95 | return this; 96 | } 97 | 98 | /** 99 | * Applies a specific filter to the player. 100 | * 101 | * This method allows you to set the value of a specific filter property. 102 | * The filter property must be a valid key of the Filters object. 103 | * 104 | * @param {{ property: T; value: Filters[T] }} filter - An object containing the filter property and value. 105 | * @param {boolean} [updateFilters=true] - Whether to update the filters after applying the filter. 106 | * @returns {Promise} - Returns the current instance of the Filters class for method chaining. 107 | */ 108 | private async applyFilter(filter: { property: T; value: Filters[T] }, updateFilters: boolean = true): Promise { 109 | this[filter.property] = filter.value as this[T]; 110 | if (updateFilters) { 111 | await this.updateFilters(); 112 | } 113 | return this; 114 | } 115 | 116 | private emitPlayersTasteUpdate(oldState: Filters) { 117 | this.manager.emit(ManagerEventTypes.PlayerStateUpdate, oldState, this, { 118 | changeType: PlayerStateEventTypes.FilterChange, 119 | details: { action: "change" }, 120 | } as PlayerStateUpdateEvent); 121 | } 122 | 123 | /** 124 | * Sets the status of a specific filter. 125 | * 126 | * This method updates the filter status to either true or false, indicating whether 127 | * the filter is applied or not. This helps track which filters are active. 128 | * 129 | * @param {AvailableFilters} filter - The filter to update. 130 | * @param {boolean} status - The status to set (true for active, false for inactive). 131 | * @returns {this} - Returns the current instance of the Filters class for method chaining. 132 | */ 133 | private setFilterStatus(filter: AvailableFilters, status: boolean): this { 134 | this.filtersStatus[filter] = status; 135 | return this; 136 | } 137 | 138 | /** 139 | * Retrieves the status of a specific filter. 140 | * 141 | * This method returns whether a specific filter is currently applied or not. 142 | * 143 | * @param {AvailableFilters} filter - The filter to check. 144 | * @returns {boolean} - Returns true if the filter is active, false otherwise. 145 | */ 146 | public getFilterStatus(filter: AvailableFilters): boolean { 147 | return this.filtersStatus[filter]; 148 | } 149 | 150 | /** 151 | * Clears all filters applied to the audio. 152 | * 153 | * This method resets all filter settings to their default values and removes any 154 | * active filters from the player. 155 | * 156 | * @returns {this} - Returns the current instance of the Filters class for method chaining. 157 | */ 158 | public async clearFilters(): Promise { 159 | const oldPlayer = { ...this }; 160 | this.filtersStatus = Object.values(AvailableFilters).reduce((acc, filter) => { 161 | acc[filter] = false; 162 | return acc; 163 | }, {} as Record); 164 | 165 | this.player.filters = new Filters(this.player, this.manager); 166 | await this.setEqualizer([]); 167 | await this.setDistortion(null); 168 | await this.setKaraoke(null); 169 | await this.setRotation(null); 170 | await this.setTimescale(null); 171 | await this.setVibrato(null); 172 | 173 | await this.updateFilters(); 174 | 175 | this.emitPlayersTasteUpdate(oldPlayer); 176 | 177 | return this; 178 | } 179 | 180 | /** 181 | * Sets the own equalizer bands on the audio. 182 | * 183 | * This method adjusts the equalization curve of the player's audio output, 184 | * allowing you to control the frequency response. 185 | * 186 | * @param {Band[]} [bands] - The equalizer bands to apply (band, gain). 187 | * @returns {Promise} - Returns the current instance of the Filters class for method chaining. 188 | */ 189 | public async setEqualizer(bands?: Band[]): Promise { 190 | const oldPlayer = { ...this }; 191 | await this.applyFilter({ property: "equalizer", value: bands }); 192 | this.emitPlayersTasteUpdate(oldPlayer); 193 | return this; 194 | } 195 | 196 | /** 197 | * Sets the own karaoke options to the audio. 198 | * 199 | * This method adjusts the audio so that it sounds like a karaoke song, with the 200 | * original vocals removed. Note that not all songs can be successfully made into 201 | * karaoke tracks, and some tracks may not sound as good. 202 | * 203 | * @param {KaraokeOptions} [karaoke] - The karaoke settings to apply (level, monoLevel, filterBand, filterWidth). 204 | * @returns {Promise} - Returns the current instance of the Filters class for method chaining. 205 | */ 206 | public async setKaraoke(karaoke?: KaraokeOptions): Promise { 207 | const oldPlayer = { ...this }; 208 | await this.applyFilter({ property: "karaoke", value: karaoke ?? null }); 209 | this.setFilterStatus(AvailableFilters.SetKaraoke, !!karaoke); 210 | this.emitPlayersTasteUpdate(oldPlayer); 211 | return this; 212 | } 213 | 214 | /** 215 | * Sets the own timescale options to the audio. 216 | * 217 | * This method adjusts the speed and pitch of the audio, allowing you to control the playback speed. 218 | * 219 | * @param {TimescaleOptions} [timescale] - The timescale settings to apply (speed and pitch). 220 | * @returns {Promise} - Returns the current instance of the Filters class for method chaining. 221 | */ 222 | public async setTimescale(timescale?: TimescaleOptions): Promise { 223 | const oldPlayer = { ...this }; 224 | await this.applyFilter({ property: "timescale", value: timescale ?? null }); 225 | this.setFilterStatus(AvailableFilters.SetTimescale, !!timescale); 226 | this.emitPlayersTasteUpdate(oldPlayer); 227 | return this; 228 | } 229 | 230 | /** 231 | * Sets the own vibrato options to the audio. 232 | * 233 | * This method applies a vibrato effect to the audio, which adds a wavering, 234 | * pulsing quality to the sound. The effect is created by rapidly varying the 235 | * pitch of the audio. 236 | * 237 | * @param {VibratoOptions} [vibrato] - The vibrato settings to apply (frequency, depth). 238 | * @returns {Promise} - Returns the current instance of the Filters class for method chaining. 239 | */ 240 | public async setVibrato(vibrato?: VibratoOptions): Promise { 241 | const oldPlayer = { ...this }; 242 | await this.applyFilter({ property: "vibrato", value: vibrato ?? null }); 243 | this.setFilterStatus(AvailableFilters.Vibrato, !!vibrato); 244 | this.emitPlayersTasteUpdate(oldPlayer); 245 | return this; 246 | } 247 | 248 | /** 249 | * Sets the own rotation options effect to the audio. 250 | * 251 | * This method applies a rotation effect to the audio, which simulates the sound 252 | * moving around the listener's head. This effect can create a dynamic and immersive 253 | * audio experience by altering the directionality of the sound. 254 | * 255 | * @param {RotationOptions} [rotation] - The rotation settings to apply (rotationHz). 256 | * @returns {Promise} - Returns the current instance of the Filters class for method chaining. 257 | */ 258 | public async setRotation(rotation?: RotationOptions): Promise { 259 | const oldPlayer = { ...this }; 260 | await this.applyFilter({ property: "rotation", value: rotation ?? null }); 261 | this.setFilterStatus(AvailableFilters.SetRotation, !!rotation); 262 | this.emitPlayersTasteUpdate(oldPlayer); 263 | return this; 264 | } 265 | 266 | /** 267 | * Sets the own distortion options effect to the audio. 268 | * 269 | * This method applies a distortion effect to the audio, which adds a rougher, 270 | * more intense quality to the sound. The effect is created by altering the 271 | * audio signal to create a more jagged, irregular waveform. 272 | * 273 | * @param {DistortionOptions} [distortion] - The distortion settings to apply (sinOffset, sinScale, cosOffset, cosScale, tanOffset, tanScale, offset, scale). 274 | * @returns {Promise} - Returns the current instance of the Filters class for method chaining. 275 | */ 276 | public async setDistortion(distortion?: DistortionOptions): Promise { 277 | const oldPlayer = { ...this }; 278 | await this.applyFilter({ property: "distortion", value: distortion ?? null }); 279 | this.setFilterStatus(AvailableFilters.SetDistortion, !!distortion); 280 | this.emitPlayersTasteUpdate(oldPlayer); 281 | return this; 282 | } 283 | 284 | /** 285 | * Sets the bass boost level on the audio. 286 | * 287 | * This method scales the gain of a predefined equalizer curve to the specified level. 288 | * The curve is designed to emphasize or reduce low frequencies, creating a bass-heavy 289 | * or bass-reduced effect. 290 | * 291 | * @param {number} level - The level of bass boost to apply. The value ranges from -3 to 3, 292 | * where negative values reduce bass, 0 disables the effect, 293 | * and positive values increase bass. 294 | * @returns {Promise} - Returns the current instance of the Filters class for method chaining. 295 | * 296 | * @example 297 | * // Apply different levels of bass boost or reduction: 298 | * await player.bassBoost(3); // Maximum Bass Boost 299 | * await player.bassBoost(2); // Medium Bass Boost 300 | * await player.bassBoost(1); // Mild Bass Boost 301 | * await player.bassBoost(0); // No Effect (Disabled) 302 | * await player.bassBoost(-1); // Mild Bass Reduction 303 | * await player.bassBoost(-2); // Medium Bass Reduction 304 | * await player.bassBoost(-3); // Maximum Bass Removal 305 | */ 306 | public async bassBoost(stage: number): Promise { 307 | const oldPlayer = { ...this }; 308 | 309 | // Ensure stage is between -3 and 3 310 | stage = Math.max(-3, Math.min(3, stage)); 311 | 312 | // Map stage (-3 to 3) to range (-1.0 to 1.0) 313 | const level = stage / 3; // Converts -3 to 3 → -1.0 to 1.0 314 | 315 | // Generate a dynamic equalizer by scaling bassBoostEqualizer 316 | const equalizer = bassBoostEqualizer.map((band) => ({ 317 | band: band.band, 318 | gain: band.gain * level, 319 | })); 320 | 321 | await this.applyFilter({ property: "equalizer", value: equalizer }); 322 | this.setFilterStatus(AvailableFilters.BassBoost, stage !== 0); 323 | this.bassBoostlevel = stage; 324 | 325 | this.emitPlayersTasteUpdate(oldPlayer); 326 | return this; 327 | } 328 | 329 | /** 330 | * Toggles the chipmunk effect on the audio. 331 | * 332 | * This method applies or removes a chipmunk effect by adjusting the timescale settings. 333 | * When enabled, it increases the speed, pitch, and rate of the audio, resulting in a high-pitched, fast playback 334 | * similar to the sound of a chipmunk. 335 | * 336 | * @param {boolean} status - Whether to enable or disable the chipmunk effect. 337 | * @returns {Promise} - Returns the current instance of the Filters class for method chaining. 338 | */ 339 | public async chipmunk(status: boolean): Promise { 340 | const oldPlayer = { ...this }; 341 | await this.applyFilter({ property: "timescale", value: status ? { speed: 1.5, pitch: 1.5, rate: 1.5 } : null }); 342 | this.setFilterStatus(AvailableFilters.Chipmunk, status); 343 | this.emitPlayersTasteUpdate(oldPlayer); 344 | return this; 345 | } 346 | 347 | /** 348 | * Toggles the "China" effect on the audio. 349 | * 350 | * This method applies or removes a filter that reduces the pitch of the audio by half, 351 | * without changing the speed or rate. This creates a "hollow" or "echoey" sound. 352 | * 353 | * @param {boolean} status - Whether to enable or disable the "China" effect. 354 | * @returns {Promise} - Returns the current instance of the Filters class for method chaining. 355 | */ 356 | public async china(status: boolean): Promise { 357 | const oldPlayer = { ...this }; 358 | await this.applyFilter({ property: "timescale", value: status ? { speed: 1.0, pitch: 0.5, rate: 1.0 } : null }); 359 | this.setFilterStatus(AvailableFilters.China, status); 360 | this.emitPlayersTasteUpdate(oldPlayer); 361 | return this; 362 | } 363 | 364 | /** 365 | * Toggles the 8D audio effect on the audio. 366 | * 367 | * This method applies or removes an 8D audio effect by adjusting the rotation settings. 368 | * When enabled, it creates a sensation of the audio moving around the listener's head, 369 | * providing an immersive audio experience. 370 | * 371 | * @param {boolean} status - Whether to enable or disable the 8D effect. 372 | * @returns {Promise} - Returns the current instance of the Filters class for method chaining. 373 | */ 374 | public async eightD(status: boolean): Promise { 375 | const oldPlayer = { ...this }; 376 | await this.applyFilter({ property: "rotation", value: status ? { rotationHz: 0.2 } : null }); 377 | this.setFilterStatus(AvailableFilters.EightD, status); 378 | this.emitPlayersTasteUpdate(oldPlayer); 379 | return this; 380 | } 381 | 382 | /** 383 | * Toggles the nightcore effect on the audio. 384 | * 385 | * This method applies or removes a nightcore effect by adjusting the timescale settings. 386 | * When enabled, it increases the speed and pitch of the audio, giving it a more 387 | * upbeat and energetic feel. 388 | * 389 | * @param {boolean} status - Whether to enable or disable the nightcore effect. 390 | * @returns {Promise} - Returns the current instance of the Filters class for method chaining. 391 | */ 392 | public async nightcore(status: boolean): Promise { 393 | const oldPlayer = { ...this }; 394 | await this.applyFilter({ property: "timescale", value: status ? { speed: 1.1, pitch: 1.125, rate: 1.05 } : null }); 395 | this.setFilterStatus(AvailableFilters.Nightcore, status); 396 | this.emitPlayersTasteUpdate(oldPlayer); 397 | return this; 398 | } 399 | 400 | /** 401 | * Toggles the slowmo effect on the audio. 402 | * 403 | * This method applies or removes a slowmo effect by adjusting the timescale settings. 404 | * When enabled, it slows down the audio while keeping the pitch the same, giving it 405 | * a more relaxed and calming feel. 406 | * 407 | * @param {boolean} status - Whether to enable or disable the slowmo effect. 408 | * @returns {Promise} - Returns the current instance of the Filters class for method chaining. 409 | */ 410 | public async slowmo(status: boolean): Promise { 411 | const oldPlayer = { ...this }; 412 | await this.applyFilter({ property: "timescale", value: status ? { speed: 0.7, pitch: 1.0, rate: 0.8 } : null }); 413 | this.setFilterStatus(AvailableFilters.Slowmo, status); 414 | this.emitPlayersTasteUpdate(oldPlayer); 415 | return this; 416 | } 417 | 418 | /** 419 | * Toggles a soft equalizer effect to the audio. 420 | * 421 | * This method applies or removes a soft equalizer effect by adjusting the equalizer settings. 422 | * When enabled, it reduces the bass and treble frequencies, giving the audio a softer and more 423 | * mellow sound. 424 | * 425 | * @param {boolean} status - Whether to enable or disable the soft equalizer effect. 426 | * @returns {Promise} - Returns the current instance of the Filters class for method chaining. 427 | */ 428 | public async soft(status: boolean): Promise { 429 | const oldPlayer = { ...this }; 430 | await this.applyFilter({ property: "equalizer", value: status ? softEqualizer : [] }); 431 | this.setFilterStatus(AvailableFilters.Soft, status); 432 | this.emitPlayersTasteUpdate(oldPlayer); 433 | return this; 434 | } 435 | 436 | /** 437 | * Toggles the TV equalizer effect on the audio. 438 | * 439 | * This method applies or removes a TV equalizer effect by adjusting the equalizer settings. 440 | * When enabled, it enhances specific frequency bands to mimic the audio characteristics 441 | * typically found in television audio outputs. 442 | * 443 | * @param {boolean} status - Whether to enable or disable the TV equalizer effect. 444 | * @returns {Promise} - Returns the current instance of the Filters class for method chaining. 445 | */ 446 | public async tv(status: boolean): Promise { 447 | const oldPlayer = { ...this }; 448 | await this.applyFilter({ property: "equalizer", value: status ? tvEqualizer : [] }); 449 | this.setFilterStatus(AvailableFilters.TV, status); 450 | this.emitPlayersTasteUpdate(oldPlayer); 451 | return this; 452 | } 453 | 454 | /** 455 | * Toggles the treble/bass equalizer effect on the audio. 456 | * 457 | * This method applies or removes a treble/bass equalizer effect by adjusting the equalizer settings. 458 | * When enabled, it enhances the treble and bass frequencies, giving the audio a more balanced sound. 459 | * 460 | * @param {boolean} status - Whether to enable or disable the treble/bass equalizer effect. 461 | * @returns {Promise} - Returns the current instance of the Filters class for method chaining. 462 | */ 463 | public async trebleBass(status: boolean): Promise { 464 | const oldPlayer = { ...this }; 465 | await this.applyFilter({ property: "equalizer", value: status ? trebleBassEqualizer : [] }); 466 | this.setFilterStatus(AvailableFilters.TrebleBass, status); 467 | this.emitPlayersTasteUpdate(oldPlayer); 468 | return this; 469 | } 470 | 471 | /** 472 | * Toggles the vaporwave effect on the audio. 473 | * 474 | * This method applies or removes a vaporwave effect by adjusting the equalizer settings. 475 | * When enabled, it gives the audio a dreamy and nostalgic feel, characteristic of the vaporwave genre. 476 | * 477 | * @param {boolean} status - Whether to enable or disable the vaporwave effect. 478 | * @returns {Promise} - Returns the current instance of the Filters class for method chaining. 479 | */ 480 | public async vaporwave(status: boolean): Promise { 481 | const oldPlayer = { ...this }; 482 | await this.applyFilter({ property: "equalizer", value: status ? vaporwaveEqualizer : [] }); 483 | this.setFilterStatus(AvailableFilters.Vaporwave, status); 484 | this.emitPlayersTasteUpdate(oldPlayer); 485 | return this; 486 | } 487 | 488 | /** 489 | * Toggles the distortion effect on the audio. 490 | * 491 | * This method applies or removes a distortion effect by adjusting the distortion settings. 492 | * When enabled, it adds a rougher, more intense quality to the sound by altering the 493 | * audio signal to create a more jagged, irregular waveform. 494 | * 495 | * @param {boolean} status - Whether to enable or disable the distortion effect. 496 | * @returns {Promise} - Returns the current instance of the Filters class for method chaining. 497 | */ 498 | public async distort(status: boolean): Promise { 499 | const oldPlayer = { ...this }; 500 | if (status) { 501 | await this.setDistortion({ 502 | sinOffset: 0, 503 | sinScale: 0.2, 504 | cosOffset: 0, 505 | cosScale: 0.2, 506 | tanOffset: 0, 507 | tanScale: 0.2, 508 | offset: 0, 509 | scale: 1.2, 510 | }); 511 | this.setFilterStatus(AvailableFilters.Distort, true); 512 | } else { 513 | await this.setDistortion(); 514 | this.setFilterStatus(AvailableFilters.Distort, false); 515 | } 516 | this.emitPlayersTasteUpdate(oldPlayer); 517 | return this; 518 | } 519 | 520 | /** 521 | * Toggles the party effect on the audio. 522 | * 523 | * This method applies or removes a party effect by adjusting the equalizer settings. 524 | * When enabled, it enhances the bass and treble frequencies, providing a more energetic and lively sound. 525 | * 526 | * @param {boolean} status - Whether to enable or disable the party effect. 527 | * @returns {Promise} - Returns the current instance of the Filters class for method chaining. 528 | */ 529 | public async pop(status: boolean): Promise { 530 | const oldPlayer = { ...this }; 531 | await this.applyFilter({ property: "equalizer", value: status ? popEqualizer : [] }); 532 | this.setFilterStatus(AvailableFilters.Pop, status); 533 | this.emitPlayersTasteUpdate(oldPlayer); 534 | return this; 535 | } 536 | 537 | /** 538 | * Toggles a party effect on the audio. 539 | * 540 | * This method applies a party effect to audio. 541 | * @param {boolean} status - Whether to enable or disable the party effect. 542 | * @returns {Promise} - Returns the current instance of the Filters class for method chaining. 543 | */ 544 | public async party(status: boolean): Promise { 545 | const oldPlayer = { ...this }; 546 | await this.applyFilter({ property: "equalizer", value: status ? popEqualizer : [] }); 547 | this.setFilterStatus(AvailableFilters.Party, status); 548 | this.emitPlayersTasteUpdate(oldPlayer); 549 | return this; 550 | } 551 | 552 | /** 553 | * Toggles earrape effect on the audio. 554 | * 555 | * This method applies earrape effect to audio. 556 | * @param {boolean} status - Whether to enable or disable the earrape effect. 557 | * @returns {Promise} - Returns the current instance of the Filters class for method chaining. 558 | */ 559 | public async earrape(status: boolean): Promise { 560 | const oldPlayer = { ...this }; 561 | if (status) { 562 | await this.player.setVolume(200); 563 | this.setFilterStatus(AvailableFilters.Earrape, true); 564 | } else { 565 | await this.player.setVolume(100); 566 | this.setFilterStatus(AvailableFilters.Earrape, false); 567 | } 568 | this.emitPlayersTasteUpdate(oldPlayer); 569 | return this; 570 | } 571 | 572 | /** 573 | * Toggles electronic effect on the audio. 574 | * 575 | * This method applies electronic effect to audio. 576 | * @param {boolean} status - Whether to enable or disable the electronic effect. 577 | * @returns {Promise} - Returns the current instance of the Filters class for method chaining. 578 | */ 579 | public async electronic(status: boolean): Promise { 580 | const oldPlayer = { ...this }; 581 | await this.applyFilter({ property: "equalizer", value: status ? electronicEqualizer : [] }); 582 | this.setFilterStatus(AvailableFilters.Electronic, status); 583 | this.emitPlayersTasteUpdate(oldPlayer); 584 | return this; 585 | } 586 | 587 | /** 588 | * Toggles radio effect on the audio. 589 | * 590 | * This method applies radio effect to audio. 591 | * @param {boolean} status - Whether to enable or disable the radio effect. 592 | * @returns {Promise} - Returns the current instance of the Filters class for method chaining. 593 | */ 594 | public async radio(status: boolean): Promise { 595 | const oldPlayer = { ...this }; 596 | await this.applyFilter({ property: "equalizer", value: status ? radioEqualizer : [] }); 597 | this.setFilterStatus(AvailableFilters.Radio, status); 598 | this.emitPlayersTasteUpdate(oldPlayer); 599 | return this; 600 | } 601 | 602 | /** 603 | * Toggles a tremolo effect on the audio. 604 | * 605 | * This method applies a tremolo effect to audio. 606 | * @param {boolean} status - Whether to enable or disable the tremolo effect. 607 | * @returns {this} - Returns the current instance of the Filters class for method chaining. 608 | */ 609 | public async tremolo(status: boolean): Promise { 610 | const oldPlayer = { ...this }; 611 | await this.applyFilter({ property: "vibrato", value: status ? { frequency: 5, depth: 0.5 } : null }); 612 | this.setFilterStatus(AvailableFilters.Tremolo, status); 613 | this.emitPlayersTasteUpdate(oldPlayer); 614 | return this; 615 | } 616 | 617 | /** 618 | * Toggless a darthvader effect on the audio. 619 | * 620 | * This method applies a darthvader effect to audio. 621 | * @param {boolean} status - Whether to enable or disable the darthvader effect. 622 | * @returns {this} - Returns the current instance of the Filters class for method chaining. 623 | */ 624 | public async darthvader(status: boolean): Promise { 625 | const oldPlayer = { ...this }; 626 | await this.applyFilter({ property: "timescale", value: status ? { speed: 1.0, pitch: 0.5, rate: 1.0 } : null }); 627 | this.setFilterStatus(AvailableFilters.Darthvader, status); 628 | this.emitPlayersTasteUpdate(oldPlayer); 629 | return this; 630 | } 631 | 632 | /** 633 | * Toggles a daycore effect on the audio. 634 | * 635 | * This method applies a daycore effect to audio. 636 | * @param {boolean} status - Whether to enable or disable the daycore effect. 637 | * @returns {this} - Returns the current instance of the Filters class for method chaining. 638 | */ 639 | public async daycore(status: boolean): Promise { 640 | const oldPlayer = { ...this }; 641 | await this.applyFilter({ property: "timescale", value: status ? { speed: 0.7, pitch: 0.8, rate: 0.8 } : null }); 642 | this.setFilterStatus(AvailableFilters.Daycore, status); 643 | this.emitPlayersTasteUpdate(oldPlayer); 644 | return this; 645 | } 646 | 647 | /** 648 | * Toggles a doubletime effect on the audio. 649 | * 650 | * This method applies a doubletime effect to audio. 651 | * @param {boolean} status - Whether to enable or disable the doubletime effect. 652 | * @returns {this} - Returns the current instance of the Filters class for method chaining 653 | */ 654 | public async doubletime(status: boolean): Promise { 655 | const oldPlayer = { ...this }; 656 | await this.applyFilter({ property: "timescale", value: status ? { speed: 2.0, pitch: 1.0, rate: 2.0 } : null }); 657 | this.setFilterStatus(AvailableFilters.Doubletime, status); 658 | this.emitPlayersTasteUpdate(oldPlayer); 659 | return this; 660 | } 661 | 662 | /** 663 | * Toggles the demon effect on the audio. 664 | * 665 | * This method applies or removes a demon effect by adjusting the equalizer, 666 | * timescale, and reverb settings. When enabled, it creates a deeper and more 667 | * intense sound by lowering the pitch and adding reverb to the audio. 668 | * 669 | * @param {boolean} status - Whether to enable or disable the demon effect. 670 | * @returns {Promise} - Returns the current instance of the Filters class for method chaining. 671 | */ 672 | public async demon(status: boolean): Promise { 673 | const oldPlayer = { ...this }; 674 | const filters = status 675 | ? { 676 | equalizer: demonEqualizer, 677 | timescale: { pitch: 0.8 } as TimescaleOptions, 678 | reverb: { wet: 0.7, dry: 0.3, roomSize: 0.8, damping: 0.5 } as ReverbOptions, 679 | } 680 | : { 681 | equalizer: [] as Band[], 682 | timescale: null as TimescaleOptions | null, 683 | reverb: null as ReverbOptions | null, 684 | }; 685 | 686 | await Promise.all(Object.entries(filters).map(([property, value]) => this.applyFilter({ property: property as keyof Filters, value }))); 687 | 688 | this.setFilterStatus(AvailableFilters.Demon, status); 689 | 690 | this.emitPlayersTasteUpdate(oldPlayer); 691 | return this; 692 | } 693 | } 694 | -------------------------------------------------------------------------------- /src/structures/Utils.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-require-imports */ 2 | import axios from "axios"; 3 | import { JSDOM } from "jsdom"; 4 | import { AutoPlayPlatform, LoadTypes, MagmaStreamErrorCode, SearchPlatform, TrackPartial } from "./Enums"; 5 | import { Manager } from "./Manager"; 6 | import { AnyUser, ErrorOrEmptySearchResult, Extendable, LavalinkResponse, PlaylistRawData, SearchResult, Track, TrackData, TrackSourceName } from "./Types"; 7 | import { Player } from "./Player"; 8 | import path from "path"; 9 | import stringify from "safe-stable-stringify"; 10 | import { MagmaStreamError } from "./MagmastreamError"; 11 | // import playwright from "playwright"; 12 | 13 | /** @hidden */ 14 | const SIZES = ["0", "1", "2", "3", "default", "mqdefault", "hqdefault", "maxresdefault"]; 15 | 16 | export abstract class TrackUtils { 17 | static trackPartial: TrackPartial[] | null = null; 18 | private static manager: Manager; 19 | 20 | /** 21 | * Initializes the TrackUtils class with the given manager. 22 | * @param manager The manager instance to use. 23 | * @hidden 24 | */ 25 | public static init(manager: Manager): void { 26 | // Set the manager instance for TrackUtils. 27 | this.manager = manager; 28 | } 29 | 30 | /** 31 | * Sets the partial properties for the Track class. If a Track has some of its properties removed by the partial, 32 | * it will be considered a partial Track. 33 | * @param {TrackPartial} partial The array of string property names to remove from the Track class. 34 | */ 35 | static setTrackPartial(partial: TrackPartial[]): void { 36 | if (!Array.isArray(partial) || !partial.every((str) => typeof str === "string")) { 37 | throw new MagmaStreamError({ 38 | code: MagmaStreamErrorCode.UTILS_TRACK_PARTIAL_INVALID, 39 | message: "Partial must be an array of strings.", 40 | }); 41 | } 42 | 43 | const defaultProperties = [ 44 | TrackPartial.Track, 45 | TrackPartial.Title, 46 | TrackPartial.Identifier, 47 | TrackPartial.Author, 48 | TrackPartial.Duration, 49 | TrackPartial.Isrc, 50 | TrackPartial.IsSeekable, 51 | TrackPartial.IsStream, 52 | TrackPartial.Uri, 53 | TrackPartial.ArtworkUrl, 54 | TrackPartial.SourceName, 55 | TrackPartial.ThumbNail, 56 | TrackPartial.Requester, 57 | TrackPartial.PluginInfo, 58 | TrackPartial.CustomData, 59 | ]; 60 | 61 | /** The array of property names that will be removed from the Track class */ 62 | this.trackPartial = Array.from(new Set([...defaultProperties, ...partial])); 63 | 64 | /** Make sure that the "track" property is always included */ 65 | if (!this.trackPartial.includes(TrackPartial.Track)) this.trackPartial.unshift(TrackPartial.Track); 66 | } 67 | 68 | /** 69 | * Checks if the provided argument is a valid Track. 70 | * If provided an array then every element will be checked. 71 | * @param trackOrTracks The Track or array of Tracks to check. 72 | * @returns {boolean} Whether the provided argument is a valid Track. 73 | */ 74 | static validate(trackOrTracks: unknown): boolean { 75 | if (typeof trackOrTracks !== "object" || trackOrTracks === null) { 76 | return false; 77 | } 78 | 79 | const isValidTrack = (track: unknown): track is Track => { 80 | if (typeof track !== "object" || track === null) { 81 | return false; 82 | } 83 | const t = track as Record; 84 | return ( 85 | typeof t.track === "string" && typeof t.title === "string" && typeof t.identifier === "string" && typeof t.isrc === "string" && typeof t.uri === "string" 86 | ); 87 | }; 88 | 89 | if (Array.isArray(trackOrTracks)) { 90 | return trackOrTracks.every(isValidTrack); 91 | } 92 | 93 | return isValidTrack(trackOrTracks); 94 | } 95 | 96 | /** 97 | * Builds a Track from the raw data from Lavalink and a optional requester. 98 | * @param data The raw data from Lavalink to build the Track from. 99 | * @param requester The user who requested the track, if any. 100 | * @returns The built Track. 101 | */ 102 | static build(data: TrackData, requester?: T): Track { 103 | if (typeof data === "undefined") { 104 | throw new MagmaStreamError({ 105 | code: MagmaStreamErrorCode.UTILS_TRACK_BUILD_FAILED, 106 | message: 'Argument "data" must be present.', 107 | }); 108 | } 109 | 110 | try { 111 | const sourceNameMap: Record = { 112 | applemusic: "AppleMusic", 113 | audius: "Audius", 114 | bandcamp: "Bandcamp", 115 | deezer: "Deezer", 116 | jiosaavn: "Jiosaavn", 117 | soundcloud: "SoundCloud", 118 | spotify: "Spotify", 119 | tidal: "Tidal", 120 | youtube: "YouTube", 121 | vkmusic: "VKMusic", 122 | qobuz: "Qobuz", 123 | http: "Http", 124 | tts: "Tts", 125 | clypit: "Clypit", 126 | pornhub: "Pornhub", 127 | soundgasm: "Soundgasm", 128 | reddit: "Reddit", 129 | flowertts: "Flowertts", 130 | ocremix: "Ocremix", 131 | mixcloud: "Mixcloud", 132 | tiktok: "TikTok", 133 | }; 134 | 135 | const track: Track = { 136 | track: data.encoded, 137 | title: data.info.title, 138 | identifier: data.info.identifier, 139 | author: data.info.author, 140 | duration: data.info.length, 141 | isrc: data.info?.isrc, 142 | isSeekable: data.info.isSeekable, 143 | isStream: data.info.isStream, 144 | uri: data.info.uri, 145 | artworkUrl: data.info?.artworkUrl ?? null, 146 | sourceName: sourceNameMap[data.info?.sourceName?.toLowerCase() ?? ""] ?? data.info?.sourceName, 147 | thumbnail: data.info.uri.includes("youtube") ? `https://img.youtube.com/vi/${data.info.identifier}/default.jpg` : null, 148 | displayThumbnail(size = "default"): string | null { 149 | const finalSize = SIZES.find((s) => s === size) ?? "default"; 150 | return this.uri.includes("youtube") ? `https://img.youtube.com/vi/${data.info.identifier}/${finalSize}.jpg` : null; 151 | }, 152 | requester: requester as AnyUser, 153 | pluginInfo: data.pluginInfo, 154 | customData: {}, 155 | }; 156 | 157 | track.displayThumbnail = track.displayThumbnail.bind(track); 158 | 159 | if (this.trackPartial) { 160 | for (const key of Object.keys(track)) { 161 | if (this.trackPartial.includes(key as TrackPartial)) continue; 162 | delete track[key]; 163 | } 164 | } 165 | 166 | return track; 167 | } catch (error) { 168 | throw new MagmaStreamError({ 169 | code: MagmaStreamErrorCode.UTILS_TRACK_BUILD_FAILED, 170 | message: `Argument "data" is not a valid track: ${error.message}`, 171 | context: { 172 | data, 173 | requester, 174 | }, 175 | }); 176 | } 177 | } 178 | 179 | /** 180 | * Validates a search result. 181 | * @param result The search result to validate. 182 | * @returns Whether the search result is valid. 183 | */ 184 | static isErrorOrEmptySearchResult(result: SearchResult): result is ErrorOrEmptySearchResult { 185 | return result.loadType === LoadTypes.Empty || result.loadType === LoadTypes.Error; 186 | } 187 | 188 | /** 189 | * Revives a track. 190 | * @param track The track to revive. 191 | * @returns The revived track. 192 | */ 193 | static revive(track: Track): Track { 194 | if (!track) return track; 195 | 196 | track.displayThumbnail = function (size = "default"): string | null { 197 | const finalSize = SIZES.find((s) => s === size) ?? "default"; 198 | return this.uri.includes("youtube") ? `https://img.youtube.com/vi/${this.identifier}/${finalSize}.jpg` : null; 199 | }.bind(track); 200 | 201 | return track; 202 | } 203 | } 204 | 205 | export abstract class AutoPlayUtils { 206 | private static manager: Manager; 207 | // private static cachedAccessToken: string | null = null; 208 | // private static cachedAccessTokenExpiresAt: number = 0; 209 | 210 | /** 211 | * Initializes the AutoPlayUtils class with the given manager. 212 | * @param manager The manager instance to use. 213 | * @hidden 214 | */ 215 | public static async init(manager: Manager): Promise { 216 | if (!manager) { 217 | throw new MagmaStreamError({ 218 | code: MagmaStreamErrorCode.GENERAL_INVALID_MANAGER, 219 | message: "AutoPlayUtils requires a valid Manager instance.", 220 | }); 221 | } 222 | this.manager = manager; 223 | } 224 | 225 | /** 226 | * Gets recommended tracks for the given track. 227 | * @param track The track to get recommended tracks for. 228 | * @returns An array of recommended tracks. 229 | */ 230 | public static async getRecommendedTracks(track: Track): Promise { 231 | const node = this.manager.useableNode; 232 | if (!node) { 233 | throw new MagmaStreamError({ 234 | code: MagmaStreamErrorCode.MANAGER_NO_NODES, 235 | message: "No available nodes to get recommended tracks from.", 236 | context: { track }, 237 | }); 238 | } 239 | 240 | const apiKey = this.manager.options.lastFmApiKey; 241 | 242 | // Check if Last.fm API is available 243 | if (apiKey) { 244 | return await this.getRecommendedTracksFromLastFm(track, apiKey); 245 | } 246 | 247 | const enabledSources = node.info.sourceManagers; 248 | const autoPlaySearchPlatforms: AutoPlayPlatform[] = this.manager.options.autoPlaySearchPlatforms; 249 | 250 | // Iterate over autoplay platforms in order of priority 251 | for (const platform of autoPlaySearchPlatforms) { 252 | if (enabledSources.includes(platform)) { 253 | const recommendedTracks = await this.getRecommendedTracksFromSource(track, platform); 254 | 255 | // If tracks are found, return them immediately 256 | if (recommendedTracks.length > 0) { 257 | return recommendedTracks; 258 | } 259 | } 260 | } 261 | 262 | return []; 263 | } 264 | 265 | /** 266 | * Gets recommended tracks from Last.fm for the given track. 267 | * @param track The track to get recommended tracks for. 268 | * @param apiKey The API key for Last.fm. 269 | * @returns An array of recommended tracks. 270 | */ 271 | static async getRecommendedTracksFromLastFm(track: Track, apiKey: string): Promise { 272 | let { author: artist } = track; 273 | const { title } = track; 274 | 275 | if (!artist || !title) { 276 | if (!title) { 277 | // No title provided, search for the artist's top tracks 278 | const noTitleUrl = `https://ws.audioscrobbler.com/2.0/?method=artist.getTopTracks&artist=${artist}&autocorrect=1&api_key=${apiKey}&format=json`; 279 | 280 | const response = await axios.get(noTitleUrl); 281 | 282 | if (response.data.error || !response.data.toptracks?.track?.length) { 283 | return []; 284 | } 285 | 286 | const randomTrack = response.data.toptracks.track[Math.floor(Math.random() * response.data.toptracks.track.length)]; 287 | const resolvedTracks = await this.resolveTracksFromQuery( 288 | `${randomTrack.artist.name} - ${randomTrack.name}`, 289 | this.manager.options.defaultSearchPlatform, 290 | track.requester 291 | ); 292 | 293 | if (!resolvedTracks.length) return []; 294 | 295 | return resolvedTracks; 296 | } 297 | if (!artist) { 298 | // No artist provided, search for the track title 299 | const noArtistUrl = `https://ws.audioscrobbler.com/2.0/?method=track.search&track=${title}&api_key=${apiKey}&format=json`; 300 | 301 | const response = await axios.get(noArtistUrl); 302 | artist = response.data.results.trackmatches?.track?.[0]?.artist; 303 | 304 | if (!artist) { 305 | return []; 306 | } 307 | } 308 | } 309 | 310 | // Search for similar tracks to the current track 311 | const url = `https://ws.audioscrobbler.com/2.0/?method=track.getSimilar&artist=${artist}&track=${title}&limit=10&autocorrect=1&api_key=${apiKey}&format=json`; 312 | 313 | let response: axios.AxiosResponse; 314 | 315 | try { 316 | response = await axios.get(url); 317 | } catch (error) { 318 | console.error("[AutoPlay] Error fetching similar tracks from Last.fm:", error); 319 | return []; 320 | } 321 | 322 | if (response.data.error || !response.data.similartracks?.track?.length) { 323 | // Retry the request if the first attempt fails 324 | const retryUrl = `https://ws.audioscrobbler.com/2.0/?method=artist.getTopTracks&artist=${artist}&autocorrect=1&api_key=${apiKey}&format=json`; 325 | const retryResponse = await axios.get(retryUrl); 326 | 327 | if (retryResponse.data.error || !retryResponse.data.toptracks?.track?.length) { 328 | return []; 329 | } 330 | 331 | const randomTrack = retryResponse.data.toptracks.track[Math.floor(Math.random() * retryResponse.data.toptracks.track.length)]; 332 | const resolvedTracks = await this.resolveTracksFromQuery( 333 | `${randomTrack.artist.name} - ${randomTrack.name}`, 334 | this.manager.options.defaultSearchPlatform, 335 | track.requester 336 | ); 337 | 338 | if (!resolvedTracks.length) return []; 339 | 340 | const filteredTracks = resolvedTracks.filter((t) => t.uri !== track.uri); 341 | if (!filteredTracks.length) { 342 | return []; 343 | } 344 | 345 | return filteredTracks; 346 | } 347 | 348 | const randomTrack = response.data.similartracks.track.sort(() => Math.random() - 0.5).shift(); 349 | 350 | if (!randomTrack) { 351 | return []; 352 | } 353 | 354 | const resolvedTracks = await this.resolveTracksFromQuery( 355 | `${randomTrack.artist.name} - ${randomTrack.name}`, 356 | this.manager.options.defaultSearchPlatform, 357 | track.requester 358 | ); 359 | 360 | if (!resolvedTracks.length) return []; 361 | 362 | return resolvedTracks; 363 | } 364 | 365 | /** 366 | * Gets recommended tracks from the given source. 367 | * @param track The track to get recommended tracks for. 368 | * @param platform The source to get recommended tracks from. 369 | * @returns An array of recommended tracks. 370 | */ 371 | static async getRecommendedTracksFromSource(track: Track, platform: AutoPlayPlatform): Promise { 372 | const requester = track.requester; 373 | const parsedURL = new URL(track.uri); 374 | 375 | switch (platform) { 376 | case AutoPlayPlatform.Spotify: { 377 | const allowedSpotifyHosts = ["open.spotify.com", "www.spotify.com"]; 378 | if (!allowedSpotifyHosts.includes(parsedURL.host)) { 379 | const resolvedTrack = await this.resolveFirstTrackFromQuery(`${track.author} - ${track.title}`, SearchPlatform.Spotify, requester); 380 | 381 | if (!resolvedTrack) return []; 382 | 383 | track = resolvedTrack; 384 | } 385 | 386 | // const extractSpotifyArtistID = (url: string): string | null => { 387 | // const regex = /https:\/\/open\.spotify\.com\/artist\/([a-zA-Z0-9]+)/; 388 | // const match = url.match(regex); 389 | // return match ? match[1] : null; 390 | // }; 391 | 392 | // const identifier = `sprec:seed_artists=${extractSpotifyArtistID(track.pluginInfo.artistUrl)}&seed_tracks=${track.identifier}`; 393 | const identifier = `sprec:mix:track:${track.identifier}`; 394 | const recommendedResult = (await this.manager.useableNode.rest.get(`/v4/loadtracks?identifier=${encodeURIComponent(identifier)}`)) as LavalinkResponse; 395 | const tracks = this.buildTracksFromResponse(recommendedResult, requester); 396 | 397 | return tracks; 398 | } 399 | 400 | case AutoPlayPlatform.Deezer: { 401 | const allowedDeezerHosts = ["deezer.com", "www.deezer.com", "www.deezer.page.link"]; 402 | if (!allowedDeezerHosts.includes(parsedURL.host)) { 403 | const resolvedTrack = await this.resolveFirstTrackFromQuery(`${track.author} - ${track.title}`, SearchPlatform.Deezer, requester); 404 | 405 | if (!resolvedTrack) return []; 406 | 407 | track = resolvedTrack; 408 | } 409 | 410 | const identifier = `dzrec:${track.identifier}`; 411 | const recommendedResult = (await this.manager.useableNode.rest.get(`/v4/loadtracks?identifier=${encodeURIComponent(identifier)}`)) as LavalinkResponse; 412 | const tracks = this.buildTracksFromResponse(recommendedResult, requester); 413 | 414 | return tracks; 415 | } 416 | 417 | case AutoPlayPlatform.SoundCloud: { 418 | const allowedSoundCloudHosts = ["soundcloud.com", "www.soundcloud.com"]; 419 | if (!allowedSoundCloudHosts.includes(parsedURL.host)) { 420 | const resolvedTrack = await this.resolveFirstTrackFromQuery(`${track.author} - ${track.title}`, SearchPlatform.SoundCloud, requester); 421 | 422 | if (!resolvedTrack) return []; 423 | 424 | track = resolvedTrack; 425 | } 426 | 427 | try { 428 | const recommendedRes = await axios.get(`${track.uri}/recommended`).catch((err) => { 429 | console.error(`[AutoPlay] Failed to fetch SoundCloud recommendations. Status: ${err.response?.status || "Unknown"}`, err.message); 430 | return null; 431 | }); 432 | 433 | if (!recommendedRes) { 434 | return []; 435 | } 436 | 437 | const html = recommendedRes.data; 438 | 439 | const dom = new JSDOM(html); 440 | const window = dom.window; 441 | 442 | // Narrow the element types using instanceof 443 | const secondNoscript = window.querySelectorAll("noscript")[1]; 444 | if (!secondNoscript || !(secondNoscript instanceof window.Element)) return []; 445 | 446 | const sectionElement = secondNoscript.querySelector("section"); 447 | if (!sectionElement || !(sectionElement instanceof window.HTMLElement)) return []; 448 | 449 | const articleElements = sectionElement.querySelectorAll("article"); 450 | 451 | if (!articleElements || articleElements.length === 0) return []; 452 | 453 | const urls = Array.from(articleElements) 454 | .map((element) => { 455 | const h2 = element.querySelector('h2[itemprop="name"]'); 456 | if (!h2) return null; 457 | 458 | const a = h2.querySelector('a[itemprop="url"]'); 459 | if (!a) return null; 460 | 461 | const href = a.getAttribute("href"); 462 | return href ? `https://soundcloud.com${href}` : null; 463 | }) 464 | .filter(Boolean); 465 | 466 | if (!urls.length) return []; 467 | 468 | const randomUrl = urls[Math.floor(Math.random() * urls.length)]; 469 | const resolvedTrack = await this.resolveFirstTrackFromQuery(randomUrl, SearchPlatform.SoundCloud, requester); 470 | 471 | return resolvedTrack ? [resolvedTrack] : []; 472 | } catch (error) { 473 | console.error("[AutoPlay] Error occurred while fetching soundcloud recommendations:", error); 474 | return []; 475 | } 476 | } 477 | 478 | case AutoPlayPlatform.YouTube: { 479 | const allowedYouTubeHosts = ["youtube.com", "youtu.be"]; 480 | const hasYouTubeURL = allowedYouTubeHosts.some((url) => track.uri.includes(url)); 481 | let videoID: string | null = null; 482 | 483 | if (hasYouTubeURL) { 484 | videoID = track.uri.split("=").pop(); 485 | } else { 486 | const resolvedTrack = await this.resolveFirstTrackFromQuery(`${track.author} - ${track.title}`, SearchPlatform.YouTube, requester); 487 | 488 | if (!resolvedTrack) return []; 489 | 490 | videoID = resolvedTrack.uri.split("=").pop(); 491 | } 492 | 493 | if (!videoID) { 494 | return []; 495 | } 496 | 497 | let randomIndex: number; 498 | let searchURI: string; 499 | 500 | do { 501 | randomIndex = Math.floor(Math.random() * 23) + 2; 502 | searchURI = `https://www.youtube.com/watch?v=${videoID}&list=RD${videoID}&index=${randomIndex}`; 503 | } while (track.uri.includes(searchURI)); 504 | const resolvedTracks = await this.resolveTracksFromQuery(searchURI, SearchPlatform.YouTube, requester); 505 | const filteredTracks = resolvedTracks.filter((t) => t.uri !== track.uri); 506 | 507 | return filteredTracks; 508 | } 509 | 510 | case AutoPlayPlatform.Tidal: { 511 | const allowedTidalHosts = ["tidal.com", "www.tidal.com"]; 512 | if (!allowedTidalHosts.includes(parsedURL.host)) { 513 | const resolvedTrack = await this.resolveFirstTrackFromQuery(`${track.author} - ${track.title}`, SearchPlatform.Tidal, requester); 514 | 515 | if (!resolvedTrack) return []; 516 | 517 | track = resolvedTrack; 518 | } 519 | 520 | const identifier = `tdrec:${track.identifier}`; 521 | const recommendedResult = (await this.manager.useableNode.rest.get(`/v4/loadtracks?identifier=${encodeURIComponent(identifier)}`)) as LavalinkResponse; 522 | const tracks = this.buildTracksFromResponse(recommendedResult, requester); 523 | 524 | return tracks; 525 | } 526 | 527 | case AutoPlayPlatform.VKMusic: { 528 | const allowedVKHosts = ["vk.com", "www.vk.com", "vk.ru", "www.vk.ru"]; 529 | if (!allowedVKHosts.includes(parsedURL.host)) { 530 | const resolvedTrack = await this.resolveFirstTrackFromQuery(`${track.author} - ${track.title}`, SearchPlatform.VKMusic, requester); 531 | 532 | if (!resolvedTrack) return []; 533 | 534 | track = resolvedTrack; 535 | } 536 | 537 | const identifier = `vkrec:${track.identifier}`; 538 | const recommendedResult = (await this.manager.useableNode.rest.get(`/v4/loadtracks?identifier=${encodeURIComponent(identifier)}`)) as LavalinkResponse; 539 | const tracks = this.buildTracksFromResponse(recommendedResult, requester); 540 | 541 | return tracks; 542 | } 543 | 544 | case AutoPlayPlatform.Qobuz: { 545 | const allowedQobuzHosts = ["qobuz.com", "www.qobuz.com", "play.qobuz.com"]; 546 | if (!allowedQobuzHosts.includes(parsedURL.host)) { 547 | const resolvedTrack = await this.resolveFirstTrackFromQuery(`${track.author} - ${track.title}`, SearchPlatform.Qobuz, requester); 548 | 549 | if (!resolvedTrack) return []; 550 | 551 | track = resolvedTrack; 552 | } 553 | 554 | const identifier = `qbrec:${track.identifier}`; 555 | const recommendedResult = (await this.manager.useableNode.rest.get(`/v4/loadtracks?identifier=${encodeURIComponent(identifier)}`)) as LavalinkResponse; 556 | const tracks = this.buildTracksFromResponse(recommendedResult, requester); 557 | 558 | return tracks; 559 | } 560 | 561 | default: 562 | return []; 563 | } 564 | } 565 | 566 | /** 567 | * Searches for a track using the manager and returns resolved tracks. 568 | * @param query The search query (artist - title). 569 | * @param requester The requester who initiated the search. 570 | * @returns An array of resolved tracks, or an empty array if not found or error occurred. 571 | */ 572 | private static async resolveTracksFromQuery(query: string, source: SearchPlatform, requester: unknown): Promise { 573 | try { 574 | const searchResult = await this.manager.search({ query, source }, requester); 575 | 576 | if (TrackUtils.isErrorOrEmptySearchResult(searchResult)) { 577 | return []; 578 | } 579 | 580 | switch (searchResult.loadType) { 581 | case LoadTypes.Album: 582 | case LoadTypes.Artist: 583 | case LoadTypes.Station: 584 | case LoadTypes.Podcast: 585 | case LoadTypes.Show: 586 | case LoadTypes.Playlist: 587 | return searchResult.playlist.tracks; 588 | case LoadTypes.Track: 589 | case LoadTypes.Search: 590 | case LoadTypes.Short: 591 | return searchResult.tracks; 592 | default: 593 | return []; 594 | } 595 | } catch (error) { 596 | console.error("[TrackResolver] Failed to resolve query:", query, error); 597 | return []; 598 | } 599 | } 600 | 601 | /** 602 | * Resolves the first available track from a search query using the specified source. 603 | * Useful for normalizing tracks that lack platform-specific metadata or URIs. 604 | * 605 | * @param query - The search query string (usually "Artist - Title"). 606 | * @param source - The search platform to use (e.g., Spotify, Deezer, YouTube). 607 | * @param requester - The requester object, used for context or attribution. 608 | * @returns A single resolved {@link Track} object if found, or `null` if the search fails or returns no results. 609 | */ 610 | private static async resolveFirstTrackFromQuery(query: string, source: SearchPlatform, requester: unknown): Promise { 611 | try { 612 | const searchResult = await this.manager.search({ query, source }, requester); 613 | 614 | if (TrackUtils.isErrorOrEmptySearchResult(searchResult)) return null; 615 | 616 | switch (searchResult.loadType) { 617 | case LoadTypes.Album: 618 | case LoadTypes.Artist: 619 | case LoadTypes.Station: 620 | case LoadTypes.Podcast: 621 | case LoadTypes.Show: 622 | case LoadTypes.Playlist: 623 | return searchResult.playlist.tracks[0] || null; 624 | case LoadTypes.Track: 625 | case LoadTypes.Search: 626 | case LoadTypes.Short: 627 | return searchResult.tracks[0] || null; 628 | default: 629 | return null; 630 | } 631 | } catch (err) { 632 | console.error(`[AutoPlay] Failed to resolve track from query: "${query}" on source: ${source}`, err); 633 | return null; 634 | } 635 | } 636 | 637 | private static isPlaylistRawData(data: unknown): data is PlaylistRawData { 638 | return typeof data === "object" && data !== null && Array.isArray((data as PlaylistRawData).tracks); 639 | } 640 | 641 | private static isTrackData(data: unknown): data is TrackData { 642 | return typeof data === "object" && data !== null && "encoded" in data && "info" in data; 643 | } 644 | 645 | private static isTrackDataArray(data: unknown): data is TrackData[] { 646 | return ( 647 | Array.isArray(data) && 648 | data.every((track) => typeof track === "object" && track !== null && "encoded" in track && "info" in track && typeof track.encoded === "string") 649 | ); 650 | } 651 | 652 | static buildTracksFromResponse(recommendedResult: LavalinkResponse, requester?: T): Track[] { 653 | if (!recommendedResult) return []; 654 | 655 | if (TrackUtils.isErrorOrEmptySearchResult(recommendedResult as unknown as SearchResult)) return []; 656 | 657 | switch (recommendedResult.loadType) { 658 | case LoadTypes.Track: { 659 | const data = recommendedResult.data; 660 | 661 | if (!this.isTrackData(data)) { 662 | throw new MagmaStreamError({ 663 | code: MagmaStreamErrorCode.UTILS_AUTOPLAY_BUILD_FAILED, 664 | message: "Invalid TrackData object.", 665 | context: { recommendedResult }, 666 | }); 667 | } 668 | 669 | return [TrackUtils.build(data, requester)]; 670 | } 671 | 672 | case LoadTypes.Short: 673 | case LoadTypes.Search: { 674 | const data = recommendedResult.data; 675 | 676 | if (!this.isTrackDataArray(data)) { 677 | throw new MagmaStreamError({ 678 | code: MagmaStreamErrorCode.UTILS_AUTOPLAY_BUILD_FAILED, 679 | message: "Invalid TrackData[] array for LoadTypes.Search or Short.", 680 | context: { recommendedResult }, 681 | }); 682 | } 683 | 684 | return data.map((d) => TrackUtils.build(d, requester)); 685 | } 686 | case LoadTypes.Album: 687 | case LoadTypes.Artist: 688 | case LoadTypes.Station: 689 | case LoadTypes.Podcast: 690 | case LoadTypes.Show: 691 | case LoadTypes.Playlist: { 692 | const data = recommendedResult.data; 693 | 694 | if (this.isPlaylistRawData(data)) { 695 | return data.tracks.map((d) => TrackUtils.build(d, requester)); 696 | } 697 | 698 | throw new MagmaStreamError({ 699 | code: MagmaStreamErrorCode.UTILS_AUTOPLAY_BUILD_FAILED, 700 | message: "Invalid playlist data for loadType: " + recommendedResult.loadType, 701 | context: { recommendedResult }, 702 | }); 703 | } 704 | default: 705 | throw new MagmaStreamError({ 706 | code: MagmaStreamErrorCode.UTILS_AUTOPLAY_BUILD_FAILED, 707 | message: "Unsupported loadType: " + recommendedResult.loadType, 708 | context: { recommendedResult }, 709 | }); 710 | } 711 | } 712 | } 713 | 714 | export abstract class PlayerUtils { 715 | private static manager: Manager; 716 | 717 | /** 718 | * Initializes the PlayerUtils class with the given manager. 719 | * @param manager The manager instance to use. 720 | * @hidden 721 | */ 722 | public static init(manager: Manager): void { 723 | if (!manager) { 724 | throw new MagmaStreamError({ 725 | code: MagmaStreamErrorCode.GENERAL_INVALID_MANAGER, 726 | message: "PlayerUtils requires a valid Manager instance.", 727 | }); 728 | } 729 | this.manager = manager; 730 | } 731 | 732 | /** 733 | * Serializes a Player instance to avoid circular references. 734 | * @param player The Player instance to serialize 735 | * @returns The serialized Player instance 736 | */ 737 | public static async serializePlayer(player: Player): Promise> { 738 | try { 739 | const current = await player.queue.getCurrent(); 740 | const tracks = await player.queue.getTracks(); 741 | const previous = await player.queue.getPrevious(); 742 | 743 | const serializeTrack = (track: Track) => ({ 744 | ...track, 745 | requester: track.requester ? { id: track.requester.id, username: track.requester.username } : null, 746 | }); 747 | 748 | const safeNode = player.node 749 | ? JSON.parse( 750 | JSON.stringify(player.node, (key, value) => { 751 | if (key === "rest" || key === "players" || key === "shards" || key === "manager") return undefined; 752 | return value; 753 | }) 754 | ) 755 | : null; 756 | 757 | return JSON.parse( 758 | JSON.stringify(player, (key, value) => { 759 | if (key === "manager") return null; 760 | 761 | if (key === "node") return safeNode; 762 | 763 | if (key === "filters") { 764 | return { 765 | distortion: value?.distortion ?? null, 766 | equalizer: value?.equalizer ?? [], 767 | karaoke: value?.karaoke ?? null, 768 | rotation: value?.rotation ?? null, 769 | timescale: value?.timescale ?? null, 770 | vibrato: value?.vibrato ?? null, 771 | reverb: value?.reverb ?? null, 772 | volume: value?.volume ?? 1.0, 773 | bassBoostlevel: value?.bassBoostlevel ?? null, 774 | filterStatus: value?.filtersStatus ? { ...value.filtersStatus } : {}, 775 | }; 776 | } 777 | 778 | if (key === "queue") { 779 | return { 780 | current: current ? serializeTrack(current) : null, 781 | tracks: tracks.map(serializeTrack), 782 | previous: previous.map(serializeTrack), 783 | }; 784 | } 785 | 786 | if (key === "data") { 787 | return { 788 | clientUser: value?.Internal_AutoplayUser ?? null, 789 | nowPlayingMessage: value?.nowPlayingMessage ?? null, 790 | }; 791 | } 792 | 793 | return value; 794 | }) 795 | ); 796 | } catch (err) { 797 | throw err instanceof MagmaStreamError 798 | ? err 799 | : new MagmaStreamError({ 800 | code: MagmaStreamErrorCode.MANAGER_SEARCH_FAILED, 801 | message: `An error occurred while searching: ${err instanceof Error ? err.message : String(err)}`, 802 | cause: err instanceof Error ? err : undefined, 803 | }); 804 | } 805 | } 806 | 807 | /** 808 | * Gets the base directory for player data. 809 | */ 810 | public static getPlayersBaseDir(): string { 811 | return path.join(process.cwd(), "magmastream", "sessionData", "players"); 812 | } 813 | 814 | /** 815 | * Gets the path to the player's directory. 816 | */ 817 | public static getGuildDir(guildId: string): string { 818 | return path.join(this.getPlayersBaseDir(), guildId); 819 | } 820 | 821 | /** 822 | * Gets the path to the player's state file. 823 | */ 824 | public static getPlayerStatePath(guildId: string): string { 825 | return path.join(this.getGuildDir(guildId), "state.json"); 826 | } 827 | 828 | /** 829 | * Gets the path to the player's current track file. 830 | */ 831 | public static getPlayerCurrentPath(guildId: string): string { 832 | return path.join(this.getGuildDir(guildId), "current.json"); 833 | } 834 | 835 | /** 836 | * Gets the path to the player's queue file. 837 | */ 838 | public static getPlayerQueuePath(guildId: string): string { 839 | return path.join(this.getGuildDir(guildId), "queue.json"); 840 | } 841 | 842 | /** 843 | * Gets the path to the player's previous tracks file. 844 | */ 845 | public static getPlayerPreviousPath(guildId: string): string { 846 | return path.join(this.getGuildDir(guildId), "previous.json"); 847 | } 848 | } 849 | /** Gets or extends structures to extend the built in, or already extended, classes to add more functionality. */ 850 | export abstract class Structure { 851 | /** 852 | * Extends a class. 853 | * @param name 854 | * @param extender 855 | */ 856 | public static extend(name: K, extender: (target: Extendable[K]) => T): T { 857 | if (!structures[name]) throw new TypeError(`"${name} is not a valid structure`); 858 | const extended = extender(structures[name]); 859 | structures[name] = extended; 860 | return extended; 861 | } 862 | 863 | /** 864 | * Get a structure from available structures by name. 865 | * @param name 866 | */ 867 | public static get(name: K): Extendable[K] { 868 | const structure = structures[name]; 869 | if (!structure) throw new TypeError('"structure" must be provided.'); 870 | return structure; 871 | } 872 | } 873 | 874 | export abstract class JSONUtils { 875 | static safe(obj: T, space?: number): string { 876 | return stringify(obj, null, space); 877 | } 878 | 879 | static serializeTrack(track: Track) { 880 | const serialized = { 881 | ...track, 882 | requester: track.requester ? { id: track.requester.id, username: track.requester.username } : null, 883 | }; 884 | 885 | return JSON.stringify(serialized); 886 | } 887 | } 888 | 889 | const structures = { 890 | Player: require("./Player").Player, 891 | Queue: require("../statestorage/MemoryQueue").MemoryQueue, 892 | Node: require("./Node").Node, 893 | Filters: require("./Filters").Filters, 894 | Manager: require("./Manager").Manager, 895 | Plugin: require("./Plugin").Plugin, 896 | Rest: require("./Rest").Rest, 897 | Utils: require("./Utils"), 898 | }; 899 | --------------------------------------------------------------------------------