Merge branch 'v5'

This commit is contained in:
DevAndromeda 2021-08-07 23:09:51 +05:45
commit 854a2f721c
47 changed files with 4366 additions and 1476 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"
}
}

1
.gitattributes vendored Normal file
View file

@ -0,0 +1 @@
* text=auto eol=lf

View file

@ -1,7 +1,6 @@
---
name: Bug report
about: Create a bug report to help us improve
title: "[BUG] "
labels: bug
assignees: ''
@ -13,9 +12,9 @@ assignees: ''
**To Reproduce**
Steps to reproduce the behavior:
<!-- 1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error -->
1. Click on '....'
2. Scroll down to '....'
3. See error -->
**Expected behavior**
<!-- A clear and concise description of what you expected to happen. -->
@ -25,7 +24,7 @@ Steps to reproduce the behavior:
**Please complete the following information:**
- Node Version: [x.x.x]
- Library Version: [x.x.x]
- Discord Player Version: [x.x.x]
- Discord.js Version: [x.x.x]
**Additional context**

View file

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

View file

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

View file

@ -5,7 +5,8 @@ on:
- '*'
- '!docs'
- '!gh-pages'
- '!v5'
tags:
- '*'
jobs:
docs:
name: Documentation

40
.github/workflows/eslint.yml vendored Normal file
View file

@ -0,0 +1,40 @@
name: ESLint
on:
push:
branches:
- '*'
- '!docs'
- '!develop'
- '!master'
- '!gh-pages'
pull_request:
branches:
- '*'
- '!docs'
- '!develop'
- '!master'
- '!gh-pages'
jobs:
test:
strategy:
matrix:
node: ['14', '16']
name: ESLint (Node v${{ matrix.node }})
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Install Node v${{ matrix.node }}
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node }}
- name: Install dependencies
run: npm install
- name: Run ESLint
run: npm run lint
- name: Run TSC
run: npm run build:check

View file

@ -12,7 +12,7 @@ jobs:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
node-version: 12
node-version: 16
registry-url: https://registry.npmjs.org/
- run: npm install
- run: npm run build

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,5 +1,5 @@
{
"printWidth": 120,
"printWidth": 200,
"trailingComma": "none",
"singleQuote": false,
"tabWidth": 4,

100
README.md
View file

@ -4,8 +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)
> V5 WIP
[![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
@ -42,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
}
});
client.login(settings.token);
// 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(process.env.DISCORD_TOKEN);
```
## Supported websites
@ -115,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"
@ -135,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

@ -0,0 +1,12 @@
# How to add custom audio filters?
Audio filters in **Discord Player** are **[FFmpeg audio filters](http://ffmpeg.org/ffmpeg-all.html#Audio-Filters)**. You can add your own audio filter like this:
```js
const { AudioFilters } = require("discord-player");
AudioFilters.define("3D", "apulsator=hz=0.128");
// later, it can be used like this
queue.setFilters({ "3D": true });
```

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,11 +1,22 @@
- 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
path: extractor.md
- name: FAQ
files:
- name: Custom Filters
path: custom_filters.md
- name: Slash Commands
path: slash_commands.md
- name: YouTube
files:
- name: Using Cookies

View file

@ -0,0 +1,54 @@
# 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();
```

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}**`;
});
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}**` }]
}
]
});
} 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.0-dev.fe5d56c.1625443439"
}
}

View file

@ -2,18 +2,27 @@
"name": "discord-player",
"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": "cd test && ts-node index.ts",
"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": [
@ -48,37 +57,39 @@
"bugs": {
"url": "https://github.com/Androz2091/discord-player/issues"
},
"homepage": "https://github.com/Androz2091/discord-player#readme",
"homepage": "https://discord-player.js.org",
"dependencies": {
"@discordjs/voice": "^0.3.1",
"discord-ytdl-core": "^5.0.3",
"soundcloud-scraper": "^5.0.0",
"@discordjs/voice": "^0.5.5",
"discord-ytdl-core": "^5.0.4",
"libsodium-wrappers": "^0.7.9",
"soundcloud-scraper": "^5.0.1",
"spotify-url-info": "^2.2.3",
"tiny-typed-emitter": "^2.0.3",
"youtube-sr": "^4.1.4",
"ytdl-core": "^4.8.2"
"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": "^2.0.0",
"@discordjs/opus": "^0.5.0",
"@types/node": "^14.14.41",
"@types/ws": "^7.4.1",
"discord-api-types": "^0.18.1",
"discord.js": "^13.0.0-dev.f5f3f772865ee98bbb44df938e0e71f9f8865c10",
"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",
"ts-node": "^10.0.0",
"tslint": "^6.1.3",
"tslint-config-prettier": "^1.18.0",
"typescipt": "^1.0.0",
"typescript": "^4.2.3"
},
"optionalDependencies": {
"sodium": "^3.0.2"
"prettier": "^2.3.2",
"rimraf": "^3.0.2",
"ts-node": "^10.1.0",
"typescript": "^4.3.5"
}
}

View file

@ -1,29 +1,502 @@
import { Client, Collection, Guild, Snowflake } from "discord.js";
import { Client, Collection, GuildResolvable, Snowflake, User, VoiceState } from "discord.js";
import { TypedEmitter as EventEmitter } from "tiny-typed-emitter";
import { Queue } from "./Structures/Queue";
import { VoiceUtils } from "./VoiceInterface/VoiceUtils";
import { PlayerOptions } from "./types/types";
import { PlayerEvents, PlayerOptions, QueryType, SearchOptions, PlayerInitOptions } from "./types/types";
import Track from "./Structures/Track";
import { QueryResolver } from "./utils/QueryResolver";
import YouTube from "youtube-sr";
import { Util } from "./utils/Util";
import Spotify from "spotify-url-info";
import { PlayerError, ErrorStatusCode } from "./Structures/PlayerError";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import { Client as SoundCloud } from "soundcloud-scraper";
import { Playlist } from "./Structures/Playlist";
import { ExtractorModel } from "./Structures/ExtractorModel";
import { generateDependencyReport } from "@discordjs/voice";
class DiscordPlayer extends EventEmitter {
const soundcloud = new SoundCloud();
class Player extends EventEmitter<PlayerEvents> {
public readonly client: Client;
public readonly options: PlayerInitOptions = {
autoRegisterExtractor: true,
ytdlOptions: {
highWaterMark: 1 << 25
},
connectionTimeout: 20000
};
public readonly queues = new Collection<Snowflake, Queue>();
public readonly voiceUtils = new VoiceUtils();
public readonly extractors = new Collection<string, ExtractorModel>();
constructor(client: Client) {
/**
* Creates new Discord Player
* @param {Client} client The Discord Client
* @param {PlayerInitOptions} [options={}] The player init options
*/
constructor(client: Client, options: PlayerInitOptions = {}) {
super();
/**
* The discord.js client
* @type {Client}
*/
this.client = client;
/**
* The extractors collection
* @type {ExtractorModel}
*/
this.options = Object.assign(this.options, options);
this.client.on("voiceStateUpdate", this._handleVoiceState.bind(this));
if (this.options?.autoRegisterExtractor) {
let nv: any; // eslint-disable-line @typescript-eslint/no-explicit-any
if ((nv = Util.require("@discord-player/extractor"))) {
["Attachment", "Facebook", "Reverbnation", "Vimeo"].forEach((ext) => void this.use(ext, nv[ext]));
}
}
}
createQueue(guild: Guild, queueInitOptions?: PlayerOptions) {
if (this.queues.has(guild.id)) return this.queues.get(guild.id);
/**
* Handles voice state update
* @param {VoiceState} oldState The old voice state
* @param {VoiceState} newState The new voice state
* @returns {void}
* @private
*/
private _handleVoiceState(oldState: VoiceState, newState: VoiceState): void {
const queue = this.getQueue(oldState.guild.id);
if (!queue) return;
if (oldState.channelId && newState.channelId && oldState.channelId !== newState.channelId) {
queue.connection.channel = newState.channel;
}
if (!oldState.channelId && newState.channelId && newState.member.id === newState.guild.me.id) {
if (newState.serverMute || !newState.serverMute) {
queue.setPaused(newState.serverMute);
} else if (newState.suppress || !newState.suppress) {
if (newState.suppress) newState.guild.me.voice.setRequestToSpeak(true).catch(Util.noop);
queue.setPaused(newState.suppress);
}
}
if (oldState.channelId === newState.channelId && oldState.member.id === newState.guild.me.id) {
if (oldState.serverMute !== newState.serverMute) {
queue.setPaused(newState.serverMute);
} else if (oldState.suppress !== newState.suppress) {
if (newState.suppress) newState.guild.me.voice.setRequestToSpeak(true).catch(Util.noop);
queue.setPaused(newState.suppress);
}
}
if (oldState.member.id === this.client.user.id && !newState.channelId) {
queue.destroy();
return void this.emit("botDisconnect", queue);
}
if (!queue.options.leaveOnEmpty || !queue.connection || !queue.connection.channel) return;
if (!oldState.channelId || newState.channelId) {
const emptyTimeout = queue._cooldownsTimeout.get(`empty_${oldState.guild.id}`);
const channelEmpty = Util.isVoiceEmpty(queue.connection.channel);
if (!channelEmpty && emptyTimeout) {
clearTimeout(emptyTimeout);
queue._cooldownsTimeout.delete(`empty_${oldState.guild.id}`);
}
} else {
if (!Util.isVoiceEmpty(queue.connection.channel)) return;
const timeout = setTimeout(() => {
if (!Util.isVoiceEmpty(queue.connection.channel)) return;
if (!this.queues.has(queue.guild.id)) return;
queue.destroy();
this.emit("channelEmpty", queue);
}, queue.options.leaveOnEmptyCooldown || 0);
queue._cooldownsTimeout.set(`empty_${oldState.guild.id}`, timeout);
}
}
/**
* Creates a queue for a guild if not available, else returns existing queue
* @param {GuildResolvable} guild The guild
* @param {PlayerOptions} queueInitOptions Queue init options
* @returns {Queue}
*/
createQueue<T = unknown>(guild: GuildResolvable, queueInitOptions: PlayerOptions & { metadata?: T } = {}): Queue<T> {
guild = this.client.guilds.resolve(guild);
if (!guild) throw new PlayerError("Unknown Guild", ErrorStatusCode.UNKNOWN_GUILD);
if (this.queues.has(guild.id)) return this.queues.get(guild.id) as Queue<T>;
const _meta = queueInitOptions.metadata;
delete queueInitOptions["metadata"];
queueInitOptions.ytdlOptions ??= this.options.ytdlOptions;
const queue = new Queue(this, guild, queueInitOptions);
queue.metadata = _meta;
this.queues.set(guild.id, queue);
return queue;
return queue as Queue<T>;
}
getQueue(guild: Snowflake) {
return this.queues.get(guild);
/**
* Returns the queue if available
* @param {GuildResolvable} guild The guild id
* @returns {Queue}
*/
getQueue<T = unknown>(guild: GuildResolvable) {
guild = this.client.guilds.resolve(guild);
if (!guild) throw new PlayerError("Unknown Guild", ErrorStatusCode.UNKNOWN_GUILD);
return this.queues.get(guild.id) as Queue<T>;
}
/**
* Deletes a queue and returns deleted queue object
* @param {GuildResolvable} guild The guild id to remove
* @returns {Queue}
*/
deleteQueue<T = unknown>(guild: GuildResolvable) {
guild = this.client.guilds.resolve(guild);
if (!guild) throw new PlayerError("Unknown Guild", ErrorStatusCode.UNKNOWN_GUILD);
const prev = this.getQueue<T>(guild);
try {
prev.destroy();
} catch {} // eslint-disable-line no-empty
this.queues.delete(guild.id);
return prev;
}
/**
* @typedef {object} SearchResult
* @property {Playlist} [playlist] The playlist (if any)
* @property {Track[]} tracks The tracks
*/
/**
* Search tracks
* @param {string|Track} query The search query
* @param {SearchOptions} options The search options
* @returns {Promise<SearchResult>}
*/
async search(query: string | Track, options: SearchOptions) {
if (query instanceof Track) return { playlist: null, tracks: [query] };
if (!options) throw new PlayerError("DiscordPlayer#search needs search options!", ErrorStatusCode.INVALID_ARG_TYPE);
options.requestedBy = this.client.users.resolve(options.requestedBy);
if (!("searchEngine" in options)) options.searchEngine = QueryType.AUTO;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
for (const [_, extractor] of this.extractors) {
if (options.blockExtractor) break;
if (!extractor.validate(query)) continue;
const data = await extractor.handle(query);
if (data && data.data.length) {
const playlist = !data.playlist
? null
: new Playlist(this, {
...data.playlist,
tracks: []
});
const tracks = data.data.map(
(m) =>
new Track(this, {
...m,
requestedBy: options.requestedBy as User,
duration: Util.buildTimeCode(Util.parseMS(m.duration)),
playlist: playlist
})
);
if (playlist) playlist.tracks = tracks;
return { playlist: playlist, tracks: tracks };
}
}
const qt = options.searchEngine === QueryType.AUTO ? QueryResolver.resolve(query) : options.searchEngine;
switch (qt) {
case QueryType.YOUTUBE_SEARCH: {
const videos = await YouTube.search(query, {
type: "video"
}).catch(Util.noop);
if (!videos) return { playlist: null, tracks: [] };
const tracks = videos.map((m) => {
(m as any).source = "youtube"; // eslint-disable-line @typescript-eslint/no-explicit-any
return new Track(this, {
title: m.title,
description: m.description,
author: m.channel?.name,
url: m.url,
requestedBy: options.requestedBy as User,
thumbnail: m.thumbnail?.displayThumbnailURL("maxresdefault"),
views: m.views,
duration: m.durationFormatted,
raw: m
});
});
return { playlist: null, tracks };
}
case QueryType.SOUNDCLOUD_TRACK:
case QueryType.SOUNDCLOUD_SEARCH: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-empty-function
const result: any[] = QueryResolver.resolve(query) === QueryType.SOUNDCLOUD_TRACK ? [{ url: query }] : await soundcloud.search(query, "track").catch(Util.noop);
if (!result || !result.length) return { playlist: null, tracks: [] };
const res: Track[] = [];
for (const r of result) {
const trackInfo = await soundcloud.getSongInfo(r.url).catch(Util.noop);
if (!trackInfo) continue;
const track = new Track(this, {
title: trackInfo.title,
url: trackInfo.url,
duration: Util.buildTimeCode(Util.parseMS(trackInfo.duration)),
description: trackInfo.description,
thumbnail: trackInfo.thumbnail,
views: trackInfo.playCount,
author: trackInfo.author.name,
requestedBy: options.requestedBy,
source: "soundcloud",
engine: trackInfo
});
res.push(track);
}
return { playlist: null, tracks: res };
}
case QueryType.SPOTIFY_SONG: {
const spotifyData = await Spotify.getData(query).catch(Util.noop);
if (!spotifyData) return { playlist: null, tracks: [] };
const spotifyTrack = new Track(this, {
title: spotifyData.name,
description: spotifyData.description ?? "",
author: spotifyData.artists[0]?.name ?? "Unknown Artist",
url: spotifyData.external_urls?.spotify ?? query,
thumbnail:
spotifyData.album?.images[0]?.url ?? spotifyData.preview_url?.length
? `https://i.scdn.co/image/${spotifyData.preview_url?.split("?cid=")[1]}`
: "https://www.scdn.co/i/_global/twitter_card-default.jpg",
duration: Util.buildTimeCode(Util.parseMS(spotifyData.duration_ms)),
views: 0,
requestedBy: options.requestedBy,
source: "spotify"
});
return { playlist: null, tracks: [spotifyTrack] };
}
case QueryType.SPOTIFY_PLAYLIST:
case QueryType.SPOTIFY_ALBUM: {
const spotifyPlaylist = await Spotify.getData(query).catch(Util.noop);
if (!spotifyPlaylist) return { playlist: null, tracks: [] };
const playlist = new Playlist(this, {
title: spotifyPlaylist.name ?? spotifyPlaylist.title,
description: spotifyPlaylist.description ?? "",
thumbnail: spotifyPlaylist.images[0]?.url ?? "https://www.scdn.co/i/_global/twitter_card-default.jpg",
type: spotifyPlaylist.type,
source: "spotify",
author:
spotifyPlaylist.type !== "playlist"
? {
name: spotifyPlaylist.artists[0]?.name ?? "Unknown Artist",
url: spotifyPlaylist.artists[0]?.external_urls?.spotify ?? null
}
: {
name: spotifyPlaylist.owner?.display_name ?? spotifyPlaylist.owner?.id ?? "Unknown Artist",
url: spotifyPlaylist.owner?.external_urls?.spotify ?? null
},
tracks: [],
id: spotifyPlaylist.id,
url: spotifyPlaylist.external_urls?.spotify ?? query,
rawPlaylist: spotifyPlaylist
});
if (spotifyPlaylist.type !== "playlist") {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
playlist.tracks = spotifyPlaylist.tracks.items.map((m: any) => {
const data = new Track(this, {
title: m.name ?? "",
description: m.description ?? "",
author: m.artists[0]?.name ?? "Unknown Artist",
url: m.external_urls?.spotify ?? query,
thumbnail: spotifyPlaylist.images[0]?.url ?? "https://www.scdn.co/i/_global/twitter_card-default.jpg",
duration: Util.buildTimeCode(Util.parseMS(m.duration_ms)),
views: 0,
requestedBy: options.requestedBy as User,
playlist,
source: "spotify"
});
return data;
}) as Track[];
} else {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
playlist.tracks = spotifyPlaylist.tracks.items.map((m: any) => {
const data = new Track(this, {
title: m.track.name ?? "",
description: m.track.description ?? "",
author: m.track.artists[0]?.name ?? "Unknown Artist",
url: m.track.external_urls?.spotify ?? query,
thumbnail: m.track.album?.images[0]?.url ?? "https://www.scdn.co/i/_global/twitter_card-default.jpg",
duration: Util.buildTimeCode(Util.parseMS(m.track.duration_ms)),
views: 0,
requestedBy: options.requestedBy as User,
playlist,
source: "spotify"
});
return data;
}) as Track[];
}
return { playlist: playlist, tracks: playlist.tracks };
}
case QueryType.SOUNDCLOUD_PLAYLIST: {
const data = await SoundCloud.getPlaylist(query).catch(Util.noop);
if (!data) return { playlist: null, tracks: [] };
const res = new Playlist(this, {
title: data.title,
description: data.description ?? "",
thumbnail: data.thumbnail ?? "https://soundcloud.com/pwa-icon-192.png",
type: "playlist",
source: "soundcloud",
author: {
name: data.author?.name ?? data.author?.username ?? "Unknown Artist",
url: data.author?.profile
},
tracks: [],
id: `${data.id}`, // stringified
url: data.url,
rawPlaylist: data
});
for (const song of data) {
const track = new Track(this, {
title: song.title,
description: song.description ?? "",
author: song.author?.username ?? song.author?.name ?? "Unknown Artist",
url: song.url,
thumbnail: song.thumbnail,
duration: Util.buildTimeCode(Util.parseMS(song.duration)),
views: song.playCount ?? 0,
requestedBy: options.requestedBy,
playlist: res,
source: "soundcloud",
engine: song
});
res.tracks.push(track);
}
return { playlist: res, tracks: res.tracks };
}
case QueryType.YOUTUBE_PLAYLIST: {
const ytpl = await YouTube.getPlaylist(query).catch(Util.noop);
if (!ytpl) return { playlist: null, tracks: [] };
await ytpl.fetch().catch(Util.noop);
const playlist: Playlist = new Playlist(this, {
title: ytpl.title,
thumbnail: ytpl.thumbnail as unknown as string,
description: "",
type: "playlist",
source: "youtube",
author: {
name: ytpl.channel.name,
url: ytpl.channel.url
},
tracks: [],
id: ytpl.id,
url: ytpl.url,
rawPlaylist: ytpl
});
playlist.tracks = ytpl.videos.map(
(video) =>
new Track(this, {
title: video.title,
description: video.description,
author: video.channel?.name,
url: video.url,
requestedBy: options.requestedBy as User,
thumbnail: video.thumbnail.url,
views: video.views,
duration: video.durationFormatted,
raw: video,
playlist: playlist,
source: "youtube"
})
);
return { playlist: playlist, tracks: playlist.tracks };
}
default:
return { playlist: null, tracks: [] };
}
}
/**
* Registers extractor
* @param {string} extractorName The extractor name
* @param {ExtractorModel|any} extractor The extractor object
* @param {boolean} [force=false] Overwrite existing extractor with this name (if available)
* @returns {ExtractorModel}
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
use(extractorName: string, extractor: ExtractorModel | any, force = false): ExtractorModel {
if (!extractorName) throw new PlayerError("Cannot use unknown extractor!", ErrorStatusCode.UNKNOWN_EXTRACTOR);
if (this.extractors.has(extractorName) && !force) return this.extractors.get(extractorName);
if (extractor instanceof ExtractorModel) {
this.extractors.set(extractorName, extractor);
return extractor;
}
for (const method of ["validate", "getInfo"]) {
if (typeof extractor[method] !== "function") throw new PlayerError("Invalid extractor data!", ErrorStatusCode.INVALID_EXTRACTOR);
}
const model = new ExtractorModel(extractorName, extractor);
this.extractors.set(model.name, model);
return model;
}
/**
* Removes registered extractor
* @param {string} extractorName The extractor name
* @returns {ExtractorModel}
*/
unuse(extractorName: string) {
if (!this.extractors.has(extractorName)) throw new PlayerError(`Cannot find extractor "${extractorName}"`, ErrorStatusCode.UNKNOWN_EXTRACTOR);
const prev = this.extractors.get(extractorName);
this.extractors.delete(extractorName);
return prev;
}
/**
* Generates a report of the dependencies used by the `@discordjs/voice` module. Useful for debugging.
* @returns {string}
*/
scanDeps() {
return generateDependencyReport();
}
/**
* Resolves qeuue
* @param {GuildResolvable|Queue} queueLike Queue like object
* @returns {Queue}
*/
resolveQueue<T>(queueLike: GuildResolvable | Queue): Queue<T> {
return this.getQueue(queueLike instanceof Queue ? queueLike.guild : queueLike);
}
*[Symbol.iterator]() {
@ -31,4 +504,4 @@ class DiscordPlayer extends EventEmitter {
}
}
export { DiscordPlayer as Player };
export { Player };

View file

@ -2,26 +2,33 @@ 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;
/**
* 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,19 +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);
}
}
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;
}
}

View file

@ -1,18 +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
constructor(player: Player, tracks: Track[]) {
/**
* 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;
this.tracks = tracks ?? [];
/**
* 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,66 +1,743 @@
import { Guild, StageChannel, VoiceChannel } from "discord.js";
import { Collection, Guild, StageChannel, VoiceChannel, Snowflake, SnowflakeUtil, GuildChannelResolvable } from "discord.js";
import { Player } from "../Player";
import { VoiceSubscription } from "../VoiceInterface/VoiceSubscription";
import { StreamDispatcher } from "../VoiceInterface/StreamDispatcher";
import Track from "./Track";
import { PlayerOptions } from "../types/types";
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";
class Queue {
class Queue<T = unknown> {
public readonly guild: Guild;
public readonly player: Player;
public voiceConnection: VoiceSubscription;
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 {Guild} guild The guild that instantiated this queue
* @param {PlayerOptions} [options={}] Player options for the queue
*/
constructor(player: Player, guild: Guild, options: PlayerOptions = {}) {
/**
* The player that instantiated this queue
* @type {Player}
* @readonly
*/
this.player = player;
/**
* The guild that instantiated this queue
* @type {Guild}
* @readonly
*/
this.guild = guild;
/**
* The player options for this queue
* @type {PlayerOptions}
*/
this.options = {};
/**
* Queue repeat mode
* @type {QueueRepeatMode}
* @name Queue#repeatMode
*/
/**
* Queue metadata
* @type {any}
* @name Queue#metadata
*/
/**
* Previous tracks
* @type {Track[]}
* @name Queue#previousTracks
*/
/**
* Regular tracks
* @type {Track[]}
* @name Queue#tracks
*/
/**
* The connection
* @type {StreamDispatcher}
* @name Queue#connection
*/
/**
* The ID of this queue
* @type {Snowflake}
* @name Queue#id
*/
Object.assign(
this.options,
{
leaveOnEnd: true,
leaveOnEndCooldown: 1000,
leaveOnStop: true,
leaveOnEmpty: true,
leaveOnEmptyCooldown: 1000,
autoSelfDeaf: true,
enableLive: false,
ytdlDownloadOptions: {},
useSafeSearch: false,
disableAutoRegister: false,
fetchBeforeQueued: false
ytdlOptions: {
highWaterMark: 1 << 25
},
initialVolume: 100,
bufferingTimeout: 3000
} as PlayerOptions,
options
);
}
/**
* Returns current track
* @type {Track}
*/
get current() {
return this.voiceConnection.audioResource?.metadata ?? this.tracks[0];
if (this.#watchDestroyed()) return;
return this.connection.audioResource?.metadata ?? this.tracks[0];
}
async joinVoiceChannel(channel: StageChannel | VoiceChannel) {
if (!["stage", "voice"].includes(channel.type))
throw new TypeError(`Channel type must be voice or stage, got ${channel.type}!`);
const connection = await this.player.voiceUtils.connect(channel);
this.voiceConnection = connection;
/**
* If this queue is destroyed
* @type {boolean}
*/
get destroyed() {
return this.#destroyed;
}
/**
* Returns current track
* @returns {Track}
*/
nowPlaying() {
if (this.#watchDestroyed()) return;
return this.current;
}
/**
* 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;
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);
});
}
this.connection.on("error", (err) => this.player.emit("connectionError", this, err));
this.connection.on("debug", (msg) => this.player.emit("debug", this, msg));
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;
}
destroy() {
this.voiceConnection.stop();
this.voiceConnection.disconnect();
/**
* 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;
}
play() {
throw new Error("Not implemented");
/**
* 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]
});
}
/**
* Seeks to the given time
* @param {number} position The position
* @returns {boolean}
*/
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;
}
/**
* Plays previous track
* @returns {Promise<void>}
*/
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)}`;
}
}
}
/**
* Total duration
* @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;
}
/**
* 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>}
*/
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;
this.player.emit("debug", this, "Received play request");
if (!options.filtersUpdate) {
this.previousTracks = this.previousTracks.filter((x) => x.id !== track.id);
this.previousTracks.push(track);
}
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());
}
/**
* Private method to handle autoplay
* @param {Track} track The source track to find its similar track for autoplay
* @returns {Promise<void>}
* @private
*/
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);
}
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;
}
/**
* JSON representation of this queue
* @returns {object}
*/
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}
*/
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 { Queue };

View file

@ -1,6 +1,7 @@
import { User } from "discord.js";
import { User, Util, SnowflakeUtil, Snowflake } from "discord.js";
import { Player } from "../Player";
import { RawTrackData } from "../types/types";
import { RawTrackData, TrackJSON } from "../types/types";
import { Playlist } from "./Playlist";
import { Queue } from "./Queue";
class Track {
@ -13,8 +14,9 @@ class Track {
public duration!: string;
public views!: number;
public requestedBy!: User;
public fromPlaylist!: boolean;
public raw!: RawTrackData;
public playlist?: Playlist;
public readonly raw: RawTrackData = {} as RawTrackData;
public readonly id: Snowflake = SnowflakeUtil.generate();
/**
* Track constructor
@ -33,55 +35,55 @@ class Track {
/**
* 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}
*/
/**
@ -90,21 +92,28 @@ class Track {
* @type {RawTrackData}
*/
/**
* The track id
* @name Track#id
* @type {Snowflake}
* @readonly
*/
void this._patch(data);
}
private _patch(data: RawTrackData) {
this.title = data.title ?? "";
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 });
}
/**
@ -117,7 +126,7 @@ class Track {
/**
* The track duration in millisecond
* @type {Number}
* @type {number}
*/
get durationMS(): number {
const times = (n: number, t: number) => {
@ -143,11 +152,31 @@ class Track {
/**
* 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;

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

@ -1,141 +0,0 @@
import {
AudioPlayer,
AudioPlayerError,
AudioPlayerStatus,
AudioResource,
createAudioPlayer,
createAudioResource,
entersState,
StreamType,
VoiceConnection,
VoiceConnectionStatus
} from "@discordjs/voice";
import { Duplex, Readable } from "stream";
import { TypedEmitter as EventEmitter } from "tiny-typed-emitter";
import Track from "../Structures/Track";
import PlayerError from "../utils/PlayerError";
export interface VoiceEvents {
error: (error: AudioPlayerError) => any;
debug: (message: string) => any;
start: () => any;
finish: () => any;
}
class VoiceSubscription extends EventEmitter<VoiceEvents> {
public readonly voiceConnection: VoiceConnection;
public readonly audioPlayer: AudioPlayer;
public connectPromise?: Promise<void>;
public audioResource?: AudioResource<Track>;
constructor(connection: VoiceConnection) {
super();
this.voiceConnection = connection;
this.audioPlayer = createAudioPlayer();
this.voiceConnection.on("stateChange", (_, newState) => {
if (newState.status === VoiceConnectionStatus.Disconnected) {
if (this.voiceConnection.reconnectAttempts < 5) {
setTimeout(() => {
if (this.voiceConnection.state.status === VoiceConnectionStatus.Disconnected) {
this.voiceConnection.reconnect();
}
}, (this.voiceConnection.reconnectAttempts + 1) * 5000).unref();
} else {
this.voiceConnection.destroy();
}
} else if (newState.status === VoiceConnectionStatus.Destroyed) {
this.stop();
} else if (
!this.connectPromise &&
(newState.status === VoiceConnectionStatus.Connecting ||
newState.status === VoiceConnectionStatus.Signalling)
) {
this.connectPromise = entersState(this.voiceConnection, VoiceConnectionStatus.Ready, 20000)
.then(() => undefined)
.catch(() => {
if (this.voiceConnection.state.status !== VoiceConnectionStatus.Destroyed)
this.voiceConnection.destroy();
})
.finally(() => (this.connectPromise = undefined));
}
});
this.audioPlayer.on("stateChange", (oldState, newState) => {
if (newState.status === AudioPlayerStatus.Idle && oldState.status !== AudioPlayerStatus.Idle) {
void this.emit("finish");
} else if (newState.status === AudioPlayerStatus.Playing) {
void this.emit("start");
}
});
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 {({type?:StreamType;data?:any;inlineVolume?:boolean})} [ops] Options
* @returns {AudioResource}
*/
createStream(src: Readable | Duplex | string, ops?: { type?: StreamType; data?: any; inlineVolume?: boolean }) {
this.audioResource = createAudioResource(src, {
inputType: ops?.type ?? StreamType.Arbitrary,
metadata: ops?.data,
inlineVolume: Boolean(ops?.inlineVolume)
});
return this.audioResource;
}
/**
* The player status
*/
get status() {
return this.audioPlayer.state.status;
}
/**
* Disconnects from voice
*/
disconnect() {
this.voiceConnection.destroy();
}
/**
* Stops the player
*/
stop() {
this.audioPlayer.stop();
}
pause(interpolateSilence?: boolean) {
return this.audioPlayer.pause(interpolateSilence);
}
resume() {
return this.audioPlayer.unpause();
}
/**
* Play stream
* @param {AudioResource} resource The audio resource to play
*/
playStream(resource: AudioResource<Track> = this.audioResource) {
if (!resource) throw new PlayerError("Audio resource is not available!");
if (!this.audioResource && resource) this.audioResource = resource;
this.audioPlayer.play(resource);
return this;
}
get streamTime() {
if (!this.audioResource) return 0;
return this.audioResource.playbackDuration;
}
}
export { VoiceSubscription };

View file

@ -1,15 +1,27 @@
import { VoiceChannel, StageChannel, Collection, Snowflake } from "discord.js";
import { entersState, joinVoiceChannel, VoiceConnection, VoiceConnectionStatus } from "@discordjs/voice";
import { VoiceSubscription } from "./VoiceSubscription";
import { DiscordGatewayAdapterCreator, entersState, joinVoiceChannel, VoiceConnection, VoiceConnectionStatus } from "@discordjs/voice";
import { StreamDispatcher } from "./StreamDispatcher";
class VoiceUtils {
public cache = new Collection<Snowflake, VoiceSubscription>();
public cache: Collection<Snowflake, StreamDispatcher>;
/**
* Joins a voice channel
* 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 {({deaf?: boolean;maxTime?: number;})} [options] Join options
* @returns {Promise<VoiceSubscription>}
* @param {object} [options={}] Join options
* @returns {Promise<StreamDispatcher>}
*/
public async connect(
channel: VoiceChannel | StageChannel,
@ -17,19 +29,36 @@ class VoiceUtils {
deaf?: boolean;
maxTime?: number;
}
): Promise<VoiceSubscription> {
): 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,
selfDeaf: Boolean(options?.deaf)
adapterCreator: channel.guild.voiceAdapterCreator as unknown as DiscordGatewayAdapterCreator,
selfDeaf: Boolean(options.deaf)
});
try {
conn = await entersState(conn, VoiceConnectionStatus.Ready, options?.maxTime ?? 20000);
const sub = new VoiceSubscription(conn);
this.cache.set(channel.guild.id, sub);
return sub;
return conn;
} catch (err) {
conn.destroy();
throw err;
@ -39,12 +68,18 @@ class VoiceUtils {
/**
* Disconnects voice connection
* @param {VoiceConnection} connection The voice connection
* @returns {void}
*/
public disconnect(connection: VoiceConnection | VoiceSubscription) {
if (connection instanceof VoiceSubscription) return connection.voiceConnection.destroy();
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);
}

View file

@ -1,4 +1,12 @@
export { AudioFilters } from "./utils/AudioFilters";
export { PlayerError } from "./utils/PlayerError";
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, VoiceSubscription } from "./VoiceInterface/VoiceSubscription";
export { VoiceEvents, StreamDispatcher } from "./VoiceInterface/StreamDispatcher";
export * from "./types/types";

View file

@ -1,11 +1,20 @@
import { User } from "discord.js";
import { downloadOptions } from "ytdl-core";
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 QueueFilters = {
/**
* @typedef {AudioFilters} QueueFilters
*/
export interface QueueFilters {
bassboost_low?: boolean;
bassboost?: boolean;
bassboost_high?: boolean;
"8D"?: boolean;
vaporwave?: boolean;
nightcore?: boolean;
@ -15,6 +24,7 @@ export type QueueFilters = {
reverse?: boolean;
treble?: boolean;
normalizer?: boolean;
normalizer2?: boolean;
surrounding?: boolean;
pulsator?: boolean;
subboost?: boolean;
@ -33,10 +43,36 @@ export type QueueFilters = {
chorus2d?: boolean;
chorus3d?: boolean;
fadein?: boolean;
};
dim?: boolean;
earrape?: boolean;
}
/**
* The track source:
* - soundcloud
* - youtube
* - spotify
* - arbitrary
* @typedef {string} TrackSource
*/
export type TrackSource = "soundcloud" | "youtube" | "spotify" | "arbitrary";
/**
* @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;
description: string;
@ -46,12 +82,20 @@ export interface RawTrackData {
duration: string;
views: number;
requestedBy: User;
fromPlaylist: boolean;
playlist?: Playlist;
source?: TrackSource;
engine?: any;
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;
@ -59,29 +103,89 @@ export interface TimeData {
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;
queue?: 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;
leaveOnEndCooldown?: number;
leaveOnStop?: boolean;
leaveOnEmpty?: boolean;
leaveOnEmptyCooldown?: number;
autoSelfDeaf?: boolean;
enableLive?: boolean;
ytdlDownloadOptions?: downloadOptions;
useSafeSearch?: boolean;
disableAutoRegister?: boolean;
fetchBeforeQueued?: 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;
@ -91,11 +195,32 @@ export interface ExtractorModelData {
description: string;
url: string;
version?: string;
important?: boolean;
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",
@ -108,5 +233,231 @@ export enum QueryType {
VIMEO = "vimeo",
ARBITRARY = "arbitrary",
REVERBNATION = "reverbnation",
YOUTUBE_SEARCH = "youtube_search"
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,40 +1,49 @@
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",
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",
@ -44,6 +53,7 @@ const FilterList = {
reverse: "areverse",
treble: "treble=g=5",
normalizer: "dynaudnorm=g=101",
normalizer2: "acompressor",
surrounding: "surround",
pulsator: "apulsator=hz=1",
subboost: "asubboost",
@ -62,6 +72,8 @@ const FilterList = {
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)) {
@ -70,19 +82,15 @@ const FilterList = {
},
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 {

View file

@ -1 +0,0 @@
export {};

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

@ -1,16 +1,15 @@
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 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 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 =
@ -18,12 +17,21 @@ const attachmentRegex =
// 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 (validateID(query) || validateURL(query)) return QueryType.YOUTUBE;
if (YouTube.validate(query, "PLAYLIST_ID")) return QueryType.YOUTUBE_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;
@ -35,6 +43,11 @@ class QueryResolver {
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

View file

@ -1 +1,100 @@
export {};
import { StageChannel, VoiceChannel } from "discord.js";
import { TimeData } from "../types/types";
class Util {
/**
* Utils
*/
private constructor() {} // eslint-disable-line @typescript-eslint/no-empty-function
/**
* Creates duration string
* @param {object} durObj The duration object
* @returns {string}
*/
static durationString(durObj: Record<string, number>) {
return Object.values(durObj)
.map((m) => (isNaN(m) ? 0 : m))
.join(":");
}
/**
* Parses milliseconds to consumable time object
* @param {number} milliseconds The time in ms
* @returns {TimeData}
*/
static parseMS(milliseconds: number) {
const round = milliseconds > 0 ? Math.floor : Math.ceil;
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 {TimeData} duration The duration object
* @returns {string}
*/
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) => duration[m as keyof TimeData]);
const final = parsed
.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;
}
/**
* Picks last item of the given array
* @param {any[]} arr The array
* @returns {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 {
return null;
}
}
/**
* Asynchronous timeout
* @param {number} time The time in ms to wait
* @returns {Promise<unknown>}
*/
static wait(time: number) {
return new Promise((r) => setTimeout(r, time));
}
static noop() {} // eslint-disable-line @typescript-eslint/no-empty-function
}
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": []
}

2404
yarn.lock

File diff suppressed because it is too large Load diff