Merge pull request #1210 from Androz2091/next

✏️ discord.js v14 support
This commit is contained in:
benny 2022-08-04 14:37:05 +05:45 committed by GitHub
commit 42ef4d1436
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 1050 additions and 4145 deletions

View file

@ -1,4 +1,4 @@
example/ examples/
node_modules/ node_modules/
dist/ dist/
.github/ .github/

1
.gitattributes vendored
View file

@ -1 +1,2 @@
* text=auto eol=lf * text=auto eol=lf
*.js,*.mjs linguist-language=TypeScript

View file

@ -15,10 +15,10 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@master uses: actions/checkout@master
- name: Install Node v14 - name: Install Node v18
uses: actions/setup-node@master uses: actions/setup-node@master
with: with:
node-version: 14 node-version: 18
- name: Install dependencies - name: Install dependencies
run: npm install run: npm install

6
.gitignore vendored
View file

@ -9,8 +9,4 @@ dist
# Yarn logs # Yarn logs
yarn*.log yarn*.log
# example dev/
example/test
example/music-bot/node_modules
example/music-bot/package-lock.json
example/music-bot/.env

3
.gitmodules vendored Normal file
View file

@ -0,0 +1,3 @@
[submodule "examples/music-bot"]
path = examples/music-bot
url = https://github.com/Androz2091/discord-music-bot

View file

@ -47,34 +47,33 @@ First of all, you will need to register slash commands:
```js ```js
const { REST } = require("@discordjs/rest"); const { REST } = require("@discordjs/rest");
const { Routes } = require("discord-api-types/v9"); const { Routes, ApplicationCommandOptionType } = require("discord.js");
const commands = [{ const commands = [
{
name: "play", name: "play",
description: "Plays a song!", description: "Plays a song!",
options: [ options: [
{ {
name: "query", name: "query",
type: "STRING", type: ApplicationCommandOptionType.String,
description: "The song you want to play", description: "The song you want to play",
required: true required: true
} }
] ]
}]; }
];
const rest = new REST({ version: "9" }).setToken(process.env.DISCORD_TOKEN); const rest = new REST({ version: "10" }).setToken("BOT_TOKEN");
(async () => { (async () => {
try { try {
console.log("Started refreshing application [/] commands."); console.log("Started refreshing application [/] commands.");
await rest.put( await rest.put(Routes.applicationGuildCommands(CLIENT_ID, GUILD_ID), { body: commands });
Routes.applicationGuildCommands(CLIENT_ID, GUILD_ID),
{ body: commands },
);
console.log("Successfully reloaded application [/] commands."); console.log("Successfully reloaded application [/] commands.");
} catch (error) { } catch(error) {
console.error(error); console.error(error);
} }
})(); })();
@ -83,8 +82,13 @@ const rest = new REST({ version: "9" }).setToken(process.env.DISCORD_TOKEN);
Now you can implement your bot's logic: Now you can implement your bot's logic:
```js ```js
const { Client, Intents } = require("discord.js"); const { Client } = require("discord.js");
const client = new Discord.Client({ intents: [Intents.FLAGS.GUILDS, Intents.FLAGS.GUILD_MESSAGES, Intents.FLAGS.GUILD_VOICE_STATES] }); const client = new Discord.Client({
intents: [
"Guilds",
"GuildVoiceStates"
]
});
const { Player } = require("discord-player"); const { Player } = require("discord-player");
// Create a new Player (you don't need any API Key) // Create a new Player (you don't need any API Key)
@ -98,14 +102,14 @@ client.once("ready", () => {
}); });
client.on("interactionCreate", async (interaction) => { client.on("interactionCreate", async (interaction) => {
if (!interaction.isCommand()) return; if (!interaction.isChatInputCommand()) return;
// /play track:Despacito // /play track:Despacito
// will play "Despacito" in the voice channel // will play "Despacito" in the voice channel
if (interaction.commandName === "play") { if (interaction.commandName === "play") {
if (!interaction.member.voice.channelId) return await interaction.reply({ content: "You are not in a voice channel!", ephemeral: true }); if (!interaction.member.voice.channelId) return await interaction.reply({ content: "You are not in a voice channel!", ephemeral: true });
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!", ephemeral: true }); if (interaction.guild.members.me.voice.channelId && interaction.member.voice.channelId !== interaction.guild.members.me.voice.channelId) return await interaction.reply({ content: "You are not in my voice channel!", ephemeral: true });
const query = interaction.options.get("query").value; const query = interaction.options.getString("query");
const queue = player.createQueue(interaction.guild, { const queue = player.createQueue(interaction.guild, {
metadata: { metadata: {
channel: interaction.channel channel: interaction.channel
@ -132,7 +136,7 @@ client.on("interactionCreate", async (interaction) => {
} }
}); });
client.login(process.env.DISCORD_TOKEN); client.login("BOT_TOKEN");
``` ```
## Supported websites ## Supported websites
@ -143,14 +147,14 @@ By default, discord-player supports **YouTube**, **Spotify** and **SoundCloud**
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 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) #### [@discord-player/extractor](https://github.com/DevAndromeda/discord-player-extractors) (optional)
Optional package that adds support for `vimeo`, `reverbnation`, `facebook`, `attachment links` and `lyrics`. 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). 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](https://github.com/DevAndromeda/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). `@discord-player/downloader` is an optional package that brings support for +700 websites. The documentation is available [here](https://github.com/DevAndromeda/discord-player-downloader).
## Examples of bots made with Discord Player ## Examples of bots made with Discord Player

View file

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

View file

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

View file

@ -1,3 +0,0 @@
module.exports = {
token: process.env.DISCORD_TOKEN
};

View file

@ -1,331 +0,0 @@
require("dotenv").config({
path: __dirname+"/.env"
});
const { Client, GuildMember, Intents } = require("discord.js");
const config = require("./config");
const { Player, QueryType, QueueRepeatMode } = require("discord-player");
const client = new Client({
intents: [Intents.FLAGS.GUILD_VOICE_STATES, Intents.FLAGS.GUILD_MESSAGES, Intents.FLAGS.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, {
ytdlOptions: {
headers: {
cookie: process.env.YT_COOKIE
}
}
});
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"
},
{
name: "ping",
description: "Shows bot latency"
}
]);
await message.reply("Deployed!");
}
});
client.on("interactionCreate", async (interaction) => {
if (!interaction.isCommand() || !interaction.guildId) return;
if (interaction.commandName === "ping") {
await interaction.deferReply();
const queue = player.getQueue(interaction.guild);
return void interaction.followUp({
embeds: [
{
title: "⏱️ | Latency",
fields: [
{ name: "Bot Latency", value: `\`${Math.round(client.ws.ping)}ms\`` },
{ name: "Voice Latency", value: !queue ? "N/A" : `UDP: \`${queue.connection.voiceConnection.ping.udp ?? "N/A"}\`ms\nWebSocket: \`${queue.connection.voiceConnection.ping.ws ?? "N/A"}\`ms` }
],
color: 0xFFFFFF
}
]
});
}
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 paused = queue.setPaused(true);
return void interaction.followUp({ content: paused ? "⏸ | 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 paused = queue.setPaused(false);
return void interaction.followUp({ content: !paused ? "❌ | Something went wrong!" : "▶ | Resumed!" });
} else if (interaction.commandName === "stop") {
await interaction.deferReply();
const queue = player.getQueue(interaction.guildId);
if (!queue || !queue.playing) return void interaction.followUp({ content: "❌ | No music is being played!" });
queue.destroy();
return void interaction.followUp({ content: "🛑 | Stopped the player!" });
} else if (interaction.commandName === "np") {
await interaction.deferReply();
const queue = player.getQueue(interaction.guildId);
if (!queue || !queue.playing) return void interaction.followUp({ content: "❌ | No music is being played!" });
const progress = queue.createProgressBar();
const perc = queue.getPlayerTimestamp();
return void interaction.followUp({
embeds: [
{
title: "Now Playing",
description: `🎶 | **${queue.current.title}**! (\`${perc.progress}%\`)`,
fields: [
{
name: "\u200b",
value: progress
}
],
color: 0xffffff
}
]
});
} else if (interaction.commandName === "loop") {
await interaction.deferReply();
const queue = player.getQueue(interaction.guildId);
if (!queue || !queue.playing) return void interaction.followUp({ content: "❌ | No music is being played!" });
const loopMode = interaction.options.get("mode").value;
const success = queue.setRepeatMode(loopMode);
const mode = loopMode === QueueRepeatMode.TRACK ? "🔂" : loopMode === QueueRepeatMode.QUEUE ? "🔁" : "▶";
return void interaction.followUp({ content: success ? `${mode} | Updated loop mode!` : "❌ | Could not update loop mode!" });
} else if (interaction.commandName === "bassboost") {
await interaction.deferReply();
const queue = player.getQueue(interaction.guildId);
if (!queue || !queue.playing) return void interaction.followUp({ content: "❌ | No music is being played!" });
await queue.setFilters({
bassboost: !queue.getFiltersEnabled().includes("bassboost"),
normalizer2: !queue.getFiltersEnabled().includes("bassboost") // because we need to toggle it with bass
});
return void interaction.followUp({ content: `🎵 | Bassboost ${queue.getFiltersEnabled().includes("bassboost") ? "Enabled" : "Disabled"}!` });
} else {
interaction.reply({
content: "Unknown command!",
ephemeral: true
});
}
});
client.login(config.token);

View file

@ -1,17 +0,0 @@
{
"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.2.1",
"discord.js": "^13.0.1",
"dotenv": "^10.0.0"
}
}

3
examples/README.md Normal file
View file

@ -0,0 +1,3 @@
# Discord Player Examples
Examples for **[discord-player](https://github.com/Androz2091/discord-player)**.

1
examples/music-bot Submodule

@ -0,0 +1 @@
Subproject commit cae67e4e466b5884435572b9968859bc88220408

View file

@ -1,17 +0,0 @@
{
"source": {
"includePattern": ".+\\.ts(doc|x)?$"
},
"plugins": [
"plugins/markdown",
"node_modules/jsdoc-babel"
],
"babel": {
"extensions": ["ts"],
"babelrc": false,
"presets": [
["@babel/preset-env", { "targets": { "node": true } }],
"@babel/preset-typescript"
]
}
}

View file

@ -1,6 +1,6 @@
{ {
"name": "discord-player", "name": "discord-player",
"version": "5.2.2", "version": "5.3.0-dev",
"description": "Complete framework to facilitate music commands using discord.js", "description": "Complete framework to facilitate music commands using discord.js",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
@ -23,9 +23,9 @@
"build:check": "tsc --noEmit --incremental false", "build:check": "tsc --noEmit --incremental false",
"prepublishOnly": "rollup-type-bundler -e stream", "prepublishOnly": "rollup-type-bundler -e stream",
"build:esm": "gen-esm-wrapper ./dist/index.js ./dist/index.mjs", "build:esm": "gen-esm-wrapper ./dist/index.js ./dist/index.mjs",
"format": "prettier --write \"src/**/*.ts\" \"example/**/*.ts\"", "format": "prettier --write \"src/**/*.ts\"",
"docs": "docgen --jsdoc jsdoc.json --source src/*.ts src/**/*.ts --custom docs/index.yml --output docs/docs.json", "docs": "typedoc --json docs/typedoc.json src/index.ts",
"docs:test": "docgen --jsdoc jsdoc.json --source src/*.ts src/**/*.ts --custom docs/index.yml", "postdocs": "node scripts/docgen.js",
"lint": "eslint src --ext .ts", "lint": "eslint src --ext .ts",
"prepare": "husky install", "prepare": "husky install",
"lint:fix": "eslint src --ext .ts --fix" "lint:fix": "eslint src --ext .ts --fix"
@ -65,37 +65,32 @@
}, },
"homepage": "https://discord-player.js.org", "homepage": "https://discord-player.js.org",
"dependencies": { "dependencies": {
"@discordjs/voice": "^0.8.0", "@discordjs/voice": "^0.11.0",
"discord-ytdl-core": "^5.0.4", "libsodium-wrappers": "^0.7.10",
"libsodium-wrappers": "^0.7.9", "soundcloud-scraper": "^5.0.3",
"soundcloud-scraper": "^5.0.2", "spotify-url-info": "^3.1.2",
"spotify-url-info": "^2.2.5",
"tiny-typed-emitter": "^2.1.0", "tiny-typed-emitter": "^2.1.0",
"youtube-sr": "^4.1.13", "tslib": "^2.4.0",
"ytdl-core": "^4.10.0" "youtube-sr": "^4.3.0",
"ytdl-core": "^4.11.0"
}, },
"devDependencies": { "devDependencies": {
"@babel/cli": "^7.16.0", "@discordjs/ts-docgen": "^0.4.1",
"@babel/core": "^7.16.0", "@favware/rollup-type-bundler": "^1.0.10",
"@babel/preset-env": "^7.16.4", "@types/node": "^18.6.3",
"@babel/preset-typescript": "^7.16.0", "@types/ws": "^8.5.3",
"@devsnowflake/docgen": "devsnowflake/docgen#ts-patch", "@typescript-eslint/eslint-plugin": "^5.32.0",
"@discord-player/extractor": "^3.0.2", "@typescript-eslint/parser": "^5.32.0",
"@discordjs/opus": "github:discordjs/opus", "discord-api-types": "^0.37.0",
"@favware/rollup-type-bundler": "^1.0.6", "discord.js": "^14.1.2",
"@types/node": "^16.11.10", "eslint": "^8.21.0",
"@types/ws": "^8.2.0",
"@typescript-eslint/eslint-plugin": "^5.4.0",
"@typescript-eslint/parser": "^5.4.0",
"discord-api-types": "^0.24.0",
"discord.js": "^13.6.0",
"eslint": "^8.3.0",
"gen-esm-wrapper": "^1.1.3", "gen-esm-wrapper": "^1.1.3",
"husky": "^7.0.4", "husky": "^8.0.1",
"jsdoc-babel": "^0.5.0", "opusscript": "^0.0.8",
"prettier": "^2.5.0", "prettier": "^2.7.1",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"ts-node": "^10.4.0", "ts-node": "^10.9.1",
"typescript": "^4.5.2" "typedoc": "^0.23.10",
"typescript": "^4.7.4"
} }
} }

8
scripts/docgen.js Normal file
View file

@ -0,0 +1,8 @@
/* eslint-disable */
const { runGenerator } = require("@discordjs/ts-docgen");
runGenerator({
existingOutput: "docs/typedoc.json",
custom: "docs/config.yml",
output: "docs/docs.json"
});

View file

@ -1,8 +1,8 @@
import { Client, Collection, GuildResolvable, Snowflake, User, VoiceState, Intents } from "discord.js"; import { Client, Collection, GuildResolvable, Snowflake, User, VoiceState, IntentsBitField } from "discord.js";
import { TypedEmitter as EventEmitter } from "tiny-typed-emitter"; import { TypedEmitter as EventEmitter } from "tiny-typed-emitter";
import { Queue } from "./Structures/Queue"; import { Queue } from "./Structures/Queue";
import { VoiceUtils } from "./VoiceInterface/VoiceUtils"; import { VoiceUtils } from "./VoiceInterface/VoiceUtils";
import { PlayerEvents, PlayerOptions, QueryType, SearchOptions, PlayerInitOptions, PlayerSearchResult } from "./types/types"; import { PlayerEvents, PlayerOptions, QueryType, SearchOptions, PlayerInitOptions, PlayerSearchResult, PlaylistInitData } from "./types/types";
import Track from "./Structures/Track"; import Track from "./Structures/Track";
import { QueryResolver } from "./utils/QueryResolver"; import { QueryResolver } from "./utils/QueryResolver";
import YouTube from "youtube-sr"; import YouTube from "youtube-sr";
@ -34,7 +34,7 @@ class Player extends EventEmitter<PlayerEvents> {
/** /**
* Creates new Discord Player * Creates new Discord Player
* @param {Client} client The Discord Client * @param {Client} client The Discord Client
* @param {PlayerInitOptions} [options={}] The player init options * @param {PlayerInitOptions} [options] The player init options
*/ */
constructor(client: Client, options: PlayerInitOptions = {}) { constructor(client: Client, options: PlayerInitOptions = {}) {
super(); super();
@ -45,8 +45,8 @@ class Player extends EventEmitter<PlayerEvents> {
*/ */
this.client = client; this.client = client;
if (this.client?.options?.intents && !new Intents(this.client?.options?.intents).has(Intents.FLAGS.GUILD_VOICE_STATES)) { if (this.client?.options?.intents && !new IntentsBitField(this.client?.options?.intents).has(IntentsBitField.Flags.GuildVoiceStates)) {
throw new PlayerError('client is missing "GUILD_VOICE_STATES" intent'); throw new PlayerError('client is missing "GuildVoiceStates" intent');
} }
/** /**
@ -75,68 +75,79 @@ class Player extends EventEmitter<PlayerEvents> {
*/ */
private _handleVoiceState(oldState: VoiceState, newState: VoiceState): void { private _handleVoiceState(oldState: VoiceState, newState: VoiceState): void {
const queue = this.getQueue(oldState.guild.id); const queue = this.getQueue(oldState.guild.id);
if (!queue) return; if (!queue || !queue.connection) return;
if (oldState.channelId && newState.channelId && oldState.channelId !== newState.channelId) { if (oldState.channelId && !newState.channelId && newState.member.id === newState.guild.members.me.id) {
if (queue?.connection && newState.member.id === newState.guild.me.id) queue.connection.channel = newState.channel; try {
if (newState.member.id === newState.guild.me.id || (newState.member.id !== newState.guild.me.id && oldState.channelId === queue.connection.channel.id)) { queue.destroy();
} catch {
/* noop */
}
return void this.emit("botDisconnect", queue);
}
if (!oldState.channelId && newState.channelId && newState.member.id === newState.guild.members.me.id) {
if (!oldState.serverMute && newState.serverMute) {
// state.serverMute can be null
queue.setPaused(!!newState.serverMute);
} else if (!oldState.suppress && newState.suppress) {
// state.suppress can be null
queue.setPaused(!!newState.suppress);
if (newState.suppress) {
newState.guild.members.me.voice.setRequestToSpeak(true).catch(Util.noop);
}
}
}
if (oldState.channelId === newState.channelId && newState.member.id === newState.guild.members.me.id) {
if (!oldState.serverMute && newState.serverMute) {
// state.serverMute can be null
queue.setPaused(!!newState.serverMute);
} else if (!oldState.suppress && newState.suppress) {
// state.suppress can be null
queue.setPaused(!!newState.suppress);
if (newState.suppress) {
newState.guild.members.me.voice.setRequestToSpeak(true).catch(Util.noop);
}
}
}
if (queue.connection && !newState.channelId && oldState.channelId === queue.connection.channel.id) {
if (!Util.isVoiceEmpty(queue.connection.channel)) return;
const timeout = setTimeout(() => {
if (!Util.isVoiceEmpty(queue.connection.channel)) return; if (!Util.isVoiceEmpty(queue.connection.channel)) return;
if (!this.queues.has(queue.guild.id)) return;
if (queue.options.leaveOnEmpty) queue.destroy(true);
this.emit("channelEmpty", queue);
}, queue.options.leaveOnEmptyCooldown || 0).unref();
queue._cooldownsTimeout.set(`empty_${oldState.guild.id}`, timeout);
}
if (queue.connection && newState.channelId && newState.channelId === queue.connection.channel.id) {
const emptyTimeout = queue._cooldownsTimeout.get(`empty_${oldState.guild.id}`);
const channelEmpty = Util.isVoiceEmpty(queue.connection.channel);
if (!channelEmpty && emptyTimeout) {
clearTimeout(emptyTimeout);
queue._cooldownsTimeout.delete(`empty_${oldState.guild.id}`);
}
}
if (oldState.channelId && newState.channelId && oldState.channelId !== newState.channelId && newState.member.id === newState.guild.members.me.id) {
if (queue.connection && newState.member.id === newState.guild.members.me.id) queue.connection.channel = newState.channel;
const emptyTimeout = queue._cooldownsTimeout.get(`empty_${oldState.guild.id}`);
const channelEmpty = Util.isVoiceEmpty(queue.connection.channel);
if (!channelEmpty && emptyTimeout) {
clearTimeout(emptyTimeout);
queue._cooldownsTimeout.delete(`empty_${oldState.guild.id}`);
} else {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
if (!Util.isVoiceEmpty(queue.connection.channel)) return; if (queue.connection && !Util.isVoiceEmpty(queue.connection.channel)) return;
if (!this.queues.has(queue.guild.id)) return; if (!this.queues.has(queue.guild.id)) return;
if (queue.options.leaveOnEmpty) queue.destroy(); if (queue.options.leaveOnEmpty) queue.destroy(true);
this.emit("channelEmpty", queue); this.emit("channelEmpty", queue);
}, queue.options.leaveOnEmptyCooldown || 0).unref(); }, queue.options.leaveOnEmptyCooldown || 0).unref();
queue._cooldownsTimeout.set(`empty_${oldState.guild.id}`, timeout); queue._cooldownsTimeout.set(`empty_${oldState.guild.id}`, timeout);
} }
if (!oldState.channelId && newState.channelId && newState.member.id === newState.guild.me.id) {
if (newState.serverMute || !newState.serverMute) {
queue.setPaused(newState.serverMute);
} else if (newState.suppress || !newState.suppress) {
if (newState.suppress) newState.guild.me.voice.setRequestToSpeak(true).catch(Util.noop);
queue.setPaused(newState.suppress);
}
}
if (oldState.channelId === newState.channelId && oldState.member.id === newState.guild.me.id) {
if (oldState.serverMute !== newState.serverMute) {
queue.setPaused(newState.serverMute);
} else if (oldState.suppress !== newState.suppress) {
if (newState.suppress) newState.guild.me.voice.setRequestToSpeak(true).catch(Util.noop);
queue.setPaused(newState.suppress);
}
}
if (oldState.member.id === this.client.user.id && !newState.channelId) {
queue.destroy();
return void this.emit("botDisconnect", queue);
}
if (!queue.connection || !queue.connection.channel) return;
if (!oldState.channelId || newState.channelId) {
const emptyTimeout = queue._cooldownsTimeout.get(`empty_${oldState.guild.id}`);
const channelEmpty = Util.isVoiceEmpty(queue.connection.channel);
if (newState.channelId === queue.connection.channel.id) {
if (!channelEmpty && emptyTimeout) {
clearTimeout(emptyTimeout);
queue._cooldownsTimeout.delete(`empty_${oldState.guild.id}`);
}
}
} else {
if (oldState.channelId === queue.connection.channel.id) {
if (!Util.isVoiceEmpty(queue.connection.channel)) return;
const timeout = setTimeout(() => {
if (!Util.isVoiceEmpty(queue.connection.channel)) return;
if (!this.queues.has(queue.guild.id)) return;
if (queue.options.leaveOnEmpty) queue.destroy();
this.emit("channelEmpty", queue);
}, queue.options.leaveOnEmptyCooldown || 0).unref();
queue._cooldownsTimeout.set(`empty_${oldState.guild.id}`, timeout);
}
}
} }
} }
@ -338,7 +349,9 @@ class Player extends EventEmitter<PlayerEvents> {
return { playlist: null, tracks: res }; return { playlist: null, tracks: res };
} }
case QueryType.SPOTIFY_SONG: { case QueryType.SPOTIFY_SONG: {
const spotifyData = await Spotify.getData(query).catch(Util.noop); const spotifyData = await Spotify(await Util.getFetch())
.getData(query)
.catch(Util.noop);
if (!spotifyData) return { playlist: null, tracks: [] }; if (!spotifyData) return { playlist: null, tracks: [] };
const spotifyTrack = new Track(this, { const spotifyTrack = new Track(this, {
title: spotifyData.name, title: spotifyData.name,
@ -359,7 +372,9 @@ class Player extends EventEmitter<PlayerEvents> {
} }
case QueryType.SPOTIFY_PLAYLIST: case QueryType.SPOTIFY_PLAYLIST:
case QueryType.SPOTIFY_ALBUM: { case QueryType.SPOTIFY_ALBUM: {
const spotifyPlaylist = await Spotify.getData(query).catch(Util.noop); const spotifyPlaylist = await Spotify(await Util.getFetch())
.getData(query)
.catch(Util.noop);
if (!spotifyPlaylist) return { playlist: null, tracks: [] }; if (!spotifyPlaylist) return { playlist: null, tracks: [] };
const playlist = new Playlist(this, { const playlist = new Playlist(this, {
@ -408,9 +423,9 @@ class Player extends EventEmitter<PlayerEvents> {
const data = new Track(this, { const data = new Track(this, {
title: m.track.name ?? "", title: m.track.name ?? "",
description: m.track.description ?? "", description: m.track.description ?? "",
author: m.track.artists[0]?.name ?? "Unknown Artist", author: m.track.artists?.[0]?.name ?? "Unknown Artist",
url: m.track.external_urls?.spotify ?? query, url: m.track.external_urls?.spotify ?? query,
thumbnail: m.track.album?.images[0]?.url ?? "https://www.scdn.co/i/_global/twitter_card-default.jpg", thumbnail: m.track.album?.images?.[0]?.url ?? "https://www.scdn.co/i/_global/twitter_card-default.jpg",
duration: Util.buildTimeCode(Util.parseMS(m.track.duration_ms)), duration: Util.buildTimeCode(Util.parseMS(m.track.duration_ms)),
views: 0, views: 0,
requestedBy: options.requestedBy as User, requestedBy: options.requestedBy as User,
@ -585,6 +600,14 @@ class Player extends EventEmitter<PlayerEvents> {
*[Symbol.iterator]() { *[Symbol.iterator]() {
yield* Array.from(this.queues.values()); yield* Array.from(this.queues.values());
} }
/**
* Creates `Playlist` instance
* @param data The data to initialize a playlist
*/
createPlaylist(data: PlaylistInitData) {
return new Playlist(this, data);
}
} }
export { Player }; export { Player };

View file

@ -1,9 +1,9 @@
import { Collection, Guild, StageChannel, VoiceChannel, Snowflake, SnowflakeUtil, GuildChannelResolvable } from "discord.js"; import { Collection, Guild, StageChannel, VoiceChannel, SnowflakeUtil, GuildChannelResolvable, ChannelType } from "discord.js";
import { Player } from "../Player"; import { Player } from "../Player";
import { StreamDispatcher } from "../VoiceInterface/StreamDispatcher"; import { StreamDispatcher } from "../VoiceInterface/StreamDispatcher";
import Track from "./Track"; import Track from "./Track";
import { PlayerOptions, PlayerProgressbarOptions, PlayOptions, QueueFilters, QueueRepeatMode, TrackSource } from "../types/types"; import { PlayerOptions, PlayerProgressbarOptions, PlayOptions, QueueFilters, QueueRepeatMode, TrackSource } from "../types/types";
import ytdl from "discord-ytdl-core"; import ytdl from "ytdl-core";
import { AudioResource, StreamType } from "@discordjs/voice"; import { AudioResource, StreamType } from "@discordjs/voice";
import { Util } from "../utils/Util"; import { Util } from "../utils/Util";
import YouTube from "youtube-sr"; import YouTube from "youtube-sr";
@ -11,6 +11,7 @@ import AudioFilters from "../utils/AudioFilters";
import { PlayerError, ErrorStatusCode } from "./PlayerError"; import { PlayerError, ErrorStatusCode } from "./PlayerError";
import type { Readable } from "stream"; import type { Readable } from "stream";
import { VolumeTransformer } from "../VoiceInterface/VolumeTransformer"; import { VolumeTransformer } from "../VoiceInterface/VolumeTransformer";
import { createFFmpegStream } from "../utils/FFmpegStream";
class Queue<T = unknown> { class Queue<T = unknown> {
public readonly guild: Guild; public readonly guild: Guild;
@ -22,7 +23,7 @@ class Queue<T = unknown> {
public playing = false; public playing = false;
public metadata?: T = null; public metadata?: T = null;
public repeatMode: QueueRepeatMode = 0; public repeatMode: QueueRepeatMode = 0;
public readonly id: Snowflake = SnowflakeUtil.generate(); public readonly id = SnowflakeUtil.generate().toString();
private _streamTime = 0; private _streamTime = 0;
public _cooldownsTimeout = new Collection<string, NodeJS.Timeout>(); public _cooldownsTimeout = new Collection<string, NodeJS.Timeout>();
private _activeFilters: any[] = []; // eslint-disable-line @typescript-eslint/no-explicit-any private _activeFilters: any[] = []; // eslint-disable-line @typescript-eslint/no-explicit-any
@ -35,7 +36,7 @@ class Queue<T = unknown> {
* Queue constructor * Queue constructor
* @param {Player} player The player that instantiated this queue * @param {Player} player The player that instantiated this queue
* @param {Guild} guild The guild that instantiated this queue * @param {Guild} guild The guild that instantiated this queue
* @param {PlayerOptions} [options={}] Player options for the queue * @param {PlayerOptions} [options] Player options for the queue
*/ */
constructor(player: Player, guild: Guild, options: PlayerOptions = {}) { constructor(player: Player, guild: Guild, options: PlayerOptions = {}) {
/** /**
@ -152,16 +153,16 @@ class Queue<T = unknown> {
async connect(channel: GuildChannelResolvable) { async connect(channel: GuildChannelResolvable) {
if (this.#watchDestroyed()) return; if (this.#watchDestroyed()) return;
const _channel = this.guild.channels.resolve(channel) as StageChannel | VoiceChannel; const _channel = this.guild.channels.resolve(channel) as StageChannel | VoiceChannel;
if (!["GUILD_STAGE_VOICE", "GUILD_VOICE"].includes(_channel?.type)) if (![ChannelType.GuildStageVoice, ChannelType.GuildVoice].includes(_channel?.type))
throw new PlayerError(`Channel type must be GUILD_VOICE or GUILD_STAGE_VOICE, got ${_channel?.type}!`, ErrorStatusCode.INVALID_ARG_TYPE); throw new PlayerError(`Channel type must be GuildVoice or GuildStageVoice, got ${_channel?.type}!`, ErrorStatusCode.INVALID_ARG_TYPE);
const connection = await this.player.voiceUtils.connect(_channel, { const connection = await this.player.voiceUtils.connect(_channel, {
deaf: this.options.autoSelfDeaf deaf: this.options.autoSelfDeaf
}); });
this.connection = connection; this.connection = connection;
if (_channel.type === "GUILD_STAGE_VOICE") { if (_channel.type === ChannelType.GuildStageVoice) {
await _channel.guild.me.voice.setSuppressed(false).catch(async () => { await _channel.guild.members.me.voice.setSuppressed(false).catch(async () => {
return await _channel.guild.me.voice.setRequestToSpeak(true).catch(Util.noop); return await _channel.guild.members.me.voice.setRequestToSpeak(true).catch(Util.noop);
}); });
} }
@ -179,7 +180,7 @@ class Queue<T = unknown> {
this.connection.on("start", (resource) => { this.connection.on("start", (resource) => {
if (this.#watchDestroyed(false)) return; if (this.#watchDestroyed(false)) return;
this.playing = true; this.playing = true;
if (!this._filtersUpdate && resource?.metadata) this.player.emit("trackStart", this, resource?.metadata ?? this.current); if (!this._filtersUpdate) this.player.emit("trackStart", this, resource?.metadata ?? this.current);
this._filtersUpdate = false; this._filtersUpdate = false;
}); });
@ -188,7 +189,7 @@ class Queue<T = unknown> {
this.playing = false; this.playing = false;
if (this._filtersUpdate) return; if (this._filtersUpdate) return;
this._streamTime = 0; this._streamTime = 0;
if (resource && resource.metadata) this.previousTracks.push(resource.metadata); if (resource?.metadata) this.previousTracks.push(resource.metadata);
this.player.emit("trackEnd", this, resource.metadata); this.player.emit("trackEnd", this, resource.metadata);
@ -206,10 +207,6 @@ class Queue<T = unknown> {
} }
}); });
await this.player.voiceUtils.enterReady(this.connection.voiceConnection, {
maxTime: this.player.options.connectionTimeout || 30_000
});
return this; return this;
} }
@ -473,10 +470,10 @@ class Queue<T = unknown> {
/** /**
* Removes a track from the queue * Removes a track from the queue
* @param {Track|Snowflake|number} track The track to remove * @param {Track|string|number} track The track to remove
* @returns {Track} * @returns {Track}
*/ */
remove(track: Track | Snowflake | number) { remove(track: Track | string | number) {
if (this.#watchDestroyed()) return; if (this.#watchDestroyed()) return;
let trackFound: Track = null; let trackFound: Track = null;
if (typeof track === "number") { if (typeof track === "number") {
@ -496,10 +493,10 @@ class Queue<T = unknown> {
/** /**
* Returns the index of the specified track. If found, returns the track index else returns -1. * Returns the index of the specified track. If found, returns the track index else returns -1.
* @param {number|Track|Snowflake} track The track * @param {number|Track|string} track The track
* @returns {number} * @returns {number}
*/ */
getTrackPosition(track: number | Track | Snowflake) { getTrackPosition(track: number | Track | string) {
if (this.#watchDestroyed()) return; if (this.#watchDestroyed()) return;
if (typeof track === "number") return this.tracks[track] != null ? track : -1; if (typeof track === "number") return this.tracks[track] != null ? track : -1;
return this.tracks.findIndex((pred) => pred.id === (track instanceof Track ? track.id : track)); return this.tracks.findIndex((pred) => pred.id === (track instanceof Track ? track.id : track));
@ -621,7 +618,7 @@ class Queue<T = unknown> {
/** /**
* Play stream in a voice/stage channel * Play stream in a voice/stage channel
* @param {Track} [src] The track to play (if empty, uses first track from the queue) * @param {Track} [src] The track to play (if empty, uses first track from the queue)
* @param {PlayOptions} [options={}] The options * @param {PlayOptions} [options] The options
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async play(src?: Track, options: PlayOptions = {}): Promise<void> { async play(src?: Track, options: PlayOptions = {}): Promise<void> {
@ -639,66 +636,46 @@ class Queue<T = unknown> {
} }
let stream = null; let stream = null;
const customDownloader = typeof this.onBeforeCreateStream === "function"; const hasCustomDownloader = typeof this.onBeforeCreateStream === "function";
if (["youtube", "spotify"].includes(track.raw.source)) { if (["youtube", "spotify"].includes(track.raw.source)) {
let spotifyResolved = false; let spotifyResolved = false;
if (this.options.spotifyBridge && track.raw.source === "spotify" && !track.raw.engine) { if (this.options.spotifyBridge && track.raw.source === "spotify" && !track.raw.engine) {
track.raw.engine = await YouTube.search(`${track.author} ${track.title}`, { type: "video" }) track.raw.engine = await YouTube.search(`${track.author} ${track.title}`, { type: "video" })
.then((x) => x[0].url) .then((res) => res[0].url)
.catch(() => null); .catch(() => null);
spotifyResolved = true; spotifyResolved = true;
} }
const link = track.raw.source === "spotify" ? track.raw.engine : track.url;
if (!link) return void this.play(this.tracks.shift(), { immediate: true });
if (customDownloader) { const url = track.raw.source === "spotify" ? track.raw.engine : track.url;
stream = (await this.onBeforeCreateStream(track, spotifyResolved ? "youtube" : track.raw.source, this)) ?? null; if (!url) return void this.play(this.tracks.shift(), { immediate: true });
if (stream)
stream = ytdl if (hasCustomDownloader) {
.arbitraryStream(stream, { stream = (await this.onBeforeCreateStream(track, spotifyResolved ? "youtube" : track.raw.source, this)) || null;
opusEncoded: false, }
fmt: "s16le",
encoderArgs: options.encoderArgs ?? this._activeFilters.length ? ["-af", AudioFilters.create(this._activeFilters)] : [], if (!stream) {
seek: options.seek ? options.seek / 1000 : 0 stream = ytdl(url, this.options.ytdlOptions);
})
.on("error", (err) => {
return err.message.toLowerCase().includes("premature close") ? null : this.player.emit("error", this, err);
});
} else {
stream = ytdl(link, {
...this.options.ytdlOptions,
// discord-ytdl-core
opusEncoded: false,
fmt: "s16le",
encoderArgs: options.encoderArgs ?? this._activeFilters.length ? ["-af", AudioFilters.create(this._activeFilters)] : [],
seek: options.seek ? options.seek / 1000 : 0
}).on("error", (err) => {
return err.message.toLowerCase().includes("premature close") ? null : this.player.emit("error", this, err);
});
} }
} else { } else {
const tryArb = (customDownloader && (await this.onBeforeCreateStream(track, track.raw.source || track.raw.engine, this))) || null; const arbitraryStream = (hasCustomDownloader && (await this.onBeforeCreateStream(track, track.raw.source || track.raw.engine, this))) || null;
const arbitrarySource = tryArb stream =
? tryArb arbitraryStream || (track.raw.source === "soundcloud" && typeof track.raw.engine?.downloadProgressive === "function")
: track.raw.source === "soundcloud" ? await track.raw.engine.downloadProgressive()
? await track.raw.engine.downloadProgressive() : typeof track.raw.engine === "function"
: typeof track.raw.engine === "function" ? await track.raw.engine()
? await track.raw.engine() : track.raw.engine;
: track.raw.engine;
stream = ytdl
.arbitraryStream(arbitrarySource, {
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, { const ffmpegStream = createFFmpegStream(stream, {
encoderArgs: options.encoderArgs || this._activeFilters.length ? ["-af", AudioFilters.create(this._activeFilters)] : [],
seek: options.seek ? options.seek / 1000 : 0,
fmt: "s16le"
}).on("error", (err) => {
if (!`${err}`.toLowerCase().includes("premature close")) this.player.emit("error", this, err);
});
const resource: AudioResource<Track> = this.connection.createStream(ffmpegStream, {
type: StreamType.Raw, type: StreamType.Raw,
data: track, data: track,
disableVolume: Boolean(this.options.disableVolume) disableVolume: Boolean(this.options.disableVolume)
@ -708,12 +685,11 @@ class Queue<T = unknown> {
this._filtersUpdate = options.filtersUpdate; this._filtersUpdate = options.filtersUpdate;
const volumeTransformer = resource.volume as VolumeTransformer; const volumeTransformer = resource.volume as VolumeTransformer;
if (volumeTransformer && typeof this.options.initialVolume === "number") Reflect.set(volumeTransformer, "volume", Math.pow(this.options.initialVolume / 100, 1.660964));
if (volumeTransformer?.hasSmoothness && typeof this.options.volumeSmoothness === "number") { if (volumeTransformer?.hasSmoothness && typeof this.options.volumeSmoothness === "number") {
if (typeof volumeTransformer.setSmoothness === "function") volumeTransformer.setSmoothness(this.options.volumeSmoothness || 0); if (typeof volumeTransformer.setSmoothness === "function") volumeTransformer.setSmoothness(this.options.volumeSmoothness || 0);
} }
this.setVolume(this.options.initialVolume);
setTimeout(() => { setTimeout(() => {
this.connection.playStream(resource); this.connection.playStream(resource);
}, this.#getBufferingTimeout()).unref(); }, this.#getBufferingTimeout()).unref();
@ -731,9 +707,14 @@ class Queue<T = unknown> {
if (this.options.leaveOnEnd) this.destroy(); if (this.options.leaveOnEnd) this.destroy();
return void this.player.emit("queueEnd", this); return void this.player.emit("queueEnd", this);
} }
const info = await YouTube.getVideo(track.url) let info = await YouTube.getVideo(track.url)
.then((x) => x.videos[0]) .then((x) => x.videos[0])
.catch(Util.noop); .catch(Util.noop);
// fallback
if (!info)
info = await YouTube.search(track.author)
.then((x) => x[0])
.catch(Util.noop);
if (!info) { if (!info) {
if (this.options.leaveOnEnd) this.destroy(); if (this.options.leaveOnEnd) this.destroy();
return void this.player.emit("queueEnd", this); return void this.player.emit("queueEnd", this);

View file

@ -1,4 +1,4 @@
import { User, Util, SnowflakeUtil, Snowflake } from "discord.js"; import { User, escapeMarkdown, SnowflakeUtil } from "discord.js";
import { Player } from "../Player"; import { Player } from "../Player";
import { RawTrackData, TrackJSON } from "../types/types"; import { RawTrackData, TrackJSON } from "../types/types";
import { Playlist } from "./Playlist"; import { Playlist } from "./Playlist";
@ -16,7 +16,7 @@ class Track {
public requestedBy!: User; public requestedBy!: User;
public playlist?: Playlist; public playlist?: Playlist;
public readonly raw: RawTrackData = {} as RawTrackData; public readonly raw: RawTrackData = {} as RawTrackData;
public readonly id: Snowflake = SnowflakeUtil.generate(); public readonly id = SnowflakeUtil.generate().toString();
/** /**
* Track constructor * Track constructor
@ -109,7 +109,7 @@ class Track {
} }
private _patch(data: RawTrackData) { private _patch(data: RawTrackData) {
this.title = Util.escapeMarkdown(data.title ?? ""); this.title = escapeMarkdown(data.title ?? "");
this.author = data.author ?? ""; this.author = data.author ?? "";
this.url = data.url ?? ""; this.url = data.url ?? "";
this.thumbnail = data.thumbnail ?? ""; this.thumbnail = data.thumbnail ?? "";

View file

@ -129,7 +129,7 @@ class StreamDispatcher extends EventEmitter<VoiceEvents> {
/** /**
* Creates stream * Creates stream
* @param {Readable|Duplex|string} src The stream source * @param {Readable|Duplex|string} src The stream source
* @param {object} [ops={}] Options * @param {object} [ops] Options
* @returns {AudioResource} * @returns {AudioResource}
*/ */
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any

View file

@ -1,5 +1,5 @@
import { VoiceChannel, StageChannel, Collection, Snowflake } from "discord.js"; import { VoiceChannel, StageChannel, Collection, Snowflake } from "discord.js";
import { DiscordGatewayAdapterCreator, entersState, joinVoiceChannel, VoiceConnection, VoiceConnectionStatus } from "@discordjs/voice"; import { DiscordGatewayAdapterCreator, joinVoiceChannel, VoiceConnection } from "@discordjs/voice";
import { StreamDispatcher } from "./StreamDispatcher"; import { StreamDispatcher } from "./StreamDispatcher";
class VoiceUtils { class VoiceUtils {
@ -20,7 +20,7 @@ class VoiceUtils {
/** /**
* Joins a voice channel, creating basic stream dispatch manager * Joins a voice channel, creating basic stream dispatch manager
* @param {StageChannel|VoiceChannel} channel The voice channel * @param {StageChannel|VoiceChannel} channel The voice channel
* @param {object} [options={}] Join options * @param {object} [options] Join options
* @returns {Promise<StreamDispatcher>} * @returns {Promise<StreamDispatcher>}
*/ */
public async connect( public async connect(
@ -39,7 +39,7 @@ class VoiceUtils {
/** /**
* Joins a voice channel * Joins a voice channel
* @param {StageChannel|VoiceChannel} [channel] The voice/stage channel to join * @param {StageChannel|VoiceChannel} [channel] The voice/stage channel to join
* @param {object} [options={}] Join options * @param {object} [options] Join options
* @returns {VoiceConnection} * @returns {VoiceConnection}
*/ */
public async join( public async join(
@ -59,16 +59,6 @@ class VoiceUtils {
return conn; return conn;
} }
public async enterReady(conn: VoiceConnection, options: { maxTime?: number } = {}) {
try {
conn = await entersState(conn, VoiceConnectionStatus.Ready, options?.maxTime ?? 20000);
return conn;
} catch (err) {
conn.destroy();
throw err;
}
}
/** /**
* Disconnects voice connection * Disconnects voice connection
* @param {VoiceConnection} connection The voice connection * @param {VoiceConnection} connection The voice connection

View file

@ -13,6 +13,7 @@ export { VoiceUtils } from "./VoiceInterface/VoiceUtils";
export { VoiceEvents, StreamDispatcher } from "./VoiceInterface/StreamDispatcher"; export { VoiceEvents, StreamDispatcher } from "./VoiceInterface/StreamDispatcher";
export { Util } from "./utils/Util"; export { Util } from "./utils/Util";
export * from "./types/types"; export * from "./types/types";
export * from "./utils/FFmpegStream";
// eslint-disable-next-line @typescript-eslint/no-var-requires // eslint-disable-next-line @typescript-eslint/no-var-requires
export const version: string = require(`${__dirname}/../package.json`).version; export const version: string = require(`${__dirname}/../package.json`).version;

View file

@ -121,6 +121,7 @@ export interface PlayerProgressbarOptions {
length?: number; length?: number;
line?: string; line?: string;
indicator?: string; indicator?: string;
queue?: boolean;
} }
/** /**
@ -130,12 +131,12 @@ export interface PlayerProgressbarOptions {
* @property {boolean} [leaveOnEmpty=true] If it should leave on empty * @property {boolean} [leaveOnEmpty=true] If it should leave on empty
* @property {number} [leaveOnEmptyCooldown=1000] The cooldown in ms * @property {number} [leaveOnEmptyCooldown=1000] The cooldown in ms
* @property {boolean} [autoSelfDeaf=true] If it should set the bot in deaf mode * @property {boolean} [autoSelfDeaf=true] If it should set the bot in deaf mode
* @property {YTDLDownloadOptions} [ytdlOptions={}] The youtube download options * @property {YTDLDownloadOptions} [ytdlOptions] The youtube download options
* @property {number} [initialVolume=100] The initial player volume * @property {number} [initialVolume=100] The initial player volume
* @property {number} [bufferingTimeout=3000] Buffering timeout for the stream * @property {number} [bufferingTimeout=3000] Buffering timeout for the stream
* @property {boolean} [spotifyBridge=true] If player should bridge spotify source to youtube * @property {boolean} [spotifyBridge=true] If player should bridge spotify source to youtube
* @property {boolean} [disableVolume=false] If player should disable inline volume * @property {boolean} [disableVolume=false] If player should disable inline volume
* @property {boolean} [volumeSmoothness=0] The volume transition smoothness between volume changes (lower the value to get better result) * @property {number} [volumeSmoothness=0] The volume transition smoothness between volume changes (lower the value to get better result)
* Setting this or leaving this empty will disable this effect. Example: `volumeSmoothness: 0.1` * Setting this or leaving this empty will disable this effect. Example: `volumeSmoothness: 0.1`
* @property {Function} [onBeforeCreateStream] Runs before creating stream * @property {Function} [onBeforeCreateStream] Runs before creating stream
*/ */
@ -476,7 +477,7 @@ export interface PlaylistJSON {
/** /**
* @typedef {object} PlayerInitOptions * @typedef {object} PlayerInitOptions
* @property {boolean} [autoRegisterExtractor=true] If it should automatically register `@discord-player/extractor` * @property {boolean} [autoRegisterExtractor=true] If it should automatically register `@discord-player/extractor`
* @property {YTDLDownloadOptions} [ytdlOptions={}] The options passed to `ytdl-core` * @property {YTDLDownloadOptions} [ytdlOptions] The options passed to `ytdl-core`
* @property {number} [connectionTimeout=20000] The voice connection timeout * @property {number} [connectionTimeout=20000] The voice connection timeout
*/ */
export interface PlayerInitOptions { export interface PlayerInitOptions {

View file

@ -2,115 +2,106 @@ import { FiltersName } from "../types/types";
const bass = (g: number) => `bass=g=${g}:f=110:w=0.3`; const bass = (g: number) => `bass=g=${g}:f=110:w=0.3`;
/** class AudioFilters {
* The available audio filters public constructor() {
* @typedef {object} AudioFilters return 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} karaoke 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_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 };
}
},
get names() {
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;
},
toString() {
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")
.map((m) => this[m])
.join(",");
},
define(filterName: string, value: string): void {
if (typeof this[filterName as FiltersName] && typeof this[filterName as FiltersName] === "function") return;
this[filterName as FiltersName] = value;
},
defineBulk(filterArray: { name: string; value: string }[]): void {
filterArray.forEach((arr) => this.define(arr.name, arr.value));
} }
};
export default FilterList; public static get filters(): Record<FiltersName, string> {
export { FilterList as AudioFilters }; return {
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",
normalizer2: "dynaudnorm=g=101",
normalizer: "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"
};
}
public static get<K extends FiltersName>(name: K) {
return this.filters[name];
}
public static has<K extends FiltersName>(name: K) {
return name in this.filters;
}
public static *[Symbol.iterator](): IterableIterator<{ name: FiltersName; value: string }> {
for (const [k, v] of Object.entries(this)) {
if (typeof this.filters[k as FiltersName] === "string") yield { name: k as FiltersName, value: v as string };
}
}
public static get names() {
return Object.keys(this).filter((p) => !["names", "length"].includes(p) && typeof this.filters[p as FiltersName] !== "function") as FiltersName[];
}
// @ts-expect-error AudioFilters.length
public static get length() {
return Object.keys(this).filter((p) => !["names", "length"].includes(p) && typeof this.filters[p as FiltersName] !== "function").length;
}
public static toString() {
return this.names.map((m) => (this as any)[m]).join(","); // eslint-disable-line @typescript-eslint/no-explicit-any
}
/**
* Create ffmpeg args from the specified filters name
* @param filter The filter name
* @returns
*/
public static create(filters?: FiltersName[]) {
if (!filters || !Array.isArray(filters)) return this.toString();
return filters
.filter((predicate) => typeof predicate === "string")
.map((m) => this.get(m))
.join(",");
}
/**
* Defines audio filter
* @param filterName The name of the filter
* @param value The ffmpeg args
*/
public static define(filterName: string, value: string) {
this.filters[filterName as FiltersName] = value;
}
/**
* Defines multiple audio filters
* @param filtersArray Array of filters containing the filter name and ffmpeg args
*/
public static defineBulk(filtersArray: { name: string; value: string }[]) {
filtersArray.forEach((arr) => this.define(arr.name, arr.value));
}
}
export default AudioFilters;
export { AudioFilters };

59
src/utils/FFmpegStream.ts Normal file
View file

@ -0,0 +1,59 @@
import { FFmpeg } from "prism-media";
import type { Duplex, Readable } from "stream";
export interface FFmpegStreamOptions {
fmt?: string;
encoderArgs?: string[];
seek?: number;
skip?: boolean;
}
export function FFMPEG_ARGS_STRING(stream: string, fmt?: string) {
// prettier-ignore
return [
"-reconnect", "1",
"-reconnect_streamed", "1",
"-reconnect_delay_max", "5",
"-i", stream,
"-analyzeduration", "0",
"-loglevel", "0",
"-f", `${typeof fmt === "string" ? fmt : "s16le"}`,
"-ar", "48000",
"-ac", "2"
];
}
export function FFMPEG_ARGS_PIPED(fmt?: string) {
// prettier-ignore
return [
"-analyzeduration", "0",
"-loglevel", "0",
"-f", `${typeof fmt === "string" ? fmt : "s16le"}`,
"-ar", "48000",
"-ac", "2"
];
}
/**
* Creates FFmpeg stream
* @param stream The source stream
* @param options FFmpeg stream options
*/
export function createFFmpegStream(stream: Readable | Duplex | string, options?: FFmpegStreamOptions) {
if (options.skip && typeof stream !== "string") return stream;
options ??= {};
const args = typeof stream === "string" ? FFMPEG_ARGS_STRING(stream, options.fmt) : FFMPEG_ARGS_PIPED(options.fmt);
if (!Number.isNaN(options.seek)) args.unshift("-ss", String(options.seek));
if (Array.isArray(options.encoderArgs)) args.push(...options.encoderArgs);
const transcoder = new FFmpeg({ shell: false, args });
transcoder.on("close", () => transcoder.destroy());
if (typeof stream !== "string") {
stream.on("error", () => transcoder.destroy());
stream.pipe(transcoder);
}
return transcoder;
}

View file

@ -95,6 +95,23 @@ class Util {
} }
static noop() {} // eslint-disable-line @typescript-eslint/no-empty-function static noop() {} // eslint-disable-line @typescript-eslint/no-empty-function
static async getFetch() {
if ("fetch" in globalThis) return globalThis.fetch;
for (const lib of ["undici", "node-fetch"]) {
try {
return await import(lib).then((res) => res.fetch || res.default?.fetch || res.default);
} catch {
try {
// eslint-disable-next-line
const res = require(lib);
if (res) return res.fetch || res.default?.fetch || res.default;
} catch {
// no?
}
}
}
}
} }
export { Util }; export { Util };

View file

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

View file

@ -1,6 +1,6 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES6", "target": "ES2020",
"module": "commonjs", "module": "commonjs",
"declaration": true, "declaration": true,
"outDir": "./dist", "outDir": "./dist",

4095
yarn.lock

File diff suppressed because it is too large Load diff