🔀 Merge v5 branch with master
This commit is contained in:
commit
940d559980
44 changed files with 4554 additions and 3695 deletions
7
.eslintignore
Normal file
7
.eslintignore
Normal file
|
@ -0,0 +1,7 @@
|
|||
example/
|
||||
node_modules/
|
||||
dist/
|
||||
.github/
|
||||
docs/
|
||||
|
||||
*.d.ts
|
22
.eslintrc.json
Normal file
22
.eslintrc.json
Normal 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
.github/ISSUE_TEMPLATE/bug_report.md
vendored
1
.github/ISSUE_TEMPLATE/bug_report.md
vendored
|
@ -1,7 +1,6 @@
|
|||
---
|
||||
name: Bug report
|
||||
about: Create a bug report to help us improve
|
||||
title: ""
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
|
|
1
.github/ISSUE_TEMPLATE/feature_request.md
vendored
1
.github/ISSUE_TEMPLATE/feature_request.md
vendored
|
@ -1,7 +1,6 @@
|
|||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ""
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
|
||||
|
|
1
.github/ISSUE_TEMPLATE/question.md
vendored
1
.github/ISSUE_TEMPLATE/question.md
vendored
|
@ -1,7 +1,6 @@
|
|||
---
|
||||
name: Question
|
||||
about: Some questions related to this lib
|
||||
title: ""
|
||||
labels: question
|
||||
assignees: ''
|
||||
|
||||
|
|
12
.gitignore
vendored
12
.gitignore
vendored
|
@ -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
4
.husky/pre-commit
Normal file
|
@ -0,0 +1,4 @@
|
|||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
npm run format && npm run lint:fix
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"printWidth": 400,
|
||||
"printWidth": 200,
|
||||
"trailingComma": "none",
|
||||
"singleQuote": true,
|
||||
"tabWidth": 4
|
||||
"singleQuote": false,
|
||||
"tabWidth": 4,
|
||||
"semi": true
|
||||
}
|
98
README.md
98
README.md
|
@ -4,6 +4,8 @@ Complete framework to facilitate music commands using **[discord.js](https://dis
|
|||
[![downloadsBadge](https://img.shields.io/npm/dt/discord-player?style=for-the-badge)](https://npmjs.com/discord-player)
|
||||
[![versionBadge](https://img.shields.io/npm/v/discord-player?style=for-the-badge)](https://npmjs.com/discord-player)
|
||||
[![discordBadge](https://img.shields.io/discord/558328638911545423?style=for-the-badge&color=7289da)](https://androz2091.fr/discord)
|
||||
[![wakatime](https://wakatime.com/badge/github/Androz2091/discord-player.svg)](https://wakatime.com/badge/github/Androz2091/discord-player)
|
||||
[![CodeFactor](https://www.codefactor.io/repository/github/androz2091/discord-player/badge/v5)](https://www.codefactor.io/repository/github/androz2091/discord-player/overview/v5)
|
||||
|
||||
## Installation
|
||||
|
||||
|
@ -40,46 +42,96 @@ $ npm install --save @discordjs/opus
|
|||
|
||||
## Getting Started
|
||||
|
||||
Here is the code you will need to get started with discord-player. Then, you will be able to use `client.player` everywhere in your code!
|
||||
First of all, you will need to register slash commands:
|
||||
|
||||
```js
|
||||
const Discord = require("discord.js"),
|
||||
client = new Discord.Client,
|
||||
settings = {
|
||||
prefix: "!",
|
||||
token: "Your Discord Token"
|
||||
};
|
||||
const { REST } = require("@discordjs/rest");
|
||||
const { Routes } = require("discord-api-types/v9");
|
||||
|
||||
const commands = [{
|
||||
name: "play",
|
||||
description: "Plays a song!",
|
||||
options: [
|
||||
{
|
||||
name: "query",
|
||||
type: "STRING",
|
||||
description: "The song you want to play",
|
||||
required: true
|
||||
}
|
||||
]
|
||||
}];
|
||||
|
||||
const rest = new REST({ version: "9" }).setToken(process.env.DISCORD_TOKEN);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
console.log("Started refreshing application [/] commands.");
|
||||
|
||||
await rest.put(
|
||||
Routes.applicationGuildCommands(CLIENT_ID, GUILD_ID),
|
||||
{ body: commands },
|
||||
);
|
||||
|
||||
console.log("Successfully reloaded application [/] commands.");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
})();
|
||||
```
|
||||
|
||||
Now you can implement your bot's logic:
|
||||
|
||||
```js
|
||||
const { Client, Intents } = require("discord.js");
|
||||
const client = new Discord.Client({ intents: [Intents.FLAGS.GUILDS, Intents.FLAGS.GUILD_MESSAGES, Intents.FLAGS.GUILD_VOICE_STATES] });
|
||||
const { Player } = require("discord-player");
|
||||
|
||||
// Create a new Player (you don't need any API Key)
|
||||
const player = new Player(client);
|
||||
|
||||
// To easily access the player
|
||||
client.player = player;
|
||||
|
||||
// add the trackStart event so when a song will be played this message will be sent
|
||||
client.player.on("trackStart", (message, track) => message.channel.send(`Now playing ${track.title}...`))
|
||||
player.on("trackStart", (queue, track) => queue.metadata.channel.send(`🎶 | Now playing **${track.title}**!`))
|
||||
|
||||
client.once("ready", () => {
|
||||
console.log("I'm ready !");
|
||||
});
|
||||
|
||||
client.on("message", async (message) => {
|
||||
client.on("interactionCreate", async (interaction) => {
|
||||
if (!interaction.isCommand()) return;
|
||||
|
||||
const args = message.content.slice(settings.prefix.length).trim().split(/ +/g);
|
||||
const command = args.shift().toLowerCase();
|
||||
|
||||
// !play Despacito
|
||||
// /play Despacito
|
||||
// will play "Despacito" in the voice channel
|
||||
if(command === "play"){
|
||||
client.player.play(message, args[0]);
|
||||
// as we registered the event above, no need to send a success message here
|
||||
}
|
||||
if (interaction.commandName === "play") {
|
||||
if (!interaction.member.voice.channelId) return await interaction.reply({ content: "You are not in a voice channel!", empheral: true });
|
||||
if (interaction.guild.me.voice.channelId && interaction.member.voice.channelId !== interaction.guild.me.voice.channelId) return await interaction.reply({ content: "You are not in my voice channel!", empheral: true });
|
||||
const query = interaction.options.get("query").value;
|
||||
const queue = player.createQueue(message.guild, {
|
||||
metadata: {
|
||||
channel: interaction.channel
|
||||
}
|
||||
});
|
||||
|
||||
// verify vc connection
|
||||
try {
|
||||
if (!queue.connection) await queue.connect(interaction.member.voice.channel);
|
||||
} catch {
|
||||
queue.destroy();
|
||||
return await interaction.reply({ content: "Could not join your voice channel!", empheral: true });
|
||||
}
|
||||
|
||||
await interaction.defer();
|
||||
const track = await player.search(query, {
|
||||
requestedBy: message.author
|
||||
}).then(x => x.tracks[1]);
|
||||
if (!track) return await interaction.followUp({ content: `❌ | Track **${query}** not found!` });
|
||||
|
||||
queue.play(track);
|
||||
|
||||
return await interaction.followUp({ content: `⏱️ | Loading track **${track.title}**!` });
|
||||
}
|
||||
});
|
||||
|
||||
client.login(settings.token);
|
||||
client.login(process.env.DISCORD_TOKEN);
|
||||
```
|
||||
|
||||
## Supported websites
|
||||
|
@ -113,7 +165,7 @@ These bots are made by the community, they can help you build your own!
|
|||
|
||||
```js
|
||||
const player = new Player(client, {
|
||||
ytdlDownloadOptions: {
|
||||
ytdlOptions: {
|
||||
requestOptions: {
|
||||
headers: {
|
||||
cookie: "YOUR_YOUTUBE_COOKIE"
|
||||
|
@ -133,7 +185,7 @@ const proxy = "http://user:pass@111.111.111.111:8080";
|
|||
const agent = HttpsProxyAgent(proxy);
|
||||
|
||||
const player = new Player(client, {
|
||||
ytdlDownloadOptions: {
|
||||
ytdlOptions: {
|
||||
requestOptions: { agent }
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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`
|
||||
|
||||
|
|
|
@ -8,5 +8,5 @@ const { AudioFilters } = require("discord-player");
|
|||
AudioFilters.define("3D", "apulsator=hz=0.128");
|
||||
|
||||
// later, it can be used like this
|
||||
player.setFilters(message, { "3D": true });
|
||||
queue.setFilters({ "3D": true });
|
||||
```
|
|
@ -1,11 +0,0 @@
|
|||
# How to play live videos?
|
||||
|
||||
You cannot play live videos by default. If you need to play the live video, just add this option:
|
||||
|
||||
```js
|
||||
const player = new Player(client, {
|
||||
enableLive: true // enables livestream
|
||||
});
|
||||
```
|
||||
|
||||
However, you cannot use audio filters with livestreams using this library!
|
|
@ -1,15 +0,0 @@
|
|||
# Pause and Resume is not working properly
|
||||
|
||||
This is a bug in **[discord.js#5300](https://github.com/discordjs/discord.js/issues/5300)**.
|
||||
|
||||
# Fix
|
||||
|
||||
You have to update your command something like this:
|
||||
|
||||
```diff
|
||||
- client.player.resume(message);
|
||||
|
||||
+ client.player.resume(message);
|
||||
+ client.player.pause(message);
|
||||
+ client.player.resume(message);
|
||||
```
|
3
docs/faq/slash_commands.md
Normal file
3
docs/faq/slash_commands.md
Normal 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.
|
|
@ -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 }
|
||||
}
|
||||
});
|
||||
```
|
|
@ -1,7 +1,12 @@
|
|||
- name: General
|
||||
files:
|
||||
- name: Welcome
|
||||
path: welcome.md
|
||||
id: welcome
|
||||
path: ../../README.md
|
||||
- name: Migrating
|
||||
files:
|
||||
- name: Migrating to v5
|
||||
path: migrating.md
|
||||
- name: Extractors
|
||||
files:
|
||||
- name: Extractors API
|
||||
|
@ -10,10 +15,8 @@
|
|||
files:
|
||||
- name: Custom Filters
|
||||
path: custom_filters.md
|
||||
- name: Livestreams
|
||||
path: live_video.md
|
||||
- name: Pause & Resume
|
||||
path: pause_resume.md
|
||||
- name: Slash Commands
|
||||
path: slash_commands.md
|
||||
- name: YouTube
|
||||
files:
|
||||
- name: Using Cookies
|
||||
|
|
59
docs/migrating/migrating.md
Normal file
59
docs/migrating/migrating.md
Normal file
|
@ -0,0 +1,59 @@
|
|||
# Migrating to Discord Player v5
|
||||
|
||||
We have introduced some breaking changes in Discord Player v5. Which means, your old code will no longer work with v5.
|
||||
The new update brings new features as well as better management of different things. This also uses the new **[@discordjs/voice](https://github.com/discordjs/voice)** library!
|
||||
|
||||
## Basic Example
|
||||
|
||||
```diff
|
||||
- player.play(message, query);
|
||||
+ const queue = player.createQueue(message.guild);
|
||||
+ const song = await player.search(query, {
|
||||
+ requestedBy: message.author
|
||||
});
|
||||
+
|
||||
+ try {
|
||||
+ await queue.connect(message.member.voice.channel);
|
||||
+ } catch {
|
||||
+ message.reply("Could not join your voice channel");
|
||||
+ }
|
||||
+
|
||||
+ queue.addTrack(song.tracks[0]);
|
||||
+ queue.play();
|
||||
```
|
||||
|
||||
> Everything related to music player is moved to `Queue`.
|
||||
|
||||
## How do I reply to the event like v4?
|
||||
|
||||
Since we got rid of `message` parameter in every method of the Discord Player, you no longer have access to the `message` object in events.
|
||||
Instead, we have added `<Queue>.metadata` prop as an alternative. This `metadata` can be anything, declared while creating queue:
|
||||
|
||||
```js
|
||||
const queue = player.createQueue(message.guild, {
|
||||
metadata: message
|
||||
});
|
||||
```
|
||||
|
||||
The metadata `message` will always be available in every events emitted for that specific `Queue`. You can access it via `queue.metadata`:
|
||||
|
||||
```js
|
||||
player.on("trackStart", (queue, track) => {
|
||||
const channel = queue.metadata.channel; // queue.metadata is your "message" object
|
||||
channel.send(`🎶 | Started playing **${track.title}**`);
|
||||
});
|
||||
```
|
||||
|
||||
## How do I stop the player
|
||||
|
||||
You have to use `<Queue>.destroy()` to destroy the queue. It will also stop the player.
|
||||
|
||||
```js
|
||||
const queue = player.getQueue(message.guild.id);
|
||||
if (queue) queue.destroy();
|
||||
```
|
||||
|
||||
## Updating filters
|
||||
|
||||
Discord Player v5.x has new option `bufferingTimeout` in queue init options which allows you to set stream buffering timeout before playing.
|
||||
This might be useful if you want to have smooth filters update. By default, it is set to 3 seconds.
|
|
@ -4,7 +4,7 @@
|
|||
const { Player } = require("discord-player");
|
||||
|
||||
const player = new Player(client, {
|
||||
ytdlDownloadOptions: {
|
||||
ytdlOptions: {
|
||||
requestOptions: {
|
||||
headers: {
|
||||
cookie: "YOUR_YOUTUBE_COOKIE"
|
||||
|
|
|
@ -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
2
example/README.md
Normal file
|
@ -0,0 +1,2 @@
|
|||
# Discord Player Examples
|
||||
This section contains example bot(s) made with Discord Player.
|
2
example/music-bot/README.md
Normal file
2
example/music-bot/README.md
Normal file
|
@ -0,0 +1,2 @@
|
|||
# Music Bot
|
||||
Slash commands music bot backed by **[Discord Player](https://discord-player.js.org)**.
|
3
example/music-bot/config.js
Normal file
3
example/music-bot/config.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
module.exports = {
|
||||
token: "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
|
||||
};
|
301
example/music-bot/index.js
Normal file
301
example/music-bot/index.js
Normal file
|
@ -0,0 +1,301 @@
|
|||
const { Client, GuildMember } = require("discord.js");
|
||||
const config = require("./config");
|
||||
const { Player, QueryType, QueueRepeatMode } = require("discord-player");
|
||||
|
||||
const client = new Client({
|
||||
intents: ["GUILD_VOICE_STATES", "GUILD_MESSAGES", "GUILDS"]
|
||||
});
|
||||
|
||||
client.on("ready", () => {
|
||||
console.log("Bot is online!");
|
||||
client.user.setActivity({
|
||||
name: "🎶 | Music Time",
|
||||
type: "LISTENING"
|
||||
});
|
||||
});
|
||||
client.on("error", console.error);
|
||||
client.on("warn", console.warn);
|
||||
|
||||
// instantiate the player
|
||||
const player = new Player(client);
|
||||
|
||||
player.on("error", (queue, error) => {
|
||||
console.log(`[${queue.guild.name}] Error emitted from the queue: ${error.message}`);
|
||||
});
|
||||
player.on("connectionError", (queue, error) => {
|
||||
console.log(`[${queue.guild.name}] Error emitted from the connection: ${error.message}`);
|
||||
});
|
||||
|
||||
player.on("trackStart", (queue, track) => {
|
||||
queue.metadata.send(`🎶 | Started playing: **${track.title}** in **${queue.connection.channel.name}**!`);
|
||||
});
|
||||
|
||||
player.on("trackAdd", (queue, track) => {
|
||||
queue.metadata.send(`🎶 | Track **${track.title}** queued!`);
|
||||
});
|
||||
|
||||
player.on("botDisconnect", (queue) => {
|
||||
queue.metadata.send("❌ | I was manually disconnected from the voice channel, clearing queue!");
|
||||
});
|
||||
|
||||
player.on("channelEmpty", (queue) => {
|
||||
queue.metadata.send("❌ | Nobody is in the voice channel, leaving...");
|
||||
});
|
||||
|
||||
player.on("queueEnd", (queue) => {
|
||||
queue.metadata.send("✅ | Queue finished!");
|
||||
});
|
||||
|
||||
client.on("messageCreate", async (message) => {
|
||||
if (message.author.bot || !message.guild) return;
|
||||
if (!client.application?.owner) await client.application?.fetch();
|
||||
|
||||
if (message.content === "!deploy" && message.author.id === client.application?.owner?.id) {
|
||||
await message.guild.commands.set([
|
||||
{
|
||||
name: "play",
|
||||
description: "Plays a song from youtube",
|
||||
options: [
|
||||
{
|
||||
name: "query",
|
||||
type: "STRING",
|
||||
description: "The song you want to play",
|
||||
required: true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "soundcloud",
|
||||
description: "Plays a song from soundcloud",
|
||||
options: [
|
||||
{
|
||||
name: "query",
|
||||
type: "STRING",
|
||||
description: "The song you want to play",
|
||||
required: true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "volume",
|
||||
description: "Sets music volume",
|
||||
options: [
|
||||
{
|
||||
name: "amount",
|
||||
type: "INTEGER",
|
||||
description: "The volume amount to set (0-100)",
|
||||
required: false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "loop",
|
||||
description: "Sets loop mode",
|
||||
options: [
|
||||
{
|
||||
name: "mode",
|
||||
type: "INTEGER",
|
||||
description: "Loop type",
|
||||
required: true,
|
||||
choices: [
|
||||
{
|
||||
name: "Off",
|
||||
value: QueueRepeatMode.OFF
|
||||
},
|
||||
{
|
||||
name: "Track",
|
||||
value: QueueRepeatMode.TRACK
|
||||
},
|
||||
{
|
||||
name: "Queue",
|
||||
value: QueueRepeatMode.QUEUE
|
||||
},
|
||||
{
|
||||
name: "Autoplay",
|
||||
value: QueueRepeatMode.AUTOPLAY
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "skip",
|
||||
description: "Skip to the current song"
|
||||
},
|
||||
{
|
||||
name: "queue",
|
||||
description: "See the queue"
|
||||
},
|
||||
{
|
||||
name: "pause",
|
||||
description: "Pause the current song"
|
||||
},
|
||||
{
|
||||
name: "resume",
|
||||
description: "Resume the current song"
|
||||
},
|
||||
{
|
||||
name: "stop",
|
||||
description: "Stop the player"
|
||||
},
|
||||
{
|
||||
name: "np",
|
||||
description: "Now Playing"
|
||||
},
|
||||
{
|
||||
name: "bassboost",
|
||||
description: "Toggles bassboost filter"
|
||||
}
|
||||
]);
|
||||
|
||||
await message.reply("Deployed!");
|
||||
}
|
||||
});
|
||||
|
||||
client.on("interactionCreate", async (interaction) => {
|
||||
if (!interaction.isCommand() || !interaction.guildId) return;
|
||||
|
||||
if (!(interaction.member instanceof GuildMember) || !interaction.member.voice.channel) {
|
||||
return void interaction.reply({ content: "You are not in a voice channel!", ephemeral: true });
|
||||
}
|
||||
|
||||
if (interaction.guild.me.voice.channelId && interaction.member.voice.channelId !== interaction.guild.me.voice.channelId) {
|
||||
return void interaction.reply({ content: "You are not in my voice channel!", ephemeral: true });
|
||||
}
|
||||
|
||||
if (interaction.commandName === "play" || interaction.commandName === "soundcloud") {
|
||||
await interaction.deferReply();
|
||||
|
||||
const query = interaction.options.get("query").value;
|
||||
const searchResult = await player
|
||||
.search(query, {
|
||||
requestedBy: interaction.user,
|
||||
searchEngine: interaction.commandName === "soundcloud" ? QueryType.SOUNDCLOUD_SEARCH : QueryType.AUTO
|
||||
})
|
||||
.catch(() => {});
|
||||
if (!searchResult || !searchResult.tracks.length) return void interaction.followUp({ content: "No results were found!" });
|
||||
|
||||
const queue = await player.createQueue(interaction.guild, {
|
||||
metadata: interaction.channel
|
||||
});
|
||||
|
||||
try {
|
||||
if (!queue.connection) await queue.connect(interaction.member.voice.channel);
|
||||
} catch {
|
||||
void player.deleteQueue(interaction.guildId);
|
||||
return void interaction.followUp({ content: "Could not join your voice channel!" });
|
||||
}
|
||||
|
||||
await interaction.followUp({ content: `⏱ | Loading your ${searchResult.playlist ? "playlist" : "track"}...` });
|
||||
searchResult.playlist ? queue.addTracks(searchResult.tracks) : queue.addTrack(searchResult.tracks[0]);
|
||||
if (!queue.playing) await queue.play();
|
||||
} else if (interaction.commandName === "volume") {
|
||||
await interaction.deferReply();
|
||||
const queue = player.getQueue(interaction.guildId);
|
||||
if (!queue || !queue.playing) return void interaction.followUp({ content: "❌ | No music is being played!" });
|
||||
const vol = interaction.options.get("amount");
|
||||
if (!vol) return void interaction.followUp({ content: `🎧 | Current volume is **${queue.volume}**%!` });
|
||||
if ((vol.value) < 0 || (vol.value) > 100) return void interaction.followUp({ content: "❌ | Volume range must be 0-100" });
|
||||
const success = queue.setVolume(vol.value);
|
||||
return void interaction.followUp({
|
||||
content: success ? `✅ | Volume set to **${vol.value}%**!` : "❌ | Something went wrong!"
|
||||
});
|
||||
} else if (interaction.commandName === "skip") {
|
||||
await interaction.deferReply();
|
||||
const queue = player.getQueue(interaction.guildId);
|
||||
if (!queue || !queue.playing) return void interaction.followUp({ content: "❌ | No music is being played!" });
|
||||
const currentTrack = queue.current;
|
||||
const success = queue.skip();
|
||||
return void interaction.followUp({
|
||||
content: success ? `✅ | Skipped **${currentTrack}**!` : "❌ | Something went wrong!"
|
||||
});
|
||||
} else if (interaction.commandName === "queue") {
|
||||
await interaction.deferReply();
|
||||
const queue = player.getQueue(interaction.guildId);
|
||||
if (!queue || !queue.playing) return void interaction.followUp({ content: "❌ | No music is being played!" });
|
||||
const currentTrack = queue.current;
|
||||
const tracks = queue.tracks.slice(0, 10).map((m, i) => {
|
||||
return `${i + 1}. **${m.title}** ([link](${m.url}))`;
|
||||
});
|
||||
|
||||
return void interaction.followUp({
|
||||
embeds: [
|
||||
{
|
||||
title: "Server Queue",
|
||||
description: `${tracks.join("\n")}${
|
||||
queue.tracks.length > tracks.length
|
||||
? `\n...${queue.tracks.length - tracks.length === 1 ? `${queue.tracks.length - tracks.length} more track` : `${queue.tracks.length - tracks.length} more tracks`}`
|
||||
: ""
|
||||
}`,
|
||||
color: 0xff0000,
|
||||
fields: [{ name: "Now Playing", value: `🎶 | **${currentTrack.title}** ([link](${currentTrack.url}))` }]
|
||||
}
|
||||
]
|
||||
});
|
||||
} else if (interaction.commandName === "pause") {
|
||||
await interaction.deferReply();
|
||||
const queue = player.getQueue(interaction.guildId);
|
||||
if (!queue || !queue.playing) return void interaction.followUp({ content: "❌ | No music is being played!" });
|
||||
const success = queue.setPaused(true);
|
||||
return void interaction.followUp({ content: success ? "⏸ | Paused!" : "❌ | Something went wrong!" });
|
||||
} else if (interaction.commandName === "resume") {
|
||||
await interaction.deferReply();
|
||||
const queue = player.getQueue(interaction.guildId);
|
||||
if (!queue || !queue.playing) return void interaction.followUp({ content: "❌ | No music is being played!" });
|
||||
const success = queue.setPaused(false);
|
||||
return void interaction.followUp({ content: success ? "▶ | Resumed!" : "❌ | Something went wrong!" });
|
||||
} else if (interaction.commandName === "stop") {
|
||||
await interaction.deferReply();
|
||||
const queue = player.getQueue(interaction.guildId);
|
||||
if (!queue || !queue.playing) return void interaction.followUp({ content: "❌ | No music is being played!" });
|
||||
queue.destroy();
|
||||
return void interaction.followUp({ content: "🛑 | Stopped the player!" });
|
||||
} else if (interaction.commandName === "np") {
|
||||
await interaction.deferReply();
|
||||
const queue = player.getQueue(interaction.guildId);
|
||||
if (!queue || !queue.playing) return void interaction.followUp({ content: "❌ | No music is being played!" });
|
||||
const progress = queue.createProgressBar();
|
||||
const perc = queue.getPlayerTimestamp();
|
||||
|
||||
return void interaction.followUp({
|
||||
embeds: [
|
||||
{
|
||||
title: "Now Playing",
|
||||
description: `🎶 | **${queue.current.title}**! (\`${perc.progress}%\`)`,
|
||||
fields: [
|
||||
{
|
||||
name: "\u200b",
|
||||
value: progress
|
||||
}
|
||||
],
|
||||
color: 0xffffff
|
||||
}
|
||||
]
|
||||
});
|
||||
} else if (interaction.commandName === "loop") {
|
||||
await interaction.deferReply();
|
||||
const queue = player.getQueue(interaction.guildId);
|
||||
if (!queue || !queue.playing) return void interaction.followUp({ content: "❌ | No music is being played!" });
|
||||
const loopMode = interaction.options.get("mode").value;
|
||||
const success = queue.setRepeatMode(loopMode);
|
||||
const mode = loopMode === QueueRepeatMode.TRACK ? "🔂" : loopMode === QueueRepeatMode.QUEUE ? "🔁" : "▶";
|
||||
return void interaction.followUp({ content: success ? `${mode} | Updated loop mode!` : "❌ | Could not update loop mode!" });
|
||||
} else if (interaction.commandName === "bassboost") {
|
||||
await interaction.deferReply();
|
||||
const queue = player.getQueue(interaction.guildId);
|
||||
if (!queue || !queue.playing) return void interaction.followUp({ content: "❌ | No music is being played!" });
|
||||
await queue.setFilters({
|
||||
bassboost: !queue.getFiltersEnabled().includes("bassboost"),
|
||||
normalizer2: !queue.getFiltersEnabled().includes("bassboost") // because we need to toggle it with bass
|
||||
});
|
||||
|
||||
return void interaction.followUp({ content: `🎵 | Bassboost ${queue.getFiltersEnabled().includes("bassboost") ? "Enabled" : "Disabled"}!` });
|
||||
} else {
|
||||
interaction.reply({
|
||||
content: "Unknown command!",
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
client.login(config.token);
|
16
example/music-bot/package.json
Normal file
16
example/music-bot/package.json
Normal file
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"name": "music-bot",
|
||||
"version": "1.0.0",
|
||||
"description": "Simple music bot created with discord-player",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node index.js"
|
||||
},
|
||||
"author": "Snowflake107",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@discordjs/opus": "^0.5.3",
|
||||
"discord-player": "^5.0.0-dev.39f503a.1625470163",
|
||||
"discord.js": "^13.0.1"
|
||||
}
|
||||
}
|
73
package.json
73
package.json
|
@ -1,19 +1,28 @@
|
|||
{
|
||||
"name": "discord-player",
|
||||
"version": "4.1.4",
|
||||
"version": "5.0.0-dev",
|
||||
"description": "Complete framework to facilitate music commands using discord.js",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/index.d.ts",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
"lib/"
|
||||
"dist/"
|
||||
],
|
||||
"module": "dist/index.mjs",
|
||||
"exports": {
|
||||
"require": "./dist/index.js",
|
||||
"import": "./dist/index.mjs"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "yarn build && cd test && node index.js",
|
||||
"build": "tsc",
|
||||
"format": "prettier --write \"src/**/*.ts\"",
|
||||
"lint": "tslint -p tsconfig.json",
|
||||
"docs": "docgen --jsdoc jsdoc.json --verbose --source src/*.ts src/**/*.ts --custom docs/index.yml --output docs/docs.json",
|
||||
"docs:test": "docgen --jsdoc jsdoc.json --verbose --source src/*.ts src/**/*.ts --custom docs/index.yml"
|
||||
"dev": "cd example/test && ts-node index.ts",
|
||||
"build": "rimraf dist && tsc && npm run build:esm",
|
||||
"build:check": "tsc --noEmit --incremental false",
|
||||
"prepublishOnly": "rollup-type-bundler -e stream",
|
||||
"build:esm": "gen-esm-wrapper ./dist/index.js ./dist/index.mjs",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"example/**/*.ts\"",
|
||||
"docs": "docgen --jsdoc jsdoc.json --source src/*.ts src/**/*.ts --custom docs/index.yml --output docs/docs.json",
|
||||
"docs:test": "docgen --jsdoc jsdoc.json --source src/*.ts src/**/*.ts --custom docs/index.yml",
|
||||
"lint": "eslint src --ext .ts",
|
||||
"lint:fix": "eslint src --ext .ts --fix"
|
||||
},
|
||||
"funding": "https://github.com/Androz2091/discord-player?sponsor=1",
|
||||
"contributors": [
|
||||
|
@ -50,27 +59,37 @@
|
|||
},
|
||||
"homepage": "https://discord-player.js.org",
|
||||
"dependencies": {
|
||||
"@discordjs/voice": "^0.5.5",
|
||||
"discord-ytdl-core": "^5.0.4",
|
||||
"soundcloud-scraper": "^5.0.0",
|
||||
"libsodium-wrappers": "^0.7.9",
|
||||
"soundcloud-scraper": "^5.0.1",
|
||||
"spotify-url-info": "^2.2.3",
|
||||
"youtube-sr": "^4.1.5",
|
||||
"ytdl-core": "^4.8.3"
|
||||
"tiny-typed-emitter": "^2.1.0",
|
||||
"youtube-sr": "^4.1.7",
|
||||
"ytdl-core": "^4.9.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.13.16",
|
||||
"@babel/core": "^7.13.16",
|
||||
"@babel/preset-env": "^7.13.15",
|
||||
"@babel/preset-typescript": "^7.13.0",
|
||||
"@discord-player/extractor": "^3.0.0",
|
||||
"@discordjs/opus": "^0.5.0",
|
||||
"@types/node": "^15.6.1",
|
||||
"@types/ws": "^7.4.1",
|
||||
"discord.js": "^12.5.3",
|
||||
"discord.js-docgen": "discordjs/docgen#ts-patch",
|
||||
"@babel/cli": "^7.14.8",
|
||||
"@babel/core": "^7.15.0",
|
||||
"@babel/preset-env": "^7.15.0",
|
||||
"@babel/preset-typescript": "^7.15.0",
|
||||
"@devsnowflake/docgen": "devsnowflake/docgen#ts-patch",
|
||||
"@discord-player/extractor": "^3.0.2",
|
||||
"@discordjs/opus": "github:discordjs/opus",
|
||||
"@favware/rollup-type-bundler": "^1.0.3",
|
||||
"@types/node": "^16.4.13",
|
||||
"@types/ws": "^7.4.7",
|
||||
"@typescript-eslint/eslint-plugin": "^4.29.0",
|
||||
"@typescript-eslint/parser": "^4.29.0",
|
||||
"discord-api-types": "^0.22.0",
|
||||
"discord.js": "^13.0.1",
|
||||
"eslint": "^7.32.0",
|
||||
"gen-esm-wrapper": "^1.1.2",
|
||||
"husky": "^7.0.1",
|
||||
"jsdoc-babel": "^0.5.0",
|
||||
"prettier": "^2.2.1",
|
||||
"tslint": "^6.1.3",
|
||||
"tslint-config-prettier": "^1.18.0",
|
||||
"typescript": "^4.2.3"
|
||||
"prettier": "^2.3.2",
|
||||
"rimraf": "^3.0.2",
|
||||
"ts-node": "^10.1.0",
|
||||
"typescript": "^4.3.5"
|
||||
}
|
||||
}
|
||||
|
|
1942
src/Player.ts
1942
src/Player.ts
File diff suppressed because it is too large
Load diff
|
@ -1,27 +1,34 @@
|
|||
import { ExtractorModelData } from '../types/types';
|
||||
import { ExtractorModelData } from "../types/types";
|
||||
|
||||
class ExtractorModel {
|
||||
name: string;
|
||||
private _raw: any;
|
||||
private _raw: any; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
/**
|
||||
* Model for raw Discord Player extractors
|
||||
* @param {String} extractorName Name of the extractor
|
||||
* @param {Object} data Extractor object
|
||||
* @param {string} extractorName Name of the extractor
|
||||
* @param {object} data Extractor object
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
constructor(extractorName: string, data: any) {
|
||||
/**
|
||||
* The extractor name
|
||||
* @type {String}
|
||||
* @type {string}
|
||||
*/
|
||||
this.name = extractorName;
|
||||
|
||||
Object.defineProperty(this, '_raw', { value: data, configurable: false, writable: false, enumerable: false });
|
||||
/**
|
||||
* The raw model
|
||||
* @name ExtractorModel#_raw
|
||||
* @type {any}
|
||||
* @private
|
||||
*/
|
||||
Object.defineProperty(this, "_raw", { value: data, configurable: false, writable: false, enumerable: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to handle requests from `Player.play()`
|
||||
* @param {String} query Query to handle
|
||||
* @param {string} query Query to handle
|
||||
* @returns {Promise<ExtractorModelData>}
|
||||
*/
|
||||
async handle(query: string): Promise<ExtractorModelData> {
|
||||
|
@ -29,21 +36,26 @@ class ExtractorModel {
|
|||
if (!data) return null;
|
||||
|
||||
return {
|
||||
title: data.title,
|
||||
duration: data.duration,
|
||||
thumbnail: data.thumbnail,
|
||||
engine: data.engine,
|
||||
views: data.views,
|
||||
author: data.author,
|
||||
description: data.description,
|
||||
url: data.url
|
||||
playlist: data.playlist ?? null,
|
||||
data:
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
data.info?.map((m: any) => ({
|
||||
title: m.title,
|
||||
duration: m.duration,
|
||||
thumbnail: m.thumbnail,
|
||||
engine: m.engine,
|
||||
views: m.views,
|
||||
author: m.author,
|
||||
description: m.description,
|
||||
url: m.url
|
||||
})) ?? []
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Method used by Discord Player to validate query with this extractor
|
||||
* @param {String} query The query to validate
|
||||
* @returns {Boolean}
|
||||
* @param {string} query The query to validate
|
||||
* @returns {boolean}
|
||||
*/
|
||||
validate(query: string): boolean {
|
||||
return Boolean(this._raw.validate(query));
|
||||
|
@ -51,20 +63,11 @@ class ExtractorModel {
|
|||
|
||||
/**
|
||||
* The extractor version
|
||||
* @type {String}
|
||||
* @type {string}
|
||||
*/
|
||||
get version(): string {
|
||||
return this._raw.version ?? '0.0.0';
|
||||
}
|
||||
|
||||
/**
|
||||
* If player should mark this extractor as important
|
||||
* @type {Boolean}
|
||||
*/
|
||||
get important(): boolean {
|
||||
return Boolean(this._raw.important);
|
||||
return this._raw.version ?? "0.0.0";
|
||||
}
|
||||
}
|
||||
|
||||
export default ExtractorModel;
|
||||
export { ExtractorModel };
|
||||
|
|
48
src/Structures/PlayerError.ts
Normal file
48
src/Structures/PlayerError.ts
Normal file
|
@ -0,0 +1,48 @@
|
|||
export enum ErrorStatusCode {
|
||||
STREAM_ERROR = "StreamError",
|
||||
AUDIO_PLAYER_ERROR = "AudioPlayerError",
|
||||
PLAYER_ERROR = "PlayerError",
|
||||
NO_AUDIO_RESOURCE = "NoAudioResource",
|
||||
UNKNOWN_GUILD = "UnknownGuild",
|
||||
INVALID_ARG_TYPE = "InvalidArgType",
|
||||
UNKNOWN_EXTRACTOR = "UnknownExtractor",
|
||||
INVALID_EXTRACTOR = "InvalidExtractor",
|
||||
INVALID_CHANNEL_TYPE = "InvalidChannelType",
|
||||
INVALID_TRACK = "InvalidTrack",
|
||||
UNKNOWN_REPEAT_MODE = "UnknownRepeatMode",
|
||||
TRACK_NOT_FOUND = "TrackNotFound",
|
||||
NO_CONNECTION = "NoConnection",
|
||||
DESTROYED_QUEUE = "DestroyedQueue"
|
||||
}
|
||||
|
||||
export class PlayerError extends Error {
|
||||
message: string;
|
||||
statusCode: ErrorStatusCode;
|
||||
createdAt = new Date();
|
||||
|
||||
constructor(message: string, code: ErrorStatusCode = ErrorStatusCode.PLAYER_ERROR) {
|
||||
super();
|
||||
|
||||
this.message = `[${code}] ${message}`;
|
||||
this.statusCode = code;
|
||||
this.name = code;
|
||||
|
||||
Error.captureStackTrace(this);
|
||||
}
|
||||
|
||||
get createdTimestamp() {
|
||||
return this.createdAt.getTime();
|
||||
}
|
||||
|
||||
valueOf() {
|
||||
return this.statusCode;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return { stack: this.stack, code: this.statusCode, created: this.createdTimestamp };
|
||||
}
|
||||
|
||||
toString() {
|
||||
return this.stack;
|
||||
}
|
||||
}
|
138
src/Structures/Playlist.ts
Normal file
138
src/Structures/Playlist.ts
Normal file
|
@ -0,0 +1,138 @@
|
|||
import { Player } from "../Player";
|
||||
import { Track } from "./Track";
|
||||
import { PlaylistInitData, PlaylistJSON, TrackJSON, TrackSource } from "../types/types";
|
||||
|
||||
class Playlist {
|
||||
public readonly player: Player;
|
||||
public tracks: Track[];
|
||||
public title: string;
|
||||
public description: string;
|
||||
public thumbnail: string;
|
||||
public type: "album" | "playlist";
|
||||
public source: TrackSource;
|
||||
public author: {
|
||||
name: string;
|
||||
url: string;
|
||||
};
|
||||
public id: string;
|
||||
public url: string;
|
||||
public readonly rawPlaylist?: any; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
/**
|
||||
* Playlist constructor
|
||||
* @param {Player} player The player
|
||||
* @param {PlaylistInitData} data The data
|
||||
*/
|
||||
constructor(player: Player, data: PlaylistInitData) {
|
||||
/**
|
||||
* The player
|
||||
* @name Playlist#player
|
||||
* @type {Player}
|
||||
* @readonly
|
||||
*/
|
||||
this.player = player;
|
||||
|
||||
/**
|
||||
* The tracks in this playlist
|
||||
* @name Playlist#tracks
|
||||
* @type {Track[]}
|
||||
*/
|
||||
this.tracks = data.tracks ?? [];
|
||||
|
||||
/**
|
||||
* The author of this playlist
|
||||
* @name Playlist#author
|
||||
* @type {object}
|
||||
*/
|
||||
this.author = data.author;
|
||||
|
||||
/**
|
||||
* The description
|
||||
* @name Playlist#description
|
||||
* @type {string}
|
||||
*/
|
||||
this.description = data.description;
|
||||
|
||||
/**
|
||||
* The thumbnail of this playlist
|
||||
* @name Playlist#thumbnail
|
||||
* @type {string}
|
||||
*/
|
||||
this.thumbnail = data.thumbnail;
|
||||
|
||||
/**
|
||||
* The playlist type:
|
||||
* - `album`
|
||||
* - `playlist`
|
||||
* @name Playlist#type
|
||||
* @type {string}
|
||||
*/
|
||||
this.type = data.type;
|
||||
|
||||
/**
|
||||
* The source of this playlist:
|
||||
* - `youtube`
|
||||
* - `soundcloud`
|
||||
* - `spotify`
|
||||
* - `arbitrary`
|
||||
* @name Playlist#source
|
||||
* @type {string}
|
||||
*/
|
||||
this.source = data.source;
|
||||
|
||||
/**
|
||||
* The playlist id
|
||||
* @name Playlist#id
|
||||
* @type {string}
|
||||
*/
|
||||
this.id = data.id;
|
||||
|
||||
/**
|
||||
* The playlist url
|
||||
* @name Playlist#url
|
||||
* @type {string}
|
||||
*/
|
||||
this.url = data.url;
|
||||
|
||||
/**
|
||||
* The playlist title
|
||||
* @type {string}
|
||||
*/
|
||||
this.title = data.title;
|
||||
|
||||
/**
|
||||
* @name Playlist#rawPlaylist
|
||||
* @type {any}
|
||||
* @readonly
|
||||
*/
|
||||
}
|
||||
|
||||
*[Symbol.iterator]() {
|
||||
yield* this.tracks;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON representation of this playlist
|
||||
* @param {boolean} [withTracks=true] If it should build json with tracks
|
||||
* @returns {PlaylistJSON}
|
||||
*/
|
||||
toJSON(withTracks = true) {
|
||||
const payload = {
|
||||
id: this.id,
|
||||
url: this.url,
|
||||
title: this.title,
|
||||
description: this.description,
|
||||
thumbnail: this.thumbnail,
|
||||
type: this.type,
|
||||
source: this.source,
|
||||
author: this.author,
|
||||
tracks: [] as TrackJSON[]
|
||||
};
|
||||
|
||||
if (withTracks) payload.tracks = this.tracks.map((m) => m.toJSON(true));
|
||||
|
||||
return payload as PlaylistJSON;
|
||||
}
|
||||
}
|
||||
|
||||
export { Playlist };
|
|
@ -1,146 +1,583 @@
|
|||
import { Message, Snowflake, VoiceConnection } from 'discord.js';
|
||||
import AudioFilters from '../utils/AudioFilters';
|
||||
import { Player } from '../Player';
|
||||
import { EventEmitter } from 'events';
|
||||
import { Track } from './Track';
|
||||
import { QueueFilters } from '../types/types';
|
||||
import { Collection, Guild, StageChannel, VoiceChannel, Snowflake, SnowflakeUtil, GuildChannelResolvable } from "discord.js";
|
||||
import { Player } from "../Player";
|
||||
import { StreamDispatcher } from "../VoiceInterface/StreamDispatcher";
|
||||
import Track from "./Track";
|
||||
import { PlayerOptions, PlayerProgressbarOptions, PlayOptions, QueueFilters, QueueRepeatMode } from "../types/types";
|
||||
import ytdl from "discord-ytdl-core";
|
||||
import { AudioResource, StreamType } from "@discordjs/voice";
|
||||
import { Util } from "../utils/Util";
|
||||
import YouTube from "youtube-sr";
|
||||
import AudioFilters from "../utils/AudioFilters";
|
||||
import { PlayerError, ErrorStatusCode } from "./PlayerError";
|
||||
|
||||
export class Queue extends EventEmitter {
|
||||
public player!: Player;
|
||||
public guildID: Snowflake;
|
||||
public voiceConnection?: VoiceConnection;
|
||||
public stream?: any;
|
||||
public tracks: Track[];
|
||||
public previousTracks: Track[];
|
||||
public stopped: boolean;
|
||||
public lastSkipped: boolean;
|
||||
public volume: number;
|
||||
public paused: boolean;
|
||||
public repeatMode: boolean;
|
||||
public loopMode: boolean;
|
||||
public filters: QueueFilters;
|
||||
public additionalStreamTime: number;
|
||||
public firstMessage: Message;
|
||||
|
||||
/**
|
||||
* If autoplay is enabled in this queue
|
||||
* @type {boolean}
|
||||
*/
|
||||
public autoPlay = false;
|
||||
class Queue<T = unknown> {
|
||||
public readonly guild: Guild;
|
||||
public readonly player: Player;
|
||||
public connection: StreamDispatcher;
|
||||
public tracks: Track[] = [];
|
||||
public previousTracks: Track[] = [];
|
||||
public options: PlayerOptions;
|
||||
public playing = false;
|
||||
public metadata?: T = null;
|
||||
public repeatMode: QueueRepeatMode = 0;
|
||||
public readonly id: Snowflake = SnowflakeUtil.generate();
|
||||
private _streamTime = 0;
|
||||
public _cooldownsTimeout = new Collection<string, NodeJS.Timeout>();
|
||||
private _activeFilters: any[] = []; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
private _filtersUpdate = false;
|
||||
#lastVolume = 0;
|
||||
#destroyed = false;
|
||||
|
||||
/**
|
||||
* Queue constructor
|
||||
* @param {Player} player The player that instantiated this Queue
|
||||
* @param {DiscordMessage} message The message object
|
||||
* @param {Player} player The player that instantiated this queue
|
||||
* @param {Guild} guild The guild that instantiated this queue
|
||||
* @param {PlayerOptions} [options={}] Player options for the queue
|
||||
*/
|
||||
constructor(player: Player, message: Message) {
|
||||
super();
|
||||
|
||||
constructor(player: Player, guild: Guild, options: PlayerOptions = {}) {
|
||||
/**
|
||||
* The player that instantiated this Queue
|
||||
* @name Queue#player
|
||||
* The player that instantiated this queue
|
||||
* @type {Player}
|
||||
* @readonly
|
||||
*/
|
||||
Object.defineProperty(this, 'player', { value: player, enumerable: false });
|
||||
this.player = player;
|
||||
|
||||
/**
|
||||
* ID of the guild assigned to this queue
|
||||
* @type {DiscordSnowflake}
|
||||
* The guild that instantiated this queue
|
||||
* @type {Guild}
|
||||
* @readonly
|
||||
*/
|
||||
this.guildID = message.guild.id;
|
||||
this.guild = guild;
|
||||
|
||||
/**
|
||||
* The voice connection of this queue
|
||||
* @type {DiscordVoiceConnection}
|
||||
* The player options for this queue
|
||||
* @type {PlayerOptions}
|
||||
*/
|
||||
this.voiceConnection = null;
|
||||
this.options = {};
|
||||
|
||||
/**
|
||||
* Tracks of this queue
|
||||
* Queue repeat mode
|
||||
* @type {QueueRepeatMode}
|
||||
* @name Queue#repeatMode
|
||||
*/
|
||||
|
||||
/**
|
||||
* Queue metadata
|
||||
* @type {any}
|
||||
* @name Queue#metadata
|
||||
*/
|
||||
|
||||
/**
|
||||
* Previous tracks
|
||||
* @type {Track[]}
|
||||
* @name Queue#previousTracks
|
||||
*/
|
||||
this.tracks = [];
|
||||
|
||||
/**
|
||||
* Previous tracks of this queue
|
||||
* Regular tracks
|
||||
* @type {Track[]}
|
||||
* @name Queue#tracks
|
||||
*/
|
||||
this.previousTracks = [];
|
||||
|
||||
/**
|
||||
* If the player of this queue is stopped
|
||||
* @type {boolean}
|
||||
* The connection
|
||||
* @type {StreamDispatcher}
|
||||
* @name Queue#connection
|
||||
*/
|
||||
this.stopped = false;
|
||||
|
||||
/**
|
||||
* If last track was skipped
|
||||
* @type {boolean}
|
||||
* The ID of this queue
|
||||
* @type {Snowflake}
|
||||
* @name Queue#id
|
||||
*/
|
||||
this.lastSkipped = false;
|
||||
|
||||
/**
|
||||
* Queue volume
|
||||
* @type {Number}
|
||||
*/
|
||||
this.volume = 100;
|
||||
Object.assign(
|
||||
this.options,
|
||||
{
|
||||
leaveOnEnd: true,
|
||||
leaveOnStop: true,
|
||||
leaveOnEmpty: true,
|
||||
leaveOnEmptyCooldown: 1000,
|
||||
autoSelfDeaf: true,
|
||||
ytdlOptions: {
|
||||
highWaterMark: 1 << 25
|
||||
},
|
||||
initialVolume: 100,
|
||||
bufferingTimeout: 3000
|
||||
} as PlayerOptions,
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* If the player of this queue is paused
|
||||
* @type {boolean}
|
||||
*/
|
||||
this.paused = Boolean(this.voiceConnection?.dispatcher?.paused);
|
||||
/**
|
||||
* Returns current track
|
||||
* @type {Track}
|
||||
*/
|
||||
get current() {
|
||||
if (this.#watchDestroyed()) return;
|
||||
return this.connection.audioResource?.metadata ?? this.tracks[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* If repeat mode is enabled in this queue
|
||||
* @type {boolean}
|
||||
*/
|
||||
this.repeatMode = false;
|
||||
/**
|
||||
* If this queue is destroyed
|
||||
* @type {boolean}
|
||||
*/
|
||||
get destroyed() {
|
||||
return this.#destroyed;
|
||||
}
|
||||
|
||||
/**
|
||||
* If loop mode is enabled in this queue
|
||||
* @type {boolean}
|
||||
*/
|
||||
this.loopMode = false;
|
||||
/**
|
||||
* Returns current track
|
||||
* @returns {Track}
|
||||
*/
|
||||
nowPlaying() {
|
||||
if (this.#watchDestroyed()) return;
|
||||
return this.current;
|
||||
}
|
||||
|
||||
/**
|
||||
* The additional calculated stream time
|
||||
* @type {Number}
|
||||
*/
|
||||
this.additionalStreamTime = 0;
|
||||
/**
|
||||
* Connects to a voice channel
|
||||
* @param {GuildChannelResolvable} channel The voice/stage channel
|
||||
* @returns {Promise<Queue>}
|
||||
*/
|
||||
async connect(channel: GuildChannelResolvable) {
|
||||
if (this.#watchDestroyed()) return;
|
||||
const _channel = this.guild.channels.resolve(channel) as StageChannel | VoiceChannel;
|
||||
if (!["GUILD_STAGE_VOICE", "GUILD_VOICE"].includes(_channel?.type))
|
||||
throw new PlayerError(`Channel type must be GUILD_VOICE or GUILD_STAGE_VOICE, got ${_channel?.type}!`, ErrorStatusCode.INVALID_ARG_TYPE);
|
||||
const connection = await this.player.voiceUtils.connect(_channel, {
|
||||
deaf: this.options.autoSelfDeaf,
|
||||
maxTime: this.player.options.connectionTimeout || 20000
|
||||
});
|
||||
this.connection = connection;
|
||||
|
||||
/**
|
||||
* The initial message object
|
||||
* @type {DiscordMessage}
|
||||
*/
|
||||
this.firstMessage = message;
|
||||
if (_channel.type === "GUILD_STAGE_VOICE") {
|
||||
await _channel.guild.me.voice.setSuppressed(false).catch(async () => {
|
||||
return await _channel.guild.me.voice.setRequestToSpeak(true).catch(Util.noop);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The audio filters in this queue
|
||||
* @type {QueueFilters}
|
||||
*/
|
||||
this.filters = {};
|
||||
this.connection.on("error", (err) => this.player.emit("connectionError", this, err));
|
||||
this.connection.on("debug", (msg) => this.player.emit("debug", this, msg));
|
||||
|
||||
Object.keys(AudioFilters).forEach((fn) => {
|
||||
this.filters[fn as keyof QueueFilters] = false;
|
||||
this.player.emit("connectionCreate", this, this.connection);
|
||||
|
||||
this.connection.on("start", (resource) => {
|
||||
this.playing = true;
|
||||
if (!this._filtersUpdate && resource?.metadata) this.player.emit("trackStart", this, resource?.metadata ?? this.current);
|
||||
this._filtersUpdate = false;
|
||||
});
|
||||
|
||||
this.connection.on("finish", async (resource) => {
|
||||
this.playing = false;
|
||||
if (this._filtersUpdate) return;
|
||||
this._streamTime = 0;
|
||||
if (resource && resource.metadata) this.previousTracks.push(resource.metadata);
|
||||
|
||||
this.player.emit("trackEnd", this, resource.metadata);
|
||||
|
||||
if (!this.tracks.length && this.repeatMode === QueueRepeatMode.OFF) {
|
||||
if (this.options.leaveOnEnd) this.destroy();
|
||||
this.player.emit("queueEnd", this);
|
||||
} else {
|
||||
if (this.repeatMode !== QueueRepeatMode.AUTOPLAY) {
|
||||
if (this.repeatMode === QueueRepeatMode.TRACK) return void this.play(Util.last(this.previousTracks), { immediate: true });
|
||||
if (this.repeatMode === QueueRepeatMode.QUEUE) this.tracks.push(Util.last(this.previousTracks));
|
||||
const nextTrack = this.tracks.shift();
|
||||
this.play(nextTrack, { immediate: true });
|
||||
return;
|
||||
} else {
|
||||
this._handleAutoplay(Util.last(this.previousTracks));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroys this queue
|
||||
* @param {boolean} [disconnect=this.options.leaveOnStop] If it should leave on destroy
|
||||
* @returns {void}
|
||||
*/
|
||||
destroy(disconnect = this.options.leaveOnStop) {
|
||||
if (this.#watchDestroyed()) return;
|
||||
if (this.connection) this.connection.end();
|
||||
if (disconnect) this.connection?.disconnect();
|
||||
this.player.queues.delete(this.guild.id);
|
||||
this.player.voiceUtils.cache.delete(this.guild.id);
|
||||
this.#destroyed = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skips current track
|
||||
* @returns {boolean}
|
||||
*/
|
||||
skip() {
|
||||
if (this.#watchDestroyed()) return;
|
||||
if (!this.connection) return false;
|
||||
this._filtersUpdate = false;
|
||||
this.connection.end();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds single track to the queue
|
||||
* @param {Track} track The track to add
|
||||
* @returns {void}
|
||||
*/
|
||||
addTrack(track: Track) {
|
||||
if (this.#watchDestroyed()) return;
|
||||
if (!(track instanceof Track)) throw new PlayerError("invalid track", ErrorStatusCode.INVALID_TRACK);
|
||||
this.tracks.push(track);
|
||||
this.player.emit("trackAdd", this, track);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds multiple tracks to the queue
|
||||
* @param {Track[]} tracks Array of tracks to add
|
||||
*/
|
||||
addTracks(tracks: Track[]) {
|
||||
if (this.#watchDestroyed()) return;
|
||||
if (!tracks.every((y) => y instanceof Track)) throw new PlayerError("invalid track", ErrorStatusCode.INVALID_TRACK);
|
||||
this.tracks.push(...tracks);
|
||||
this.player.emit("tracksAdd", this, tracks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets paused state
|
||||
* @param {boolean} paused The paused state
|
||||
* @returns {boolean}
|
||||
*/
|
||||
setPaused(paused?: boolean) {
|
||||
if (this.#watchDestroyed()) return;
|
||||
if (!this.connection) return false;
|
||||
return paused ? this.connection.pause(true) : this.connection.resume();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets bitrate
|
||||
* @param {number|auto} bitrate bitrate to set
|
||||
* @returns {void}
|
||||
*/
|
||||
setBitrate(bitrate: number | "auto") {
|
||||
if (this.#watchDestroyed()) return;
|
||||
if (!this.connection?.audioResource?.encoder) return;
|
||||
if (bitrate === "auto") bitrate = this.connection.channel?.bitrate ?? 64000;
|
||||
this.connection.audioResource.encoder.setBitrate(bitrate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets volume
|
||||
* @param {number} amount The volume amount
|
||||
* @returns {boolean}
|
||||
*/
|
||||
setVolume(amount: number) {
|
||||
if (this.#watchDestroyed()) return;
|
||||
if (!this.connection) return false;
|
||||
this.#lastVolume = amount;
|
||||
this.options.initialVolume = amount;
|
||||
return this.connection.setVolume(amount);
|
||||
}
|
||||
/**
|
||||
* Sets repeat mode
|
||||
* @param {QueueRepeatMode} mode The repeat mode
|
||||
* @returns {boolean}
|
||||
*/
|
||||
setRepeatMode(mode: QueueRepeatMode) {
|
||||
if (this.#watchDestroyed()) return;
|
||||
if (![QueueRepeatMode.OFF, QueueRepeatMode.QUEUE, QueueRepeatMode.TRACK, QueueRepeatMode.AUTOPLAY].includes(mode))
|
||||
throw new PlayerError(`Unknown repeat mode "${mode}"!`, ErrorStatusCode.UNKNOWN_REPEAT_MODE);
|
||||
if (mode === this.repeatMode) return false;
|
||||
this.repeatMode = mode;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The current volume amount
|
||||
* @type {number}
|
||||
*/
|
||||
get volume() {
|
||||
if (this.#watchDestroyed()) return;
|
||||
if (!this.connection) return 100;
|
||||
return this.connection.volume;
|
||||
}
|
||||
|
||||
set volume(amount: number) {
|
||||
this.setVolume(amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutes the playback
|
||||
* @returns {void}
|
||||
*/
|
||||
mute() {
|
||||
const lv = this.#lastVolume;
|
||||
this.volume = 0;
|
||||
this.#lastVolume = lv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unmutes the playback. If the last volume was set to 0, unmute will produce no effect.
|
||||
* @returns {void}
|
||||
*/
|
||||
unmute() {
|
||||
this.volume = this.#lastVolume;
|
||||
}
|
||||
|
||||
/**
|
||||
* The stream time of this queue
|
||||
* @type {number}
|
||||
*/
|
||||
get streamTime() {
|
||||
if (this.#watchDestroyed()) return;
|
||||
if (!this.connection) return 0;
|
||||
const playbackTime = this._streamTime + this.connection.streamTime;
|
||||
const NC = this._activeFilters.includes("nightcore") ? 1.25 : null;
|
||||
const VW = this._activeFilters.includes("vaporwave") ? 0.8 : null;
|
||||
|
||||
if (NC && VW) return playbackTime * (NC + VW);
|
||||
return NC ? playbackTime * NC : VW ? playbackTime * VW : playbackTime;
|
||||
}
|
||||
|
||||
set streamTime(time: number) {
|
||||
if (this.#watchDestroyed()) return;
|
||||
this.seek(time);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns enabled filters
|
||||
* @returns {AudioFilters}
|
||||
*/
|
||||
getFiltersEnabled() {
|
||||
if (this.#watchDestroyed()) return;
|
||||
return AudioFilters.names.filter((x) => this._activeFilters.includes(x));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns disabled filters
|
||||
* @returns {AudioFilters}
|
||||
*/
|
||||
getFiltersDisabled() {
|
||||
if (this.#watchDestroyed()) return;
|
||||
return AudioFilters.names.filter((x) => !this._activeFilters.includes(x));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets filters
|
||||
* @param {QueueFilters} filters Queue filters
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async setFilters(filters?: QueueFilters) {
|
||||
if (this.#watchDestroyed()) return;
|
||||
if (!filters || !Object.keys(filters).length) {
|
||||
// reset filters
|
||||
const streamTime = this.streamTime;
|
||||
this._activeFilters = [];
|
||||
return await this.play(this.current, {
|
||||
immediate: true,
|
||||
filtersUpdate: true,
|
||||
seek: streamTime,
|
||||
encoderArgs: []
|
||||
});
|
||||
}
|
||||
|
||||
const _filters: any[] = []; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
for (const filter in filters) {
|
||||
if (filters[filter as keyof QueueFilters] === true) _filters.push(filter);
|
||||
}
|
||||
|
||||
if (this._activeFilters.join("") === _filters.join("")) return;
|
||||
|
||||
const newFilters = AudioFilters.create(_filters).trim();
|
||||
const streamTime = this.streamTime;
|
||||
this._activeFilters = _filters;
|
||||
|
||||
return await this.play(this.current, {
|
||||
immediate: true,
|
||||
filtersUpdate: true,
|
||||
seek: streamTime,
|
||||
encoderArgs: !_filters.length ? undefined : ["-af", newFilters]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Currently playing track
|
||||
* @type {Track}
|
||||
* Seeks to the given time
|
||||
* @param {number} position The position
|
||||
* @returns {boolean}
|
||||
*/
|
||||
get playing(): Track {
|
||||
return this.tracks[0];
|
||||
async seek(position: number) {
|
||||
if (this.#watchDestroyed()) return;
|
||||
if (!this.playing || !this.current) return false;
|
||||
if (position < 1) position = 0;
|
||||
if (position >= this.current.durationMS) return this.skip();
|
||||
|
||||
await this.play(this.current, {
|
||||
immediate: true,
|
||||
filtersUpdate: true, // to stop events
|
||||
seek: position
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculated volume of this queue
|
||||
* @type {Number}
|
||||
* Plays previous track
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
get calculatedVolume(): number {
|
||||
return this.filters.normalizer ? this.volume + 70 : this.volume;
|
||||
async back() {
|
||||
if (this.#watchDestroyed()) return;
|
||||
const prev = this.previousTracks[this.previousTracks.length - 2]; // because last item is the current track
|
||||
if (!prev) throw new PlayerError("Could not find previous track", ErrorStatusCode.TRACK_NOT_FOUND);
|
||||
|
||||
return await this.play(prev, { immediate: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear this queue
|
||||
*/
|
||||
clear() {
|
||||
if (this.#watchDestroyed()) return;
|
||||
this.tracks = [];
|
||||
this.previousTracks = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the player
|
||||
* @returns {void}
|
||||
*/
|
||||
stop() {
|
||||
if (this.#watchDestroyed()) return;
|
||||
return this.destroy();
|
||||
}
|
||||
|
||||
/**
|
||||
* Shuffles this queue
|
||||
* @returns {boolean}
|
||||
*/
|
||||
shuffle() {
|
||||
if (this.#watchDestroyed()) return;
|
||||
if (!this.tracks.length || this.tracks.length < 3) return false;
|
||||
const currentTrack = this.tracks.shift();
|
||||
|
||||
for (let i = this.tracks.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[this.tracks[i], this.tracks[j]] = [this.tracks[j], this.tracks[i]];
|
||||
}
|
||||
|
||||
this.tracks.unshift(currentTrack);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a track from the queue
|
||||
* @param {Track|Snowflake|number} track The track to remove
|
||||
* @returns {Track}
|
||||
*/
|
||||
remove(track: Track | Snowflake | number) {
|
||||
if (this.#watchDestroyed()) return;
|
||||
let trackFound: Track = null;
|
||||
if (typeof track === "number") {
|
||||
trackFound = this.tracks[track];
|
||||
if (trackFound) {
|
||||
this.tracks = this.tracks.filter((t) => t.id !== trackFound.id);
|
||||
}
|
||||
} else {
|
||||
trackFound = this.tracks.find((s) => s.id === (track instanceof Track ? track.id : track));
|
||||
if (trackFound) {
|
||||
this.tracks = this.tracks.filter((s) => s.id !== trackFound.id);
|
||||
}
|
||||
}
|
||||
|
||||
return trackFound;
|
||||
}
|
||||
|
||||
/**
|
||||
* Jumps to particular track
|
||||
* @param {Track|number} track The track
|
||||
* @returns {void}
|
||||
*/
|
||||
jump(track: Track | number): void {
|
||||
if (this.#watchDestroyed()) return;
|
||||
const foundTrack = this.remove(track);
|
||||
if (!foundTrack) throw new PlayerError("Track not found", ErrorStatusCode.TRACK_NOT_FOUND);
|
||||
this.tracks.splice(1, 0, foundTrack);
|
||||
|
||||
return void this.skip();
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts the given track to specified index
|
||||
* @param {Track} track The track to insert
|
||||
* @param {number} [index=0] The index where this track should be
|
||||
*/
|
||||
insert(track: Track, index = 0) {
|
||||
if (!track || !(track instanceof Track)) throw new PlayerError("track must be the instance of Track", ErrorStatusCode.INVALID_TRACK);
|
||||
if (typeof index !== "number" || index < 0 || !Number.isFinite(index)) throw new PlayerError(`Invalid index "${index}"`, ErrorStatusCode.INVALID_ARG_TYPE);
|
||||
|
||||
this.tracks.splice(index, 0, track);
|
||||
|
||||
this.player.emit("trackAdd", this, track);
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {object} PlayerTimestamp
|
||||
* @property {string} current The current progress
|
||||
* @property {string} end The total time
|
||||
* @property {number} progress Progress in %
|
||||
*/
|
||||
|
||||
/**
|
||||
* Returns player stream timestamp
|
||||
* @returns {PlayerTimestamp}
|
||||
*/
|
||||
getPlayerTimestamp() {
|
||||
if (this.#watchDestroyed()) return;
|
||||
const currentStreamTime = this.streamTime;
|
||||
const totalTime = this.current.durationMS;
|
||||
|
||||
const currentTimecode = Util.buildTimeCode(Util.parseMS(currentStreamTime));
|
||||
const endTimecode = Util.buildTimeCode(Util.parseMS(totalTime));
|
||||
|
||||
return {
|
||||
current: currentTimecode,
|
||||
end: endTimecode,
|
||||
progress: Math.round((currentStreamTime / totalTime) * 100)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates progress bar string
|
||||
* @param {PlayerProgressbarOptions} options The progress bar options
|
||||
* @returns {string}
|
||||
*/
|
||||
createProgressBar(options: PlayerProgressbarOptions = { timecodes: true }) {
|
||||
if (this.#watchDestroyed()) return;
|
||||
const length = typeof options.length === "number" ? (options.length <= 0 || options.length === Infinity ? 15 : options.length) : 15;
|
||||
|
||||
const index = Math.round((this.streamTime / this.current.durationMS) * length);
|
||||
const indicator = typeof options.indicator === "string" && options.indicator.length > 0 ? options.indicator : "🔘";
|
||||
const line = typeof options.line === "string" && options.line.length > 0 ? options.line : "▬";
|
||||
|
||||
if (index >= 1 && index <= length) {
|
||||
const bar = line.repeat(length - 1).split("");
|
||||
bar.splice(index, 0, indicator);
|
||||
if (options.timecodes) {
|
||||
const timestamp = this.getPlayerTimestamp();
|
||||
return `${timestamp.current} ┃ ${bar.join("")} ┃ ${timestamp.end}`;
|
||||
} else {
|
||||
return `${bar.join("")}`;
|
||||
}
|
||||
} else {
|
||||
if (options.timecodes) {
|
||||
const timestamp = this.getPlayerTimestamp();
|
||||
return `${timestamp.current} ┃ ${indicator}${line.repeat(length - 1)} ┃ ${timestamp.end}`;
|
||||
} else {
|
||||
return `${indicator}${line.repeat(length - 1)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -148,70 +585,159 @@ export class Queue extends EventEmitter {
|
|||
* @type {Number}
|
||||
*/
|
||||
get totalTime(): number {
|
||||
if (this.#watchDestroyed()) return;
|
||||
return this.tracks.length > 0 ? this.tracks.map((t) => t.durationMS).reduce((p, c) => p + c) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Current stream time
|
||||
* @type {Number}
|
||||
*/
|
||||
get currentStreamTime(): number {
|
||||
const NC = this.filters.nightcore ? 1.25 : null;
|
||||
const VW = this.filters.vaporwave ? 0.8 : null;
|
||||
const streamTime = this.voiceConnection?.dispatcher?.streamTime + this.additionalStreamTime || 0;
|
||||
|
||||
if (NC && VW) return streamTime * (NC + VW);
|
||||
return NC ? streamTime * NC : VW ? streamTime * VW : streamTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets audio filters in this player
|
||||
* @param {QueueFilters} filters Audio filters to set
|
||||
* Play stream in a voice/stage channel
|
||||
* @param {Track} [src] The track to play (if empty, uses first track from the queue)
|
||||
* @param {PlayOptions} [options={}] The options
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
setFilters(filters: QueueFilters): Promise<void> {
|
||||
return this.player.setFilters(this.firstMessage, filters);
|
||||
}
|
||||
async play(src?: Track, options: PlayOptions = {}): Promise<void> {
|
||||
if (!this.destroyed) this.#watchDestroyed();
|
||||
if (!this.connection || !this.connection.voiceConnection) throw new PlayerError("Voice connection is not available, use <Queue>.connect()!", ErrorStatusCode.NO_CONNECTION);
|
||||
if (src && (this.playing || this.tracks.length) && !options.immediate) return this.addTrack(src);
|
||||
const track = options.filtersUpdate && !options.immediate ? src || this.current : src ?? this.tracks.shift();
|
||||
if (!track) return;
|
||||
|
||||
/**
|
||||
* Returns array of all enabled filters
|
||||
* @returns {String[]}
|
||||
*/
|
||||
getFiltersEnabled(): string[] {
|
||||
const filters: string[] = [];
|
||||
this.player.emit("debug", this, "Received play request");
|
||||
|
||||
for (const filter in this.filters) {
|
||||
if (this.filters[filter as keyof QueueFilters] !== false) filters.push(filter);
|
||||
if (!options.filtersUpdate) {
|
||||
this.previousTracks = this.previousTracks.filter((x) => x.id !== track.id);
|
||||
this.previousTracks.push(track);
|
||||
}
|
||||
|
||||
return filters;
|
||||
let stream;
|
||||
if (["youtube", "spotify"].includes(track.raw.source)) {
|
||||
if (track.raw.source === "spotify" && !track.raw.engine) {
|
||||
track.raw.engine = await YouTube.search(`${track.author} ${track.title}`, { type: "video" })
|
||||
.then((x) => x[0].url)
|
||||
.catch(() => null);
|
||||
}
|
||||
const link = track.raw.source === "spotify" ? track.raw.engine : track.url;
|
||||
if (!link) return void this.play(this.tracks.shift(), { immediate: true });
|
||||
|
||||
stream = ytdl(link, {
|
||||
...this.options.ytdlOptions,
|
||||
// discord-ytdl-core
|
||||
opusEncoded: false,
|
||||
fmt: "s16le",
|
||||
encoderArgs: options.encoderArgs ?? this._activeFilters.length ? ["-af", AudioFilters.create(this._activeFilters)] : [],
|
||||
seek: options.seek ? options.seek / 1000 : 0
|
||||
}).on("error", (err) => {
|
||||
return err.message.toLowerCase().includes("premature close") ? null : this.player.emit("error", this, err);
|
||||
});
|
||||
} else {
|
||||
stream = ytdl
|
||||
.arbitraryStream(
|
||||
track.raw.source === "soundcloud" ? await track.raw.engine.downloadProgressive() : typeof track.raw.engine === "function" ? await track.raw.engine() : track.raw.engine,
|
||||
{
|
||||
opusEncoded: false,
|
||||
fmt: "s16le",
|
||||
encoderArgs: options.encoderArgs ?? this._activeFilters.length ? ["-af", AudioFilters.create(this._activeFilters)] : [],
|
||||
seek: options.seek ? options.seek / 1000 : 0
|
||||
}
|
||||
)
|
||||
.on("error", (err) => {
|
||||
return err.message.toLowerCase().includes("premature close") ? null : this.player.emit("error", this, err);
|
||||
});
|
||||
}
|
||||
|
||||
const resource: AudioResource<Track> = this.connection.createStream(stream, {
|
||||
type: StreamType.Raw,
|
||||
data: track
|
||||
});
|
||||
|
||||
if (options.seek) this._streamTime = options.seek;
|
||||
this._filtersUpdate = options.filtersUpdate;
|
||||
|
||||
setTimeout(() => {
|
||||
this.connection.playStream(resource).then(() => {
|
||||
this.setVolume(this.options.initialVolume);
|
||||
});
|
||||
}, this.#getBufferingTimeout()).unref();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all disabled filters
|
||||
* @returns {String[]}
|
||||
* Private method to handle autoplay
|
||||
* @param {Track} track The source track to find its similar track for autoplay
|
||||
* @returns {Promise<void>}
|
||||
* @private
|
||||
*/
|
||||
getFiltersDisabled(): string[] {
|
||||
const enabled = this.getFiltersEnabled();
|
||||
private async _handleAutoplay(track: Track): Promise<void> {
|
||||
if (this.#watchDestroyed()) return;
|
||||
if (!track || ![track.source, track.raw?.source].includes("youtube")) {
|
||||
if (this.options.leaveOnEnd) this.destroy();
|
||||
return void this.player.emit("queueEnd", this);
|
||||
}
|
||||
const info = await YouTube.getVideo(track.url)
|
||||
.then((x) => x.videos[0])
|
||||
.catch(Util.noop);
|
||||
if (!info) {
|
||||
if (this.options.leaveOnEnd) this.destroy();
|
||||
return void this.player.emit("queueEnd", this);
|
||||
}
|
||||
|
||||
return Object.keys(this.filters).filter((f) => !enabled.includes(f));
|
||||
const nextTrack = new Track(this.player, {
|
||||
title: info.title,
|
||||
url: `https://www.youtube.com/watch?v=${info.id}`,
|
||||
duration: info.durationFormatted ? Util.buildTimeCode(Util.parseMS(info.duration * 1000)) : "0:00",
|
||||
description: "",
|
||||
thumbnail: typeof info.thumbnail === "string" ? info.thumbnail : info.thumbnail.url,
|
||||
views: info.views,
|
||||
author: info.channel.name,
|
||||
requestedBy: track.requestedBy,
|
||||
source: "youtube"
|
||||
});
|
||||
|
||||
this.play(nextTrack, { immediate: true });
|
||||
}
|
||||
|
||||
*[Symbol.iterator]() {
|
||||
if (this.#watchDestroyed()) return;
|
||||
yield* this.tracks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroys this queue
|
||||
* @returns {Boolean}
|
||||
* JSON representation of this queue
|
||||
* @returns {object}
|
||||
*/
|
||||
destroy() {
|
||||
return this.player.stop(this.firstMessage);
|
||||
toJSON() {
|
||||
if (this.#watchDestroyed()) return;
|
||||
return {
|
||||
id: this.id,
|
||||
guild: this.guild.id,
|
||||
voiceChannel: this.connection?.channel?.id,
|
||||
options: this.options,
|
||||
tracks: this.tracks.map((m) => m.toJSON())
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* String representation of this Queue
|
||||
* @returns {String}
|
||||
* String representation of this queue
|
||||
* @returns {string}
|
||||
*/
|
||||
toString(): string {
|
||||
return `<Queue ${this.guildID}>`;
|
||||
toString() {
|
||||
if (this.#watchDestroyed()) return;
|
||||
if (!this.tracks.length) return "No songs available to display!";
|
||||
return `**Upcoming Songs:**\n${this.tracks.map((m, i) => `${i + 1}. **${m.title}**`).join("\n")}`;
|
||||
}
|
||||
|
||||
#watchDestroyed() {
|
||||
if (this.#destroyed) {
|
||||
this.player.emit("error", this, new PlayerError("Cannot use destroyed queue", ErrorStatusCode.DESTROYED_QUEUE));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
#getBufferingTimeout() {
|
||||
const timeout = this.options.bufferingTimeout;
|
||||
|
||||
if (isNaN(timeout) || timeout < 0 || !Number.isFinite(timeout)) return 1000;
|
||||
return timeout;
|
||||
}
|
||||
}
|
||||
|
||||
export default Queue;
|
||||
export { Queue };
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import { Player } from '../Player';
|
||||
import { User } from 'discord.js';
|
||||
import { TrackData } from '../types/types';
|
||||
import Queue from './Queue';
|
||||
import { User, Util, SnowflakeUtil, Snowflake } from "discord.js";
|
||||
import { Player } from "../Player";
|
||||
import { RawTrackData, TrackJSON } from "../types/types";
|
||||
import { Playlist } from "./Playlist";
|
||||
import { Queue } from "./Queue";
|
||||
|
||||
export class Track {
|
||||
class Track {
|
||||
public player!: Player;
|
||||
public title!: string;
|
||||
public description!: string;
|
||||
|
@ -13,99 +14,106 @@ export class Track {
|
|||
public duration!: string;
|
||||
public views!: number;
|
||||
public requestedBy!: User;
|
||||
public fromPlaylist!: boolean;
|
||||
public raw!: TrackData;
|
||||
public playlist?: Playlist;
|
||||
public readonly raw: RawTrackData = {} as RawTrackData;
|
||||
public readonly id: Snowflake = SnowflakeUtil.generate();
|
||||
|
||||
/**
|
||||
* Track constructor
|
||||
* @param {Player} player The player that instantiated this Track
|
||||
* @param {TrackData} data Track data
|
||||
* @param {RawTrackData} data Track data
|
||||
*/
|
||||
constructor(player: Player, data: TrackData) {
|
||||
constructor(player: Player, data: RawTrackData) {
|
||||
/**
|
||||
* The player that instantiated this Track
|
||||
* @name Track#player
|
||||
* @type {Player}
|
||||
* @readonly
|
||||
*/
|
||||
Object.defineProperty(this, 'player', { value: player, enumerable: false });
|
||||
Object.defineProperty(this, "player", { value: player, enumerable: false });
|
||||
|
||||
/**
|
||||
* Title of this track
|
||||
* @name Track#title
|
||||
* @type {String}
|
||||
* @type {string}
|
||||
*/
|
||||
|
||||
/**
|
||||
* Description of this track
|
||||
* @name Track#description
|
||||
* @type {String}
|
||||
* @type {string}
|
||||
*/
|
||||
|
||||
/**
|
||||
* Author of this track
|
||||
* @name Track#author
|
||||
* @type {String}
|
||||
* @type {string}
|
||||
*/
|
||||
|
||||
/**
|
||||
* URL of this track
|
||||
* @name Track#url
|
||||
* @type {String}
|
||||
* @type {string}
|
||||
*/
|
||||
|
||||
/**
|
||||
* Thumbnail of this track
|
||||
* @name Track#thumbnail
|
||||
* @type {String}
|
||||
* @type {string}
|
||||
*/
|
||||
|
||||
/**
|
||||
* Duration of this track
|
||||
* @name Track#duration
|
||||
* @type {String}
|
||||
* @type {string}
|
||||
*/
|
||||
|
||||
/**
|
||||
* Views count of this track
|
||||
* @name Track#views
|
||||
* @type {Number}
|
||||
* @type {number}
|
||||
*/
|
||||
|
||||
/**
|
||||
* Person who requested this track
|
||||
* @name Track#requestedBy
|
||||
* @type {DiscordUser}
|
||||
* @type {User}
|
||||
*/
|
||||
|
||||
/**
|
||||
* If this track belongs to playlist
|
||||
* @name Track#fromPlaylist
|
||||
* @type {Boolean}
|
||||
* @type {boolean}
|
||||
*/
|
||||
|
||||
/**
|
||||
* Raw track data
|
||||
* @name Track#raw
|
||||
* @type {TrackData}
|
||||
* @type {RawTrackData}
|
||||
*/
|
||||
|
||||
/**
|
||||
* The track id
|
||||
* @name Track#id
|
||||
* @type {Snowflake}
|
||||
* @readonly
|
||||
*/
|
||||
|
||||
void this._patch(data);
|
||||
}
|
||||
|
||||
private _patch(data: TrackData) {
|
||||
this.title = data.title ?? '';
|
||||
this.description = data.description ?? '';
|
||||
this.author = data.author ?? '';
|
||||
this.url = data.url ?? '';
|
||||
this.thumbnail = data.thumbnail ?? '';
|
||||
this.duration = data.duration ?? '';
|
||||
private _patch(data: RawTrackData) {
|
||||
this.title = Util.escapeMarkdown(data.title ?? "");
|
||||
this.author = data.author ?? "";
|
||||
this.url = data.url ?? "";
|
||||
this.thumbnail = data.thumbnail ?? "";
|
||||
this.duration = data.duration ?? "";
|
||||
this.views = data.views ?? 0;
|
||||
this.requestedBy = data.requestedBy;
|
||||
this.fromPlaylist = Boolean(data.fromPlaylist);
|
||||
this.playlist = data.playlist;
|
||||
|
||||
// raw
|
||||
Object.defineProperty(this, 'raw', { get: () => data, enumerable: false });
|
||||
Object.defineProperty(this, "raw", { value: Object.assign({}, { source: data.raw?.source ?? data.source }, data.raw ?? data), enumerable: false });
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -118,7 +126,7 @@ export class Track {
|
|||
|
||||
/**
|
||||
* The track duration in millisecond
|
||||
* @type {Number}
|
||||
* @type {number}
|
||||
*/
|
||||
get durationMS(): number {
|
||||
const times = (n: number, t: number) => {
|
||||
|
@ -128,7 +136,7 @@ export class Track {
|
|||
};
|
||||
|
||||
return this.duration
|
||||
.split(':')
|
||||
.split(":")
|
||||
.reverse()
|
||||
.map((m, i) => parseInt(m) * times(60, i))
|
||||
.reduce((a, c) => a + c, 0);
|
||||
|
@ -139,16 +147,38 @@ export class Track {
|
|||
* @type {TrackSource}
|
||||
*/
|
||||
get source() {
|
||||
return this.raw.source ?? 'arbitrary';
|
||||
return this.raw.source ?? "arbitrary";
|
||||
}
|
||||
|
||||
/**
|
||||
* String representation of this track
|
||||
* @returns {String}
|
||||
* @returns {string}
|
||||
*/
|
||||
toString(): string {
|
||||
return `${this.title} by ${this.author}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw JSON representation of this track
|
||||
* @returns {TrackJSON}
|
||||
*/
|
||||
toJSON(hidePlaylist?: boolean) {
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.title,
|
||||
description: this.description,
|
||||
author: this.author,
|
||||
url: this.url,
|
||||
thumbnail: this.thumbnail,
|
||||
duration: this.duration,
|
||||
durationMS: this.durationMS,
|
||||
views: this.views,
|
||||
requestedBy: this.requestedBy.id,
|
||||
playlist: hidePlaylist ? null : this.playlist?.toJSON(false) ?? null
|
||||
} as TrackJSON;
|
||||
}
|
||||
}
|
||||
|
||||
export default Track;
|
||||
|
||||
export { Track };
|
||||
|
|
227
src/VoiceInterface/StreamDispatcher.ts
Normal file
227
src/VoiceInterface/StreamDispatcher.ts
Normal 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 };
|
88
src/VoiceInterface/VoiceUtils.ts
Normal file
88
src/VoiceInterface/VoiceUtils.ts
Normal file
|
@ -0,0 +1,88 @@
|
|||
import { VoiceChannel, StageChannel, Collection, Snowflake } from "discord.js";
|
||||
import { DiscordGatewayAdapterCreator, entersState, joinVoiceChannel, VoiceConnection, VoiceConnectionStatus } from "@discordjs/voice";
|
||||
import { StreamDispatcher } from "./StreamDispatcher";
|
||||
|
||||
class VoiceUtils {
|
||||
public cache: Collection<Snowflake, StreamDispatcher>;
|
||||
|
||||
/**
|
||||
* The voice utils
|
||||
* @private
|
||||
*/
|
||||
constructor() {
|
||||
/**
|
||||
* The cache where voice utils stores stream managers
|
||||
* @type {Collection<Snowflake, StreamDispatcher>}
|
||||
*/
|
||||
this.cache = new Collection<Snowflake, StreamDispatcher>();
|
||||
}
|
||||
|
||||
/**
|
||||
* Joins a voice channel, creating basic stream dispatch manager
|
||||
* @param {StageChannel|VoiceChannel} channel The voice channel
|
||||
* @param {object} [options={}] Join options
|
||||
* @returns {Promise<StreamDispatcher>}
|
||||
*/
|
||||
public async connect(
|
||||
channel: VoiceChannel | StageChannel,
|
||||
options?: {
|
||||
deaf?: boolean;
|
||||
maxTime?: number;
|
||||
}
|
||||
): Promise<StreamDispatcher> {
|
||||
const conn = await this.join(channel, options);
|
||||
const sub = new StreamDispatcher(conn, channel, options.maxTime);
|
||||
this.cache.set(channel.guild.id, sub);
|
||||
return sub;
|
||||
}
|
||||
|
||||
/**
|
||||
* Joins a voice channel
|
||||
* @param {StageChannel|VoiceChannel} [channel] The voice/stage channel to join
|
||||
* @param {object} [options={}] Join options
|
||||
* @returns {VoiceConnection}
|
||||
*/
|
||||
public async join(
|
||||
channel: VoiceChannel | StageChannel,
|
||||
options?: {
|
||||
deaf?: boolean;
|
||||
maxTime?: number;
|
||||
}
|
||||
) {
|
||||
let conn = joinVoiceChannel({
|
||||
guildId: channel.guild.id,
|
||||
channelId: channel.id,
|
||||
adapterCreator: channel.guild.voiceAdapterCreator as unknown as DiscordGatewayAdapterCreator,
|
||||
selfDeaf: Boolean(options.deaf)
|
||||
});
|
||||
|
||||
try {
|
||||
conn = await entersState(conn, VoiceConnectionStatus.Ready, options?.maxTime ?? 20000);
|
||||
return conn;
|
||||
} catch (err) {
|
||||
conn.destroy();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnects voice connection
|
||||
* @param {VoiceConnection} connection The voice connection
|
||||
* @returns {void}
|
||||
*/
|
||||
public disconnect(connection: VoiceConnection | StreamDispatcher) {
|
||||
if (connection instanceof StreamDispatcher) return connection.voiceConnection.destroy();
|
||||
return connection.destroy();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns Discord Player voice connection
|
||||
* @param {Snowflake} guild The guild id
|
||||
* @returns {StreamDispatcher}
|
||||
*/
|
||||
public getConnection(guild: Snowflake) {
|
||||
return this.cache.get(guild);
|
||||
}
|
||||
}
|
||||
|
||||
export { VoiceUtils };
|
21
src/index.ts
21
src/index.ts
|
@ -1,9 +1,12 @@
|
|||
export { AudioFilters } from './utils/AudioFilters';
|
||||
export * as Constants from './utils/Constants';
|
||||
export { ExtractorModel } from './Structures/ExtractorModel';
|
||||
export { Player } from './Player';
|
||||
export { Util } from './utils/Util';
|
||||
export { Track } from './Structures/Track';
|
||||
export { Queue } from './Structures/Queue';
|
||||
export * from './types/types';
|
||||
export { PlayerError } from './utils/PlayerError';
|
||||
export { AudioFilters } from "./utils/AudioFilters";
|
||||
export { ExtractorModel } from "./Structures/ExtractorModel";
|
||||
export { Playlist } from "./Structures/Playlist";
|
||||
export { Player } from "./Player";
|
||||
export { PlayerError, ErrorStatusCode } from "./Structures/PlayerError";
|
||||
export { QueryResolver } from "./utils/QueryResolver";
|
||||
export { Queue } from "./Structures/Queue";
|
||||
export { Track } from "./Structures/Track";
|
||||
export { Util } from "./utils/Util";
|
||||
export { VoiceUtils } from "./VoiceInterface/VoiceUtils";
|
||||
export { VoiceEvents, StreamDispatcher } from "./VoiceInterface/StreamDispatcher";
|
||||
export * from "./types/types";
|
||||
|
|
|
@ -1,45 +1,21 @@
|
|||
import { downloadOptions } from 'ytdl-core';
|
||||
import { User } from 'discord.js';
|
||||
import { Readable, Duplex } from 'stream';
|
||||
|
||||
export interface PlayerOptions {
|
||||
leaveOnEnd?: boolean;
|
||||
leaveOnEndCooldown?: number;
|
||||
leaveOnStop?: boolean;
|
||||
leaveOnEmpty?: boolean;
|
||||
leaveOnEmptyCooldown?: number;
|
||||
autoSelfDeaf?: boolean;
|
||||
enableLive?: boolean;
|
||||
ytdlDownloadOptions?: downloadOptions;
|
||||
useSafeSearch?: boolean;
|
||||
disableAutoRegister?: boolean;
|
||||
disableArtistSearch?: boolean;
|
||||
fetchBeforeQueued?: boolean;
|
||||
volume?: number;
|
||||
}
|
||||
import { Snowflake, User, UserResolvable } from "discord.js";
|
||||
import { Readable, Duplex } from "stream";
|
||||
import { Queue } from "../Structures/Queue";
|
||||
import Track from "../Structures/Track";
|
||||
import { Playlist } from "../Structures/Playlist";
|
||||
import { StreamDispatcher } from "../VoiceInterface/StreamDispatcher";
|
||||
import { downloadOptions } from "ytdl-core";
|
||||
|
||||
export type FiltersName = keyof QueueFilters;
|
||||
|
||||
export type TrackSource = 'soundcloud' | 'youtube' | 'spotify' | 'arbitrary';
|
||||
|
||||
export interface TrackData {
|
||||
title: string;
|
||||
description: string;
|
||||
author: string;
|
||||
url: string;
|
||||
thumbnail: string;
|
||||
duration: string;
|
||||
views: number;
|
||||
requestedBy: User;
|
||||
fromPlaylist: boolean;
|
||||
source?: TrackSource;
|
||||
engine?: any;
|
||||
live?: boolean;
|
||||
}
|
||||
|
||||
export type QueueFilters = {
|
||||
/**
|
||||
* @typedef {AudioFilters} QueueFilters
|
||||
*/
|
||||
export interface QueueFilters {
|
||||
bassboost_low?: boolean;
|
||||
bassboost?: boolean;
|
||||
'8D'?: boolean;
|
||||
bassboost_high?: boolean;
|
||||
"8D"?: boolean;
|
||||
vaporwave?: boolean;
|
||||
nightcore?: boolean;
|
||||
phaser?: boolean;
|
||||
|
@ -48,6 +24,7 @@ export type QueueFilters = {
|
|||
reverse?: boolean;
|
||||
treble?: boolean;
|
||||
normalizer?: boolean;
|
||||
normalizer2?: boolean;
|
||||
surrounding?: boolean;
|
||||
pulsator?: boolean;
|
||||
subboost?: boolean;
|
||||
|
@ -66,75 +43,421 @@ export type QueueFilters = {
|
|||
chorus2d?: boolean;
|
||||
chorus3d?: boolean;
|
||||
fadein?: boolean;
|
||||
};
|
||||
dim?: boolean;
|
||||
earrape?: boolean;
|
||||
}
|
||||
|
||||
export type QueryType = 'soundcloud_track' | 'soundcloud_playlist' | 'spotify_song' | 'spotify_album' | 'spotify_playlist' | 'youtube_video' | 'youtube_playlist' | 'vimeo' | 'facebook' | 'reverbnation' | 'attachment' | 'youtube_search';
|
||||
/**
|
||||
* The track source:
|
||||
* - soundcloud
|
||||
* - youtube
|
||||
* - spotify
|
||||
* - arbitrary
|
||||
* @typedef {string} TrackSource
|
||||
*/
|
||||
export type TrackSource = "soundcloud" | "youtube" | "spotify" | "arbitrary";
|
||||
|
||||
export interface ExtractorModelData {
|
||||
/**
|
||||
* @typedef {object} RawTrackData
|
||||
* @property {string} title The title
|
||||
* @property {string} description The description
|
||||
* @property {string} author The author
|
||||
* @property {string} url The url
|
||||
* @property {string} thumbnail The thumbnail
|
||||
* @property {string} duration The duration
|
||||
* @property {number} views The views
|
||||
* @property {User} requestedBy The user who requested this track
|
||||
* @property {Playlist} [playlist] The playlist
|
||||
* @property {TrackSource} [source="arbitrary"] The source
|
||||
* @property {any} [engine] The engine
|
||||
* @property {boolean} [live] If this track is live
|
||||
* @property {any} [raw] The raw data
|
||||
*/
|
||||
export interface RawTrackData {
|
||||
title: string;
|
||||
duration: number;
|
||||
thumbnail: string;
|
||||
engine: string | Readable | Duplex;
|
||||
views: number;
|
||||
author: string;
|
||||
description: string;
|
||||
author: string;
|
||||
url: string;
|
||||
version?: string;
|
||||
important?: boolean;
|
||||
source?: TrackSource;
|
||||
}
|
||||
|
||||
export interface PlayerProgressbarOptions {
|
||||
timecodes?: boolean;
|
||||
queue?: boolean;
|
||||
length?: number;
|
||||
line?: string;
|
||||
indicator?: string;
|
||||
}
|
||||
|
||||
export interface LyricsData {
|
||||
title: string;
|
||||
id: number;
|
||||
thumbnail: string;
|
||||
image: string;
|
||||
url: string;
|
||||
artist: {
|
||||
name: string;
|
||||
id: number;
|
||||
url: string;
|
||||
image: string;
|
||||
};
|
||||
lyrics?: string;
|
||||
}
|
||||
|
||||
export interface PlayerStats {
|
||||
uptime: number;
|
||||
connections: number;
|
||||
users: number;
|
||||
queues: number;
|
||||
extractors: number;
|
||||
versions: {
|
||||
ffmpeg: string;
|
||||
node: string;
|
||||
v8: string;
|
||||
};
|
||||
system: {
|
||||
arch: string;
|
||||
platform: 'aix' | 'android' | 'darwin' | 'freebsd' | 'linux' | 'openbsd' | 'sunos' | 'win32' | 'cygwin' | 'netbsd';
|
||||
cpu: number;
|
||||
memory: {
|
||||
total: string;
|
||||
usage: string;
|
||||
rss: string;
|
||||
arrayBuffers: string;
|
||||
};
|
||||
uptime: number;
|
||||
};
|
||||
duration: string;
|
||||
views: number;
|
||||
requestedBy: User;
|
||||
playlist?: Playlist;
|
||||
source?: TrackSource;
|
||||
engine?: any; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
live?: boolean;
|
||||
raw?: any; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {object} TimeData
|
||||
* @property {number} days Time in days
|
||||
* @property {number} hours Time in hours
|
||||
* @property {number} minutes Time in minutes
|
||||
* @property {number} seconds Time in seconds
|
||||
*/
|
||||
export interface TimeData {
|
||||
days: number;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {object} PlayerProgressbarOptions
|
||||
* @property {boolean} [timecodes] If it should render time codes
|
||||
* @property {boolean} [queue] If it should create progress bar for the whole queue
|
||||
* @property {number} [length] The bar length
|
||||
* @property {string} [line] The bar track
|
||||
* @property {string} [indicator] The indicator
|
||||
*/
|
||||
export interface PlayerProgressbarOptions {
|
||||
timecodes?: boolean;
|
||||
length?: number;
|
||||
line?: string;
|
||||
indicator?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {object} PlayerOptions
|
||||
* @property {boolean} [leaveOnEnd=true] If it should leave on end
|
||||
* @property {boolean} [leaveOnStop=true] If it should leave on stop
|
||||
* @property {boolean} [leaveOnEmpty=true] If it should leave on empty
|
||||
* @property {number} [leaveOnEmptyCooldown=1000] The cooldown in ms
|
||||
* @property {boolean} [autoSelfDeaf=true] If it should set the bot in deaf mode
|
||||
* @property {YTDLDownloadOptions} [ytdlOptions={}] The youtube download options
|
||||
* @property {number} [initialVolume=100] The initial player volume
|
||||
* @property {number} [bufferingTimeout=3000] Buffering timeout for the stream
|
||||
*/
|
||||
export interface PlayerOptions {
|
||||
leaveOnEnd?: boolean;
|
||||
leaveOnStop?: boolean;
|
||||
leaveOnEmpty?: boolean;
|
||||
leaveOnEmptyCooldown?: number;
|
||||
autoSelfDeaf?: boolean;
|
||||
ytdlOptions?: downloadOptions;
|
||||
initialVolume?: number;
|
||||
bufferingTimeout?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {object} ExtractorModelData
|
||||
* @property {object} [playlist] The playlist info (if any)
|
||||
* @property {string} [playlist.title] The playlist title
|
||||
* @property {string} [playlist.description] The playlist description
|
||||
* @property {string} [playlist.thumbnail] The playlist thumbnail
|
||||
* @property {album|playlist} [playlist.type] The playlist type: `album` | `playlist`
|
||||
* @property {TrackSource} [playlist.source] The playlist source
|
||||
* @property {object} [playlist.author] The playlist author
|
||||
* @property {string} [playlist.author.name] The author name
|
||||
* @property {string} [playlist.author.url] The author url
|
||||
* @property {string} [playlist.id] The playlist id
|
||||
* @property {string} [playlist.url] The playlist url
|
||||
* @property {any} [playlist.rawPlaylist] The raw data
|
||||
* @property {ExtractorData[]} data The data
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} ExtractorData
|
||||
* @property {string} title The title
|
||||
* @property {number} duration The duration
|
||||
* @property {string} thumbnail The thumbnail
|
||||
* @property {string|Readable|Duplex} engine The stream engine
|
||||
* @property {number} views The views count
|
||||
* @property {string} author The author
|
||||
* @property {string} description The description
|
||||
* @property {string} url The url
|
||||
* @property {string} [version] The extractor version
|
||||
* @property {TrackSource} [source="arbitrary"] The source
|
||||
*/
|
||||
export interface ExtractorModelData {
|
||||
playlist?: {
|
||||
title: string;
|
||||
description: string;
|
||||
thumbnail: string;
|
||||
type: "album" | "playlist";
|
||||
source: TrackSource;
|
||||
author: {
|
||||
name: string;
|
||||
url: string;
|
||||
};
|
||||
id: string;
|
||||
url: string;
|
||||
rawPlaylist?: any; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
};
|
||||
data: {
|
||||
title: string;
|
||||
duration: number;
|
||||
thumbnail: string;
|
||||
engine: string | Readable | Duplex;
|
||||
views: number;
|
||||
author: string;
|
||||
description: string;
|
||||
url: string;
|
||||
version?: string;
|
||||
source?: TrackSource;
|
||||
}[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The search query type
|
||||
* This can be one of:
|
||||
* - AUTO
|
||||
* - YOUTUBE
|
||||
* - YOUTUBE_PLAYLIST
|
||||
* - SOUNDCLOUD_TRACK
|
||||
* - SOUNDCLOUD_PLAYLIST
|
||||
* - SOUNDCLOUD
|
||||
* - SPOTIFY_SONG
|
||||
* - SPOTIFY_ALBUM
|
||||
* - SPOTIFY_PLAYLIST
|
||||
* - FACEBOOK
|
||||
* - VIMEO
|
||||
* - ARBITRARY
|
||||
* - REVERBNATION
|
||||
* - YOUTUBE_SEARCH
|
||||
* - SOUNDCLOUD_SEARCH
|
||||
* @typedef {string} QueryType
|
||||
*/
|
||||
export enum QueryType {
|
||||
AUTO = "auto",
|
||||
YOUTUBE = "youtube",
|
||||
YOUTUBE_PLAYLIST = "youtube_playlist",
|
||||
SOUNDCLOUD_TRACK = "soundcloud_track",
|
||||
SOUNDCLOUD_PLAYLIST = "soundcloud_playlist",
|
||||
SOUNDCLOUD = "soundcloud",
|
||||
SPOTIFY_SONG = "spotify_song",
|
||||
SPOTIFY_ALBUM = "spotify_album",
|
||||
SPOTIFY_PLAYLIST = "spotify_playlist",
|
||||
FACEBOOK = "facebook",
|
||||
VIMEO = "vimeo",
|
||||
ARBITRARY = "arbitrary",
|
||||
REVERBNATION = "reverbnation",
|
||||
YOUTUBE_SEARCH = "youtube_search",
|
||||
SOUNDCLOUD_SEARCH = "soundcloud_search"
|
||||
}
|
||||
|
||||
/**
|
||||
* Emitted when bot gets disconnected from a voice channel
|
||||
* @event Player#botDisconnect
|
||||
* @param {Queue} queue The queue
|
||||
*/
|
||||
|
||||
/**
|
||||
* Emitted when the voice channel is empty
|
||||
* @event Player#channelEmpty
|
||||
* @param {Queue} queue The queue
|
||||
*/
|
||||
|
||||
/**
|
||||
* Emitted when bot connects to a voice channel
|
||||
* @event Player#connectionCreate
|
||||
* @param {Queue} queue The queue
|
||||
* @param {StreamDispatcher} connection The discord player connection object
|
||||
*/
|
||||
|
||||
/**
|
||||
* Debug information
|
||||
* @event Player#debug
|
||||
* @param {Queue} queue The queue
|
||||
* @param {string} message The message
|
||||
*/
|
||||
|
||||
/**
|
||||
* Emitted on error
|
||||
* <warn>This event should handled properly otherwise it may crash your process!</warn>
|
||||
* @event Player#error
|
||||
* @param {Queue} queue The queue
|
||||
* @param {Error} error The error
|
||||
*/
|
||||
|
||||
/**
|
||||
* Emitted on connection error. Sometimes stream errors are emitted here as well.
|
||||
* @event Player#connectionError
|
||||
* @param {Queue} queue The queue
|
||||
* @param {Error} error The error
|
||||
*/
|
||||
|
||||
/**
|
||||
* Emitted when queue ends
|
||||
* @event Player#queueEnd
|
||||
* @param {Queue} queue The queue
|
||||
*/
|
||||
|
||||
/**
|
||||
* Emitted when a single track is added
|
||||
* @event Player#trackAdd
|
||||
* @param {Queue} queue The queue
|
||||
* @param {Track} track The track
|
||||
*/
|
||||
|
||||
/**
|
||||
* Emitted when multiple tracks are added
|
||||
* @event Player#tracksAdd
|
||||
* @param {Queue} queue The queue
|
||||
* @param {Track[]} tracks The tracks
|
||||
*/
|
||||
|
||||
/**
|
||||
* Emitted when a track starts playing
|
||||
* @event Player#trackStart
|
||||
* @param {Queue} queue The queue
|
||||
* @param {Track} track The track
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
export interface PlayerEvents {
|
||||
botDisconnect: (queue: Queue) => any;
|
||||
channelEmpty: (queue: Queue) => any;
|
||||
connectionCreate: (queue: Queue, connection: StreamDispatcher) => any;
|
||||
debug: (queue: Queue, message: string) => any;
|
||||
error: (queue: Queue, error: Error) => any;
|
||||
connectionError: (queue: Queue, error: Error) => any;
|
||||
queueEnd: (queue: Queue) => any;
|
||||
trackAdd: (queue: Queue, track: Track) => any;
|
||||
tracksAdd: (queue: Queue, track: Track[]) => any;
|
||||
trackStart: (queue: Queue, track: Track) => any;
|
||||
trackEnd: (queue: Queue, track: Track) => any;
|
||||
}
|
||||
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
/**
|
||||
* @typedef {object} PlayOptions
|
||||
* @property {boolean} [filtersUpdate=false] If this play was triggered for filters update
|
||||
* @property {string[]} [encoderArgs=[]] FFmpeg args passed to encoder
|
||||
* @property {number} [seek] Time to seek to before playing
|
||||
* @property {boolean} [immediate=false] If it should start playing the provided track immediately
|
||||
*/
|
||||
export interface PlayOptions {
|
||||
filtersUpdate?: boolean;
|
||||
encoderArgs?: string[];
|
||||
seek?: number;
|
||||
immediate?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {object} SearchOptions
|
||||
* @property {UserResolvable} requestedBy The user who requested this search
|
||||
* @property {QueryType} [searchEngine=QueryType.AUTO] The query search engine
|
||||
* @property {boolean} [blockExtractor=false] If it should block custom extractors
|
||||
*/
|
||||
export interface SearchOptions {
|
||||
requestedBy: UserResolvable;
|
||||
searchEngine?: QueryType;
|
||||
blockExtractor?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The queue repeat mode. This can be one of:
|
||||
* - OFF
|
||||
* - TRACK
|
||||
* - QUEUE
|
||||
* - AUTOPLAY
|
||||
* @typedef {number} QueueRepeatMode
|
||||
*/
|
||||
export enum QueueRepeatMode {
|
||||
OFF = 0,
|
||||
TRACK = 1,
|
||||
QUEUE = 2,
|
||||
AUTOPLAY = 3
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {object} PlaylistInitData
|
||||
* @property {Track[]} tracks The tracks of this playlist
|
||||
* @property {string} title The playlist title
|
||||
* @property {string} description The description
|
||||
* @property {string} thumbnail The thumbnail
|
||||
* @property {album|playlist} type The playlist type: `album` | `playlist`
|
||||
* @property {TrackSource} source The playlist source
|
||||
* @property {object} author The playlist author
|
||||
* @property {string} [author.name] The author name
|
||||
* @property {string} [author.url] The author url
|
||||
* @property {string} id The playlist id
|
||||
* @property {string} url The playlist url
|
||||
* @property {any} [rawPlaylist] The raw playlist data
|
||||
*/
|
||||
export interface PlaylistInitData {
|
||||
tracks: Track[];
|
||||
title: string;
|
||||
description: string;
|
||||
thumbnail: string;
|
||||
type: "album" | "playlist";
|
||||
source: TrackSource;
|
||||
author: {
|
||||
name: string;
|
||||
url: string;
|
||||
};
|
||||
id: string;
|
||||
url: string;
|
||||
rawPlaylist?: any; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {object} TrackJSON
|
||||
* @property {string} title The track title
|
||||
* @property {string} description The track description
|
||||
* @property {string} author The author
|
||||
* @property {string} url The url
|
||||
* @property {string} thumbnail The thumbnail
|
||||
* @property {string} duration The duration
|
||||
* @property {number} durationMS The duration in ms
|
||||
* @property {number} views The views count
|
||||
* @property {Snowflake} requestedBy The id of the user who requested this track
|
||||
* @property {PlaylistJSON} [playlist] The playlist info (if any)
|
||||
*/
|
||||
export interface TrackJSON {
|
||||
id: Snowflake;
|
||||
title: string;
|
||||
description: string;
|
||||
author: string;
|
||||
url: string;
|
||||
thumbnail: string;
|
||||
duration: string;
|
||||
durationMS: number;
|
||||
views: number;
|
||||
requestedBy: Snowflake;
|
||||
playlist?: PlaylistJSON;
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {object} PlaylistJSON
|
||||
* @property {string} id The playlist id
|
||||
* @property {string} url The playlist url
|
||||
* @property {string} title The playlist title
|
||||
* @property {string} description The playlist description
|
||||
* @property {string} thumbnail The thumbnail
|
||||
* @property {album|playlist} type The playlist type: `album` | `playlist`
|
||||
* @property {TrackSource} source The track source
|
||||
* @property {object} author The playlist author
|
||||
* @property {string} [author.name] The author name
|
||||
* @property {string} [author.url] The author url
|
||||
* @property {TrackJSON[]} tracks The tracks data (if any)
|
||||
*/
|
||||
export interface PlaylistJSON {
|
||||
id: string;
|
||||
url: string;
|
||||
title: string;
|
||||
description: string;
|
||||
thumbnail: string;
|
||||
type: "album" | "playlist";
|
||||
source: TrackSource;
|
||||
author: {
|
||||
name: string;
|
||||
url: string;
|
||||
};
|
||||
tracks: TrackJSON[];
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {object} PlayerInitOptions
|
||||
* @property {boolean} [autoRegisterExtractor=true] If it should automatically register `@discord-player/extractor`
|
||||
* @property {YTDLDownloadOptions} [ytdlOptions={}] The options passed to `ytdl-core`
|
||||
* @property {number} [connectionTimeout=20000] The voice connection timeout
|
||||
*/
|
||||
export interface PlayerInitOptions {
|
||||
autoRegisterExtractor?: boolean;
|
||||
ytdlOptions?: downloadOptions;
|
||||
connectionTimeout?: number;
|
||||
}
|
||||
|
|
|
@ -1,96 +1,108 @@
|
|||
import { FiltersName } from '../types/types';
|
||||
import { FiltersName } from "../types/types";
|
||||
|
||||
const bass = (g: number) => `bass=g=${g}:f=110:w=0.3`;
|
||||
|
||||
/**
|
||||
* The available audio filters
|
||||
* @typedef {Object} AudioFilters
|
||||
* @property {String} bassboost The bassboost filter
|
||||
* @property {String} 8D The 8D filter
|
||||
* @property {String} vaporwave The vaporwave filter
|
||||
* @property {String} nightcore The nightcore filter
|
||||
* @property {String} phaser The phaser filter
|
||||
* @property {String} tremolo The tremolo filter
|
||||
* @property {String} vibrato The vibrato filter
|
||||
* @property {String} reverse The reverse filter
|
||||
* @property {String} treble The treble filter
|
||||
* @property {String} normalizer The normalizer filter
|
||||
* @property {String} surrounding The surrounding filter
|
||||
* @property {String} pulsator The pulsator filter
|
||||
* @property {String} subboost The subboost filter
|
||||
* @property {String} kakaoke The kakaoke filter
|
||||
* @property {String} flanger The flanger filter
|
||||
* @property {String} gate The gate filter
|
||||
* @property {String} haas The haas filter
|
||||
* @property {String} mcompand The mcompand filter
|
||||
* @property {String} mono The mono filter
|
||||
* @property {String} mstlr The mstlr filter
|
||||
* @property {String} mstrr The mstrr filter
|
||||
* @property {String} compressor The compressor filter
|
||||
* @property {String} expander The expander filter
|
||||
* @property {String} softlimiter The softlimiter filter
|
||||
* @property {String} chorus The chorus filter
|
||||
* @property {String} chorus2d The chorus2d filter
|
||||
* @property {String} chorus3d The chorus3d filter
|
||||
* @property {String} fadein The fadein filter
|
||||
* @typedef {object} AudioFilters
|
||||
* @property {string} bassboost_low The bassboost filter (+15dB)
|
||||
* @property {string} bassboost The bassboost filter (+20dB)
|
||||
* @property {string} bassboost_high The bassboost filter (+30dB)
|
||||
* @property {string} 8D The 8D filter
|
||||
* @property {string} vaporwave The vaporwave filter
|
||||
* @property {string} nightcore The nightcore filter
|
||||
* @property {string} phaser The phaser filter
|
||||
* @property {string} tremolo The tremolo filter
|
||||
* @property {string} vibrato The vibrato filter
|
||||
* @property {string} reverse The reverse filter
|
||||
* @property {string} treble The treble filter
|
||||
* @property {string} normalizer The normalizer filter (dynamic audio normalizer based)
|
||||
* @property {string} normalizer2 The normalizer filter (audio compressor based)
|
||||
* @property {string} surrounding The surrounding filter
|
||||
* @property {string} pulsator The pulsator filter
|
||||
* @property {string} subboost The subboost filter
|
||||
* @property {string} kakaoke The kakaoke filter
|
||||
* @property {string} flanger The flanger filter
|
||||
* @property {string} gate The gate filter
|
||||
* @property {string} haas The haas filter
|
||||
* @property {string} mcompand The mcompand filter
|
||||
* @property {string} mono The mono filter
|
||||
* @property {string} mstlr The mstlr filter
|
||||
* @property {string} mstrr The mstrr filter
|
||||
* @property {string} compressor The compressor filter
|
||||
* @property {string} expander The expander filter
|
||||
* @property {string} softlimiter The softlimiter filter
|
||||
* @property {string} chorus The chorus filter
|
||||
* @property {string} chorus2d The chorus2d filter
|
||||
* @property {string} chorus3d The chorus3d filter
|
||||
* @property {string} fadein The fadein filter
|
||||
* @property {string} dim The dim filter
|
||||
* @property {string} earrape The earrape filter
|
||||
*/
|
||||
|
||||
const FilterList = {
|
||||
bassboost: 'bass=g=20',
|
||||
'8D': 'apulsator=hz=0.09',
|
||||
vaporwave: 'aresample=48000,asetrate=48000*0.8',
|
||||
nightcore: 'aresample=48000,asetrate=48000*1.25',
|
||||
phaser: 'aphaser=in_gain=0.4',
|
||||
tremolo: 'tremolo',
|
||||
vibrato: 'vibrato=f=6.5',
|
||||
reverse: 'areverse',
|
||||
treble: 'treble=g=5',
|
||||
normalizer: 'dynaudnorm=g=101',
|
||||
surrounding: 'surround',
|
||||
pulsator: 'apulsator=hz=1',
|
||||
subboost: 'asubboost',
|
||||
karaoke: 'stereotools=mlev=0.03',
|
||||
flanger: 'flanger',
|
||||
gate: 'agate',
|
||||
haas: 'haas',
|
||||
mcompand: 'mcompand',
|
||||
mono: 'pan=mono|c0=.5*c0+.5*c1',
|
||||
mstlr: 'stereotools=mode=ms>lr',
|
||||
mstrr: 'stereotools=mode=ms>rr',
|
||||
compressor: 'compand=points=-80/-105|-62/-80|-15.4/-15.4|0/-12|20/-7.6',
|
||||
expander: 'compand=attacks=0:points=-80/-169|-54/-80|-49.5/-64.6|-41.1/-41.1|-25.8/-15|-10.8/-4.5|0/0|20/8.3',
|
||||
softlimiter: 'compand=attacks=0:points=-80/-80|-12.4/-12.4|-6/-8|0/-6.8|20/-2.8',
|
||||
chorus: 'chorus=0.7:0.9:55:0.4:0.25:2',
|
||||
chorus2d: 'chorus=0.6:0.9:50|60:0.4|0.32:0.25|0.4:2|1.3',
|
||||
chorus3d: 'chorus=0.5:0.9:50|60|40:0.4|0.32|0.3:0.25|0.4|0.3:2|2.3|1.3',
|
||||
fadein: 'afade=t=in:ss=0:d=10',
|
||||
bassboost_low: bass(15),
|
||||
bassboost: bass(20),
|
||||
bassboost_high: bass(30),
|
||||
"8D": "apulsator=hz=0.09",
|
||||
vaporwave: "aresample=48000,asetrate=48000*0.8",
|
||||
nightcore: "aresample=48000,asetrate=48000*1.25",
|
||||
phaser: "aphaser=in_gain=0.4",
|
||||
tremolo: "tremolo",
|
||||
vibrato: "vibrato=f=6.5",
|
||||
reverse: "areverse",
|
||||
treble: "treble=g=5",
|
||||
normalizer: "dynaudnorm=g=101",
|
||||
normalizer2: "acompressor",
|
||||
surrounding: "surround",
|
||||
pulsator: "apulsator=hz=1",
|
||||
subboost: "asubboost",
|
||||
karaoke: "stereotools=mlev=0.03",
|
||||
flanger: "flanger",
|
||||
gate: "agate",
|
||||
haas: "haas",
|
||||
mcompand: "mcompand",
|
||||
mono: "pan=mono|c0=.5*c0+.5*c1",
|
||||
mstlr: "stereotools=mode=ms>lr",
|
||||
mstrr: "stereotools=mode=ms>rr",
|
||||
compressor: "compand=points=-80/-105|-62/-80|-15.4/-15.4|0/-12|20/-7.6",
|
||||
expander: "compand=attacks=0:points=-80/-169|-54/-80|-49.5/-64.6|-41.1/-41.1|-25.8/-15|-10.8/-4.5|0/0|20/8.3",
|
||||
softlimiter: "compand=attacks=0:points=-80/-80|-12.4/-12.4|-6/-8|0/-6.8|20/-2.8",
|
||||
chorus: "chorus=0.7:0.9:55:0.4:0.25:2",
|
||||
chorus2d: "chorus=0.6:0.9:50|60:0.4|0.32:0.25|0.4:2|1.3",
|
||||
chorus3d: "chorus=0.5:0.9:50|60|40:0.4|0.32|0.3:0.25|0.4|0.3:2|2.3|1.3",
|
||||
fadein: "afade=t=in:ss=0:d=10",
|
||||
dim: `afftfilt="'real=re * (1-clip((b/nb)*b,0,1))':imag='im * (1-clip((b/nb)*b,0,1))'"`,
|
||||
earrape: "channelsplit,sidechaingate=level_in=64",
|
||||
|
||||
*[Symbol.iterator](): IterableIterator<{ name: FiltersName; value: string }> {
|
||||
for (const [k, v] of Object.entries(this)) {
|
||||
if (typeof this[k as FiltersName] === 'string') yield { name: k as FiltersName, value: v as string };
|
||||
if (typeof this[k as FiltersName] === "string") yield { name: k as FiltersName, value: v as string };
|
||||
}
|
||||
},
|
||||
|
||||
get names() {
|
||||
return Object.keys(this).filter((p) => !['names', 'length'].includes(p) && typeof this[p as FiltersName] !== 'function');
|
||||
return Object.keys(this).filter((p) => !["names", "length"].includes(p) && typeof this[p as FiltersName] !== "function") as FiltersName[];
|
||||
},
|
||||
|
||||
get length() {
|
||||
return Object.keys(this).filter((p) => !['names', 'length'].includes(p) && typeof this[p as FiltersName] !== 'function').length;
|
||||
return Object.keys(this).filter((p) => !["names", "length"].includes(p) && typeof this[p as FiltersName] !== "function").length;
|
||||
},
|
||||
|
||||
toString() {
|
||||
return `${Object.values(this).join(',')}`;
|
||||
return this.names.map((m) => (this as any)[m]).join(","); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
},
|
||||
|
||||
create(filter?: FiltersName[]): string {
|
||||
if (!filter || !Array.isArray(filter)) return this.toString();
|
||||
return filter
|
||||
.filter((predicate) => typeof predicate === 'string')
|
||||
.filter((predicate) => typeof predicate === "string")
|
||||
.map((m) => this[m])
|
||||
.join(',');
|
||||
.join(",");
|
||||
},
|
||||
|
||||
define(filterName: string, value: string): void {
|
||||
if (typeof this[filterName as FiltersName] && typeof this[filterName as FiltersName] === 'function') return;
|
||||
if (typeof this[filterName as FiltersName] && typeof this[filterName as FiltersName] === "function") return;
|
||||
|
||||
this[filterName as FiltersName] = value;
|
||||
},
|
||||
|
|
|
@ -1,41 +0,0 @@
|
|||
import { PlayerOptions as DP_OPTIONS } from '../types/types';
|
||||
|
||||
export enum PlayerEvents {
|
||||
BOT_DISCONNECT = 'botDisconnect',
|
||||
CHANNEL_EMPTY = 'channelEmpty',
|
||||
CONNECTION_CREATE = 'connectionCreate',
|
||||
ERROR = 'error',
|
||||
MUSIC_STOP = 'musicStop',
|
||||
NO_RESULTS = 'noResults',
|
||||
PLAYLIST_ADD = 'playlistAdd',
|
||||
PLAYLIST_PARSE_END = 'playlistParseEnd',
|
||||
PLAYLIST_PARSE_START = 'playlistParseStart',
|
||||
QUEUE_CREATE = 'queueCreate',
|
||||
QUEUE_END = 'queueEnd',
|
||||
SEARCH_CANCEL = 'searchCancel',
|
||||
SEARCH_INVALID_RESPONSE = 'searchInvalidResponse',
|
||||
SEARCH_RESULTS = 'searchResults',
|
||||
TRACK_ADD = 'trackAdd',
|
||||
TRACK_START = 'trackStart'
|
||||
}
|
||||
|
||||
export enum PlayerErrorEventCodes {
|
||||
LIVE_VIDEO = 'LiveVideo',
|
||||
NOT_CONNECTED = 'NotConnected',
|
||||
UNABLE_TO_JOIN = 'UnableToJoin',
|
||||
NOT_PLAYING = 'NotPlaying',
|
||||
PARSE_ERROR = 'ParseError',
|
||||
VIDEO_UNAVAILABLE = 'VideoUnavailable',
|
||||
MUSIC_STARTING = 'MusicStarting'
|
||||
}
|
||||
|
||||
export const PlayerOptions: DP_OPTIONS = {
|
||||
leaveOnEnd: true,
|
||||
leaveOnStop: true,
|
||||
leaveOnEmpty: true,
|
||||
leaveOnEmptyCooldown: 0,
|
||||
autoSelfDeaf: true,
|
||||
enableLive: false,
|
||||
ytdlDownloadOptions: {},
|
||||
volume: 100
|
||||
};
|
|
@ -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 };
|
61
src/utils/QueryResolver.ts
Normal file
61
src/utils/QueryResolver.ts
Normal file
|
@ -0,0 +1,61 @@
|
|||
import { validateID, validateURL } from "ytdl-core";
|
||||
import { YouTube } from "youtube-sr";
|
||||
import { QueryType } from "../types/types";
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
import { validateURL as SoundcloudValidateURL } from "soundcloud-scraper";
|
||||
|
||||
// scary things below *sigh*
|
||||
const spotifySongRegex = /https?:\/\/(?:embed\.|open\.)(?:spotify\.com\/)(?:track\/|\?uri=spotify:track:)((\w|-){22})/;
|
||||
const spotifyPlaylistRegex = /https?:\/\/(?:embed\.|open\.)(?:spotify\.com\/)(?:playlist\/|\?uri=spotify:playlist:)((\w|-){22})/;
|
||||
const spotifyAlbumRegex = /https?:\/\/(?:embed\.|open\.)(?:spotify\.com\/)(?:album\/|\?uri=spotify:album:)((\w|-){22})/;
|
||||
const vimeoRegex = /(http|https)?:\/\/(www\.|player\.)?vimeo\.com\/(?:channels\/(?:\w+\/)?|groups\/([^/]*)\/videos\/|video\/|)(\d+)(?:|\/\?)/;
|
||||
const facebookRegex = /(https?:\/\/)(www\.|m\.)?(facebook|fb).com\/.*\/videos\/.*/;
|
||||
const reverbnationRegex = /https:\/\/(www.)?reverbnation.com\/(.+)\/song\/(.+)/;
|
||||
const attachmentRegex =
|
||||
/^(?:(?:https?|ftp):\/\/)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,})))(?::\d{2,5})?(?:\/\S*)?$/;
|
||||
// scary things above *sigh*
|
||||
|
||||
class QueryResolver {
|
||||
/**
|
||||
* Query resolver
|
||||
*/
|
||||
private constructor() {} // eslint-disable-line @typescript-eslint/no-empty-function
|
||||
|
||||
/**
|
||||
* Resolves the given search query
|
||||
* @param {string} query The query
|
||||
* @returns {QueryType}
|
||||
*/
|
||||
static resolve(query: string): QueryType {
|
||||
if (SoundcloudValidateURL(query, "track")) return QueryType.SOUNDCLOUD_TRACK;
|
||||
if (SoundcloudValidateURL(query, "playlist") || query.includes("/sets/")) return QueryType.SOUNDCLOUD_PLAYLIST;
|
||||
if (YouTube.isPlaylist(query)) return QueryType.YOUTUBE_PLAYLIST;
|
||||
if (validateID(query) || validateURL(query)) return QueryType.YOUTUBE_SEARCH;
|
||||
if (spotifySongRegex.test(query)) return QueryType.SPOTIFY_SONG;
|
||||
if (spotifyPlaylistRegex.test(query)) return QueryType.SPOTIFY_PLAYLIST;
|
||||
if (spotifyAlbumRegex.test(query)) return QueryType.SPOTIFY_ALBUM;
|
||||
if (vimeoRegex.test(query)) return QueryType.VIMEO;
|
||||
if (facebookRegex.test(query)) return QueryType.FACEBOOK;
|
||||
if (reverbnationRegex.test(query)) return QueryType.REVERBNATION;
|
||||
if (attachmentRegex.test(query)) return QueryType.ARBITRARY;
|
||||
|
||||
return QueryType.YOUTUBE_SEARCH;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses vimeo id from url
|
||||
* @param {string} query The query
|
||||
* @returns {string}
|
||||
*/
|
||||
static getVimeoID(query: string): string {
|
||||
return QueryResolver.resolve(query) === QueryType.VIMEO
|
||||
? query
|
||||
.split("/")
|
||||
.filter((x) => !!x)
|
||||
.pop()
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
export { QueryResolver };
|
|
@ -1,200 +1,83 @@
|
|||
import { QueryType, TimeData } from '../types/types';
|
||||
import { FFmpeg } from 'prism-media';
|
||||
import YouTube from 'youtube-sr';
|
||||
import { Track } from '../Structures/Track';
|
||||
// @ts-ignore
|
||||
import { validateURL as SoundcloudValidateURL } from 'soundcloud-scraper';
|
||||
import { VoiceChannel } from 'discord.js';
|
||||
import { StageChannel, VoiceChannel } from "discord.js";
|
||||
import { TimeData } from "../types/types";
|
||||
|
||||
const spotifySongRegex = /https?:\/\/(?:embed\.|open\.)(?:spotify\.com\/)(?:track\/|\?uri=spotify:track:)((\w|-){22})/;
|
||||
const spotifyPlaylistRegex = /https?:\/\/(?:embed\.|open\.)(?:spotify\.com\/)(?:playlist\/|\?uri=spotify:playlist:)((\w|-){22})/;
|
||||
const spotifyAlbumRegex = /https?:\/\/(?:embed\.|open\.)(?:spotify\.com\/)(?:album\/|\?uri=spotify:album:)((\w|-){22})/;
|
||||
const vimeoRegex = /(http|https)?:\/\/(www\.|player\.)?vimeo\.com\/(?:channels\/(?:\w+\/)?|groups\/([^/]*)\/videos\/|video\/|)(\d+)(?:|\/\?)/;
|
||||
const facebookRegex = /(https?:\/\/)(www\.|m\.)?(facebook|fb).com\/.*\/videos\/.*/;
|
||||
const reverbnationRegex = /https:\/\/(www.)?reverbnation.com\/(.+)\/song\/(.+)/;
|
||||
const attachmentRegex = /^(?:(?:https?|ftp):\/\/)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,})))(?::\d{2,5})?(?:\/\S*)?$/;
|
||||
|
||||
export class Util {
|
||||
class Util {
|
||||
/**
|
||||
* Static Player Util class
|
||||
* Utils
|
||||
*/
|
||||
constructor() {
|
||||
throw new Error(`The ${this.constructor.name} class is static and cannot be instantiated!`);
|
||||
}
|
||||
private constructor() {} // eslint-disable-line @typescript-eslint/no-empty-function
|
||||
|
||||
/**
|
||||
* Checks FFmpeg Version
|
||||
* @param {Boolean} [force] If it should forcefully get the version
|
||||
* @returns {String}
|
||||
* Creates duration string
|
||||
* @param {object} durObj The duration object
|
||||
* @returns {string}
|
||||
*/
|
||||
static getFFmpegVersion(force?: boolean): string {
|
||||
try {
|
||||
const info = FFmpeg.getInfo(Boolean(force));
|
||||
|
||||
return info.version;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks FFmpeg
|
||||
* @param {Boolean} [force] If it should forcefully get the version
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
static checkFFmpeg(force?: boolean): boolean {
|
||||
const version = Util.getFFmpegVersion(force);
|
||||
return version === null ? false : true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alerts if FFmpeg is not available
|
||||
*/
|
||||
static alertFFmpeg(): void {
|
||||
const hasFFmpeg = Util.checkFFmpeg();
|
||||
|
||||
if (!hasFFmpeg) console.warn('[Discord Player] FFmpeg/Avconv not found! Install via "npm install ffmpeg-static" or download from https://ffmpeg.org/download.html');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves query type
|
||||
* @param {String} query The query
|
||||
* @returns {QueryType}
|
||||
*/
|
||||
static getQueryType(query: string): QueryType {
|
||||
if (SoundcloudValidateURL(query, 'track')) return 'soundcloud_track';
|
||||
if (SoundcloudValidateURL(query, 'playlist') || query.includes('/sets/')) return 'soundcloud_playlist';
|
||||
if (spotifySongRegex.test(query)) return 'spotify_song';
|
||||
if (spotifyAlbumRegex.test(query)) return 'spotify_album';
|
||||
if (spotifyPlaylistRegex.test(query)) return 'spotify_playlist';
|
||||
if (YouTube.validate(query, 'PLAYLIST')) return 'youtube_playlist';
|
||||
if (YouTube.validate(query, 'VIDEO')) return 'youtube_video';
|
||||
if (vimeoRegex.test(query)) return 'vimeo';
|
||||
if (facebookRegex.test(query)) return 'facebook';
|
||||
if (reverbnationRegex.test(query)) return 'reverbnation';
|
||||
if (Util.isURL(query)) return 'attachment';
|
||||
|
||||
return 'youtube_search';
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given string is url
|
||||
* @param {String} str URL to check
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
static isURL(str: string): boolean {
|
||||
return str.length < 2083 && attachmentRegex.test(str);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns Vimeo ID
|
||||
* @param {String} query Vimeo link
|
||||
* @returns {String}
|
||||
*/
|
||||
static getVimeoID(query: string): string {
|
||||
return Util.getQueryType(query) === 'vimeo'
|
||||
? query
|
||||
.split('/')
|
||||
.filter((x) => !!x)
|
||||
.pop()
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses ms time
|
||||
* @param {Number} milliseconds Time to parse
|
||||
* @returns {TimeData}
|
||||
*/
|
||||
static parseMS(milliseconds: number): TimeData {
|
||||
const roundTowardsZero = milliseconds > 0 ? Math.floor : Math.ceil;
|
||||
|
||||
return {
|
||||
days: roundTowardsZero(milliseconds / 86400000),
|
||||
hours: roundTowardsZero(milliseconds / 3600000) % 24,
|
||||
minutes: roundTowardsZero(milliseconds / 60000) % 60,
|
||||
seconds: roundTowardsZero(milliseconds / 1000) % 60
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates simple duration string
|
||||
* @param {object} durObj Duration object
|
||||
* @returns {String}
|
||||
*/
|
||||
static durationString(durObj: object): string {
|
||||
static durationString(durObj: Record<string, number>) {
|
||||
return Object.values(durObj)
|
||||
.map((m) => (isNaN(m) ? 0 : m))
|
||||
.join(':');
|
||||
.join(":");
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes youtube searches
|
||||
* @param {String} query The query
|
||||
* @param {any} options Options
|
||||
* @returns {Promise<Track[]>}
|
||||
* Parses milliseconds to consumable time object
|
||||
* @param {number} milliseconds The time in ms
|
||||
* @returns {TimeData}
|
||||
*/
|
||||
static ytSearch(query: string, options?: any): Promise<Track[]> {
|
||||
return new Promise(async (resolve) => {
|
||||
await YouTube.search(query, {
|
||||
type: 'video',
|
||||
safeSearch: Boolean(options?.player.options.useSafeSearch),
|
||||
limit: options.limit ?? 10
|
||||
})
|
||||
.then((results) => {
|
||||
resolve(
|
||||
results.map(
|
||||
(r) =>
|
||||
new Track(options?.player, {
|
||||
title: r.title,
|
||||
description: r.description,
|
||||
author: r.channel.name,
|
||||
url: r.url,
|
||||
thumbnail: r.thumbnail.displayThumbnailURL(),
|
||||
duration: Util.buildTimeCode(Util.parseMS(r.duration)),
|
||||
views: r.views,
|
||||
requestedBy: options?.user,
|
||||
fromPlaylist: Boolean(options?.pl),
|
||||
source: 'youtube'
|
||||
})
|
||||
)
|
||||
);
|
||||
})
|
||||
.catch(() => resolve([]));
|
||||
});
|
||||
}
|
||||
static parseMS(milliseconds: number) {
|
||||
const round = milliseconds > 0 ? Math.floor : Math.ceil;
|
||||
|
||||
/**
|
||||
* Checks if the given voice channel is empty
|
||||
* @param {DiscordVoiceChannel} channel The voice channel
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
static isVoiceEmpty(channel: VoiceChannel): boolean {
|
||||
return channel.members.filter((member) => !member.user.bot).size === 0;
|
||||
return {
|
||||
days: round(milliseconds / 86400000),
|
||||
hours: round(milliseconds / 3600000) % 24,
|
||||
minutes: round(milliseconds / 60000) % 60,
|
||||
seconds: round(milliseconds / 1000) % 60
|
||||
} as TimeData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds time code
|
||||
* @param {object} data The data to build time code from
|
||||
* @returns {String}
|
||||
* @param {TimeData} duration The duration object
|
||||
* @returns {string}
|
||||
*/
|
||||
static buildTimeCode(data: any): string {
|
||||
const items = Object.keys(data);
|
||||
const required = ['days', 'hours', 'minutes', 'seconds'];
|
||||
static buildTimeCode(duration: TimeData) {
|
||||
const items = Object.keys(duration);
|
||||
const required = ["days", "hours", "minutes", "seconds"];
|
||||
|
||||
const parsed = items.filter((x) => required.includes(x)).map((m) => (data[m] > 0 ? data[m] : ''));
|
||||
const parsed = items.filter((x) => required.includes(x)).map((m) => duration[m as keyof TimeData]);
|
||||
const final = parsed
|
||||
.filter((x) => !!x)
|
||||
.map((x) => x.toString().padStart(2, '0'))
|
||||
.join(':');
|
||||
return final.length <= 3 ? `0:${final.padStart(2, '0') || 0}` : final;
|
||||
.slice(parsed.findIndex((x) => x !== 0))
|
||||
.map((x) => x.toString().padStart(2, "0"))
|
||||
.join(":");
|
||||
|
||||
return final.length <= 3 ? `0:${final.padStart(2, "0") || 0}` : final;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manage CJS require
|
||||
* @param {String} id id to require
|
||||
* Picks last item of the given array
|
||||
* @param {any[]} arr The array
|
||||
* @returns {any}
|
||||
*/
|
||||
static require(id: string): any {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
static last<T = any>(arr: T[]): T {
|
||||
if (!Array.isArray(arr)) return;
|
||||
return arr[arr.length - 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the voice channel is empty
|
||||
* @param {VoiceChannel|StageChannel} channel The voice channel
|
||||
* @returns {boolean}
|
||||
*/
|
||||
static isVoiceEmpty(channel: VoiceChannel | StageChannel) {
|
||||
return channel.members.filter((member) => !member.user.bot).size === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safer require
|
||||
* @param {string} id Node require id
|
||||
* @returns {any}
|
||||
*/
|
||||
static require(id: string) {
|
||||
try {
|
||||
return require(id);
|
||||
} catch {
|
||||
|
@ -203,20 +86,15 @@ export class Util {
|
|||
}
|
||||
|
||||
/**
|
||||
* Defines a property in the given object
|
||||
* @param {any} target The target
|
||||
* @param {any} prop The property to define
|
||||
* @param {any} value The value
|
||||
* @returns {void}
|
||||
* Asynchronous timeout
|
||||
* @param {number} time The time in ms to wait
|
||||
* @returns {Promise<unknown>}
|
||||
*/
|
||||
static define(ops: { target: any; prop: any; value: any; enumerate?: boolean }) {
|
||||
Object.defineProperty(ops.target, ops.prop, {
|
||||
value: ops.value,
|
||||
writable: true,
|
||||
enumerable: Boolean(ops.enumerate),
|
||||
configurable: true
|
||||
});
|
||||
static wait(time: number) {
|
||||
return new Promise((r) => setTimeout(r, time).unref());
|
||||
}
|
||||
|
||||
static noop() {} // eslint-disable-line @typescript-eslint/no-empty-function
|
||||
}
|
||||
|
||||
export default Util;
|
||||
export { Util };
|
||||
|
|
8
tsconfig.eslint.json
Normal file
8
tsconfig.eslint.json
Normal file
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.js"
|
||||
],
|
||||
"exclude": []
|
||||
}
|
|
@ -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/**/*"
|
||||
|
|
15
tslint.json
15
tslint.json
|
@ -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": []
|
||||
}
|
Loading…
Reference in a new issue