2021-04-06 17:58:46 +05:00
|
|
|
import YouTube from 'youtube-sr';
|
|
|
|
import { EventEmitter } from 'events';
|
|
|
|
import { Client, Collection, Snowflake, Collector, Message } from 'discord.js';
|
|
|
|
import { PlayerOptions } from './types/types';
|
|
|
|
import Util from './utils/Util';
|
|
|
|
import AudioFilters from './utils/AudioFilters';
|
|
|
|
import Queue from './Structures/Queue';
|
|
|
|
import Track from './Structures/Track';
|
2021-04-06 19:06:18 +05:00
|
|
|
import { PlayerErrorEventCodes, PlayerEvents } from './utils/Constants';
|
2021-04-06 19:08:01 +05:00
|
|
|
import PlayerError from './utils/PlayerError';
|
|
|
|
import ytdl from 'discord-ytdl-core';
|
2021-04-06 17:55:29 +05:00
|
|
|
|
|
|
|
// @ts-ignore
|
2021-04-06 17:58:46 +05:00
|
|
|
import spotify from 'spotify-url-info';
|
2021-04-06 17:55:29 +05:00
|
|
|
// @ts-ignore
|
2021-04-06 17:58:46 +05:00
|
|
|
import { Client as SoundCloudClient } from 'soundcloud-scraper';
|
2021-04-06 17:55:29 +05:00
|
|
|
|
2021-04-06 17:58:46 +05:00
|
|
|
const SoundCloud = new SoundCloudClient();
|
2021-04-04 22:36:40 +05:00
|
|
|
|
|
|
|
export default class Player extends EventEmitter {
|
|
|
|
public client!: Client;
|
|
|
|
public options: PlayerOptions;
|
2021-04-06 17:55:29 +05:00
|
|
|
public filters: typeof AudioFilters;
|
|
|
|
public queues: Collection<Snowflake, Queue>;
|
|
|
|
private _resultsCollectors: Collection<string, Collector<Snowflake, Message>>;
|
|
|
|
private _cooldownsTimeout: Collection<string, NodeJS.Timeout>;
|
2021-04-04 22:36:40 +05:00
|
|
|
|
|
|
|
constructor(client: Client, options?: PlayerOptions) {
|
|
|
|
super();
|
|
|
|
|
|
|
|
/**
|
|
|
|
* The discord client that instantiated this player
|
|
|
|
*/
|
2021-04-06 17:58:46 +05:00
|
|
|
Object.defineProperty(this, 'client', {
|
2021-04-04 22:36:40 +05:00
|
|
|
value: client,
|
|
|
|
enumerable: false
|
|
|
|
});
|
|
|
|
|
|
|
|
/**
|
|
|
|
* The player options
|
|
|
|
*/
|
|
|
|
this.options = Object.assign({}, Util.DefaultPlayerOptions, options ?? {});
|
|
|
|
|
|
|
|
// check FFmpeg
|
|
|
|
void Util.alertFFmpeg();
|
2021-04-06 17:55:29 +05:00
|
|
|
|
|
|
|
/**
|
|
|
|
* The audio filters
|
|
|
|
*/
|
|
|
|
this.filters = AudioFilters;
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Player queues
|
|
|
|
*/
|
|
|
|
this.queues = new Collection();
|
|
|
|
}
|
|
|
|
|
|
|
|
static get AudioFilters() {
|
|
|
|
return AudioFilters;
|
|
|
|
}
|
|
|
|
|
2021-04-06 19:08:01 +05:00
|
|
|
private _searchTracks(message: Message, query: string, firstResult?: boolean): Promise<Track> {
|
2021-04-06 17:55:29 +05:00
|
|
|
return new Promise(async (resolve) => {
|
|
|
|
let tracks: Track[] = [];
|
2021-04-06 17:58:46 +05:00
|
|
|
const queryType = Util.getQueryType(query);
|
|
|
|
|
|
|
|
switch (queryType) {
|
|
|
|
case 'soundcloud_track':
|
|
|
|
{
|
|
|
|
const data = await SoundCloud.getSongInfo(query).catch(() => {});
|
|
|
|
if (data) {
|
|
|
|
const track = new Track(this, {
|
|
|
|
title: data.title,
|
|
|
|
url: data.url,
|
|
|
|
duration: Util.durationString(Util.parseMS(data.duration / 1000)),
|
|
|
|
description: data.description,
|
|
|
|
thumbnail: data.thumbnail,
|
|
|
|
views: data.playCount,
|
|
|
|
author: data.author,
|
|
|
|
requestedBy: message.author,
|
|
|
|
fromPlaylist: false,
|
|
|
|
source: 'soundcloud',
|
|
|
|
engine: data
|
|
|
|
});
|
|
|
|
|
|
|
|
tracks.push(track);
|
|
|
|
}
|
2021-04-06 17:55:29 +05:00
|
|
|
}
|
2021-04-06 17:58:46 +05:00
|
|
|
break;
|
|
|
|
case 'spotify_song':
|
|
|
|
{
|
|
|
|
const matchSpotifyURL = query.match(
|
|
|
|
/https?:\/\/(?:embed\.|open\.)(?:spotify\.com\/)(?:track\/|\?uri=spotify:track:)((\w|-){22})/
|
|
|
|
);
|
|
|
|
if (matchSpotifyURL) {
|
|
|
|
const spotifyData = await spotify.getPreview(query).catch(() => {});
|
|
|
|
if (spotifyData) {
|
|
|
|
tracks = await Util.ytSearch(`${spotifyData.artist} - ${spotifyData.title}`, {
|
|
|
|
user: message.author,
|
|
|
|
player: this
|
|
|
|
});
|
|
|
|
}
|
2021-04-06 17:55:29 +05:00
|
|
|
}
|
|
|
|
}
|
2021-04-06 17:58:46 +05:00
|
|
|
break;
|
2021-04-06 17:55:29 +05:00
|
|
|
default:
|
|
|
|
tracks = await Util.ytSearch(query, { user: message.author, player: this });
|
|
|
|
}
|
|
|
|
|
|
|
|
if (tracks.length < 1) return this.emit(PlayerEvents.NO_RESULTS, message, query);
|
|
|
|
if (firstResult) return resolve(tracks[0]);
|
|
|
|
|
|
|
|
const collectorString = `${message.author.id}-${message.channel.id}`;
|
|
|
|
const currentCollector = this._resultsCollectors.get(collectorString);
|
2021-04-06 17:58:46 +05:00
|
|
|
if (currentCollector) currentCollector.stop();
|
2021-04-06 17:55:29 +05:00
|
|
|
|
|
|
|
const collector = message.channel.createMessageCollector((m) => m.author.id === message.author.id, {
|
|
|
|
time: 60000
|
|
|
|
});
|
|
|
|
|
|
|
|
this._resultsCollectors.set(collectorString, collector);
|
|
|
|
|
|
|
|
this.emit(PlayerEvents.SEARCH_RESULTS, message, query, tracks, collector);
|
|
|
|
|
2021-04-06 17:58:46 +05:00
|
|
|
collector.on('collect', ({ content }) => {
|
|
|
|
if (content === 'cancel') {
|
2021-04-06 17:55:29 +05:00
|
|
|
collector.stop();
|
|
|
|
return this.emit(PlayerEvents.SEARCH_CANCEL, message, query, tracks);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!isNaN(content) && parseInt(content) >= 1 && parseInt(content) <= tracks.length) {
|
|
|
|
const index = parseInt(content, 10);
|
|
|
|
const track = tracks[index - 1];
|
|
|
|
collector.stop();
|
|
|
|
resolve(track);
|
|
|
|
} else {
|
|
|
|
this.emit(PlayerEvents.SEARCH_INVALID_RESPONSE, message, query, tracks, content, collector);
|
|
|
|
}
|
2021-04-06 17:58:46 +05:00
|
|
|
});
|
2021-04-06 17:55:29 +05:00
|
|
|
|
2021-04-06 17:58:46 +05:00
|
|
|
collector.on('end', (collected, reason) => {
|
|
|
|
if (reason === 'time') {
|
2021-04-06 17:55:29 +05:00
|
|
|
this.emit(PlayerEvents.SEARCH_CANCEL, message, query, tracks);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
});
|
2021-04-04 22:36:40 +05:00
|
|
|
}
|
2021-04-06 19:06:18 +05:00
|
|
|
|
|
|
|
async play(message: Message, query: string | Track, firstResult?: boolean) {
|
2021-04-06 19:08:01 +05:00
|
|
|
if (!message) throw new PlayerError('Play function needs message');
|
|
|
|
if (!query) throw new PlayerError('Play function needs search query as a string or Player.Track object');
|
2021-04-06 19:06:18 +05:00
|
|
|
|
|
|
|
if (this._cooldownsTimeout.has(`end_${message.guild.id}`)) {
|
|
|
|
clearTimeout(this._cooldownsTimeout.get(`end_${message.guild.id}`));
|
|
|
|
this._cooldownsTimeout.delete(`end_${message.guild.id}`);
|
|
|
|
}
|
|
|
|
|
2021-04-06 19:08:01 +05:00
|
|
|
if (typeof query === 'string') query = query.replace(/<(.+)>/g, '$1');
|
2021-04-06 19:06:18 +05:00
|
|
|
let track;
|
|
|
|
|
|
|
|
if (query instanceof Track) track = query;
|
|
|
|
else {
|
|
|
|
if (ytdl.validateURL(query)) {
|
|
|
|
const info = await ytdl.getBasicInfo(query).catch(() => {});
|
|
|
|
if (!info) return this.emit(PlayerEvents.NO_RESULTS, message, query);
|
2021-04-06 19:08:01 +05:00
|
|
|
if (info.videoDetails.isLiveContent && !this.options.enableLive)
|
|
|
|
return this.emit(
|
|
|
|
PlayerEvents.ERROR,
|
|
|
|
PlayerErrorEventCodes.LIVE_VIDEO,
|
|
|
|
message,
|
|
|
|
new PlayerError('Live video is not enabled!')
|
|
|
|
);
|
2021-04-06 19:06:18 +05:00
|
|
|
const lastThumbnail = info.videoDetails.thumbnails[info.videoDetails.thumbnails.length - 1];
|
|
|
|
|
|
|
|
track = new Track(this, {
|
|
|
|
title: info.videoDetails.title,
|
|
|
|
description: info.videoDetails.description,
|
|
|
|
author: info.videoDetails.author.name,
|
|
|
|
url: info.videoDetails.video_url,
|
|
|
|
thumbnail: lastThumbnail.url,
|
|
|
|
duration: Util.durationString(Util.parseMS(parseInt(info.videoDetails.lengthSeconds) * 1000)),
|
|
|
|
views: parseInt(info.videoDetails.viewCount),
|
|
|
|
requestedBy: message.author,
|
|
|
|
fromPlaylist: false,
|
2021-04-06 19:08:01 +05:00
|
|
|
source: 'youtube'
|
2021-04-06 19:06:18 +05:00
|
|
|
});
|
|
|
|
} else {
|
|
|
|
track = await this._searchTracks(message, query, firstResult);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (track) {
|
|
|
|
if (this.isPlaying(message)) {
|
|
|
|
const queue = this._addTrackToQueue(message, track);
|
|
|
|
this.emit(PlayerEvents.TRACK_ADD, message, queue, queue.tracks[queue.tracks.length - 1]);
|
|
|
|
} else {
|
|
|
|
const queue = await this._createQueue(message, track);
|
|
|
|
if (queue) this.emit(PlayerEvents.TRACK_START, message, queue.tracks[0], queue);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
isPlaying(message: Message) {
|
2021-04-06 19:08:01 +05:00
|
|
|
return this.queues.some((g) => g.guildID === message.guild.id);
|
2021-04-06 19:06:18 +05:00
|
|
|
}
|
|
|
|
|
|
|
|
getQueue(message: Message) {
|
2021-04-06 19:08:01 +05:00
|
|
|
return this.queues.find((g) => g.guildID === message.guild.id);
|
2021-04-06 19:06:18 +05:00
|
|
|
}
|
|
|
|
|
|
|
|
private _addTrackToQueue(message: Message, track: Track) {
|
|
|
|
const queue = this.getQueue(message);
|
2021-04-06 19:08:01 +05:00
|
|
|
if (!queue)
|
|
|
|
this.emit(
|
|
|
|
PlayerEvents.ERROR,
|
|
|
|
PlayerErrorEventCodes.NOT_PLAYING,
|
|
|
|
message,
|
|
|
|
new PlayerError('Player is not available in this server')
|
|
|
|
);
|
2021-04-06 19:06:18 +05:00
|
|
|
if (!track || !(track instanceof Track)) throw new PlayerError('No track specified to add to the queue');
|
|
|
|
queue.tracks.push(track);
|
|
|
|
return queue;
|
|
|
|
}
|
|
|
|
|
|
|
|
private _createQueue(message: Message, track: Track): Promise<Queue> {
|
|
|
|
return new Promise((resolve) => {
|
2021-04-06 19:08:01 +05:00
|
|
|
const channel = message.member.voice ? message.member.voice.channel : null;
|
|
|
|
if (!channel)
|
|
|
|
return this.emit(
|
|
|
|
PlayerEvents.ERROR,
|
|
|
|
PlayerErrorEventCodes.NOT_CONNECTED,
|
|
|
|
message,
|
|
|
|
new PlayerError('Voice connection is not available in this server!')
|
|
|
|
);
|
2021-04-06 19:06:18 +05:00
|
|
|
|
|
|
|
const queue = new Queue(this, message, this.filters);
|
|
|
|
this.queues.set(message.guild.id, queue);
|
|
|
|
|
2021-04-06 19:08:01 +05:00
|
|
|
channel
|
|
|
|
.join()
|
|
|
|
.then((connection) => {
|
|
|
|
this.emit(PlayerEvents.CONNECTION_CREATE, message, connection);
|
|
|
|
|
|
|
|
queue.voiceConnection = connection;
|
|
|
|
if (this.options.autoSelfDeaf) connection.voice.setSelfDeaf(true);
|
|
|
|
queue.tracks.push(track);
|
|
|
|
this.emit(PlayerEvents.QUEUE_CREATE, message, queue);
|
|
|
|
resolve(queue);
|
|
|
|
// this._playTrack(queue, true)
|
|
|
|
})
|
|
|
|
.catch((err) => {
|
|
|
|
this.queues.delete(message.guild.id);
|
|
|
|
this.emit(
|
|
|
|
PlayerEvents.ERROR,
|
|
|
|
PlayerErrorEventCodes.UNABLE_TO_JOIN,
|
|
|
|
message,
|
|
|
|
new PlayerError(err.message ?? err)
|
|
|
|
);
|
|
|
|
});
|
2021-04-06 19:06:18 +05:00
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
private async _playTrack(queue: Queue, firstPlay: boolean) {
|
|
|
|
if (queue.stopped) return;
|
|
|
|
|
|
|
|
if (queue.tracks.length === 1 && !queue.loopMode && !queue.repeatMode && !firstPlay) {
|
|
|
|
if (this.options.leaveOnEnd && !queue.stopped) {
|
2021-04-06 19:08:01 +05:00
|
|
|
this.queues.delete(queue.guildID);
|
2021-04-06 19:06:18 +05:00
|
|
|
const timeout = setTimeout(() => {
|
|
|
|
queue.voiceConnection.channel.leave();
|
|
|
|
}, this.options.leaveOnEndCooldown || 0);
|
|
|
|
this._cooldownsTimeout.set(`end_${queue.guildID}`, timeout);
|
|
|
|
}
|
|
|
|
|
2021-04-06 19:08:01 +05:00
|
|
|
this.queues.delete(queue.guildID);
|
2021-04-06 19:06:18 +05:00
|
|
|
|
|
|
|
if (queue.stopped) {
|
|
|
|
return this.emit(PlayerEvents.MUSIC_STOP, queue.firstMessage);
|
|
|
|
}
|
|
|
|
|
|
|
|
return this.emit(PlayerEvents.QUEUE_END, queue.firstMessage, queue);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!queue.repeatMode && !firstPlay) {
|
|
|
|
const oldTrack = queue.tracks.shift();
|
|
|
|
if (queue.loopMode) queue.tracks.push(oldTrack);
|
|
|
|
queue.previousTracks.push(oldTrack);
|
|
|
|
}
|
|
|
|
|
|
|
|
const track = queue.playing;
|
|
|
|
|
|
|
|
queue.lastSkipped = false;
|
|
|
|
this._playStream(queue, false).then(() => {
|
|
|
|
if (!firstPlay) this.emit(PlayerEvents.TRACK_START, queue.firstMessage, track, queue);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
private _playStream(queue: Queue, updateFilter: boolean, seek?: number): Promise<void> {
|
|
|
|
return new Promise(async (resolve) => {
|
|
|
|
const ffmeg = Util.checkFFmpeg();
|
|
|
|
if (!ffmeg) return;
|
|
|
|
|
2021-04-06 19:08:01 +05:00
|
|
|
const seekTime =
|
|
|
|
typeof seek === 'number'
|
|
|
|
? seek
|
|
|
|
: updateFilter
|
|
|
|
? queue.voiceConnection.dispatcher.streamTime + queue.additionalStreamTime
|
|
|
|
: undefined;
|
2021-04-06 19:06:18 +05:00
|
|
|
const encoderArgsFilters: string[] = [];
|
|
|
|
|
|
|
|
Object.keys(queue.filters).forEach((filterName) => {
|
|
|
|
// @ts-ignore
|
|
|
|
if (queue.filters[filterName]) {
|
|
|
|
// @ts-ignore
|
|
|
|
encoderArgsFilters.push(this.filters[filterName]);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
let encoderArgs: string[];
|
|
|
|
if (encoderArgsFilters.length < 1) {
|
2021-04-06 19:08:01 +05:00
|
|
|
encoderArgs = [];
|
2021-04-06 19:06:18 +05:00
|
|
|
} else {
|
2021-04-06 19:08:01 +05:00
|
|
|
encoderArgs = ['-af', encoderArgsFilters.join(',')];
|
2021-04-06 19:06:18 +05:00
|
|
|
}
|
|
|
|
|
|
|
|
let newStream: any;
|
2021-04-06 19:08:01 +05:00
|
|
|
if (queue.playing.raw.source === 'youtube') {
|
2021-04-06 19:06:18 +05:00
|
|
|
newStream = ytdl(queue.playing.url, {
|
|
|
|
filter: 'audioonly',
|
|
|
|
opusEncoded: true,
|
|
|
|
encoderArgs,
|
|
|
|
seek: seekTime / 1000,
|
2021-04-06 19:08:01 +05:00
|
|
|
// tslint:disable-next-line:no-bitwise
|
2021-04-06 19:06:18 +05:00
|
|
|
highWaterMark: 1 << 25,
|
|
|
|
...this.options.ytdlDownloadOptions
|
|
|
|
});
|
|
|
|
} else {
|
2021-04-06 19:08:01 +05:00
|
|
|
newStream = ytdl.arbitraryStream(
|
|
|
|
queue.playing.raw.source === 'soundcloud'
|
|
|
|
? await queue.playing.raw.engine.downloadProgressive()
|
|
|
|
: queue.playing.raw.engine,
|
|
|
|
{
|
|
|
|
opusEncoded: true,
|
|
|
|
encoderArgs,
|
|
|
|
seek: seekTime / 1000
|
|
|
|
}
|
|
|
|
);
|
2021-04-06 19:06:18 +05:00
|
|
|
}
|
|
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
if (queue.stream) queue.stream.destroy();
|
|
|
|
queue.stream = newStream;
|
|
|
|
queue.voiceConnection.play(newStream, {
|
|
|
|
type: 'opus',
|
|
|
|
bitrate: 'auto'
|
|
|
|
});
|
|
|
|
|
|
|
|
if (seekTime) {
|
|
|
|
queue.additionalStreamTime = seekTime;
|
|
|
|
}
|
|
|
|
queue.voiceConnection.dispatcher.setVolumeLogarithmic(queue.calculatedVolume / 200);
|
|
|
|
queue.voiceConnection.dispatcher.on('start', () => {
|
|
|
|
resolve();
|
|
|
|
});
|
|
|
|
|
|
|
|
queue.voiceConnection.dispatcher.on('finish', () => {
|
|
|
|
queue.additionalStreamTime = 0;
|
|
|
|
return this._playTrack(queue, false);
|
|
|
|
});
|
|
|
|
|
|
|
|
newStream.on('error', (error: Error) => {
|
|
|
|
if (error.message.toLowerCase().includes('video unavailable')) {
|
2021-04-06 19:08:01 +05:00
|
|
|
this.emit(
|
|
|
|
PlayerEvents.ERROR,
|
|
|
|
PlayerErrorEventCodes.VIDEO_UNAVAILABLE,
|
|
|
|
queue.firstMessage,
|
|
|
|
queue.playing,
|
|
|
|
error
|
|
|
|
);
|
2021-04-06 19:06:18 +05:00
|
|
|
this._playTrack(queue, false);
|
|
|
|
} else {
|
|
|
|
this.emit(PlayerEvents.ERROR, error, queue.firstMessage, error);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}, 1000);
|
|
|
|
});
|
|
|
|
}
|
2021-04-04 22:44:45 +05:00
|
|
|
}
|