🔀 Merge v5 branch with master

This commit is contained in:
Androz2091 2021-08-07 21:23:09 +02:00
commit 940d559980
44 changed files with 4554 additions and 3695 deletions

7
.eslintignore Normal file
View file

@ -0,0 +1,7 @@
example/
node_modules/
dist/
.github/
docs/
*.d.ts

22
.eslintrc.json Normal file
View file

@ -0,0 +1,22 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"env": {
"node": true
},
"plugins": [
"@typescript-eslint"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended"
],
"rules": {
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/ban-ts-comment": "error",
"semi": "error",
"no-console": "error"
}
}

View file

@ -1,7 +1,6 @@
---
name: Bug report
about: Create a bug report to help us improve
title: ""
labels: bug
assignees: ''

View file

@ -1,7 +1,6 @@
---
name: Feature request
about: Suggest an idea for this project
title: ""
labels: enhancement
assignees: ''

View file

@ -1,7 +1,6 @@
---
name: Question
about: Some questions related to this lib
title: ""
labels: question
assignees: ''

12
.gitignore vendored
View file

@ -1,15 +1,15 @@
# Node
node_modules
package-lock.json
# Tests
test
yarn.lock
# Compiled files
lib
dist
# Yarn logs
yarn*.log
# Demo
demo
# example
example/test
example/music-bot/node_modules
example/music-bot/package-lock.json

4
.husky/pre-commit Normal file
View file

@ -0,0 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npm run format && npm run lint:fix

View file

@ -1,6 +1,7 @@
{
"printWidth": 400,
"printWidth": 200,
"trailingComma": "none",
"singleQuote": true,
"tabWidth": 4
"singleQuote": false,
"tabWidth": 4,
"semi": true
}

View file

@ -4,6 +4,8 @@ Complete framework to facilitate music commands using **[discord.js](https://dis
[![downloadsBadge](https://img.shields.io/npm/dt/discord-player?style=for-the-badge)](https://npmjs.com/discord-player)
[![versionBadge](https://img.shields.io/npm/v/discord-player?style=for-the-badge)](https://npmjs.com/discord-player)
[![discordBadge](https://img.shields.io/discord/558328638911545423?style=for-the-badge&color=7289da)](https://androz2091.fr/discord)
[![wakatime](https://wakatime.com/badge/github/Androz2091/discord-player.svg)](https://wakatime.com/badge/github/Androz2091/discord-player)
[![CodeFactor](https://www.codefactor.io/repository/github/androz2091/discord-player/badge/v5)](https://www.codefactor.io/repository/github/androz2091/discord-player/overview/v5)
## Installation
@ -40,46 +42,96 @@ $ npm install --save @discordjs/opus
## Getting Started
Here is the code you will need to get started with discord-player. Then, you will be able to use `client.player` everywhere in your code!
First of all, you will need to register slash commands:
```js
const Discord = require("discord.js"),
client = new Discord.Client,
settings = {
prefix: "!",
token: "Your Discord Token"
};
const { REST } = require("@discordjs/rest");
const { Routes } = require("discord-api-types/v9");
const commands = [{
name: "play",
description: "Plays a song!",
options: [
{
name: "query",
type: "STRING",
description: "The song you want to play",
required: true
}
]
}];
const rest = new REST({ version: "9" }).setToken(process.env.DISCORD_TOKEN);
(async () => {
try {
console.log("Started refreshing application [/] commands.");
await rest.put(
Routes.applicationGuildCommands(CLIENT_ID, GUILD_ID),
{ body: commands },
);
console.log("Successfully reloaded application [/] commands.");
} catch (error) {
console.error(error);
}
})();
```
Now you can implement your bot's logic:
```js
const { Client, Intents } = require("discord.js");
const client = new Discord.Client({ intents: [Intents.FLAGS.GUILDS, Intents.FLAGS.GUILD_MESSAGES, Intents.FLAGS.GUILD_VOICE_STATES] });
const { Player } = require("discord-player");
// Create a new Player (you don't need any API Key)
const player = new Player(client);
// To easily access the player
client.player = player;
// add the trackStart event so when a song will be played this message will be sent
client.player.on("trackStart", (message, track) => message.channel.send(`Now playing ${track.title}...`))
player.on("trackStart", (queue, track) => queue.metadata.channel.send(`🎶 | Now playing **${track.title}**!`))
client.once("ready", () => {
console.log("I'm ready !");
});
client.on("message", async (message) => {
client.on("interactionCreate", async (interaction) => {
if (!interaction.isCommand()) return;
const args = message.content.slice(settings.prefix.length).trim().split(/ +/g);
const command = args.shift().toLowerCase();
// !play Despacito
// /play Despacito
// will play "Despacito" in the voice channel
if(command === "play"){
client.player.play(message, args[0]);
// as we registered the event above, no need to send a success message here
}
if (interaction.commandName === "play") {
if (!interaction.member.voice.channelId) return await interaction.reply({ content: "You are not in a voice channel!", empheral: true });
if (interaction.guild.me.voice.channelId && interaction.member.voice.channelId !== interaction.guild.me.voice.channelId) return await interaction.reply({ content: "You are not in my voice channel!", empheral: true });
const query = interaction.options.get("query").value;
const queue = player.createQueue(message.guild, {
metadata: {
channel: interaction.channel
}
});
// verify vc connection
try {
if (!queue.connection) await queue.connect(interaction.member.voice.channel);
} catch {
queue.destroy();
return await interaction.reply({ content: "Could not join your voice channel!", empheral: true });
}
await interaction.defer();
const track = await player.search(query, {
requestedBy: message.author
}).then(x => x.tracks[1]);
if (!track) return await interaction.followUp({ content: `❌ | Track **${query}** not found!` });
queue.play(track);
return await interaction.followUp({ content: `⏱️ | Loading track **${track.title}**!` });
}
});
client.login(settings.token);
client.login(process.env.DISCORD_TOKEN);
```
## Supported websites
@ -113,7 +165,7 @@ These bots are made by the community, they can help you build your own!
```js
const player = new Player(client, {
ytdlDownloadOptions: {
ytdlOptions: {
requestOptions: {
headers: {
cookie: "YOUR_YOUTUBE_COOKIE"
@ -133,7 +185,7 @@ const proxy = "http://user:pass@111.111.111.111:8080";
const agent = HttpsProxyAgent(proxy);
const player = new Player(client, {
ytdlDownloadOptions: {
ytdlOptions: {
requestOptions: { agent }
}
});

View file

@ -32,9 +32,6 @@ Your extractor should have 2 methods (required):
url: "Some Link"
}
```
- `important: boolean`
You can mark your Extractor as `important` by adding `important: true` to your extractor object. Doing this will disable rest of the extractors that comes after your extractor and use your extractor to get data. By default, it is set to `false`.
- `version: string`

View file

@ -8,5 +8,5 @@ const { AudioFilters } = require("discord-player");
AudioFilters.define("3D", "apulsator=hz=0.128");
// later, it can be used like this
player.setFilters(message, { "3D": true });
queue.setFilters({ "3D": true });
```

View file

@ -1,11 +0,0 @@
# How to play live videos?
You cannot play live videos by default. If you need to play the live video, just add this option:
```js
const player = new Player(client, {
enableLive: true // enables livestream
});
```
However, you cannot use audio filters with livestreams using this library!

View file

@ -1,15 +0,0 @@
# Pause and Resume is not working properly
This is a bug in **[discord.js#5300](https://github.com/discordjs/discord.js/issues/5300)**.
# Fix
You have to update your command something like this:
```diff
- client.player.resume(message);
+ client.player.resume(message);
+ client.player.pause(message);
+ client.player.resume(message);
```

View file

@ -0,0 +1,3 @@
# Slash Commands Example
You can use Discord Player with slash commands. **[Here](https://github.com/Androz2091/discord-player/tree/v5/example/music-bot)** is an example on how to use this framework with slash commands.

View file

@ -1,139 +0,0 @@
# Discord Player
Complete framework to facilitate music commands using **[discord.js](https://discord.js.org)**.
[![downloadsBadge](https://img.shields.io/npm/dt/discord-player?style=for-the-badge)](https://npmjs.com/discord-player)
[![versionBadge](https://img.shields.io/npm/v/discord-player?style=for-the-badge)](https://npmjs.com/discord-player)
## Installation
### Install **[discord-player](https://npmjs.com/package/discord-player)**
```sh
$ npm install --save discord-player
```
### Install **[@discordjs/opus](https://npmjs.com/package/@discordjs/opus)**
```sh
$ npm install --save @discordjs/opus
```
### Install FFmpeg or Avconv
- Official FFMPEG Website: **[https://www.ffmpeg.org/download.html](https://www.ffmpeg.org/download.html)**
- Node Module (FFMPEG): **[https://npmjs.com/package/ffmpeg-static](https://npmjs.com/package/ffmpeg-static)**
- Avconv: **[https://libav.org/download](https://libav.org/download)**
# Features
- Simple & easy to use 🤘
- Beginner friendly 😱
- Audio filters 🎸
- Lightweight 🛬
- Custom extractors support 🌌
- Lyrics 📃
- Multiple sources support ✌
- Play in multiple servers at the same time 🚗
## [Documentation](https://discord-player.js.org)
## Getting Started
Here is the code you will need to get started with discord-player. Then, you will be able to use `client.player` everywhere in your code!
```js
const Discord = require("discord.js"),
client = new Discord.Client,
settings = {
prefix: "!",
token: "Your Discord Token"
};
const { Player } = require("discord-player");
// Create a new Player (you don't need any API Key)
const player = new Player(client);
// To easily access the player
client.player = player;
// add the trackStart event so when a song will be played this message will be sent
client.player.on("trackStart", (message, track) => message.channel.send(`Now playing ${track.title}...`))
client.once("ready", () => {
console.log("I'm ready !");
});
client.on("message", async (message) => {
const args = message.content.slice(settings.prefix.length).trim().split(/ +/g);
const command = args.shift().toLowerCase();
// !play Despacito
// will play the song "Despacito" in the voice channel
if(command === "play"){
client.player.play(message, args[0]);
// as we registered the event above, no need to send a success message here
}
});
client.login(settings.token);
```
## Supported websites
By default, discord-player supports **YouTube**, **Spotify** and **SoundCloud** streams only.
### Optional dependencies
Discord Player provides an **Extractor API** that enables you to use your custom stream extractor with it. Some packages have been made by the community to add new features using this API.
#### [@discord-player/extractor](https://github.com/Snowflake107/discord-player-extractors) (optional)
Optional package that adds support for `vimeo`, `reverbnation`, `facebook`, `attachment links` and `lyrics`.
You just need to install it using `npm i --save @discord-player/extractor` (discord-player will automatically detect and use it).
#### [@discord-player/downloader](https://github.com/DevSnowflake/discord-player-downloader) (optional)
`@discord-player/downloader` is an optional package that brings support for +700 websites. The documentation is available [here](https://github.com/DevSnowflake/discord-player-downloader).
## Examples of bots made with Discord Player
These bots are made by the community, they can help you build your own!
* [AtlantaBot](https://github.com/Androz2091/AtlantaBot) by [Androz2091](https://github.com/Androz2091)
* [Discord-Music](https://github.com/inhydrox/discord-music) by [inhydrox](https://github.com/inhydrox)
* [Music-bot](https://github.com/ZerioDev/Music-bot) by [ZerioDev](https://github.com/ZerioDev)
## FAQ
### How to use cookies
```js
const player = new Player(client, {
ytdlDownloadOptions: {
requestOptions: {
headers: {
cookie: "YOUR_YOUTUBE_COOKIE"
}
}
}
});
```
### How to use custom proxies
```js
const HttpsProxyAgent = require("https-proxy-agent");
// Remove "user:pass@" if you don't need to authenticate to your proxy.
const proxy = "http://user:pass@111.111.111.111:8080";
const agent = HttpsProxyAgent(proxy);
const player = new Player(client, {
ytdlDownloadOptions: {
requestOptions: { agent }
}
});
```

View file

@ -1,7 +1,12 @@
- name: General
files:
- name: Welcome
path: welcome.md
id: welcome
path: ../../README.md
- name: Migrating
files:
- name: Migrating to v5
path: migrating.md
- name: Extractors
files:
- name: Extractors API
@ -10,10 +15,8 @@
files:
- name: Custom Filters
path: custom_filters.md
- name: Livestreams
path: live_video.md
- name: Pause & Resume
path: pause_resume.md
- name: Slash Commands
path: slash_commands.md
- name: YouTube
files:
- name: Using Cookies

View file

@ -0,0 +1,59 @@
# Migrating to Discord Player v5
We have introduced some breaking changes in Discord Player v5. Which means, your old code will no longer work with v5.
The new update brings new features as well as better management of different things. This also uses the new **[@discordjs/voice](https://github.com/discordjs/voice)** library!
## Basic Example
```diff
- player.play(message, query);
+ const queue = player.createQueue(message.guild);
+ const song = await player.search(query, {
+ requestedBy: message.author
});
+
+ try {
+ await queue.connect(message.member.voice.channel);
+ } catch {
+ message.reply("Could not join your voice channel");
+ }
+
+ queue.addTrack(song.tracks[0]);
+ queue.play();
```
> Everything related to music player is moved to `Queue`.
## How do I reply to the event like v4?
Since we got rid of `message` parameter in every method of the Discord Player, you no longer have access to the `message` object in events.
Instead, we have added `<Queue>.metadata` prop as an alternative. This `metadata` can be anything, declared while creating queue:
```js
const queue = player.createQueue(message.guild, {
metadata: message
});
```
The metadata `message` will always be available in every events emitted for that specific `Queue`. You can access it via `queue.metadata`:
```js
player.on("trackStart", (queue, track) => {
const channel = queue.metadata.channel; // queue.metadata is your "message" object
channel.send(`🎶 | Started playing **${track.title}**`);
});
```
## How do I stop the player
You have to use `<Queue>.destroy()` to destroy the queue. It will also stop the player.
```js
const queue = player.getQueue(message.guild.id);
if (queue) queue.destroy();
```
## Updating filters
Discord Player v5.x has new option `bufferingTimeout` in queue init options which allows you to set stream buffering timeout before playing.
This might be useful if you want to have smooth filters update. By default, it is set to 3 seconds.

View file

@ -4,7 +4,7 @@
const { Player } = require("discord-player");
const player = new Player(client, {
ytdlDownloadOptions: {
ytdlOptions: {
requestOptions: {
headers: {
cookie: "YOUR_YOUTUBE_COOKIE"

View file

@ -9,7 +9,7 @@ const proxy = "http://user:pass@111.111.111.111:8080";
const agent = HttpsProxyAgent(proxy);
const player = new Player(client, {
ytdlDownloadOptions: {
ytdlOptions: {
requestOptions: { agent }
}
});

2
example/README.md Normal file
View file

@ -0,0 +1,2 @@
# Discord Player Examples
This section contains example bot(s) made with Discord Player.

View file

@ -0,0 +1,2 @@
# Music Bot
Slash commands music bot backed by **[Discord Player](https://discord-player.js.org)**.

View file

@ -0,0 +1,3 @@
module.exports = {
token: "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
};

301
example/music-bot/index.js Normal file
View file

@ -0,0 +1,301 @@
const { Client, GuildMember } = require("discord.js");
const config = require("./config");
const { Player, QueryType, QueueRepeatMode } = require("discord-player");
const client = new Client({
intents: ["GUILD_VOICE_STATES", "GUILD_MESSAGES", "GUILDS"]
});
client.on("ready", () => {
console.log("Bot is online!");
client.user.setActivity({
name: "🎶 | Music Time",
type: "LISTENING"
});
});
client.on("error", console.error);
client.on("warn", console.warn);
// instantiate the player
const player = new Player(client);
player.on("error", (queue, error) => {
console.log(`[${queue.guild.name}] Error emitted from the queue: ${error.message}`);
});
player.on("connectionError", (queue, error) => {
console.log(`[${queue.guild.name}] Error emitted from the connection: ${error.message}`);
});
player.on("trackStart", (queue, track) => {
queue.metadata.send(`🎶 | Started playing: **${track.title}** in **${queue.connection.channel.name}**!`);
});
player.on("trackAdd", (queue, track) => {
queue.metadata.send(`🎶 | Track **${track.title}** queued!`);
});
player.on("botDisconnect", (queue) => {
queue.metadata.send("❌ | I was manually disconnected from the voice channel, clearing queue!");
});
player.on("channelEmpty", (queue) => {
queue.metadata.send("❌ | Nobody is in the voice channel, leaving...");
});
player.on("queueEnd", (queue) => {
queue.metadata.send("✅ | Queue finished!");
});
client.on("messageCreate", async (message) => {
if (message.author.bot || !message.guild) return;
if (!client.application?.owner) await client.application?.fetch();
if (message.content === "!deploy" && message.author.id === client.application?.owner?.id) {
await message.guild.commands.set([
{
name: "play",
description: "Plays a song from youtube",
options: [
{
name: "query",
type: "STRING",
description: "The song you want to play",
required: true
}
]
},
{
name: "soundcloud",
description: "Plays a song from soundcloud",
options: [
{
name: "query",
type: "STRING",
description: "The song you want to play",
required: true
}
]
},
{
name: "volume",
description: "Sets music volume",
options: [
{
name: "amount",
type: "INTEGER",
description: "The volume amount to set (0-100)",
required: false
}
]
},
{
name: "loop",
description: "Sets loop mode",
options: [
{
name: "mode",
type: "INTEGER",
description: "Loop type",
required: true,
choices: [
{
name: "Off",
value: QueueRepeatMode.OFF
},
{
name: "Track",
value: QueueRepeatMode.TRACK
},
{
name: "Queue",
value: QueueRepeatMode.QUEUE
},
{
name: "Autoplay",
value: QueueRepeatMode.AUTOPLAY
}
]
}
]
},
{
name: "skip",
description: "Skip to the current song"
},
{
name: "queue",
description: "See the queue"
},
{
name: "pause",
description: "Pause the current song"
},
{
name: "resume",
description: "Resume the current song"
},
{
name: "stop",
description: "Stop the player"
},
{
name: "np",
description: "Now Playing"
},
{
name: "bassboost",
description: "Toggles bassboost filter"
}
]);
await message.reply("Deployed!");
}
});
client.on("interactionCreate", async (interaction) => {
if (!interaction.isCommand() || !interaction.guildId) return;
if (!(interaction.member instanceof GuildMember) || !interaction.member.voice.channel) {
return void interaction.reply({ content: "You are not in a voice channel!", ephemeral: true });
}
if (interaction.guild.me.voice.channelId && interaction.member.voice.channelId !== interaction.guild.me.voice.channelId) {
return void interaction.reply({ content: "You are not in my voice channel!", ephemeral: true });
}
if (interaction.commandName === "play" || interaction.commandName === "soundcloud") {
await interaction.deferReply();
const query = interaction.options.get("query").value;
const searchResult = await player
.search(query, {
requestedBy: interaction.user,
searchEngine: interaction.commandName === "soundcloud" ? QueryType.SOUNDCLOUD_SEARCH : QueryType.AUTO
})
.catch(() => {});
if (!searchResult || !searchResult.tracks.length) return void interaction.followUp({ content: "No results were found!" });
const queue = await player.createQueue(interaction.guild, {
metadata: interaction.channel
});
try {
if (!queue.connection) await queue.connect(interaction.member.voice.channel);
} catch {
void player.deleteQueue(interaction.guildId);
return void interaction.followUp({ content: "Could not join your voice channel!" });
}
await interaction.followUp({ content: `⏱ | Loading your ${searchResult.playlist ? "playlist" : "track"}...` });
searchResult.playlist ? queue.addTracks(searchResult.tracks) : queue.addTrack(searchResult.tracks[0]);
if (!queue.playing) await queue.play();
} else if (interaction.commandName === "volume") {
await interaction.deferReply();
const queue = player.getQueue(interaction.guildId);
if (!queue || !queue.playing) return void interaction.followUp({ content: "❌ | No music is being played!" });
const vol = interaction.options.get("amount");
if (!vol) return void interaction.followUp({ content: `🎧 | Current volume is **${queue.volume}**%!` });
if ((vol.value) < 0 || (vol.value) > 100) return void interaction.followUp({ content: "❌ | Volume range must be 0-100" });
const success = queue.setVolume(vol.value);
return void interaction.followUp({
content: success ? `✅ | Volume set to **${vol.value}%**!` : "❌ | Something went wrong!"
});
} else if (interaction.commandName === "skip") {
await interaction.deferReply();
const queue = player.getQueue(interaction.guildId);
if (!queue || !queue.playing) return void interaction.followUp({ content: "❌ | No music is being played!" });
const currentTrack = queue.current;
const success = queue.skip();
return void interaction.followUp({
content: success ? `✅ | Skipped **${currentTrack}**!` : "❌ | Something went wrong!"
});
} else if (interaction.commandName === "queue") {
await interaction.deferReply();
const queue = player.getQueue(interaction.guildId);
if (!queue || !queue.playing) return void interaction.followUp({ content: "❌ | No music is being played!" });
const currentTrack = queue.current;
const tracks = queue.tracks.slice(0, 10).map((m, i) => {
return `${i + 1}. **${m.title}** ([link](${m.url}))`;
});
return void interaction.followUp({
embeds: [
{
title: "Server Queue",
description: `${tracks.join("\n")}${
queue.tracks.length > tracks.length
? `\n...${queue.tracks.length - tracks.length === 1 ? `${queue.tracks.length - tracks.length} more track` : `${queue.tracks.length - tracks.length} more tracks`}`
: ""
}`,
color: 0xff0000,
fields: [{ name: "Now Playing", value: `🎶 | **${currentTrack.title}** ([link](${currentTrack.url}))` }]
}
]
});
} else if (interaction.commandName === "pause") {
await interaction.deferReply();
const queue = player.getQueue(interaction.guildId);
if (!queue || !queue.playing) return void interaction.followUp({ content: "❌ | No music is being played!" });
const success = queue.setPaused(true);
return void interaction.followUp({ content: success ? "⏸ | Paused!" : "❌ | Something went wrong!" });
} else if (interaction.commandName === "resume") {
await interaction.deferReply();
const queue = player.getQueue(interaction.guildId);
if (!queue || !queue.playing) return void interaction.followUp({ content: "❌ | No music is being played!" });
const success = queue.setPaused(false);
return void interaction.followUp({ content: success ? "▶ | Resumed!" : "❌ | Something went wrong!" });
} else if (interaction.commandName === "stop") {
await interaction.deferReply();
const queue = player.getQueue(interaction.guildId);
if (!queue || !queue.playing) return void interaction.followUp({ content: "❌ | No music is being played!" });
queue.destroy();
return void interaction.followUp({ content: "🛑 | Stopped the player!" });
} else if (interaction.commandName === "np") {
await interaction.deferReply();
const queue = player.getQueue(interaction.guildId);
if (!queue || !queue.playing) return void interaction.followUp({ content: "❌ | No music is being played!" });
const progress = queue.createProgressBar();
const perc = queue.getPlayerTimestamp();
return void interaction.followUp({
embeds: [
{
title: "Now Playing",
description: `🎶 | **${queue.current.title}**! (\`${perc.progress}%\`)`,
fields: [
{
name: "\u200b",
value: progress
}
],
color: 0xffffff
}
]
});
} else if (interaction.commandName === "loop") {
await interaction.deferReply();
const queue = player.getQueue(interaction.guildId);
if (!queue || !queue.playing) return void interaction.followUp({ content: "❌ | No music is being played!" });
const loopMode = interaction.options.get("mode").value;
const success = queue.setRepeatMode(loopMode);
const mode = loopMode === QueueRepeatMode.TRACK ? "🔂" : loopMode === QueueRepeatMode.QUEUE ? "🔁" : "▶";
return void interaction.followUp({ content: success ? `${mode} | Updated loop mode!` : "❌ | Could not update loop mode!" });
} else if (interaction.commandName === "bassboost") {
await interaction.deferReply();
const queue = player.getQueue(interaction.guildId);
if (!queue || !queue.playing) return void interaction.followUp({ content: "❌ | No music is being played!" });
await queue.setFilters({
bassboost: !queue.getFiltersEnabled().includes("bassboost"),
normalizer2: !queue.getFiltersEnabled().includes("bassboost") // because we need to toggle it with bass
});
return void interaction.followUp({ content: `🎵 | Bassboost ${queue.getFiltersEnabled().includes("bassboost") ? "Enabled" : "Disabled"}!` });
} else {
interaction.reply({
content: "Unknown command!",
ephemeral: true
});
}
});
client.login(config.token);

View file

@ -0,0 +1,16 @@
{
"name": "music-bot",
"version": "1.0.0",
"description": "Simple music bot created with discord-player",
"main": "index.js",
"scripts": {
"start": "node index.js"
},
"author": "Snowflake107",
"license": "MIT",
"dependencies": {
"@discordjs/opus": "^0.5.3",
"discord-player": "^5.0.0-dev.39f503a.1625470163",
"discord.js": "^13.0.1"
}
}

View file

@ -1,19 +1,28 @@
{
"name": "discord-player",
"version": "4.1.4",
"version": "5.0.0-dev",
"description": "Complete framework to facilitate music commands using discord.js",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"lib/"
"dist/"
],
"module": "dist/index.mjs",
"exports": {
"require": "./dist/index.js",
"import": "./dist/index.mjs"
},
"scripts": {
"test": "yarn build && cd test && node index.js",
"build": "tsc",
"format": "prettier --write \"src/**/*.ts\"",
"lint": "tslint -p tsconfig.json",
"docs": "docgen --jsdoc jsdoc.json --verbose --source src/*.ts src/**/*.ts --custom docs/index.yml --output docs/docs.json",
"docs:test": "docgen --jsdoc jsdoc.json --verbose --source src/*.ts src/**/*.ts --custom docs/index.yml"
"dev": "cd example/test && ts-node index.ts",
"build": "rimraf dist && tsc && npm run build:esm",
"build:check": "tsc --noEmit --incremental false",
"prepublishOnly": "rollup-type-bundler -e stream",
"build:esm": "gen-esm-wrapper ./dist/index.js ./dist/index.mjs",
"format": "prettier --write \"src/**/*.ts\" \"example/**/*.ts\"",
"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",
"lint": "eslint src --ext .ts",
"lint:fix": "eslint src --ext .ts --fix"
},
"funding": "https://github.com/Androz2091/discord-player?sponsor=1",
"contributors": [
@ -50,27 +59,37 @@
},
"homepage": "https://discord-player.js.org",
"dependencies": {
"@discordjs/voice": "^0.5.5",
"discord-ytdl-core": "^5.0.4",
"soundcloud-scraper": "^5.0.0",
"libsodium-wrappers": "^0.7.9",
"soundcloud-scraper": "^5.0.1",
"spotify-url-info": "^2.2.3",
"youtube-sr": "^4.1.5",
"ytdl-core": "^4.8.3"
"tiny-typed-emitter": "^2.1.0",
"youtube-sr": "^4.1.7",
"ytdl-core": "^4.9.1"
},
"devDependencies": {
"@babel/cli": "^7.13.16",
"@babel/core": "^7.13.16",
"@babel/preset-env": "^7.13.15",
"@babel/preset-typescript": "^7.13.0",
"@discord-player/extractor": "^3.0.0",
"@discordjs/opus": "^0.5.0",
"@types/node": "^15.6.1",
"@types/ws": "^7.4.1",
"discord.js": "^12.5.3",
"discord.js-docgen": "discordjs/docgen#ts-patch",
"@babel/cli": "^7.14.8",
"@babel/core": "^7.15.0",
"@babel/preset-env": "^7.15.0",
"@babel/preset-typescript": "^7.15.0",
"@devsnowflake/docgen": "devsnowflake/docgen#ts-patch",
"@discord-player/extractor": "^3.0.2",
"@discordjs/opus": "github:discordjs/opus",
"@favware/rollup-type-bundler": "^1.0.3",
"@types/node": "^16.4.13",
"@types/ws": "^7.4.7",
"@typescript-eslint/eslint-plugin": "^4.29.0",
"@typescript-eslint/parser": "^4.29.0",
"discord-api-types": "^0.22.0",
"discord.js": "^13.0.1",
"eslint": "^7.32.0",
"gen-esm-wrapper": "^1.1.2",
"husky": "^7.0.1",
"jsdoc-babel": "^0.5.0",
"prettier": "^2.2.1",
"tslint": "^6.1.3",
"tslint-config-prettier": "^1.18.0",
"typescript": "^4.2.3"
"prettier": "^2.3.2",
"rimraf": "^3.0.2",
"ts-node": "^10.1.0",
"typescript": "^4.3.5"
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,27 +1,34 @@
import { ExtractorModelData } from '../types/types';
import { ExtractorModelData } from "../types/types";
class ExtractorModel {
name: string;
private _raw: any;
private _raw: any; // eslint-disable-line @typescript-eslint/no-explicit-any
/**
* Model for raw Discord Player extractors
* @param {String} extractorName Name of the extractor
* @param {Object} data Extractor object
* @param {string} extractorName Name of the extractor
* @param {object} data Extractor object
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
constructor(extractorName: string, data: any) {
/**
* The extractor name
* @type {String}
* @type {string}
*/
this.name = extractorName;
Object.defineProperty(this, '_raw', { value: data, configurable: false, writable: false, enumerable: false });
/**
* The raw model
* @name ExtractorModel#_raw
* @type {any}
* @private
*/
Object.defineProperty(this, "_raw", { value: data, configurable: false, writable: false, enumerable: false });
}
/**
* Method to handle requests from `Player.play()`
* @param {String} query Query to handle
* @param {string} query Query to handle
* @returns {Promise<ExtractorModelData>}
*/
async handle(query: string): Promise<ExtractorModelData> {
@ -29,21 +36,26 @@ class ExtractorModel {
if (!data) return null;
return {
title: data.title,
duration: data.duration,
thumbnail: data.thumbnail,
engine: data.engine,
views: data.views,
author: data.author,
description: data.description,
url: data.url
playlist: data.playlist ?? null,
data:
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data.info?.map((m: any) => ({
title: m.title,
duration: m.duration,
thumbnail: m.thumbnail,
engine: m.engine,
views: m.views,
author: m.author,
description: m.description,
url: m.url
})) ?? []
};
}
/**
* Method used by Discord Player to validate query with this extractor
* @param {String} query The query to validate
* @returns {Boolean}
* @param {string} query The query to validate
* @returns {boolean}
*/
validate(query: string): boolean {
return Boolean(this._raw.validate(query));
@ -51,20 +63,11 @@ class ExtractorModel {
/**
* The extractor version
* @type {String}
* @type {string}
*/
get version(): string {
return this._raw.version ?? '0.0.0';
}
/**
* If player should mark this extractor as important
* @type {Boolean}
*/
get important(): boolean {
return Boolean(this._raw.important);
return this._raw.version ?? "0.0.0";
}
}
export default ExtractorModel;
export { ExtractorModel };

View file

@ -0,0 +1,48 @@
export enum ErrorStatusCode {
STREAM_ERROR = "StreamError",
AUDIO_PLAYER_ERROR = "AudioPlayerError",
PLAYER_ERROR = "PlayerError",
NO_AUDIO_RESOURCE = "NoAudioResource",
UNKNOWN_GUILD = "UnknownGuild",
INVALID_ARG_TYPE = "InvalidArgType",
UNKNOWN_EXTRACTOR = "UnknownExtractor",
INVALID_EXTRACTOR = "InvalidExtractor",
INVALID_CHANNEL_TYPE = "InvalidChannelType",
INVALID_TRACK = "InvalidTrack",
UNKNOWN_REPEAT_MODE = "UnknownRepeatMode",
TRACK_NOT_FOUND = "TrackNotFound",
NO_CONNECTION = "NoConnection",
DESTROYED_QUEUE = "DestroyedQueue"
}
export class PlayerError extends Error {
message: string;
statusCode: ErrorStatusCode;
createdAt = new Date();
constructor(message: string, code: ErrorStatusCode = ErrorStatusCode.PLAYER_ERROR) {
super();
this.message = `[${code}] ${message}`;
this.statusCode = code;
this.name = code;
Error.captureStackTrace(this);
}
get createdTimestamp() {
return this.createdAt.getTime();
}
valueOf() {
return this.statusCode;
}
toJSON() {
return { stack: this.stack, code: this.statusCode, created: this.createdTimestamp };
}
toString() {
return this.stack;
}
}

138
src/Structures/Playlist.ts Normal file
View file

@ -0,0 +1,138 @@
import { Player } from "../Player";
import { Track } from "./Track";
import { PlaylistInitData, PlaylistJSON, TrackJSON, TrackSource } from "../types/types";
class Playlist {
public readonly player: Player;
public tracks: Track[];
public title: string;
public description: string;
public thumbnail: string;
public type: "album" | "playlist";
public source: TrackSource;
public author: {
name: string;
url: string;
};
public id: string;
public url: string;
public readonly rawPlaylist?: any; // eslint-disable-line @typescript-eslint/no-explicit-any
/**
* Playlist constructor
* @param {Player} player The player
* @param {PlaylistInitData} data The data
*/
constructor(player: Player, data: PlaylistInitData) {
/**
* The player
* @name Playlist#player
* @type {Player}
* @readonly
*/
this.player = player;
/**
* The tracks in this playlist
* @name Playlist#tracks
* @type {Track[]}
*/
this.tracks = data.tracks ?? [];
/**
* The author of this playlist
* @name Playlist#author
* @type {object}
*/
this.author = data.author;
/**
* The description
* @name Playlist#description
* @type {string}
*/
this.description = data.description;
/**
* The thumbnail of this playlist
* @name Playlist#thumbnail
* @type {string}
*/
this.thumbnail = data.thumbnail;
/**
* The playlist type:
* - `album`
* - `playlist`
* @name Playlist#type
* @type {string}
*/
this.type = data.type;
/**
* The source of this playlist:
* - `youtube`
* - `soundcloud`
* - `spotify`
* - `arbitrary`
* @name Playlist#source
* @type {string}
*/
this.source = data.source;
/**
* The playlist id
* @name Playlist#id
* @type {string}
*/
this.id = data.id;
/**
* The playlist url
* @name Playlist#url
* @type {string}
*/
this.url = data.url;
/**
* The playlist title
* @type {string}
*/
this.title = data.title;
/**
* @name Playlist#rawPlaylist
* @type {any}
* @readonly
*/
}
*[Symbol.iterator]() {
yield* this.tracks;
}
/**
* JSON representation of this playlist
* @param {boolean} [withTracks=true] If it should build json with tracks
* @returns {PlaylistJSON}
*/
toJSON(withTracks = true) {
const payload = {
id: this.id,
url: this.url,
title: this.title,
description: this.description,
thumbnail: this.thumbnail,
type: this.type,
source: this.source,
author: this.author,
tracks: [] as TrackJSON[]
};
if (withTracks) payload.tracks = this.tracks.map((m) => m.toJSON(true));
return payload as PlaylistJSON;
}
}
export { Playlist };

View file

@ -1,146 +1,583 @@
import { Message, Snowflake, VoiceConnection } from 'discord.js';
import AudioFilters from '../utils/AudioFilters';
import { Player } from '../Player';
import { EventEmitter } from 'events';
import { Track } from './Track';
import { QueueFilters } from '../types/types';
import { Collection, Guild, StageChannel, VoiceChannel, Snowflake, SnowflakeUtil, GuildChannelResolvable } from "discord.js";
import { Player } from "../Player";
import { StreamDispatcher } from "../VoiceInterface/StreamDispatcher";
import Track from "./Track";
import { PlayerOptions, PlayerProgressbarOptions, PlayOptions, QueueFilters, QueueRepeatMode } from "../types/types";
import ytdl from "discord-ytdl-core";
import { AudioResource, StreamType } from "@discordjs/voice";
import { Util } from "../utils/Util";
import YouTube from "youtube-sr";
import AudioFilters from "../utils/AudioFilters";
import { PlayerError, ErrorStatusCode } from "./PlayerError";
export class Queue extends EventEmitter {
public player!: Player;
public guildID: Snowflake;
public voiceConnection?: VoiceConnection;
public stream?: any;
public tracks: Track[];
public previousTracks: Track[];
public stopped: boolean;
public lastSkipped: boolean;
public volume: number;
public paused: boolean;
public repeatMode: boolean;
public loopMode: boolean;
public filters: QueueFilters;
public additionalStreamTime: number;
public firstMessage: Message;
/**
* If autoplay is enabled in this queue
* @type {boolean}
*/
public autoPlay = false;
class Queue<T = unknown> {
public readonly guild: Guild;
public readonly player: Player;
public connection: StreamDispatcher;
public tracks: Track[] = [];
public previousTracks: Track[] = [];
public options: PlayerOptions;
public playing = false;
public metadata?: T = null;
public repeatMode: QueueRepeatMode = 0;
public readonly id: Snowflake = SnowflakeUtil.generate();
private _streamTime = 0;
public _cooldownsTimeout = new Collection<string, NodeJS.Timeout>();
private _activeFilters: any[] = []; // eslint-disable-line @typescript-eslint/no-explicit-any
private _filtersUpdate = false;
#lastVolume = 0;
#destroyed = false;
/**
* Queue constructor
* @param {Player} player The player that instantiated this Queue
* @param {DiscordMessage} message The message object
* @param {Player} player The player that instantiated this queue
* @param {Guild} guild The guild that instantiated this queue
* @param {PlayerOptions} [options={}] Player options for the queue
*/
constructor(player: Player, message: Message) {
super();
constructor(player: Player, guild: Guild, options: PlayerOptions = {}) {
/**
* The player that instantiated this Queue
* @name Queue#player
* The player that instantiated this queue
* @type {Player}
* @readonly
*/
Object.defineProperty(this, 'player', { value: player, enumerable: false });
this.player = player;
/**
* ID of the guild assigned to this queue
* @type {DiscordSnowflake}
* The guild that instantiated this queue
* @type {Guild}
* @readonly
*/
this.guildID = message.guild.id;
this.guild = guild;
/**
* The voice connection of this queue
* @type {DiscordVoiceConnection}
* The player options for this queue
* @type {PlayerOptions}
*/
this.voiceConnection = null;
this.options = {};
/**
* Tracks of this queue
* Queue repeat mode
* @type {QueueRepeatMode}
* @name Queue#repeatMode
*/
/**
* Queue metadata
* @type {any}
* @name Queue#metadata
*/
/**
* Previous tracks
* @type {Track[]}
* @name Queue#previousTracks
*/
this.tracks = [];
/**
* Previous tracks of this queue
* Regular tracks
* @type {Track[]}
* @name Queue#tracks
*/
this.previousTracks = [];
/**
* If the player of this queue is stopped
* @type {boolean}
* The connection
* @type {StreamDispatcher}
* @name Queue#connection
*/
this.stopped = false;
/**
* If last track was skipped
* @type {boolean}
* The ID of this queue
* @type {Snowflake}
* @name Queue#id
*/
this.lastSkipped = false;
/**
* Queue volume
* @type {Number}
*/
this.volume = 100;
Object.assign(
this.options,
{
leaveOnEnd: true,
leaveOnStop: true,
leaveOnEmpty: true,
leaveOnEmptyCooldown: 1000,
autoSelfDeaf: true,
ytdlOptions: {
highWaterMark: 1 << 25
},
initialVolume: 100,
bufferingTimeout: 3000
} as PlayerOptions,
options
);
}
/**
* If the player of this queue is paused
* @type {boolean}
*/
this.paused = Boolean(this.voiceConnection?.dispatcher?.paused);
/**
* Returns current track
* @type {Track}
*/
get current() {
if (this.#watchDestroyed()) return;
return this.connection.audioResource?.metadata ?? this.tracks[0];
}
/**
* If repeat mode is enabled in this queue
* @type {boolean}
*/
this.repeatMode = false;
/**
* If this queue is destroyed
* @type {boolean}
*/
get destroyed() {
return this.#destroyed;
}
/**
* If loop mode is enabled in this queue
* @type {boolean}
*/
this.loopMode = false;
/**
* Returns current track
* @returns {Track}
*/
nowPlaying() {
if (this.#watchDestroyed()) return;
return this.current;
}
/**
* The additional calculated stream time
* @type {Number}
*/
this.additionalStreamTime = 0;
/**
* Connects to a voice channel
* @param {GuildChannelResolvable} channel The voice/stage channel
* @returns {Promise<Queue>}
*/
async connect(channel: GuildChannelResolvable) {
if (this.#watchDestroyed()) return;
const _channel = this.guild.channels.resolve(channel) as StageChannel | VoiceChannel;
if (!["GUILD_STAGE_VOICE", "GUILD_VOICE"].includes(_channel?.type))
throw new PlayerError(`Channel type must be GUILD_VOICE or GUILD_STAGE_VOICE, got ${_channel?.type}!`, ErrorStatusCode.INVALID_ARG_TYPE);
const connection = await this.player.voiceUtils.connect(_channel, {
deaf: this.options.autoSelfDeaf,
maxTime: this.player.options.connectionTimeout || 20000
});
this.connection = connection;
/**
* The initial message object
* @type {DiscordMessage}
*/
this.firstMessage = message;
if (_channel.type === "GUILD_STAGE_VOICE") {
await _channel.guild.me.voice.setSuppressed(false).catch(async () => {
return await _channel.guild.me.voice.setRequestToSpeak(true).catch(Util.noop);
});
}
/**
* The audio filters in this queue
* @type {QueueFilters}
*/
this.filters = {};
this.connection.on("error", (err) => this.player.emit("connectionError", this, err));
this.connection.on("debug", (msg) => this.player.emit("debug", this, msg));
Object.keys(AudioFilters).forEach((fn) => {
this.filters[fn as keyof QueueFilters] = false;
this.player.emit("connectionCreate", this, this.connection);
this.connection.on("start", (resource) => {
this.playing = true;
if (!this._filtersUpdate && resource?.metadata) this.player.emit("trackStart", this, resource?.metadata ?? this.current);
this._filtersUpdate = false;
});
this.connection.on("finish", async (resource) => {
this.playing = false;
if (this._filtersUpdate) return;
this._streamTime = 0;
if (resource && resource.metadata) this.previousTracks.push(resource.metadata);
this.player.emit("trackEnd", this, resource.metadata);
if (!this.tracks.length && this.repeatMode === QueueRepeatMode.OFF) {
if (this.options.leaveOnEnd) this.destroy();
this.player.emit("queueEnd", this);
} 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.QUEUE) this.tracks.push(Util.last(this.previousTracks));
const nextTrack = this.tracks.shift();
this.play(nextTrack, { immediate: true });
return;
} else {
this._handleAutoplay(Util.last(this.previousTracks));
}
}
});
return this;
}
/**
* Destroys this queue
* @param {boolean} [disconnect=this.options.leaveOnStop] If it should leave on destroy
* @returns {void}
*/
destroy(disconnect = this.options.leaveOnStop) {
if (this.#watchDestroyed()) return;
if (this.connection) this.connection.end();
if (disconnect) this.connection?.disconnect();
this.player.queues.delete(this.guild.id);
this.player.voiceUtils.cache.delete(this.guild.id);
this.#destroyed = true;
}
/**
* Skips current track
* @returns {boolean}
*/
skip() {
if (this.#watchDestroyed()) return;
if (!this.connection) return false;
this._filtersUpdate = false;
this.connection.end();
return true;
}
/**
* Adds single track to the queue
* @param {Track} track The track to add
* @returns {void}
*/
addTrack(track: Track) {
if (this.#watchDestroyed()) return;
if (!(track instanceof Track)) throw new PlayerError("invalid track", ErrorStatusCode.INVALID_TRACK);
this.tracks.push(track);
this.player.emit("trackAdd", this, track);
}
/**
* Adds multiple tracks to the queue
* @param {Track[]} tracks Array of tracks to add
*/
addTracks(tracks: Track[]) {
if (this.#watchDestroyed()) return;
if (!tracks.every((y) => y instanceof Track)) throw new PlayerError("invalid track", ErrorStatusCode.INVALID_TRACK);
this.tracks.push(...tracks);
this.player.emit("tracksAdd", this, tracks);
}
/**
* Sets paused state
* @param {boolean} paused The paused state
* @returns {boolean}
*/
setPaused(paused?: boolean) {
if (this.#watchDestroyed()) return;
if (!this.connection) return false;
return paused ? this.connection.pause(true) : this.connection.resume();
}
/**
* Sets bitrate
* @param {number|auto} bitrate bitrate to set
* @returns {void}
*/
setBitrate(bitrate: number | "auto") {
if (this.#watchDestroyed()) return;
if (!this.connection?.audioResource?.encoder) return;
if (bitrate === "auto") bitrate = this.connection.channel?.bitrate ?? 64000;
this.connection.audioResource.encoder.setBitrate(bitrate);
}
/**
* Sets volume
* @param {number} amount The volume amount
* @returns {boolean}
*/
setVolume(amount: number) {
if (this.#watchDestroyed()) return;
if (!this.connection) return false;
this.#lastVolume = amount;
this.options.initialVolume = amount;
return this.connection.setVolume(amount);
}
/**
* Sets repeat mode
* @param {QueueRepeatMode} mode The repeat mode
* @returns {boolean}
*/
setRepeatMode(mode: QueueRepeatMode) {
if (this.#watchDestroyed()) return;
if (![QueueRepeatMode.OFF, QueueRepeatMode.QUEUE, QueueRepeatMode.TRACK, QueueRepeatMode.AUTOPLAY].includes(mode))
throw new PlayerError(`Unknown repeat mode "${mode}"!`, ErrorStatusCode.UNKNOWN_REPEAT_MODE);
if (mode === this.repeatMode) return false;
this.repeatMode = mode;
return true;
}
/**
* The current volume amount
* @type {number}
*/
get volume() {
if (this.#watchDestroyed()) return;
if (!this.connection) return 100;
return this.connection.volume;
}
set volume(amount: number) {
this.setVolume(amount);
}
/**
* Mutes the playback
* @returns {void}
*/
mute() {
const lv = this.#lastVolume;
this.volume = 0;
this.#lastVolume = lv;
}
/**
* Unmutes the playback. If the last volume was set to 0, unmute will produce no effect.
* @returns {void}
*/
unmute() {
this.volume = this.#lastVolume;
}
/**
* The stream time of this queue
* @type {number}
*/
get streamTime() {
if (this.#watchDestroyed()) return;
if (!this.connection) return 0;
const playbackTime = this._streamTime + this.connection.streamTime;
const NC = this._activeFilters.includes("nightcore") ? 1.25 : null;
const VW = this._activeFilters.includes("vaporwave") ? 0.8 : null;
if (NC && VW) return playbackTime * (NC + VW);
return NC ? playbackTime * NC : VW ? playbackTime * VW : playbackTime;
}
set streamTime(time: number) {
if (this.#watchDestroyed()) return;
this.seek(time);
}
/**
* Returns enabled filters
* @returns {AudioFilters}
*/
getFiltersEnabled() {
if (this.#watchDestroyed()) return;
return AudioFilters.names.filter((x) => this._activeFilters.includes(x));
}
/**
* Returns disabled filters
* @returns {AudioFilters}
*/
getFiltersDisabled() {
if (this.#watchDestroyed()) return;
return AudioFilters.names.filter((x) => !this._activeFilters.includes(x));
}
/**
* Sets filters
* @param {QueueFilters} filters Queue filters
* @returns {Promise<void>}
*/
async setFilters(filters?: QueueFilters) {
if (this.#watchDestroyed()) return;
if (!filters || !Object.keys(filters).length) {
// reset filters
const streamTime = this.streamTime;
this._activeFilters = [];
return await this.play(this.current, {
immediate: true,
filtersUpdate: true,
seek: streamTime,
encoderArgs: []
});
}
const _filters: any[] = []; // eslint-disable-line @typescript-eslint/no-explicit-any
for (const filter in filters) {
if (filters[filter as keyof QueueFilters] === true) _filters.push(filter);
}
if (this._activeFilters.join("") === _filters.join("")) return;
const newFilters = AudioFilters.create(_filters).trim();
const streamTime = this.streamTime;
this._activeFilters = _filters;
return await this.play(this.current, {
immediate: true,
filtersUpdate: true,
seek: streamTime,
encoderArgs: !_filters.length ? undefined : ["-af", newFilters]
});
}
/**
* Currently playing track
* @type {Track}
* Seeks to the given time
* @param {number} position The position
* @returns {boolean}
*/
get playing(): Track {
return this.tracks[0];
async seek(position: number) {
if (this.#watchDestroyed()) return;
if (!this.playing || !this.current) return false;
if (position < 1) position = 0;
if (position >= this.current.durationMS) return this.skip();
await this.play(this.current, {
immediate: true,
filtersUpdate: true, // to stop events
seek: position
});
return true;
}
/**
* Calculated volume of this queue
* @type {Number}
* Plays previous track
* @returns {Promise<void>}
*/
get calculatedVolume(): number {
return this.filters.normalizer ? this.volume + 70 : this.volume;
async back() {
if (this.#watchDestroyed()) return;
const prev = this.previousTracks[this.previousTracks.length - 2]; // because last item is the current track
if (!prev) throw new PlayerError("Could not find previous track", ErrorStatusCode.TRACK_NOT_FOUND);
return await this.play(prev, { immediate: true });
}
/**
* Clear this queue
*/
clear() {
if (this.#watchDestroyed()) return;
this.tracks = [];
this.previousTracks = [];
}
/**
* Stops the player
* @returns {void}
*/
stop() {
if (this.#watchDestroyed()) return;
return this.destroy();
}
/**
* Shuffles this queue
* @returns {boolean}
*/
shuffle() {
if (this.#watchDestroyed()) return;
if (!this.tracks.length || this.tracks.length < 3) return false;
const currentTrack = this.tracks.shift();
for (let i = this.tracks.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[this.tracks[i], this.tracks[j]] = [this.tracks[j], this.tracks[i]];
}
this.tracks.unshift(currentTrack);
return true;
}
/**
* Removes a track from the queue
* @param {Track|Snowflake|number} track The track to remove
* @returns {Track}
*/
remove(track: Track | Snowflake | number) {
if (this.#watchDestroyed()) return;
let trackFound: Track = null;
if (typeof track === "number") {
trackFound = this.tracks[track];
if (trackFound) {
this.tracks = this.tracks.filter((t) => t.id !== trackFound.id);
}
} else {
trackFound = this.tracks.find((s) => s.id === (track instanceof Track ? track.id : track));
if (trackFound) {
this.tracks = this.tracks.filter((s) => s.id !== trackFound.id);
}
}
return trackFound;
}
/**
* Jumps to particular track
* @param {Track|number} track The track
* @returns {void}
*/
jump(track: Track | number): void {
if (this.#watchDestroyed()) return;
const foundTrack = this.remove(track);
if (!foundTrack) throw new PlayerError("Track not found", ErrorStatusCode.TRACK_NOT_FOUND);
this.tracks.splice(1, 0, foundTrack);
return void this.skip();
}
/**
* Inserts the given track to specified index
* @param {Track} track The track to insert
* @param {number} [index=0] The index where this track should be
*/
insert(track: Track, index = 0) {
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);
this.tracks.splice(index, 0, track);
this.player.emit("trackAdd", this, track);
}
/**
* @typedef {object} PlayerTimestamp
* @property {string} current The current progress
* @property {string} end The total time
* @property {number} progress Progress in %
*/
/**
* Returns player stream timestamp
* @returns {PlayerTimestamp}
*/
getPlayerTimestamp() {
if (this.#watchDestroyed()) return;
const currentStreamTime = this.streamTime;
const totalTime = this.current.durationMS;
const currentTimecode = Util.buildTimeCode(Util.parseMS(currentStreamTime));
const endTimecode = Util.buildTimeCode(Util.parseMS(totalTime));
return {
current: currentTimecode,
end: endTimecode,
progress: Math.round((currentStreamTime / totalTime) * 100)
};
}
/**
* Creates progress bar string
* @param {PlayerProgressbarOptions} options The progress bar options
* @returns {string}
*/
createProgressBar(options: PlayerProgressbarOptions = { timecodes: true }) {
if (this.#watchDestroyed()) return;
const length = typeof options.length === "number" ? (options.length <= 0 || options.length === Infinity ? 15 : options.length) : 15;
const index = Math.round((this.streamTime / this.current.durationMS) * length);
const indicator = typeof options.indicator === "string" && options.indicator.length > 0 ? options.indicator : "🔘";
const line = typeof options.line === "string" && options.line.length > 0 ? options.line : "▬";
if (index >= 1 && index <= length) {
const bar = line.repeat(length - 1).split("");
bar.splice(index, 0, indicator);
if (options.timecodes) {
const timestamp = this.getPlayerTimestamp();
return `${timestamp.current}${bar.join("")}${timestamp.end}`;
} else {
return `${bar.join("")}`;
}
} else {
if (options.timecodes) {
const timestamp = this.getPlayerTimestamp();
return `${timestamp.current}${indicator}${line.repeat(length - 1)}${timestamp.end}`;
} else {
return `${indicator}${line.repeat(length - 1)}`;
}
}
}
/**
@ -148,70 +585,159 @@ export class Queue extends EventEmitter {
* @type {Number}
*/
get totalTime(): number {
if (this.#watchDestroyed()) return;
return this.tracks.length > 0 ? this.tracks.map((t) => t.durationMS).reduce((p, c) => p + c) : 0;
}
/**
* Current stream time
* @type {Number}
*/
get currentStreamTime(): number {
const NC = this.filters.nightcore ? 1.25 : null;
const VW = this.filters.vaporwave ? 0.8 : null;
const streamTime = this.voiceConnection?.dispatcher?.streamTime + this.additionalStreamTime || 0;
if (NC && VW) return streamTime * (NC + VW);
return NC ? streamTime * NC : VW ? streamTime * VW : streamTime;
}
/**
* Sets audio filters in this player
* @param {QueueFilters} filters Audio filters to set
* Play stream in a voice/stage channel
* @param {Track} [src] The track to play (if empty, uses first track from the queue)
* @param {PlayOptions} [options={}] The options
* @returns {Promise<void>}
*/
setFilters(filters: QueueFilters): Promise<void> {
return this.player.setFilters(this.firstMessage, filters);
}
async play(src?: Track, options: PlayOptions = {}): Promise<void> {
if (!this.destroyed) this.#watchDestroyed();
if (!this.connection || !this.connection.voiceConnection) throw new PlayerError("Voice connection is not available, use <Queue>.connect()!", ErrorStatusCode.NO_CONNECTION);
if (src && (this.playing || this.tracks.length) && !options.immediate) return this.addTrack(src);
const track = options.filtersUpdate && !options.immediate ? src || this.current : src ?? this.tracks.shift();
if (!track) return;
/**
* Returns array of all enabled filters
* @returns {String[]}
*/
getFiltersEnabled(): string[] {
const filters: string[] = [];
this.player.emit("debug", this, "Received play request");
for (const filter in this.filters) {
if (this.filters[filter as keyof QueueFilters] !== false) filters.push(filter);
if (!options.filtersUpdate) {
this.previousTracks = this.previousTracks.filter((x) => x.id !== track.id);
this.previousTracks.push(track);
}
return filters;
let stream;
if (["youtube", "spotify"].includes(track.raw.source)) {
if (track.raw.source === "spotify" && !track.raw.engine) {
track.raw.engine = await YouTube.search(`${track.author} ${track.title}`, { type: "video" })
.then((x) => x[0].url)
.catch(() => null);
}
const link = track.raw.source === "spotify" ? track.raw.engine : track.url;
if (!link) return void this.play(this.tracks.shift(), { immediate: true });
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 {
stream = ytdl
.arbitraryStream(
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",
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);
});
}
const resource: AudioResource<Track> = this.connection.createStream(stream, {
type: StreamType.Raw,
data: track
});
if (options.seek) this._streamTime = options.seek;
this._filtersUpdate = options.filtersUpdate;
setTimeout(() => {
this.connection.playStream(resource).then(() => {
this.setVolume(this.options.initialVolume);
});
}, this.#getBufferingTimeout()).unref();
}
/**
* Returns all disabled filters
* @returns {String[]}
* Private method to handle autoplay
* @param {Track} track The source track to find its similar track for autoplay
* @returns {Promise<void>}
* @private
*/
getFiltersDisabled(): string[] {
const enabled = this.getFiltersEnabled();
private async _handleAutoplay(track: Track): Promise<void> {
if (this.#watchDestroyed()) return;
if (!track || ![track.source, track.raw?.source].includes("youtube")) {
if (this.options.leaveOnEnd) this.destroy();
return void this.player.emit("queueEnd", this);
}
const info = await YouTube.getVideo(track.url)
.then((x) => x.videos[0])
.catch(Util.noop);
if (!info) {
if (this.options.leaveOnEnd) this.destroy();
return void this.player.emit("queueEnd", this);
}
return Object.keys(this.filters).filter((f) => !enabled.includes(f));
const nextTrack = new Track(this.player, {
title: info.title,
url: `https://www.youtube.com/watch?v=${info.id}`,
duration: info.durationFormatted ? Util.buildTimeCode(Util.parseMS(info.duration * 1000)) : "0:00",
description: "",
thumbnail: typeof info.thumbnail === "string" ? info.thumbnail : info.thumbnail.url,
views: info.views,
author: info.channel.name,
requestedBy: track.requestedBy,
source: "youtube"
});
this.play(nextTrack, { immediate: true });
}
*[Symbol.iterator]() {
if (this.#watchDestroyed()) return;
yield* this.tracks;
}
/**
* Destroys this queue
* @returns {Boolean}
* JSON representation of this queue
* @returns {object}
*/
destroy() {
return this.player.stop(this.firstMessage);
toJSON() {
if (this.#watchDestroyed()) return;
return {
id: this.id,
guild: this.guild.id,
voiceChannel: this.connection?.channel?.id,
options: this.options,
tracks: this.tracks.map((m) => m.toJSON())
};
}
/**
* String representation of this Queue
* @returns {String}
* String representation of this queue
* @returns {string}
*/
toString(): string {
return `<Queue ${this.guildID}>`;
toString() {
if (this.#watchDestroyed()) return;
if (!this.tracks.length) return "No songs available to display!";
return `**Upcoming Songs:**\n${this.tracks.map((m, i) => `${i + 1}. **${m.title}**`).join("\n")}`;
}
#watchDestroyed() {
if (this.#destroyed) {
this.player.emit("error", this, new PlayerError("Cannot use destroyed queue", ErrorStatusCode.DESTROYED_QUEUE));
return true;
}
}
#getBufferingTimeout() {
const timeout = this.options.bufferingTimeout;
if (isNaN(timeout) || timeout < 0 || !Number.isFinite(timeout)) return 1000;
return timeout;
}
}
export default Queue;
export { Queue };

View file

@ -1,9 +1,10 @@
import { Player } from '../Player';
import { User } from 'discord.js';
import { TrackData } from '../types/types';
import Queue from './Queue';
import { User, Util, SnowflakeUtil, Snowflake } from "discord.js";
import { Player } from "../Player";
import { RawTrackData, TrackJSON } from "../types/types";
import { Playlist } from "./Playlist";
import { Queue } from "./Queue";
export class Track {
class Track {
public player!: Player;
public title!: string;
public description!: string;
@ -13,99 +14,106 @@ export class Track {
public duration!: string;
public views!: number;
public requestedBy!: User;
public fromPlaylist!: boolean;
public raw!: TrackData;
public playlist?: Playlist;
public readonly raw: RawTrackData = {} as RawTrackData;
public readonly id: Snowflake = SnowflakeUtil.generate();
/**
* Track constructor
* @param {Player} player The player that instantiated this Track
* @param {TrackData} data Track data
* @param {RawTrackData} data Track data
*/
constructor(player: Player, data: TrackData) {
constructor(player: Player, data: RawTrackData) {
/**
* The player that instantiated this Track
* @name Track#player
* @type {Player}
* @readonly
*/
Object.defineProperty(this, 'player', { value: player, enumerable: false });
Object.defineProperty(this, "player", { value: player, enumerable: false });
/**
* Title of this track
* @name Track#title
* @type {String}
* @type {string}
*/
/**
* Description of this track
* @name Track#description
* @type {String}
* @type {string}
*/
/**
* Author of this track
* @name Track#author
* @type {String}
* @type {string}
*/
/**
* URL of this track
* @name Track#url
* @type {String}
* @type {string}
*/
/**
* Thumbnail of this track
* @name Track#thumbnail
* @type {String}
* @type {string}
*/
/**
* Duration of this track
* @name Track#duration
* @type {String}
* @type {string}
*/
/**
* Views count of this track
* @name Track#views
* @type {Number}
* @type {number}
*/
/**
* Person who requested this track
* @name Track#requestedBy
* @type {DiscordUser}
* @type {User}
*/
/**
* If this track belongs to playlist
* @name Track#fromPlaylist
* @type {Boolean}
* @type {boolean}
*/
/**
* Raw track data
* @name Track#raw
* @type {TrackData}
* @type {RawTrackData}
*/
/**
* The track id
* @name Track#id
* @type {Snowflake}
* @readonly
*/
void this._patch(data);
}
private _patch(data: TrackData) {
this.title = data.title ?? '';
this.description = data.description ?? '';
this.author = data.author ?? '';
this.url = data.url ?? '';
this.thumbnail = data.thumbnail ?? '';
this.duration = data.duration ?? '';
private _patch(data: RawTrackData) {
this.title = Util.escapeMarkdown(data.title ?? "");
this.author = data.author ?? "";
this.url = data.url ?? "";
this.thumbnail = data.thumbnail ?? "";
this.duration = data.duration ?? "";
this.views = data.views ?? 0;
this.requestedBy = data.requestedBy;
this.fromPlaylist = Boolean(data.fromPlaylist);
this.playlist = data.playlist;
// raw
Object.defineProperty(this, 'raw', { get: () => data, enumerable: false });
Object.defineProperty(this, "raw", { value: Object.assign({}, { source: data.raw?.source ?? data.source }, data.raw ?? data), enumerable: false });
}
/**
@ -118,7 +126,7 @@ export class Track {
/**
* The track duration in millisecond
* @type {Number}
* @type {number}
*/
get durationMS(): number {
const times = (n: number, t: number) => {
@ -128,7 +136,7 @@ export class Track {
};
return this.duration
.split(':')
.split(":")
.reverse()
.map((m, i) => parseInt(m) * times(60, i))
.reduce((a, c) => a + c, 0);
@ -139,16 +147,38 @@ export class Track {
* @type {TrackSource}
*/
get source() {
return this.raw.source ?? 'arbitrary';
return this.raw.source ?? "arbitrary";
}
/**
* String representation of this track
* @returns {String}
* @returns {string}
*/
toString(): string {
return `${this.title} by ${this.author}`;
}
/**
* Raw JSON representation of this track
* @returns {TrackJSON}
*/
toJSON(hidePlaylist?: boolean) {
return {
id: this.id,
title: this.title,
description: this.description,
author: this.author,
url: this.url,
thumbnail: this.thumbnail,
duration: this.duration,
durationMS: this.durationMS,
views: this.views,
requestedBy: this.requestedBy.id,
playlist: hidePlaylist ? null : this.playlist?.toJSON(false) ?? null
} as TrackJSON;
}
}
export default Track;
export { Track };

View file

@ -0,0 +1,227 @@
import {
AudioPlayer,
AudioPlayerError,
AudioPlayerStatus,
AudioResource,
createAudioPlayer,
createAudioResource,
entersState,
StreamType,
VoiceConnection,
VoiceConnectionStatus,
VoiceConnectionDisconnectReason
} from "@discordjs/voice";
import { StageChannel, VoiceChannel } from "discord.js";
import { Duplex, Readable } from "stream";
import { TypedEmitter as EventEmitter } from "tiny-typed-emitter";
import Track from "../Structures/Track";
import { Util } from "../utils/Util";
import { PlayerError, ErrorStatusCode } from "../Structures/PlayerError";
export interface VoiceEvents {
/* eslint-disable @typescript-eslint/no-explicit-any */
error: (error: AudioPlayerError) => any;
debug: (message: string) => any;
start: (resource: AudioResource<Track>) => any;
finish: (resource: AudioResource<Track>) => any;
/* eslint-enable @typescript-eslint/no-explicit-any */
}
class StreamDispatcher extends EventEmitter<VoiceEvents> {
public readonly voiceConnection: VoiceConnection;
public readonly audioPlayer: AudioPlayer;
public channel: VoiceChannel | StageChannel;
public audioResource?: AudioResource<Track>;
private readyLock = false;
public paused: boolean;
/**
* Creates new connection object
* @param {VoiceConnection} connection The connection
* @param {VoiceChannel|StageChannel} channel The connected channel
* @private
*/
constructor(connection: VoiceConnection, channel: VoiceChannel | StageChannel, public readonly connectionTimeout: number = 20000) {
super();
/**
* The voice connection
* @type {VoiceConnection}
*/
this.voiceConnection = connection;
/**
* The audio player
* @type {AudioPlayer}
*/
this.audioPlayer = createAudioPlayer();
/**
* The voice channel
* @type {VoiceChannel|StageChannel}
*/
this.channel = channel;
/**
* The paused state
* @type {boolean}
*/
this.paused = false;
this.voiceConnection.on("stateChange", async (_, newState) => {
if (newState.status === VoiceConnectionStatus.Disconnected) {
if (newState.reason === VoiceConnectionDisconnectReason.WebSocketClose && newState.closeCode === 4014) {
try {
await entersState(this.voiceConnection, VoiceConnectionStatus.Connecting, this.connectionTimeout);
} catch {
this.voiceConnection.destroy();
}
} else if (this.voiceConnection.rejoinAttempts < 5) {
await Util.wait((this.voiceConnection.rejoinAttempts + 1) * 5000);
this.voiceConnection.rejoin();
} else {
this.voiceConnection.destroy();
}
} else if (newState.status === VoiceConnectionStatus.Destroyed) {
this.end();
} else if (!this.readyLock && (newState.status === VoiceConnectionStatus.Connecting || newState.status === VoiceConnectionStatus.Signalling)) {
this.readyLock = true;
try {
await entersState(this.voiceConnection, VoiceConnectionStatus.Ready, this.connectionTimeout);
} catch {
if (this.voiceConnection.state.status !== VoiceConnectionStatus.Destroyed) this.voiceConnection.destroy();
} finally {
this.readyLock = false;
}
}
});
this.audioPlayer.on("stateChange", (oldState, newState) => {
if (newState.status === AudioPlayerStatus.Playing) {
if (!this.paused) return void this.emit("start", this.audioResource);
} else if (newState.status === AudioPlayerStatus.Idle && oldState.status !== AudioPlayerStatus.Idle) {
if (!this.paused) {
void this.emit("finish", this.audioResource);
this.audioResource = null;
}
}
});
this.audioPlayer.on("debug", (m) => void this.emit("debug", m));
this.audioPlayer.on("error", (error) => void this.emit("error", error));
this.voiceConnection.subscribe(this.audioPlayer);
}
/**
* Creates stream
* @param {Readable|Duplex|string} src The stream source
* @param {object} [ops={}] Options
* @returns {AudioResource}
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
createStream(src: Readable | Duplex | string, ops?: { type?: StreamType; data?: any }) {
this.audioResource = createAudioResource(src, {
inputType: ops?.type ?? StreamType.Arbitrary,
metadata: ops?.data,
inlineVolume: true // we definitely need volume controls, right?
});
return this.audioResource;
}
/**
* The player status
* @type {AudioPlayerStatus}
*/
get status() {
return this.audioPlayer.state.status;
}
/**
* Disconnects from voice
* @returns {void}
*/
disconnect() {
try {
this.audioPlayer.stop(true);
this.voiceConnection.destroy();
} catch {} // eslint-disable-line no-empty
}
/**
* Stops the player
* @returns {void}
*/
end() {
this.audioPlayer.stop();
}
/**
* Pauses the stream playback
* @param {boolean} [interpolateSilence=false] If true, the player will play 5 packets of silence after pausing to prevent audio glitches.
* @returns {boolean}
*/
pause(interpolateSilence?: boolean) {
const success = this.audioPlayer.pause(interpolateSilence);
this.paused = success;
return success;
}
/**
* Resumes the stream playback
* @returns {boolean}
*/
resume() {
const success = this.audioPlayer.unpause();
this.paused = !success;
return success;
}
/**
* Play stream
* @param {AudioResource<Track>} [resource=this.audioResource] The audio resource to play
* @returns {Promise<StreamDispatcher>}
*/
async playStream(resource: AudioResource<Track> = this.audioResource) {
if (!resource) throw new PlayerError("Audio resource is not available!", ErrorStatusCode.NO_AUDIO_RESOURCE);
if (!this.audioResource) this.audioResource = resource;
if (this.voiceConnection.state.status !== VoiceConnectionStatus.Ready) await entersState(this.voiceConnection, VoiceConnectionStatus.Ready, this.connectionTimeout);
this.audioPlayer.play(resource);
return this;
}
/**
* Sets playback volume
* @param {number} value The volume amount
* @returns {boolean}
*/
setVolume(value: number) {
if (!this.audioResource || isNaN(value) || value < 0 || value > Infinity) return false;
// ye boi logarithmic ✌
this.audioResource.volume.setVolumeLogarithmic(value / 100);
return true;
}
/**
* The current volume
* @type {number}
*/
get volume() {
if (!this.audioResource || !this.audioResource.volume) return 100;
const currentVol = this.audioResource.volume.volume;
return Math.round(Math.pow(currentVol, 1 / 1.660964) * 100);
}
/**
* The playback time
* @type {number}
*/
get streamTime() {
if (!this.audioResource) return 0;
return this.audioResource.playbackDuration;
}
}
export { StreamDispatcher as StreamDispatcher };

View file

@ -0,0 +1,88 @@
import { VoiceChannel, StageChannel, Collection, Snowflake } from "discord.js";
import { DiscordGatewayAdapterCreator, entersState, joinVoiceChannel, VoiceConnection, VoiceConnectionStatus } from "@discordjs/voice";
import { StreamDispatcher } from "./StreamDispatcher";
class VoiceUtils {
public cache: Collection<Snowflake, StreamDispatcher>;
/**
* The voice utils
* @private
*/
constructor() {
/**
* The cache where voice utils stores stream managers
* @type {Collection<Snowflake, StreamDispatcher>}
*/
this.cache = new Collection<Snowflake, StreamDispatcher>();
}
/**
* Joins a voice channel, creating basic stream dispatch manager
* @param {StageChannel|VoiceChannel} channel The voice channel
* @param {object} [options={}] Join options
* @returns {Promise<StreamDispatcher>}
*/
public async connect(
channel: VoiceChannel | StageChannel,
options?: {
deaf?: boolean;
maxTime?: number;
}
): Promise<StreamDispatcher> {
const conn = await this.join(channel, options);
const sub = new StreamDispatcher(conn, channel, options.maxTime);
this.cache.set(channel.guild.id, sub);
return sub;
}
/**
* Joins a voice channel
* @param {StageChannel|VoiceChannel} [channel] The voice/stage channel to join
* @param {object} [options={}] Join options
* @returns {VoiceConnection}
*/
public async join(
channel: VoiceChannel | StageChannel,
options?: {
deaf?: boolean;
maxTime?: number;
}
) {
let conn = joinVoiceChannel({
guildId: channel.guild.id,
channelId: channel.id,
adapterCreator: channel.guild.voiceAdapterCreator as unknown as DiscordGatewayAdapterCreator,
selfDeaf: Boolean(options.deaf)
});
try {
conn = await entersState(conn, VoiceConnectionStatus.Ready, options?.maxTime ?? 20000);
return conn;
} catch (err) {
conn.destroy();
throw err;
}
}
/**
* Disconnects voice connection
* @param {VoiceConnection} connection The voice connection
* @returns {void}
*/
public disconnect(connection: VoiceConnection | StreamDispatcher) {
if (connection instanceof StreamDispatcher) return connection.voiceConnection.destroy();
return connection.destroy();
}
/**
* Returns Discord Player voice connection
* @param {Snowflake} guild The guild id
* @returns {StreamDispatcher}
*/
public getConnection(guild: Snowflake) {
return this.cache.get(guild);
}
}
export { VoiceUtils };

View file

@ -1,9 +1,12 @@
export { AudioFilters } from './utils/AudioFilters';
export * as Constants from './utils/Constants';
export { ExtractorModel } from './Structures/ExtractorModel';
export { Player } from './Player';
export { Util } from './utils/Util';
export { Track } from './Structures/Track';
export { Queue } from './Structures/Queue';
export * from './types/types';
export { PlayerError } from './utils/PlayerError';
export { AudioFilters } from "./utils/AudioFilters";
export { ExtractorModel } from "./Structures/ExtractorModel";
export { Playlist } from "./Structures/Playlist";
export { Player } from "./Player";
export { PlayerError, ErrorStatusCode } from "./Structures/PlayerError";
export { QueryResolver } from "./utils/QueryResolver";
export { Queue } from "./Structures/Queue";
export { Track } from "./Structures/Track";
export { Util } from "./utils/Util";
export { VoiceUtils } from "./VoiceInterface/VoiceUtils";
export { VoiceEvents, StreamDispatcher } from "./VoiceInterface/StreamDispatcher";
export * from "./types/types";

View file

@ -1,45 +1,21 @@
import { downloadOptions } from 'ytdl-core';
import { User } from 'discord.js';
import { Readable, Duplex } from 'stream';
export interface PlayerOptions {
leaveOnEnd?: boolean;
leaveOnEndCooldown?: number;
leaveOnStop?: boolean;
leaveOnEmpty?: boolean;
leaveOnEmptyCooldown?: number;
autoSelfDeaf?: boolean;
enableLive?: boolean;
ytdlDownloadOptions?: downloadOptions;
useSafeSearch?: boolean;
disableAutoRegister?: boolean;
disableArtistSearch?: boolean;
fetchBeforeQueued?: boolean;
volume?: number;
}
import { Snowflake, User, UserResolvable } from "discord.js";
import { Readable, Duplex } from "stream";
import { Queue } from "../Structures/Queue";
import Track from "../Structures/Track";
import { Playlist } from "../Structures/Playlist";
import { StreamDispatcher } from "../VoiceInterface/StreamDispatcher";
import { downloadOptions } from "ytdl-core";
export type FiltersName = keyof QueueFilters;
export type TrackSource = 'soundcloud' | 'youtube' | 'spotify' | 'arbitrary';
export interface TrackData {
title: string;
description: string;
author: string;
url: string;
thumbnail: string;
duration: string;
views: number;
requestedBy: User;
fromPlaylist: boolean;
source?: TrackSource;
engine?: any;
live?: boolean;
}
export type QueueFilters = {
/**
* @typedef {AudioFilters} QueueFilters
*/
export interface QueueFilters {
bassboost_low?: boolean;
bassboost?: boolean;
'8D'?: boolean;
bassboost_high?: boolean;
"8D"?: boolean;
vaporwave?: boolean;
nightcore?: boolean;
phaser?: boolean;
@ -48,6 +24,7 @@ export type QueueFilters = {
reverse?: boolean;
treble?: boolean;
normalizer?: boolean;
normalizer2?: boolean;
surrounding?: boolean;
pulsator?: boolean;
subboost?: boolean;
@ -66,75 +43,421 @@ export type QueueFilters = {
chorus2d?: boolean;
chorus3d?: boolean;
fadein?: boolean;
};
dim?: boolean;
earrape?: boolean;
}
export type QueryType = 'soundcloud_track' | 'soundcloud_playlist' | 'spotify_song' | 'spotify_album' | 'spotify_playlist' | 'youtube_video' | 'youtube_playlist' | 'vimeo' | 'facebook' | 'reverbnation' | 'attachment' | 'youtube_search';
/**
* The track source:
* - soundcloud
* - youtube
* - spotify
* - arbitrary
* @typedef {string} TrackSource
*/
export type TrackSource = "soundcloud" | "youtube" | "spotify" | "arbitrary";
export interface ExtractorModelData {
/**
* @typedef {object} RawTrackData
* @property {string} title The title
* @property {string} description The description
* @property {string} author The author
* @property {string} url The url
* @property {string} thumbnail The thumbnail
* @property {string} duration The duration
* @property {number} views The views
* @property {User} requestedBy The user who requested this track
* @property {Playlist} [playlist] The playlist
* @property {TrackSource} [source="arbitrary"] The source
* @property {any} [engine] The engine
* @property {boolean} [live] If this track is live
* @property {any} [raw] The raw data
*/
export interface RawTrackData {
title: string;
duration: number;
thumbnail: string;
engine: string | Readable | Duplex;
views: number;
author: string;
description: string;
author: string;
url: string;
version?: string;
important?: boolean;
source?: TrackSource;
}
export interface PlayerProgressbarOptions {
timecodes?: boolean;
queue?: boolean;
length?: number;
line?: string;
indicator?: string;
}
export interface LyricsData {
title: string;
id: number;
thumbnail: string;
image: string;
url: string;
artist: {
name: string;
id: number;
url: string;
image: string;
};
lyrics?: string;
}
export interface PlayerStats {
uptime: number;
connections: number;
users: number;
queues: number;
extractors: number;
versions: {
ffmpeg: string;
node: string;
v8: string;
};
system: {
arch: string;
platform: 'aix' | 'android' | 'darwin' | 'freebsd' | 'linux' | 'openbsd' | 'sunos' | 'win32' | 'cygwin' | 'netbsd';
cpu: number;
memory: {
total: string;
usage: string;
rss: string;
arrayBuffers: string;
};
uptime: number;
};
duration: string;
views: number;
requestedBy: User;
playlist?: Playlist;
source?: TrackSource;
engine?: any; // eslint-disable-line @typescript-eslint/no-explicit-any
live?: boolean;
raw?: any; // eslint-disable-line @typescript-eslint/no-explicit-any
}
/**
* @typedef {object} TimeData
* @property {number} days Time in days
* @property {number} hours Time in hours
* @property {number} minutes Time in minutes
* @property {number} seconds Time in seconds
*/
export interface TimeData {
days: number;
hours: number;
minutes: number;
seconds: number;
}
/**
* @typedef {object} PlayerProgressbarOptions
* @property {boolean} [timecodes] If it should render time codes
* @property {boolean} [queue] If it should create progress bar for the whole queue
* @property {number} [length] The bar length
* @property {string} [line] The bar track
* @property {string} [indicator] The indicator
*/
export interface PlayerProgressbarOptions {
timecodes?: boolean;
length?: number;
line?: string;
indicator?: string;
}
/**
* @typedef {object} PlayerOptions
* @property {boolean} [leaveOnEnd=true] If it should leave on end
* @property {boolean} [leaveOnStop=true] If it should leave on stop
* @property {boolean} [leaveOnEmpty=true] If it should leave on empty
* @property {number} [leaveOnEmptyCooldown=1000] The cooldown in ms
* @property {boolean} [autoSelfDeaf=true] If it should set the bot in deaf mode
* @property {YTDLDownloadOptions} [ytdlOptions={}] The youtube download options
* @property {number} [initialVolume=100] The initial player volume
* @property {number} [bufferingTimeout=3000] Buffering timeout for the stream
*/
export interface PlayerOptions {
leaveOnEnd?: boolean;
leaveOnStop?: boolean;
leaveOnEmpty?: boolean;
leaveOnEmptyCooldown?: number;
autoSelfDeaf?: boolean;
ytdlOptions?: downloadOptions;
initialVolume?: number;
bufferingTimeout?: number;
}
/**
* @typedef {object} ExtractorModelData
* @property {object} [playlist] The playlist info (if any)
* @property {string} [playlist.title] The playlist title
* @property {string} [playlist.description] The playlist description
* @property {string} [playlist.thumbnail] The playlist thumbnail
* @property {album|playlist} [playlist.type] The playlist type: `album` | `playlist`
* @property {TrackSource} [playlist.source] The playlist source
* @property {object} [playlist.author] The playlist author
* @property {string} [playlist.author.name] The author name
* @property {string} [playlist.author.url] The author url
* @property {string} [playlist.id] The playlist id
* @property {string} [playlist.url] The playlist url
* @property {any} [playlist.rawPlaylist] The raw data
* @property {ExtractorData[]} data The data
*/
/**
* @typedef {object} ExtractorData
* @property {string} title The title
* @property {number} duration The duration
* @property {string} thumbnail The thumbnail
* @property {string|Readable|Duplex} engine The stream engine
* @property {number} views The views count
* @property {string} author The author
* @property {string} description The description
* @property {string} url The url
* @property {string} [version] The extractor version
* @property {TrackSource} [source="arbitrary"] The source
*/
export interface ExtractorModelData {
playlist?: {
title: string;
description: string;
thumbnail: string;
type: "album" | "playlist";
source: TrackSource;
author: {
name: string;
url: string;
};
id: string;
url: string;
rawPlaylist?: any; // eslint-disable-line @typescript-eslint/no-explicit-any
};
data: {
title: string;
duration: number;
thumbnail: string;
engine: string | Readable | Duplex;
views: number;
author: string;
description: string;
url: string;
version?: string;
source?: TrackSource;
}[];
}
/**
* The search query type
* This can be one of:
* - AUTO
* - YOUTUBE
* - YOUTUBE_PLAYLIST
* - SOUNDCLOUD_TRACK
* - SOUNDCLOUD_PLAYLIST
* - SOUNDCLOUD
* - SPOTIFY_SONG
* - SPOTIFY_ALBUM
* - SPOTIFY_PLAYLIST
* - FACEBOOK
* - VIMEO
* - ARBITRARY
* - REVERBNATION
* - YOUTUBE_SEARCH
* - SOUNDCLOUD_SEARCH
* @typedef {string} QueryType
*/
export enum QueryType {
AUTO = "auto",
YOUTUBE = "youtube",
YOUTUBE_PLAYLIST = "youtube_playlist",
SOUNDCLOUD_TRACK = "soundcloud_track",
SOUNDCLOUD_PLAYLIST = "soundcloud_playlist",
SOUNDCLOUD = "soundcloud",
SPOTIFY_SONG = "spotify_song",
SPOTIFY_ALBUM = "spotify_album",
SPOTIFY_PLAYLIST = "spotify_playlist",
FACEBOOK = "facebook",
VIMEO = "vimeo",
ARBITRARY = "arbitrary",
REVERBNATION = "reverbnation",
YOUTUBE_SEARCH = "youtube_search",
SOUNDCLOUD_SEARCH = "soundcloud_search"
}
/**
* Emitted when bot gets disconnected from a voice channel
* @event Player#botDisconnect
* @param {Queue} queue The queue
*/
/**
* Emitted when the voice channel is empty
* @event Player#channelEmpty
* @param {Queue} queue The queue
*/
/**
* Emitted when bot connects to a voice channel
* @event Player#connectionCreate
* @param {Queue} queue The queue
* @param {StreamDispatcher} connection The discord player connection object
*/
/**
* Debug information
* @event Player#debug
* @param {Queue} queue The queue
* @param {string} message The message
*/
/**
* Emitted on error
* <warn>This event should handled properly otherwise it may crash your process!</warn>
* @event Player#error
* @param {Queue} queue The queue
* @param {Error} error The error
*/
/**
* Emitted on connection error. Sometimes stream errors are emitted here as well.
* @event Player#connectionError
* @param {Queue} queue The queue
* @param {Error} error The error
*/
/**
* Emitted when queue ends
* @event Player#queueEnd
* @param {Queue} queue The queue
*/
/**
* Emitted when a single track is added
* @event Player#trackAdd
* @param {Queue} queue The queue
* @param {Track} track The track
*/
/**
* Emitted when multiple tracks are added
* @event Player#tracksAdd
* @param {Queue} queue The queue
* @param {Track[]} tracks The tracks
*/
/**
* Emitted when a track starts playing
* @event Player#trackStart
* @param {Queue} queue The queue
* @param {Track} track The track
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
export interface PlayerEvents {
botDisconnect: (queue: Queue) => any;
channelEmpty: (queue: Queue) => any;
connectionCreate: (queue: Queue, connection: StreamDispatcher) => any;
debug: (queue: Queue, message: string) => any;
error: (queue: Queue, error: Error) => any;
connectionError: (queue: Queue, error: Error) => any;
queueEnd: (queue: Queue) => any;
trackAdd: (queue: Queue, track: Track) => any;
tracksAdd: (queue: Queue, track: Track[]) => any;
trackStart: (queue: Queue, track: Track) => any;
trackEnd: (queue: Queue, track: Track) => any;
}
/* eslint-enable @typescript-eslint/no-explicit-any */
/**
* @typedef {object} PlayOptions
* @property {boolean} [filtersUpdate=false] If this play was triggered for filters update
* @property {string[]} [encoderArgs=[]] FFmpeg args passed to encoder
* @property {number} [seek] Time to seek to before playing
* @property {boolean} [immediate=false] If it should start playing the provided track immediately
*/
export interface PlayOptions {
filtersUpdate?: boolean;
encoderArgs?: string[];
seek?: number;
immediate?: boolean;
}
/**
* @typedef {object} SearchOptions
* @property {UserResolvable} requestedBy The user who requested this search
* @property {QueryType} [searchEngine=QueryType.AUTO] The query search engine
* @property {boolean} [blockExtractor=false] If it should block custom extractors
*/
export interface SearchOptions {
requestedBy: UserResolvable;
searchEngine?: QueryType;
blockExtractor?: boolean;
}
/**
* The queue repeat mode. This can be one of:
* - OFF
* - TRACK
* - QUEUE
* - AUTOPLAY
* @typedef {number} QueueRepeatMode
*/
export enum QueueRepeatMode {
OFF = 0,
TRACK = 1,
QUEUE = 2,
AUTOPLAY = 3
}
/**
* @typedef {object} PlaylistInitData
* @property {Track[]} tracks The tracks of this playlist
* @property {string} title The playlist title
* @property {string} description The description
* @property {string} thumbnail The thumbnail
* @property {album|playlist} type The playlist type: `album` | `playlist`
* @property {TrackSource} source The playlist source
* @property {object} author The playlist author
* @property {string} [author.name] The author name
* @property {string} [author.url] The author url
* @property {string} id The playlist id
* @property {string} url The playlist url
* @property {any} [rawPlaylist] The raw playlist data
*/
export interface PlaylistInitData {
tracks: Track[];
title: string;
description: string;
thumbnail: string;
type: "album" | "playlist";
source: TrackSource;
author: {
name: string;
url: string;
};
id: string;
url: string;
rawPlaylist?: any; // eslint-disable-line @typescript-eslint/no-explicit-any
}
/**
* @typedef {object} TrackJSON
* @property {string} title The track title
* @property {string} description The track description
* @property {string} author The author
* @property {string} url The url
* @property {string} thumbnail The thumbnail
* @property {string} duration The duration
* @property {number} durationMS The duration in ms
* @property {number} views The views count
* @property {Snowflake} requestedBy The id of the user who requested this track
* @property {PlaylistJSON} [playlist] The playlist info (if any)
*/
export interface TrackJSON {
id: Snowflake;
title: string;
description: string;
author: string;
url: string;
thumbnail: string;
duration: string;
durationMS: number;
views: number;
requestedBy: Snowflake;
playlist?: PlaylistJSON;
}
/**
* @typedef {object} PlaylistJSON
* @property {string} id The playlist id
* @property {string} url The playlist url
* @property {string} title The playlist title
* @property {string} description The playlist description
* @property {string} thumbnail The thumbnail
* @property {album|playlist} type The playlist type: `album` | `playlist`
* @property {TrackSource} source The track source
* @property {object} author The playlist author
* @property {string} [author.name] The author name
* @property {string} [author.url] The author url
* @property {TrackJSON[]} tracks The tracks data (if any)
*/
export interface PlaylistJSON {
id: string;
url: string;
title: string;
description: string;
thumbnail: string;
type: "album" | "playlist";
source: TrackSource;
author: {
name: string;
url: string;
};
tracks: TrackJSON[];
}
/**
* @typedef {object} PlayerInitOptions
* @property {boolean} [autoRegisterExtractor=true] If it should automatically register `@discord-player/extractor`
* @property {YTDLDownloadOptions} [ytdlOptions={}] The options passed to `ytdl-core`
* @property {number} [connectionTimeout=20000] The voice connection timeout
*/
export interface PlayerInitOptions {
autoRegisterExtractor?: boolean;
ytdlOptions?: downloadOptions;
connectionTimeout?: number;
}

View file

@ -1,96 +1,108 @@
import { FiltersName } from '../types/types';
import { FiltersName } from "../types/types";
const bass = (g: number) => `bass=g=${g}:f=110:w=0.3`;
/**
* The available audio filters
* @typedef {Object} AudioFilters
* @property {String} bassboost The bassboost filter
* @property {String} 8D The 8D filter
* @property {String} vaporwave The vaporwave filter
* @property {String} nightcore The nightcore filter
* @property {String} phaser The phaser filter
* @property {String} tremolo The tremolo filter
* @property {String} vibrato The vibrato filter
* @property {String} reverse The reverse filter
* @property {String} treble The treble filter
* @property {String} normalizer The normalizer filter
* @property {String} surrounding The surrounding filter
* @property {String} pulsator The pulsator filter
* @property {String} subboost The subboost filter
* @property {String} kakaoke The kakaoke filter
* @property {String} flanger The flanger filter
* @property {String} gate The gate filter
* @property {String} haas The haas filter
* @property {String} mcompand The mcompand filter
* @property {String} mono The mono filter
* @property {String} mstlr The mstlr filter
* @property {String} mstrr The mstrr filter
* @property {String} compressor The compressor filter
* @property {String} expander The expander filter
* @property {String} softlimiter The softlimiter filter
* @property {String} chorus The chorus filter
* @property {String} chorus2d The chorus2d filter
* @property {String} chorus3d The chorus3d filter
* @property {String} fadein The fadein filter
* @typedef {object} AudioFilters
* @property {string} bassboost_low The bassboost filter (+15dB)
* @property {string} bassboost The bassboost filter (+20dB)
* @property {string} bassboost_high The bassboost filter (+30dB)
* @property {string} 8D The 8D filter
* @property {string} vaporwave The vaporwave filter
* @property {string} nightcore The nightcore filter
* @property {string} phaser The phaser filter
* @property {string} tremolo The tremolo filter
* @property {string} vibrato The vibrato filter
* @property {string} reverse The reverse filter
* @property {string} treble The treble filter
* @property {string} normalizer The normalizer filter (dynamic audio normalizer based)
* @property {string} normalizer2 The normalizer filter (audio compressor based)
* @property {string} surrounding The surrounding filter
* @property {string} pulsator The pulsator filter
* @property {string} subboost The subboost filter
* @property {string} kakaoke The kakaoke filter
* @property {string} flanger The flanger filter
* @property {string} gate The gate filter
* @property {string} haas The haas filter
* @property {string} mcompand The mcompand filter
* @property {string} mono The mono filter
* @property {string} mstlr The mstlr filter
* @property {string} mstrr The mstrr filter
* @property {string} compressor The compressor filter
* @property {string} expander The expander filter
* @property {string} softlimiter The softlimiter filter
* @property {string} chorus The chorus filter
* @property {string} chorus2d The chorus2d filter
* @property {string} chorus3d The chorus3d filter
* @property {string} fadein The fadein filter
* @property {string} dim The dim filter
* @property {string} earrape The earrape filter
*/
const FilterList = {
bassboost: 'bass=g=20',
'8D': 'apulsator=hz=0.09',
vaporwave: 'aresample=48000,asetrate=48000*0.8',
nightcore: 'aresample=48000,asetrate=48000*1.25',
phaser: 'aphaser=in_gain=0.4',
tremolo: 'tremolo',
vibrato: 'vibrato=f=6.5',
reverse: 'areverse',
treble: 'treble=g=5',
normalizer: 'dynaudnorm=g=101',
surrounding: 'surround',
pulsator: 'apulsator=hz=1',
subboost: 'asubboost',
karaoke: 'stereotools=mlev=0.03',
flanger: 'flanger',
gate: 'agate',
haas: 'haas',
mcompand: 'mcompand',
mono: 'pan=mono|c0=.5*c0+.5*c1',
mstlr: 'stereotools=mode=ms>lr',
mstrr: 'stereotools=mode=ms>rr',
compressor: 'compand=points=-80/-105|-62/-80|-15.4/-15.4|0/-12|20/-7.6',
expander: 'compand=attacks=0:points=-80/-169|-54/-80|-49.5/-64.6|-41.1/-41.1|-25.8/-15|-10.8/-4.5|0/0|20/8.3',
softlimiter: 'compand=attacks=0:points=-80/-80|-12.4/-12.4|-6/-8|0/-6.8|20/-2.8',
chorus: 'chorus=0.7:0.9:55:0.4:0.25:2',
chorus2d: 'chorus=0.6:0.9:50|60:0.4|0.32:0.25|0.4:2|1.3',
chorus3d: 'chorus=0.5:0.9:50|60|40:0.4|0.32|0.3:0.25|0.4|0.3:2|2.3|1.3',
fadein: 'afade=t=in:ss=0:d=10',
bassboost_low: bass(15),
bassboost: bass(20),
bassboost_high: bass(30),
"8D": "apulsator=hz=0.09",
vaporwave: "aresample=48000,asetrate=48000*0.8",
nightcore: "aresample=48000,asetrate=48000*1.25",
phaser: "aphaser=in_gain=0.4",
tremolo: "tremolo",
vibrato: "vibrato=f=6.5",
reverse: "areverse",
treble: "treble=g=5",
normalizer: "dynaudnorm=g=101",
normalizer2: "acompressor",
surrounding: "surround",
pulsator: "apulsator=hz=1",
subboost: "asubboost",
karaoke: "stereotools=mlev=0.03",
flanger: "flanger",
gate: "agate",
haas: "haas",
mcompand: "mcompand",
mono: "pan=mono|c0=.5*c0+.5*c1",
mstlr: "stereotools=mode=ms>lr",
mstrr: "stereotools=mode=ms>rr",
compressor: "compand=points=-80/-105|-62/-80|-15.4/-15.4|0/-12|20/-7.6",
expander: "compand=attacks=0:points=-80/-169|-54/-80|-49.5/-64.6|-41.1/-41.1|-25.8/-15|-10.8/-4.5|0/0|20/8.3",
softlimiter: "compand=attacks=0:points=-80/-80|-12.4/-12.4|-6/-8|0/-6.8|20/-2.8",
chorus: "chorus=0.7:0.9:55:0.4:0.25:2",
chorus2d: "chorus=0.6:0.9:50|60:0.4|0.32:0.25|0.4:2|1.3",
chorus3d: "chorus=0.5:0.9:50|60|40:0.4|0.32|0.3:0.25|0.4|0.3:2|2.3|1.3",
fadein: "afade=t=in:ss=0:d=10",
dim: `afftfilt="'real=re * (1-clip((b/nb)*b,0,1))':imag='im * (1-clip((b/nb)*b,0,1))'"`,
earrape: "channelsplit,sidechaingate=level_in=64",
*[Symbol.iterator](): IterableIterator<{ name: FiltersName; value: string }> {
for (const [k, v] of Object.entries(this)) {
if (typeof this[k as FiltersName] === 'string') yield { name: k as FiltersName, value: v as string };
if (typeof this[k as FiltersName] === "string") yield { name: k as FiltersName, value: v as string };
}
},
get names() {
return Object.keys(this).filter((p) => !['names', 'length'].includes(p) && typeof this[p as FiltersName] !== 'function');
return Object.keys(this).filter((p) => !["names", "length"].includes(p) && typeof this[p as FiltersName] !== "function") as FiltersName[];
},
get length() {
return Object.keys(this).filter((p) => !['names', 'length'].includes(p) && typeof this[p as FiltersName] !== 'function').length;
return Object.keys(this).filter((p) => !["names", "length"].includes(p) && typeof this[p as FiltersName] !== "function").length;
},
toString() {
return `${Object.values(this).join(',')}`;
return this.names.map((m) => (this as any)[m]).join(","); // eslint-disable-line @typescript-eslint/no-explicit-any
},
create(filter?: FiltersName[]): string {
if (!filter || !Array.isArray(filter)) return this.toString();
return filter
.filter((predicate) => typeof predicate === 'string')
.filter((predicate) => typeof predicate === "string")
.map((m) => this[m])
.join(',');
.join(",");
},
define(filterName: string, value: string): void {
if (typeof this[filterName as FiltersName] && typeof this[filterName as FiltersName] === 'function') return;
if (typeof this[filterName as FiltersName] && typeof this[filterName as FiltersName] === "function") return;
this[filterName as FiltersName] = value;
},

View file

@ -1,41 +0,0 @@
import { PlayerOptions as DP_OPTIONS } from '../types/types';
export enum PlayerEvents {
BOT_DISCONNECT = 'botDisconnect',
CHANNEL_EMPTY = 'channelEmpty',
CONNECTION_CREATE = 'connectionCreate',
ERROR = 'error',
MUSIC_STOP = 'musicStop',
NO_RESULTS = 'noResults',
PLAYLIST_ADD = 'playlistAdd',
PLAYLIST_PARSE_END = 'playlistParseEnd',
PLAYLIST_PARSE_START = 'playlistParseStart',
QUEUE_CREATE = 'queueCreate',
QUEUE_END = 'queueEnd',
SEARCH_CANCEL = 'searchCancel',
SEARCH_INVALID_RESPONSE = 'searchInvalidResponse',
SEARCH_RESULTS = 'searchResults',
TRACK_ADD = 'trackAdd',
TRACK_START = 'trackStart'
}
export enum PlayerErrorEventCodes {
LIVE_VIDEO = 'LiveVideo',
NOT_CONNECTED = 'NotConnected',
UNABLE_TO_JOIN = 'UnableToJoin',
NOT_PLAYING = 'NotPlaying',
PARSE_ERROR = 'ParseError',
VIDEO_UNAVAILABLE = 'VideoUnavailable',
MUSIC_STARTING = 'MusicStarting'
}
export const PlayerOptions: DP_OPTIONS = {
leaveOnEnd: true,
leaveOnStop: true,
leaveOnEmpty: true,
leaveOnEmptyCooldown: 0,
autoSelfDeaf: true,
enableLive: false,
ytdlDownloadOptions: {},
volume: 100
};

View file

@ -1,10 +0,0 @@
export default class PlayerError extends Error {
constructor(msg: string, name?: string) {
super();
this.name = name ?? 'PlayerError';
this.message = msg;
Error.captureStackTrace(this);
}
}
export { PlayerError };

View file

@ -0,0 +1,61 @@
import { validateID, validateURL } from "ytdl-core";
import { YouTube } from "youtube-sr";
import { QueryType } from "../types/types";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import { validateURL as SoundcloudValidateURL } from "soundcloud-scraper";
// scary things below *sigh*
const spotifySongRegex = /https?:\/\/(?:embed\.|open\.)(?:spotify\.com\/)(?:track\/|\?uri=spotify:track:)((\w|-){22})/;
const spotifyPlaylistRegex = /https?:\/\/(?:embed\.|open\.)(?:spotify\.com\/)(?:playlist\/|\?uri=spotify:playlist:)((\w|-){22})/;
const spotifyAlbumRegex = /https?:\/\/(?:embed\.|open\.)(?:spotify\.com\/)(?:album\/|\?uri=spotify:album:)((\w|-){22})/;
const vimeoRegex = /(http|https)?:\/\/(www\.|player\.)?vimeo\.com\/(?:channels\/(?:\w+\/)?|groups\/([^/]*)\/videos\/|video\/|)(\d+)(?:|\/\?)/;
const facebookRegex = /(https?:\/\/)(www\.|m\.)?(facebook|fb).com\/.*\/videos\/.*/;
const reverbnationRegex = /https:\/\/(www.)?reverbnation.com\/(.+)\/song\/(.+)/;
const attachmentRegex =
/^(?:(?:https?|ftp):\/\/)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,})))(?::\d{2,5})?(?:\/\S*)?$/;
// scary things above *sigh*
class QueryResolver {
/**
* Query resolver
*/
private constructor() {} // eslint-disable-line @typescript-eslint/no-empty-function
/**
* Resolves the given search query
* @param {string} query The query
* @returns {QueryType}
*/
static resolve(query: string): QueryType {
if (SoundcloudValidateURL(query, "track")) return QueryType.SOUNDCLOUD_TRACK;
if (SoundcloudValidateURL(query, "playlist") || query.includes("/sets/")) return QueryType.SOUNDCLOUD_PLAYLIST;
if (YouTube.isPlaylist(query)) return QueryType.YOUTUBE_PLAYLIST;
if (validateID(query) || validateURL(query)) return QueryType.YOUTUBE_SEARCH;
if (spotifySongRegex.test(query)) return QueryType.SPOTIFY_SONG;
if (spotifyPlaylistRegex.test(query)) return QueryType.SPOTIFY_PLAYLIST;
if (spotifyAlbumRegex.test(query)) return QueryType.SPOTIFY_ALBUM;
if (vimeoRegex.test(query)) return QueryType.VIMEO;
if (facebookRegex.test(query)) return QueryType.FACEBOOK;
if (reverbnationRegex.test(query)) return QueryType.REVERBNATION;
if (attachmentRegex.test(query)) return QueryType.ARBITRARY;
return QueryType.YOUTUBE_SEARCH;
}
/**
* Parses vimeo id from url
* @param {string} query The query
* @returns {string}
*/
static getVimeoID(query: string): string {
return QueryResolver.resolve(query) === QueryType.VIMEO
? query
.split("/")
.filter((x) => !!x)
.pop()
: null;
}
}
export { QueryResolver };

View file

@ -1,200 +1,83 @@
import { QueryType, TimeData } from '../types/types';
import { FFmpeg } from 'prism-media';
import YouTube from 'youtube-sr';
import { Track } from '../Structures/Track';
// @ts-ignore
import { validateURL as SoundcloudValidateURL } from 'soundcloud-scraper';
import { VoiceChannel } from 'discord.js';
import { StageChannel, VoiceChannel } from "discord.js";
import { TimeData } from "../types/types";
const spotifySongRegex = /https?:\/\/(?:embed\.|open\.)(?:spotify\.com\/)(?:track\/|\?uri=spotify:track:)((\w|-){22})/;
const spotifyPlaylistRegex = /https?:\/\/(?:embed\.|open\.)(?:spotify\.com\/)(?:playlist\/|\?uri=spotify:playlist:)((\w|-){22})/;
const spotifyAlbumRegex = /https?:\/\/(?:embed\.|open\.)(?:spotify\.com\/)(?:album\/|\?uri=spotify:album:)((\w|-){22})/;
const vimeoRegex = /(http|https)?:\/\/(www\.|player\.)?vimeo\.com\/(?:channels\/(?:\w+\/)?|groups\/([^/]*)\/videos\/|video\/|)(\d+)(?:|\/\?)/;
const facebookRegex = /(https?:\/\/)(www\.|m\.)?(facebook|fb).com\/.*\/videos\/.*/;
const reverbnationRegex = /https:\/\/(www.)?reverbnation.com\/(.+)\/song\/(.+)/;
const attachmentRegex = /^(?:(?:https?|ftp):\/\/)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,})))(?::\d{2,5})?(?:\/\S*)?$/;
export class Util {
class Util {
/**
* Static Player Util class
* Utils
*/
constructor() {
throw new Error(`The ${this.constructor.name} class is static and cannot be instantiated!`);
}
private constructor() {} // eslint-disable-line @typescript-eslint/no-empty-function
/**
* Checks FFmpeg Version
* @param {Boolean} [force] If it should forcefully get the version
* @returns {String}
* Creates duration string
* @param {object} durObj The duration object
* @returns {string}
*/
static getFFmpegVersion(force?: boolean): string {
try {
const info = FFmpeg.getInfo(Boolean(force));
return info.version;
} catch {
return null;
}
}
/**
* Checks FFmpeg
* @param {Boolean} [force] If it should forcefully get the version
* @returns {Boolean}
*/
static checkFFmpeg(force?: boolean): boolean {
const version = Util.getFFmpegVersion(force);
return version === null ? false : true;
}
/**
* Alerts if FFmpeg is not available
*/
static alertFFmpeg(): void {
const hasFFmpeg = Util.checkFFmpeg();
if (!hasFFmpeg) console.warn('[Discord Player] FFmpeg/Avconv not found! Install via "npm install ffmpeg-static" or download from https://ffmpeg.org/download.html');
}
/**
* Resolves query type
* @param {String} query The query
* @returns {QueryType}
*/
static getQueryType(query: string): QueryType {
if (SoundcloudValidateURL(query, 'track')) return 'soundcloud_track';
if (SoundcloudValidateURL(query, 'playlist') || query.includes('/sets/')) return 'soundcloud_playlist';
if (spotifySongRegex.test(query)) return 'spotify_song';
if (spotifyAlbumRegex.test(query)) return 'spotify_album';
if (spotifyPlaylistRegex.test(query)) return 'spotify_playlist';
if (YouTube.validate(query, 'PLAYLIST')) return 'youtube_playlist';
if (YouTube.validate(query, 'VIDEO')) return 'youtube_video';
if (vimeoRegex.test(query)) return 'vimeo';
if (facebookRegex.test(query)) return 'facebook';
if (reverbnationRegex.test(query)) return 'reverbnation';
if (Util.isURL(query)) return 'attachment';
return 'youtube_search';
}
/**
* Checks if the given string is url
* @param {String} str URL to check
* @returns {Boolean}
*/
static isURL(str: string): boolean {
return str.length < 2083 && attachmentRegex.test(str);
}
/**
* Returns Vimeo ID
* @param {String} query Vimeo link
* @returns {String}
*/
static getVimeoID(query: string): string {
return Util.getQueryType(query) === 'vimeo'
? query
.split('/')
.filter((x) => !!x)
.pop()
: null;
}
/**
* Parses ms time
* @param {Number} milliseconds Time to parse
* @returns {TimeData}
*/
static parseMS(milliseconds: number): TimeData {
const roundTowardsZero = milliseconds > 0 ? Math.floor : Math.ceil;
return {
days: roundTowardsZero(milliseconds / 86400000),
hours: roundTowardsZero(milliseconds / 3600000) % 24,
minutes: roundTowardsZero(milliseconds / 60000) % 60,
seconds: roundTowardsZero(milliseconds / 1000) % 60
};
}
/**
* Creates simple duration string
* @param {object} durObj Duration object
* @returns {String}
*/
static durationString(durObj: object): string {
static durationString(durObj: Record<string, number>) {
return Object.values(durObj)
.map((m) => (isNaN(m) ? 0 : m))
.join(':');
.join(":");
}
/**
* Makes youtube searches
* @param {String} query The query
* @param {any} options Options
* @returns {Promise<Track[]>}
* Parses milliseconds to consumable time object
* @param {number} milliseconds The time in ms
* @returns {TimeData}
*/
static ytSearch(query: string, options?: any): Promise<Track[]> {
return new Promise(async (resolve) => {
await YouTube.search(query, {
type: 'video',
safeSearch: Boolean(options?.player.options.useSafeSearch),
limit: options.limit ?? 10
})
.then((results) => {
resolve(
results.map(
(r) =>
new Track(options?.player, {
title: r.title,
description: r.description,
author: r.channel.name,
url: r.url,
thumbnail: r.thumbnail.displayThumbnailURL(),
duration: Util.buildTimeCode(Util.parseMS(r.duration)),
views: r.views,
requestedBy: options?.user,
fromPlaylist: Boolean(options?.pl),
source: 'youtube'
})
)
);
})
.catch(() => resolve([]));
});
}
static parseMS(milliseconds: number) {
const round = milliseconds > 0 ? Math.floor : Math.ceil;
/**
* Checks if the given voice channel is empty
* @param {DiscordVoiceChannel} channel The voice channel
* @returns {Boolean}
*/
static isVoiceEmpty(channel: VoiceChannel): boolean {
return channel.members.filter((member) => !member.user.bot).size === 0;
return {
days: round(milliseconds / 86400000),
hours: round(milliseconds / 3600000) % 24,
minutes: round(milliseconds / 60000) % 60,
seconds: round(milliseconds / 1000) % 60
} as TimeData;
}
/**
* Builds time code
* @param {object} data The data to build time code from
* @returns {String}
* @param {TimeData} duration The duration object
* @returns {string}
*/
static buildTimeCode(data: any): string {
const items = Object.keys(data);
const required = ['days', 'hours', 'minutes', 'seconds'];
static buildTimeCode(duration: TimeData) {
const items = Object.keys(duration);
const required = ["days", "hours", "minutes", "seconds"];
const parsed = items.filter((x) => required.includes(x)).map((m) => (data[m] > 0 ? data[m] : ''));
const parsed = items.filter((x) => required.includes(x)).map((m) => duration[m as keyof TimeData]);
const final = parsed
.filter((x) => !!x)
.map((x) => x.toString().padStart(2, '0'))
.join(':');
return final.length <= 3 ? `0:${final.padStart(2, '0') || 0}` : final;
.slice(parsed.findIndex((x) => x !== 0))
.map((x) => x.toString().padStart(2, "0"))
.join(":");
return final.length <= 3 ? `0:${final.padStart(2, "0") || 0}` : final;
}
/**
* Manage CJS require
* @param {String} id id to require
* Picks last item of the given array
* @param {any[]} arr The array
* @returns {any}
*/
static require(id: string): any {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
static last<T = any>(arr: T[]): T {
if (!Array.isArray(arr)) return;
return arr[arr.length - 1];
}
/**
* Checks if the voice channel is empty
* @param {VoiceChannel|StageChannel} channel The voice channel
* @returns {boolean}
*/
static isVoiceEmpty(channel: VoiceChannel | StageChannel) {
return channel.members.filter((member) => !member.user.bot).size === 0;
}
/**
* Safer require
* @param {string} id Node require id
* @returns {any}
*/
static require(id: string) {
try {
return require(id);
} catch {
@ -203,20 +86,15 @@ export class Util {
}
/**
* Defines a property in the given object
* @param {any} target The target
* @param {any} prop The property to define
* @param {any} value The value
* @returns {void}
* Asynchronous timeout
* @param {number} time The time in ms to wait
* @returns {Promise<unknown>}
*/
static define(ops: { target: any; prop: any; value: any; enumerate?: boolean }) {
Object.defineProperty(ops.target, ops.prop, {
value: ops.value,
writable: true,
enumerable: Boolean(ops.enumerate),
configurable: true
});
static wait(time: number) {
return new Promise((r) => setTimeout(r, time).unref());
}
static noop() {} // eslint-disable-line @typescript-eslint/no-empty-function
}
export default Util;
export { Util };

8
tsconfig.eslint.json Normal file
View file

@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"include": [
"**/*.ts",
"**/*.js"
],
"exclude": []
}

View file

@ -3,10 +3,12 @@
"target": "ES6",
"module": "commonjs",
"declaration": true,
"outDir": "./lib",
"outDir": "./dist",
"strict": true,
"strictNullChecks": false,
"esModuleInterop": true
"esModuleInterop": true,
"pretty": true,
"skipLibCheck": true
},
"include": [
"src/**/*"

View file

@ -1,15 +0,0 @@
{
"defaultSeverity": "error",
"extends": ["tslint:recommended", "tslint-config-prettier"],
"jsRules": {
"no-unused-expression": true
},
"rules": {
"object-literal-sort-keys": false,
"interface-name": false,
"no-empty": false,
"no-console": false,
"radix": false
},
"rulesDirectory": []
}

2981
yarn.lock

File diff suppressed because it is too large Load diff