Merge pull request #806 from Androz2091/develop

feat: better stream management
This commit is contained in:
and 2021-11-28 15:16:40 +05:45 committed by GitHub
commit 63bfb17e4b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 1711 additions and 1914 deletions

View file

@ -32,11 +32,12 @@ $ npm install --save @discordjs/opus
- Simple & easy to use 🤘 - Simple & easy to use 🤘
- Beginner friendly 😱 - Beginner friendly 😱
- Audio filters 🎸 - Audio filters 🎸
- Lightweight 🛬 - Lightweight ☁️
- Custom extractors support 🌌 - Custom extractors support 🌌
- Lyrics 📃
- Multiple sources support ✌ - Multiple sources support ✌
- Play in multiple servers at the same time 🚗 - Play in multiple servers at the same time 🚗
- Does not inject anything to discord.js or your discord.js client 💉
- Allows you to have full control over what is going to be streamed 👑
## [Documentation](https://discord-player.js.org) ## [Documentation](https://discord-player.js.org)
@ -99,7 +100,7 @@ client.once("ready", () => {
client.on("interactionCreate", async (interaction) => { client.on("interactionCreate", async (interaction) => {
if (!interaction.isCommand()) return; if (!interaction.isCommand()) return;
// /play Despacito // /play track:Despacito
// will play "Despacito" in the voice channel // will play "Despacito" in the voice channel
if (interaction.commandName === "play") { if (interaction.commandName === "play") {
if (!interaction.member.voice.channelId) return await interaction.reply({ content: "You are not in a voice channel!", ephemeral: true }); if (!interaction.member.voice.channelId) return await interaction.reply({ content: "You are not in a voice channel!", ephemeral: true });
@ -156,9 +157,12 @@ You just need to install it using `npm i --save @discord-player/extractor` (disc
These bots are made by the community, they can help you build your own! These bots are made by the community, they can help you build your own!
* **[Discord Music Bot](https://github.com/Androz2091/discord-music-bot)** by [Androz2091](https://github.com/Androz2091) * **[Discord Music Bot](https://github.com/Androz2091/discord-music-bot)** by [Androz2091](https://github.com/Androz2091)
* [Dodong](https://github.com/nizeic/Dodong) by [nizeic](https://github.com/nizeic)
* [Musico](https://github.com/Whirl21/Musico) by [Whirl21](https://github.com/Whirl21)
* [Eyesense-Music-Bot](https://github.com/naseif/Eyesense-Music-Bot) by [naseif](https://github.com/naseif)
* [Music-bot](https://github.com/ZerioDev/Music-bot) by [ZerioDev](https://github.com/ZerioDev)
* [AtlantaBot](https://github.com/Androz2091/AtlantaBot) by [Androz2091](https://github.com/Androz2091) (**outdated**) * [AtlantaBot](https://github.com/Androz2091/AtlantaBot) by [Androz2091](https://github.com/Androz2091) (**outdated**)
* [Discord-Music](https://github.com/inhydrox/discord-music) by [inhydrox](https://github.com/inhydrox) (**outdated**) * [Discord-Music](https://github.com/inhydrox/discord-music) by [inhydrox](https://github.com/inhydrox) (**outdated**)
* [Music-bot](https://github.com/ZerioDev/Music-bot) by [ZerioDev](https://github.com/ZerioDev) (**outdated**)
## Advanced ## Advanced
@ -191,3 +195,32 @@ const player = new Player(client, {
} }
}); });
``` ```
> You may also create a simple proxy server and forward requests through it.
> See **[https://github.com/http-party/node-http-proxy](https://github.com/http-party/node-http-proxy)** for more info.
### Custom stream Engine
Discord Player by default uses **[node-ytdl-core](https://github.com/fent/node-ytdl-core)** for youtube and some other extractors for other sources.
If you need to modify this behavior without touching extractors, you need to use `createStream` functionality of discord player.
Here's an example on how you can use **[play-dl](https://npmjs.com/package/play-dl)** to download youtube streams instead of using ytdl-core.
```js
const playdl = require("play-dl");
// other code
const queue = player.createQueue(..., {
...,
async onBeforeCreateStream(track, source, _queue) {
// only trap youtube source
if (source === "youtube") {
// track here would be youtube track
return (await playdl.stream(track.url)).stream;
// we must return readable stream or void (returning void means telling discord-player to look for default extractor)
}
}
});
```
`<Queue>.onBeforeCreateStream` is called before actually downloading the stream. It is a different concept from extractors, where you are **just** downloading
streams. `source` here will be a video source. Streams from `onBeforeCreateStream` are then piped to `FFmpeg` and finally sent to Discord voice servers.

View file

@ -0,0 +1,51 @@
# Create Stream
This is a checkpoint where discord-player calls `createStream` before downloading stream.
### Custom stream Engine
Discord Player by default uses **[node-ytdl-core](https://github.com/fent/node-ytdl-core)** for youtube and some other extractors for other sources.
If you need to modify this behavior without touching extractors, you need to use `createStream` functionality of discord player.
Here's an example on how you can use **[play-dl](https://npmjs.com/package/play-dl)** to download youtube streams instead of using ytdl-core.
```js
const playdl = require("play-dl");
// other code
const queue = player.createQueue(..., {
...,
async onBeforeCreateStream(track, source, _queue) {
// only trap youtube source
if (source === "youtube") {
// track here would be youtube track
return (await playdl.stream(track.url)).stream;
// we must return readable stream or void (returning void means telling discord-player to look for default extractor)
}
}
});
```
`<Queue>.onBeforeCreateStream` is called before actually downloading the stream. It is a different concept from extractors, where you are **just** downloading
streams. `source` here will be a video source. Streams from `onBeforeCreateStream` are then piped to `FFmpeg` and finally sent to Discord voice servers.
# FAQ
## How can I remove this?
> If you already made this change and want to switch to default mode in runtime,
> you can set `queue.onBeforeCreateStream` to `null` which will make `discord-player` use default config.
## Which stream format should I return?
> It's not necessary to return opus format or whatever, since every streams have to be converted to s16le, due to inline volume.
## Can I use ytdl-core-discord?
> Yes, you can.
## Can I use this for other sources, like soundcloud?
> Absolutely.
## This is not working properly
> `onBeforeCreateStream` may not work properly if you have `spotifyBridge` enabled (enabled by default).

View file

@ -0,0 +1,26 @@
# How does Discord Player actually work?
- Discord Player can be used by first initializing `Player` class with your discord.js client. Discord Player uses `Queue` to assign queue manager to individual guild.
Which means each guild will have its own queue object. Every player action has to go through the `Queue` object for example, `play`, `pause`, `volume` etc.
- When `Player` is initialized, it creates a cache to store external extractors or queues information. Queue is created by calling `createQueue` method of `Player` instance.
A client should have only one `Player` instance, otherwise it will be complicated to track queues and other metadata.
- Searching tracks can be done via `search` method of `Player` instance. Discord Player offers `search engine` option to target specific searches. Discord Player first
calls all the registered extractors first with the search query. If all external extractors failed to validate the query, player then passes the query to built-in extractors.
Invalid or unknown queries may return `arbitrary` result.
- The track result obtained from `search` can be loaded into `Queue` by calling `queue.addTrack`/`queue.addTracks`/`queue.play`.
- Player calls `onBeforeCreateStream` if user has enabled the function while creating queue. This method runs each time before stream is downloaded. Users may use it
to modify how and which stream will be played.
- Queue is based on FIFO method (First In First Out)
- Final stream is a pcm format, required for volume controls which is created by Discord Player itself.
- Since inline volume is enabled by default for volume controls, you may face more resource usage.
- You can disable inline volume for better performance but setting volume won't work and current volume will always be 100.
- All the audio filters are handled by FFmpeg and stream has to reload in order to update filters.

View file

@ -11,12 +11,16 @@
files: files:
- name: Extractors API - name: Extractors API
path: extractor.md path: extractor.md
- name: Creating Stream
path: create_stream.md
- name: FAQ - name: FAQ
files: files:
- name: Custom Filters - name: Custom Filters
path: custom_filters.md path: custom_filters.md
- name: Slash Commands - name: Slash Commands
path: slash_commands.md path: slash_commands.md
- name: How Does It Work
path: how_does_it_work.md
- name: YouTube - name: YouTube
files: files:
- name: Using Cookies - name: Using Cookies

View file

@ -14,3 +14,6 @@ const player = new Player(client, {
} }
}); });
``` ```
> You may also create a simple proxy server and forward requests through it.
> See **[https://github.com/http-party/node-http-proxy](https://github.com/http-party/node-http-proxy)** for more info.

View file

@ -22,6 +22,7 @@
"docs": "docgen --jsdoc jsdoc.json --source src/*.ts src/**/*.ts --custom docs/index.yml --output docs/docs.json", "docs": "docgen --jsdoc jsdoc.json --source src/*.ts src/**/*.ts --custom docs/index.yml --output docs/docs.json",
"docs:test": "docgen --jsdoc jsdoc.json --source src/*.ts src/**/*.ts --custom docs/index.yml", "docs:test": "docgen --jsdoc jsdoc.json --source src/*.ts src/**/*.ts --custom docs/index.yml",
"lint": "eslint src --ext .ts", "lint": "eslint src --ext .ts",
"prepare": "husky install",
"lint:fix": "eslint src --ext .ts --fix" "lint:fix": "eslint src --ext .ts --fix"
}, },
"funding": "https://github.com/Androz2091/discord-player?sponsor=1", "funding": "https://github.com/Androz2091/discord-player?sponsor=1",
@ -59,37 +60,37 @@
}, },
"homepage": "https://discord-player.js.org", "homepage": "https://discord-player.js.org",
"dependencies": { "dependencies": {
"@discordjs/voice": "^0.6.0", "@discordjs/voice": "^0.7.5",
"discord-ytdl-core": "^5.0.4", "discord-ytdl-core": "^5.0.4",
"libsodium-wrappers": "^0.7.9", "libsodium-wrappers": "^0.7.9",
"soundcloud-scraper": "^5.0.2", "soundcloud-scraper": "^5.0.2",
"spotify-url-info": "^2.2.3", "spotify-url-info": "^2.2.3",
"tiny-typed-emitter": "^2.1.0", "tiny-typed-emitter": "^2.1.0",
"youtube-sr": "^4.1.7", "youtube-sr": "^4.1.9",
"ytdl-core": "^4.9.1" "ytdl-core": "^4.9.1"
}, },
"devDependencies": { "devDependencies": {
"@babel/cli": "^7.14.8", "@babel/cli": "^7.16.0",
"@babel/core": "^7.15.0", "@babel/core": "^7.16.0",
"@babel/preset-env": "^7.15.0", "@babel/preset-env": "^7.16.4",
"@babel/preset-typescript": "^7.15.0", "@babel/preset-typescript": "^7.16.0",
"@devsnowflake/docgen": "devsnowflake/docgen#ts-patch", "@devsnowflake/docgen": "devsnowflake/docgen#ts-patch",
"@discord-player/extractor": "^3.0.2", "@discord-player/extractor": "^3.0.2",
"@discordjs/opus": "github:discordjs/opus", "@discordjs/opus": "github:discordjs/opus",
"@favware/rollup-type-bundler": "^1.0.3", "@favware/rollup-type-bundler": "^1.0.6",
"@types/node": "^16.6.2", "@types/node": "^16.11.10",
"@types/ws": "^7.4.7", "@types/ws": "^8.2.0",
"@typescript-eslint/eslint-plugin": "^4.29.2", "@typescript-eslint/eslint-plugin": "^5.4.0",
"@typescript-eslint/parser": "^4.29.2", "@typescript-eslint/parser": "^5.4.0",
"discord-api-types": "^0.22.0", "discord-api-types": "^0.24.0",
"discord.js": "^13.1.0", "discord.js": "^13.3.1",
"eslint": "^7.32.0", "eslint": "^8.3.0",
"gen-esm-wrapper": "^1.1.2", "gen-esm-wrapper": "^1.1.3",
"husky": "^7.0.1", "husky": "^7.0.4",
"jsdoc-babel": "^0.5.0", "jsdoc-babel": "^0.5.0",
"prettier": "^2.3.2", "prettier": "^2.5.0",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"ts-node": "^10.2.1", "ts-node": "^10.4.0",
"typescript": "^4.3.5" "typescript": "^4.5.2"
} }
} }

View file

@ -2,7 +2,7 @@ import { Client, Collection, GuildResolvable, Snowflake, User, VoiceState, Inten
import { TypedEmitter as EventEmitter } from "tiny-typed-emitter"; import { TypedEmitter as EventEmitter } from "tiny-typed-emitter";
import { Queue } from "./Structures/Queue"; import { Queue } from "./Structures/Queue";
import { VoiceUtils } from "./VoiceInterface/VoiceUtils"; import { VoiceUtils } from "./VoiceInterface/VoiceUtils";
import { PlayerEvents, PlayerOptions, QueryType, SearchOptions, PlayerInitOptions } from "./types/types"; import { PlayerEvents, PlayerOptions, QueryType, SearchOptions, PlayerInitOptions, PlayerSearchResult } from "./types/types";
import Track from "./Structures/Track"; import Track from "./Structures/Track";
import { QueryResolver } from "./utils/QueryResolver"; import { QueryResolver } from "./utils/QueryResolver";
import YouTube from "youtube-sr"; import YouTube from "youtube-sr";
@ -29,6 +29,7 @@ class Player extends EventEmitter<PlayerEvents> {
public readonly queues = new Collection<Snowflake, Queue>(); public readonly queues = new Collection<Snowflake, Queue>();
public readonly voiceUtils = new VoiceUtils(); public readonly voiceUtils = new VoiceUtils();
public readonly extractors = new Collection<string, ExtractorModel>(); public readonly extractors = new Collection<string, ExtractorModel>();
public requiredEvents = ["error", "connectionError"] as string[];
/** /**
* Creates new Discord Player * Creates new Discord Player
@ -77,51 +78,65 @@ class Player extends EventEmitter<PlayerEvents> {
if (!queue) return; if (!queue) return;
if (oldState.channelId && newState.channelId && oldState.channelId !== newState.channelId) { if (oldState.channelId && newState.channelId && oldState.channelId !== newState.channelId) {
if (queue?.connection) queue.connection.channel = newState.channel; if (queue?.connection && newState.member.id === newState.guild.me.id) queue.connection.channel = newState.channel;
} if (newState.member.id === newState.guild.me.id || (newState.member.id !== newState.guild.me.id && oldState.channelId === queue.connection.channel.id)) {
if (!oldState.channelId && newState.channelId && newState.member.id === newState.guild.me.id) {
if (newState.serverMute || !newState.serverMute) {
queue.setPaused(newState.serverMute);
} else if (newState.suppress || !newState.suppress) {
if (newState.suppress) newState.guild.me.voice.setRequestToSpeak(true).catch(Util.noop);
queue.setPaused(newState.suppress);
}
}
if (oldState.channelId === newState.channelId && oldState.member.id === newState.guild.me.id) {
if (oldState.serverMute !== newState.serverMute) {
queue.setPaused(newState.serverMute);
} else if (oldState.suppress !== newState.suppress) {
if (newState.suppress) newState.guild.me.voice.setRequestToSpeak(true).catch(Util.noop);
queue.setPaused(newState.suppress);
}
}
if (oldState.member.id === this.client.user.id && !newState.channelId) {
queue.destroy();
return void this.emit("botDisconnect", queue);
}
if (!queue.connection || !queue.connection.channel) return;
if (!oldState.channelId || newState.channelId) {
const emptyTimeout = queue._cooldownsTimeout.get(`empty_${oldState.guild.id}`);
const channelEmpty = Util.isVoiceEmpty(queue.connection.channel);
if (!channelEmpty && emptyTimeout) {
clearTimeout(emptyTimeout);
queue._cooldownsTimeout.delete(`empty_${oldState.guild.id}`);
}
} else {
if (!Util.isVoiceEmpty(queue.connection.channel)) return;
const timeout = setTimeout(() => {
if (!Util.isVoiceEmpty(queue.connection.channel)) return; if (!Util.isVoiceEmpty(queue.connection.channel)) return;
if (!this.queues.has(queue.guild.id)) return; const timeout = setTimeout(() => {
if (queue.options.leaveOnEmpty) queue.destroy(); if (!Util.isVoiceEmpty(queue.connection.channel)) return;
this.emit("channelEmpty", queue); if (!this.queues.has(queue.guild.id)) return;
}, queue.options.leaveOnEmptyCooldown || 0).unref(); if (queue.options.leaveOnEmpty) queue.destroy();
queue._cooldownsTimeout.set(`empty_${oldState.guild.id}`, timeout); this.emit("channelEmpty", queue);
}, queue.options.leaveOnEmptyCooldown || 0).unref();
queue._cooldownsTimeout.set(`empty_${oldState.guild.id}`, timeout);
}
if (!oldState.channelId && newState.channelId && newState.member.id === newState.guild.me.id) {
if (newState.serverMute || !newState.serverMute) {
queue.setPaused(newState.serverMute);
} else if (newState.suppress || !newState.suppress) {
if (newState.suppress) newState.guild.me.voice.setRequestToSpeak(true).catch(Util.noop);
queue.setPaused(newState.suppress);
}
}
if (oldState.channelId === newState.channelId && oldState.member.id === newState.guild.me.id) {
if (oldState.serverMute !== newState.serverMute) {
queue.setPaused(newState.serverMute);
} else if (oldState.suppress !== newState.suppress) {
if (newState.suppress) newState.guild.me.voice.setRequestToSpeak(true).catch(Util.noop);
queue.setPaused(newState.suppress);
}
}
if (oldState.member.id === this.client.user.id && !newState.channelId) {
queue.destroy();
return void this.emit("botDisconnect", queue);
}
if (!queue.connection || !queue.connection.channel) return;
if (!oldState.channelId || newState.channelId) {
const emptyTimeout = queue._cooldownsTimeout.get(`empty_${oldState.guild.id}`);
const channelEmpty = Util.isVoiceEmpty(queue.connection.channel);
if (newState.channelId === queue.connection.channel.id) {
if (!channelEmpty && emptyTimeout) {
clearTimeout(emptyTimeout);
queue._cooldownsTimeout.delete(`empty_${oldState.guild.id}`);
}
}
} else {
if (oldState.channelId === queue.connection.channel.id) {
if (!Util.isVoiceEmpty(queue.connection.channel)) return;
const timeout = setTimeout(() => {
if (!Util.isVoiceEmpty(queue.connection.channel)) return;
if (!this.queues.has(queue.guild.id)) return;
if (queue.options.leaveOnEmpty) queue.destroy();
this.emit("channelEmpty", queue);
}, queue.options.leaveOnEmptyCooldown || 0).unref();
queue._cooldownsTimeout.set(`empty_${oldState.guild.id}`, timeout);
}
}
} }
} }
@ -176,7 +191,7 @@ class Player extends EventEmitter<PlayerEvents> {
} }
/** /**
* @typedef {object} SearchResult * @typedef {object} PlayerSearchResult
* @property {Playlist} [playlist] The playlist (if any) * @property {Playlist} [playlist] The playlist (if any)
* @property {Track[]} tracks The tracks * @property {Track[]} tracks The tracks
*/ */
@ -184,13 +199,40 @@ class Player extends EventEmitter<PlayerEvents> {
* Search tracks * Search tracks
* @param {string|Track} query The search query * @param {string|Track} query The search query
* @param {SearchOptions} options The search options * @param {SearchOptions} options The search options
* @returns {Promise<SearchResult>} * @returns {Promise<PlayerSearchResult>}
*/ */
async search(query: string | Track, options: SearchOptions) { async search(query: string | Track, options: SearchOptions): Promise<PlayerSearchResult> {
if (query instanceof Track) return { playlist: null, tracks: [query] }; if (query instanceof Track) return { playlist: query.playlist || null, tracks: [query] };
if (!options) throw new PlayerError("DiscordPlayer#search needs search options!", ErrorStatusCode.INVALID_ARG_TYPE); if (!options) throw new PlayerError("DiscordPlayer#search needs search options!", ErrorStatusCode.INVALID_ARG_TYPE);
options.requestedBy = this.client.users.resolve(options.requestedBy); options.requestedBy = this.client.users.resolve(options.requestedBy);
if (!("searchEngine" in options)) options.searchEngine = QueryType.AUTO; if (!("searchEngine" in options)) options.searchEngine = QueryType.AUTO;
if (typeof options.searchEngine === "string" && this.extractors.has(options.searchEngine)) {
const extractor = this.extractors.get(options.searchEngine);
if (!extractor.validate(query)) return { playlist: null, tracks: [] };
const data = await extractor.handle(query);
if (data && data.data.length) {
const playlist = !data.playlist
? null
: new Playlist(this, {
...data.playlist,
tracks: []
});
const tracks = data.data.map(
(m) =>
new Track(this, {
...m,
requestedBy: options.requestedBy as User,
duration: Util.buildTimeCode(Util.parseMS(m.duration)),
playlist: playlist
})
);
if (playlist) playlist.tracks = tracks;
return { playlist: playlist, tracks: tracks };
}
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
for (const [_, extractor] of this.extractors) { for (const [_, extractor] of this.extractors) {
@ -224,7 +266,7 @@ class Player extends EventEmitter<PlayerEvents> {
const qt = options.searchEngine === QueryType.AUTO ? QueryResolver.resolve(query) : options.searchEngine; const qt = options.searchEngine === QueryType.AUTO ? QueryResolver.resolve(query) : options.searchEngine;
switch (qt) { switch (qt) {
case QueryType.YOUTUBE_VIDEO: { case QueryType.YOUTUBE_VIDEO: {
const info = await ytdlGetInfo(query).catch(Util.noop); const info = await ytdlGetInfo(query, this.options.ytdlOptions).catch(Util.noop);
if (!info) return { playlist: null, tracks: [] }; if (!info) return { playlist: null, tracks: [] };
const track = new Track(this, { const track = new Track(this, {
@ -509,7 +551,25 @@ class Player extends EventEmitter<PlayerEvents> {
* @returns {string} * @returns {string}
*/ */
scanDeps() { scanDeps() {
return generateDependencyReport(); const line = "-".repeat(50);
const depsReport = generateDependencyReport();
const extractorReport = this.extractors
.map((m) => {
return `${m.name} :: ${m.version || "0.1.0"}`;
})
.join("\n");
return `${depsReport}\n${line}\nLoaded Extractors:\n${extractorReport || "None"}`;
}
emit<U extends keyof PlayerEvents>(eventName: U, ...args: Parameters<PlayerEvents[U]>): boolean {
if (this.requiredEvents.includes(eventName) && !super.eventNames().includes(eventName)) {
// eslint-disable-next-line no-console
console.error(...args);
process.emitWarning(`[DiscordPlayerWarning] Unhandled "${eventName}" event! Events ${this.requiredEvents.map((m) => `"${m}"`).join(", ")} must have event listeners!`);
return false;
} else {
return super.emit(eventName, ...args);
}
} }
/** /**

View file

@ -39,7 +39,12 @@ export class PlayerError extends Error {
} }
toJSON() { toJSON() {
return { stack: this.stack, code: this.statusCode, created: this.createdTimestamp }; return {
stack: this.stack,
code: this.statusCode,
message: this.message,
created: this.createdTimestamp
};
} }
toString() { toString() {

View file

@ -2,13 +2,14 @@ import { Collection, Guild, StageChannel, VoiceChannel, Snowflake, SnowflakeUtil
import { Player } from "../Player"; import { Player } from "../Player";
import { StreamDispatcher } from "../VoiceInterface/StreamDispatcher"; import { StreamDispatcher } from "../VoiceInterface/StreamDispatcher";
import Track from "./Track"; import Track from "./Track";
import { PlayerOptions, PlayerProgressbarOptions, PlayOptions, QueueFilters, QueueRepeatMode } from "../types/types"; import { PlayerOptions, PlayerProgressbarOptions, PlayOptions, QueueFilters, QueueRepeatMode, TrackSource } from "../types/types";
import ytdl from "discord-ytdl-core"; import ytdl from "discord-ytdl-core";
import { AudioResource, StreamType } from "@discordjs/voice"; import { AudioResource, StreamType } from "@discordjs/voice";
import { Util } from "../utils/Util"; import { Util } from "../utils/Util";
import YouTube from "youtube-sr"; import YouTube from "youtube-sr";
import AudioFilters from "../utils/AudioFilters"; import AudioFilters from "../utils/AudioFilters";
import { PlayerError, ErrorStatusCode } from "./PlayerError"; import { PlayerError, ErrorStatusCode } from "./PlayerError";
import type { Readable } from "stream";
class Queue<T = unknown> { class Queue<T = unknown> {
public readonly guild: Guild; public readonly guild: Guild;
@ -27,6 +28,7 @@ class Queue<T = unknown> {
private _filtersUpdate = false; private _filtersUpdate = false;
#lastVolume = 0; #lastVolume = 0;
#destroyed = false; #destroyed = false;
public onBeforeCreateStream: (track: Track, source: TrackSource, queue: Queue) => Promise<Readable | undefined> = null;
/** /**
* Queue constructor * Queue constructor
@ -103,11 +105,15 @@ class Queue<T = unknown> {
highWaterMark: 1 << 25 highWaterMark: 1 << 25
}, },
initialVolume: 100, initialVolume: 100,
bufferingTimeout: 3000 bufferingTimeout: 3000,
spotifyBridge: true,
disableVolume: false
} as PlayerOptions, } as PlayerOptions,
options options
); );
if ("onBeforeCreateStream" in this.options) this.onBeforeCreateStream = this.options.onBeforeCreateStream;
this.player.emit("debug", this, `Queue initialized:\n\n${this.player.scanDeps()}`); this.player.emit("debug", this, `Queue initialized:\n\n${this.player.scanDeps()}`);
} }
@ -189,16 +195,14 @@ class Queue<T = unknown> {
if (!this.tracks.length && this.repeatMode === QueueRepeatMode.OFF) { if (!this.tracks.length && this.repeatMode === QueueRepeatMode.OFF) {
if (this.options.leaveOnEnd) this.destroy(); if (this.options.leaveOnEnd) this.destroy();
this.player.emit("queueEnd", this); this.player.emit("queueEnd", this);
} else if (!this.tracks.length && this.repeatMode === QueueRepeatMode.AUTOPLAY) {
this._handleAutoplay(Util.last(this.previousTracks));
} else { } else {
if (this.repeatMode !== QueueRepeatMode.AUTOPLAY) { if (this.repeatMode === QueueRepeatMode.TRACK) return void this.play(Util.last(this.previousTracks), { immediate: true });
if (this.repeatMode === QueueRepeatMode.TRACK) return void this.play(Util.last(this.previousTracks), { immediate: true }); if (this.repeatMode === QueueRepeatMode.QUEUE) this.tracks.push(Util.last(this.previousTracks));
if (this.repeatMode === QueueRepeatMode.QUEUE) this.tracks.push(Util.last(this.previousTracks)); const nextTrack = this.tracks.shift();
const nextTrack = this.tracks.shift(); this.play(nextTrack, { immediate: true });
this.play(nextTrack, { immediate: true }); return;
return;
} else {
this._handleAutoplay(Util.last(this.previousTracks));
}
} }
}); });
@ -486,6 +490,17 @@ class Queue<T = unknown> {
return trackFound; return trackFound;
} }
/**
* Returns the index of the specified track. If found, returns the track index else returns -1.
* @param {number|Track|Snowflake} track The track
* @returns {number}
*/
getTrackPosition(track: number | Track | Snowflake) {
if (this.#watchDestroyed()) return;
if (typeof track === "number") return this.tracks[track] != null ? track : -1;
return this.tracks.findIndex((pred) => pred.id === (track instanceof Track ? track.id : track));
}
/** /**
* Jumps to particular track * Jumps to particular track
* @param {Track|number} track The track * @param {Track|number} track The track
@ -493,14 +508,26 @@ class Queue<T = unknown> {
*/ */
jump(track: Track | number): void { jump(track: Track | number): void {
if (this.#watchDestroyed()) return; if (this.#watchDestroyed()) return;
// remove the track if exists
const foundTrack = this.remove(track); const foundTrack = this.remove(track);
if (!foundTrack) throw new PlayerError("Track not found", ErrorStatusCode.TRACK_NOT_FOUND); if (!foundTrack) throw new PlayerError("Track not found", ErrorStatusCode.TRACK_NOT_FOUND);
// since we removed the existing track from the queue,
// we now have to place that to position 1 this.tracks.splice(0, 0, foundTrack);
// because we want to jump to that track
// this will skip current track and play the next one which will be the foundTrack return void this.skip();
this.tracks.splice(1, 0, foundTrack); }
/**
* Jumps to particular track, removing other tracks on the way
* @param {Track|number} track The track
* @returns {void}
*/
skipTo(track: Track | number): void {
if (this.#watchDestroyed()) return;
const trackIndex = this.getTrackPosition(track);
const removedTrack = this.remove(track);
if (!removedTrack) throw new PlayerError("Track not found", ErrorStatusCode.TRACK_NOT_FOUND);
this.tracks.splice(0, trackIndex, removedTrack);
return void this.skip(); return void this.skip();
} }
@ -511,6 +538,7 @@ class Queue<T = unknown> {
* @param {number} [index=0] The index where this track should be * @param {number} [index=0] The index where this track should be
*/ */
insert(track: Track, index = 0) { insert(track: Track, index = 0) {
if (this.#watchDestroyed()) return;
if (!track || !(track instanceof Track)) throw new PlayerError("track must be the instance of Track", ErrorStatusCode.INVALID_TRACK); if (!track || !(track instanceof Track)) throw new PlayerError("track must be the instance of Track", ErrorStatusCode.INVALID_TRACK);
if (typeof index !== "number" || index < 0 || !Number.isFinite(index)) throw new PlayerError(`Invalid index "${index}"`, ErrorStatusCode.INVALID_ARG_TYPE); if (typeof index !== "number" || index < 0 || !Number.isFinite(index)) throw new PlayerError(`Invalid index "${index}"`, ErrorStatusCode.INVALID_ARG_TYPE);
@ -606,38 +634,61 @@ class Queue<T = unknown> {
this.previousTracks.push(track); this.previousTracks.push(track);
} }
// TODO: remove discord-ytdl-core let stream = null;
let stream; const customDownloader = typeof this.onBeforeCreateStream === "function";
if (["youtube", "spotify"].includes(track.raw.source)) { if (["youtube", "spotify"].includes(track.raw.source)) {
if (track.raw.source === "spotify" && !track.raw.engine) { let spotifyResolved = false;
if (this.options.spotifyBridge && track.raw.source === "spotify" && !track.raw.engine) {
track.raw.engine = await YouTube.search(`${track.author} ${track.title}`, { type: "video" }) track.raw.engine = await YouTube.search(`${track.author} ${track.title}`, { type: "video" })
.then((x) => x[0].url) .then((x) => x[0].url)
.catch(() => null); .catch(() => null);
spotifyResolved = true;
} }
const link = track.raw.source === "spotify" ? track.raw.engine : track.url; const link = track.raw.source === "spotify" ? track.raw.engine : track.url;
if (!link) return void this.play(this.tracks.shift(), { immediate: true }); if (!link) return void this.play(this.tracks.shift(), { immediate: true });
stream = ytdl(link, { if (customDownloader) {
...this.options.ytdlOptions, stream = (await this.onBeforeCreateStream(track, spotifyResolved ? "youtube" : track.raw.source, this)) ?? null;
// discord-ytdl-core if (stream)
opusEncoded: false, stream = ytdl
fmt: "s16le", .arbitraryStream(stream, {
encoderArgs: options.encoderArgs ?? this._activeFilters.length ? ["-af", AudioFilters.create(this._activeFilters)] : [], opusEncoded: false,
seek: options.seek ? options.seek / 1000 : 0 fmt: "s16le",
}).on("error", (err) => { encoderArgs: options.encoderArgs ?? this._activeFilters.length ? ["-af", AudioFilters.create(this._activeFilters)] : [],
return err.message.toLowerCase().includes("premature close") ? null : this.player.emit("error", this, err); seek: options.seek ? options.seek / 1000 : 0
}); })
.on("error", (err) => {
return err.message.toLowerCase().includes("premature close") ? null : this.player.emit("error", this, err);
});
} else {
stream = ytdl(link, {
...this.options.ytdlOptions,
// discord-ytdl-core
opusEncoded: false,
fmt: "s16le",
encoderArgs: options.encoderArgs ?? this._activeFilters.length ? ["-af", AudioFilters.create(this._activeFilters)] : [],
seek: options.seek ? options.seek / 1000 : 0
}).on("error", (err) => {
return err.message.toLowerCase().includes("premature close") ? null : this.player.emit("error", this, err);
});
}
} else { } else {
const tryArb = (customDownloader && (await this.onBeforeCreateStream(track, track.raw.source || track.raw.engine, this))) || null;
const arbitrarySource = tryArb
? tryArb
: track.raw.source === "soundcloud"
? await track.raw.engine.downloadProgressive()
: typeof track.raw.engine === "function"
? await track.raw.engine()
: track.raw.engine;
stream = ytdl stream = ytdl
.arbitraryStream( .arbitraryStream(arbitrarySource, {
track.raw.source === "soundcloud" ? await track.raw.engine.downloadProgressive() : typeof track.raw.engine === "function" ? await track.raw.engine() : track.raw.engine, opusEncoded: false,
{ fmt: "s16le",
opusEncoded: false, encoderArgs: options.encoderArgs ?? this._activeFilters.length ? ["-af", AudioFilters.create(this._activeFilters)] : [],
fmt: "s16le", seek: options.seek ? options.seek / 1000 : 0
encoderArgs: options.encoderArgs ?? this._activeFilters.length ? ["-af", AudioFilters.create(this._activeFilters)] : [], })
seek: options.seek ? options.seek / 1000 : 0
}
)
.on("error", (err) => { .on("error", (err) => {
return err.message.toLowerCase().includes("premature close") ? null : this.player.emit("error", this, err); return err.message.toLowerCase().includes("premature close") ? null : this.player.emit("error", this, err);
}); });
@ -645,7 +696,8 @@ class Queue<T = unknown> {
const resource: AudioResource<Track> = this.connection.createStream(stream, { const resource: AudioResource<Track> = this.connection.createStream(stream, {
type: StreamType.Raw, type: StreamType.Raw,
data: track data: track,
disableVolume: Boolean(this.options.disableVolume)
}); });
if (options.seek) this._streamTime = options.seek; if (options.seek) this._streamTime = options.seek;

View file

@ -121,7 +121,7 @@ class Track {
* @type {Queue} * @type {Queue}
*/ */
get queue(): Queue { get queue(): Queue {
return this.player.queues.find((q) => q.tracks.includes(this)); return this.player.queues.find((q) => q.tracks.some((ab) => ab.id === this.id));
} }
/** /**
@ -173,7 +173,7 @@ class Track {
duration: this.duration, duration: this.duration,
durationMS: this.durationMS, durationMS: this.durationMS,
views: this.views, views: this.views,
requestedBy: this.requestedBy.id, requestedBy: this.requestedBy?.id,
playlist: hidePlaylist ? null : this.playlist?.toJSON() ?? null playlist: hidePlaylist ? null : this.playlist?.toJSON() ?? null
} as TrackJSON; } as TrackJSON;
} }

View file

@ -74,13 +74,21 @@ class StreamDispatcher extends EventEmitter<VoiceEvents> {
try { try {
await entersState(this.voiceConnection, VoiceConnectionStatus.Connecting, this.connectionTimeout); await entersState(this.voiceConnection, VoiceConnectionStatus.Connecting, this.connectionTimeout);
} catch { } catch {
this.voiceConnection.destroy(); try {
this.voiceConnection.destroy();
} catch (err) {
this.emit("error", err as AudioPlayerError);
}
} }
} else if (this.voiceConnection.rejoinAttempts < 5) { } else if (this.voiceConnection.rejoinAttempts < 5) {
await Util.wait((this.voiceConnection.rejoinAttempts + 1) * 5000); await Util.wait((this.voiceConnection.rejoinAttempts + 1) * 5000);
this.voiceConnection.rejoin(); this.voiceConnection.rejoin();
} else { } else {
this.voiceConnection.destroy(); try {
this.voiceConnection.destroy();
} catch (err) {
this.emit("error", err as AudioPlayerError);
}
} }
} else if (newState.status === VoiceConnectionStatus.Destroyed) { } else if (newState.status === VoiceConnectionStatus.Destroyed) {
this.end(); this.end();
@ -89,7 +97,13 @@ class StreamDispatcher extends EventEmitter<VoiceEvents> {
try { try {
await entersState(this.voiceConnection, VoiceConnectionStatus.Ready, this.connectionTimeout); await entersState(this.voiceConnection, VoiceConnectionStatus.Ready, this.connectionTimeout);
} catch { } catch {
if (this.voiceConnection.state.status !== VoiceConnectionStatus.Destroyed) this.voiceConnection.destroy(); if (this.voiceConnection.state.status !== VoiceConnectionStatus.Destroyed) {
try {
this.voiceConnection.destroy();
} catch (err) {
this.emit("error", err as AudioPlayerError);
}
}
} finally { } finally {
this.readyLock = false; this.readyLock = false;
} }
@ -119,11 +133,12 @@ class StreamDispatcher extends EventEmitter<VoiceEvents> {
* @returns {AudioResource} * @returns {AudioResource}
*/ */
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
createStream(src: Readable | Duplex | string, ops?: { type?: StreamType; data?: any }) { createStream(src: Readable | Duplex | string, ops?: { type?: StreamType; data?: any; disableVolume?: boolean }) {
this.audioResource = createAudioResource(src, { this.audioResource = createAudioResource(src, {
inputType: ops?.type ?? StreamType.Arbitrary, inputType: ops?.type ?? StreamType.Arbitrary,
metadata: ops?.data, metadata: ops?.data,
inlineVolume: true // we definitely need volume controls, right? // eslint-disable-next-line no-extra-boolean-cast
inlineVolume: !Boolean(ops?.disableVolume)
}); });
return this.audioResource; return this.audioResource;
@ -209,9 +224,8 @@ class StreamDispatcher extends EventEmitter<VoiceEvents> {
* @returns {boolean} * @returns {boolean}
*/ */
setVolume(value: number) { setVolume(value: number) {
if (!this.audioResource || isNaN(value) || value < 0 || value > Infinity) return false; if (!this.audioResource?.volume || isNaN(value) || value < 0 || value > Infinity) return false;
// ye boi logarithmic ✌
this.audioResource.volume.setVolumeLogarithmic(value / 100); this.audioResource.volume.setVolumeLogarithmic(value / 100);
return true; return true;
} }
@ -221,7 +235,7 @@ class StreamDispatcher extends EventEmitter<VoiceEvents> {
* @type {number} * @type {number}
*/ */
get volume() { get volume() {
if (!this.audioResource || !this.audioResource.volume) return 100; if (!this.audioResource?.volume) return 100;
const currentVol = this.audioResource.volume.volume; const currentVol = this.audioResource.volume.volume;
return Math.round(Math.pow(currentVol, 1 / 1.660964) * 100); return Math.round(Math.pow(currentVol, 1 / 1.660964) * 100);
} }

View file

@ -8,6 +8,11 @@ import { downloadOptions } from "ytdl-core";
export type FiltersName = keyof QueueFilters; export type FiltersName = keyof QueueFilters;
export interface PlayerSearchResult {
playlist: Playlist | null;
tracks: Track[];
}
/** /**
* @typedef {AudioFilters} QueueFilters * @typedef {AudioFilters} QueueFilters
*/ */
@ -128,6 +133,9 @@ export interface PlayerProgressbarOptions {
* @property {YTDLDownloadOptions} [ytdlOptions={}] The youtube download options * @property {YTDLDownloadOptions} [ytdlOptions={}] The youtube download options
* @property {number} [initialVolume=100] The initial player volume * @property {number} [initialVolume=100] The initial player volume
* @property {number} [bufferingTimeout=3000] Buffering timeout for the stream * @property {number} [bufferingTimeout=3000] Buffering timeout for the stream
* @property {boolean} [spotifyBridge=true] If player should bridge spotify source to youtube
* @property {boolean} [disableVolume=false] If player should disable inline volume
* @property {Function} [onBeforeCreateStream] Runs before creating stream
*/ */
export interface PlayerOptions { export interface PlayerOptions {
leaveOnEnd?: boolean; leaveOnEnd?: boolean;
@ -138,6 +146,9 @@ export interface PlayerOptions {
ytdlOptions?: downloadOptions; ytdlOptions?: downloadOptions;
initialVolume?: number; initialVolume?: number;
bufferingTimeout?: number; bufferingTimeout?: number;
spotifyBridge?: boolean;
disableVolume?: boolean;
onBeforeCreateStream?: (track: Track, source: TrackSource, queue: Queue) => Promise<Readable>;
} }
/** /**
@ -218,25 +229,25 @@ export interface ExtractorModelData {
* - YOUTUBE_SEARCH * - YOUTUBE_SEARCH
* - YOUTUBE_VIDEO * - YOUTUBE_VIDEO
* - SOUNDCLOUD_SEARCH * - SOUNDCLOUD_SEARCH
* @typedef {string} QueryType * @typedef {number} QueryType
*/ */
export enum QueryType { export enum QueryType {
AUTO = "auto", AUTO,
YOUTUBE = "youtube", YOUTUBE,
YOUTUBE_PLAYLIST = "youtube_playlist", YOUTUBE_PLAYLIST,
SOUNDCLOUD_TRACK = "soundcloud_track", SOUNDCLOUD_TRACK,
SOUNDCLOUD_PLAYLIST = "soundcloud_playlist", SOUNDCLOUD_PLAYLIST,
SOUNDCLOUD = "soundcloud", SOUNDCLOUD,
SPOTIFY_SONG = "spotify_song", SPOTIFY_SONG,
SPOTIFY_ALBUM = "spotify_album", SPOTIFY_ALBUM,
SPOTIFY_PLAYLIST = "spotify_playlist", SPOTIFY_PLAYLIST,
FACEBOOK = "facebook", FACEBOOK,
VIMEO = "vimeo", VIMEO,
ARBITRARY = "arbitrary", ARBITRARY,
REVERBNATION = "reverbnation", REVERBNATION,
YOUTUBE_SEARCH = "youtube_search", YOUTUBE_SEARCH,
YOUTUBE_VIDEO = "youtube_video", YOUTUBE_VIDEO,
SOUNDCLOUD_SEARCH = "soundcloud_search" SOUNDCLOUD_SEARCH
} }
/** /**
@ -348,12 +359,12 @@ export interface PlayOptions {
/** /**
* @typedef {object} SearchOptions * @typedef {object} SearchOptions
* @property {UserResolvable} requestedBy The user who requested this search * @property {UserResolvable} requestedBy The user who requested this search
* @property {QueryType} [searchEngine=QueryType.AUTO] The query search engine * @property {QueryType|string} [searchEngine=QueryType.AUTO] The query search engine, can be extractor name to target specific one (custom)
* @property {boolean} [blockExtractor=false] If it should block custom extractors * @property {boolean} [blockExtractor=false] If it should block custom extractors
*/ */
export interface SearchOptions { export interface SearchOptions {
requestedBy: UserResolvable; requestedBy: UserResolvable;
searchEngine?: QueryType; searchEngine?: QueryType | string;
blockExtractor?: boolean; blockExtractor?: boolean;
} }

View file

@ -21,7 +21,7 @@ const bass = (g: number) => `bass=g=${g}:f=110:w=0.3`;
* @property {string} surrounding The surrounding filter * @property {string} surrounding The surrounding filter
* @property {string} pulsator The pulsator filter * @property {string} pulsator The pulsator filter
* @property {string} subboost The subboost filter * @property {string} subboost The subboost filter
* @property {string} kakaoke The kakaoke filter * @property {string} karaoke The kakaoke filter
* @property {string} flanger The flanger filter * @property {string} flanger The flanger filter
* @property {string} gate The gate filter * @property {string} gate The gate filter
* @property {string} haas The haas filter * @property {string} haas The haas filter

View file

@ -8,7 +8,9 @@
"strictNullChecks": false, "strictNullChecks": false,
"esModuleInterop": true, "esModuleInterop": true,
"pretty": true, "pretty": true,
"skipLibCheck": true "skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"importHelpers": true
}, },
"include": [ "include": [
"src/**/*" "src/**/*"

3069
yarn.lock

File diff suppressed because it is too large Load diff