discord-player-play-dl/src/Player.ts

433 lines
18 KiB
TypeScript
Raw Normal View History

2021-06-19 17:19:08 +05:00
import { Client, Collection, GuildResolvable, Snowflake, User, VoiceState } from "discord.js";
2021-06-11 15:32:22 +05:00
import { TypedEmitter as EventEmitter } from "tiny-typed-emitter";
import { Queue } from "./Structures/Queue";
2021-06-11 16:50:43 +05:00
import { VoiceUtils } from "./VoiceInterface/VoiceUtils";
2021-06-14 18:50:36 +05:00
import { PlayerEvents, PlayerOptions, QueryType, SearchOptions, DiscordPlayerInitOptions } from "./types/types";
2021-06-11 23:19:52 +05:00
import Track from "./Structures/Track";
import { QueryResolver } from "./utils/QueryResolver";
import YouTube from "youtube-sr";
2021-06-13 17:03:20 +05:00
import { Util } from "./utils/Util";
2021-06-13 18:09:25 +05:00
import Spotify from "spotify-url-info";
2021-06-13 17:03:20 +05:00
// @ts-ignore
import { Client as SoundCloud } from "soundcloud-scraper";
2021-06-13 19:31:27 +05:00
import { Playlist } from "./Structures/Playlist";
2021-06-14 18:50:36 +05:00
import { ExtractorModel } from "./Structures/ExtractorModel";
2021-06-13 17:03:20 +05:00
const soundcloud = new SoundCloud();
2021-06-11 15:32:22 +05:00
2021-06-11 23:19:52 +05:00
class DiscordPlayer extends EventEmitter<PlayerEvents> {
2021-06-11 15:32:22 +05:00
public readonly client: Client;
2021-06-14 18:50:36 +05:00
public readonly options: DiscordPlayerInitOptions = {
2021-06-14 19:32:37 +05:00
autoRegisterExtractor: true,
ytdlOptions: {}
2021-06-14 18:50:36 +05:00
};
2021-06-11 15:32:22 +05:00
public readonly queues = new Collection<Snowflake, Queue>();
2021-06-11 16:50:43 +05:00
public readonly voiceUtils = new VoiceUtils();
2021-06-14 18:50:36 +05:00
public readonly extractors = new Collection<string, ExtractorModel>();
2021-06-11 15:32:22 +05:00
2021-06-13 15:40:41 +05:00
/**
* Creates new Discord Player
2021-06-17 18:26:30 +05:00
* @param {Client} client The Discord Client
2021-06-13 15:40:41 +05:00
*/
2021-06-14 18:50:36 +05:00
constructor(client: Client, options: DiscordPlayerInitOptions = {}) {
2021-06-11 15:32:22 +05:00
super();
2021-06-13 15:40:41 +05:00
/**
* The discord.js client
2021-06-17 18:26:30 +05:00
* @type {Client}
2021-06-13 15:40:41 +05:00
*/
2021-06-11 15:32:22 +05:00
this.client = client;
2021-06-13 21:47:04 +05:00
2021-06-14 18:50:36 +05:00
/**
* The extractors collection
* @type {ExtractorModel}
*/
this.options = Object.assign(this.options, options);
2021-06-13 21:47:04 +05:00
this.client.on("voiceStateUpdate", this._handleVoiceState.bind(this));
2021-06-14 18:50:36 +05:00
if (this.options?.autoRegisterExtractor) {
let nv: any;
if ((nv = Util.require("@discord-player/extractor"))) {
["Attachment", "Facebook", "Reverbnation", "Vimeo"].forEach((ext) => void this.use(ext, nv[ext]));
}
}
2021-06-13 21:47:04 +05:00
}
private _handleVoiceState(oldState: VoiceState, newState: VoiceState): void {
const queue = this.getQueue(oldState.guild.id);
if (!queue) return;
if (oldState.member.id === this.client.user.id && !newState.channelID) {
queue.destroy();
return void this.emit("botDisconnect", queue);
}
if (!queue.options.leaveOnEmpty || !queue.connection || !queue.connection.channel) return;
if (!oldState.channelID || newState.channelID) {
const emptyTimeout = queue._cooldownsTimeout.get(`empty_${oldState.guild.id}`);
const channelEmpty = Util.isVoiceEmpty(queue.connection.channel);
if (!channelEmpty && emptyTimeout) {
clearTimeout(emptyTimeout);
queue._cooldownsTimeout.delete(`empty_${oldState.guild.id}`);
}
} else {
if (!Util.isVoiceEmpty(queue.connection.channel)) return;
const timeout = setTimeout(() => {
if (!Util.isVoiceEmpty(queue.connection.channel)) return;
if (!this.queues.has(queue.guild.id)) return;
queue.destroy();
this.emit("channelEmpty", queue);
}, queue.options.leaveOnEmptyCooldown || 0);
queue._cooldownsTimeout.set(`empty_${oldState.guild.id}`, timeout);
}
2021-06-11 15:32:22 +05:00
}
2021-06-13 15:40:41 +05:00
/**
* Creates a queue for a guild if not available, else returns existing queue
2021-06-17 18:22:03 +05:00
* @param {GuildResolvable} guild The guild
2021-06-13 15:40:41 +05:00
* @param {PlayerOptions} queueInitOptions Queue init options
* @returns {Queue}
*/
2021-06-17 18:22:03 +05:00
createQueue<T = unknown>(guild: GuildResolvable, queueInitOptions?: PlayerOptions & { metadata?: T }): Queue<T> {
guild = this.client.guilds.resolve(guild);
if (!guild) throw new Error("Unknown Guild");
2021-06-12 11:37:41 +05:00
if (this.queues.has(guild.id)) return this.queues.get(guild.id) as Queue<T>;
2021-06-13 15:40:41 +05:00
const _meta = queueInitOptions.metadata;
delete queueInitOptions["metadata"];
2021-06-14 19:44:15 +05:00
queueInitOptions.ytdlOptions ??= this.options.ytdlOptions;
2021-06-11 15:32:22 +05:00
const queue = new Queue(this, guild, queueInitOptions);
2021-06-13 15:40:41 +05:00
queue.metadata = _meta;
2021-06-11 15:32:22 +05:00
this.queues.set(guild.id, queue);
2021-06-12 11:37:41 +05:00
return queue as Queue<T>;
2021-06-11 15:32:22 +05:00
}
2021-06-13 15:40:41 +05:00
/**
* Returns the queue if available
2021-06-17 18:22:03 +05:00
* @param {GuildResolvable} guild The guild id
2021-06-13 15:40:41 +05:00
* @returns {Queue}
*/
2021-06-17 18:22:03 +05:00
getQueue<T = unknown>(guild: GuildResolvable) {
guild = this.client.guilds.resolve(guild);
if (!guild) throw new Error("Unknown Guild");
return this.queues.get(guild.id) as Queue<T>;
2021-06-11 15:32:22 +05:00
}
2021-06-11 19:57:49 +05:00
2021-06-13 15:40:41 +05:00
/**
* Deletes a queue and returns deleted queue object
2021-06-17 18:22:03 +05:00
* @param {GuildResolvable} guild The guild id to remove
2021-06-13 15:40:41 +05:00
* @returns {Queue}
*/
2021-06-17 18:22:03 +05:00
deleteQueue<T = unknown>(guild: GuildResolvable) {
guild = this.client.guilds.resolve(guild);
if (!guild) throw new Error("Unknown Guild");
2021-06-13 13:06:19 +05:00
const prev = this.getQueue<T>(guild);
2021-06-13 13:11:54 +05:00
try {
prev.destroy();
} catch {}
2021-06-17 18:22:03 +05:00
this.queues.delete(guild.id);
2021-06-13 13:11:54 +05:00
2021-06-13 13:06:19 +05:00
return prev;
}
2021-06-11 23:19:52 +05:00
/**
* Search tracks
* @param {string|Track} query The search query
* @param {UserResolvable} requestedBy The person who requested track search
2021-06-14 11:44:32 +05:00
* @returns {Promise<{playlist?: Playlist; tracks: Track[]}>}
2021-06-11 23:19:52 +05:00
*/
2021-06-13 17:03:20 +05:00
async search(query: string | Track, options: SearchOptions) {
2021-06-14 11:44:32 +05:00
if (query instanceof Track) return { playlist: null, tracks: [query] };
2021-06-13 17:03:20 +05:00
if (!options) throw new Error("DiscordPlayer#search needs search options!");
options.requestedBy = this.client.users.resolve(options.requestedBy);
2021-06-13 17:03:20 +05:00
if (!("searchEngine" in options)) options.searchEngine = QueryType.AUTO;
2021-06-11 23:19:52 +05:00
2021-06-14 18:50:36 +05:00
for (const [_, extractor] of this.extractors) {
if (!extractor.validate(query)) continue;
const data = await extractor.handle(query);
if (data && data.data.length) {
const playlist = !data.playlist
? null
: new Playlist(this, {
...data.playlist,
tracks: []
});
const tracks = data.data.map(
(m) =>
new Track(this, {
...m,
requestedBy: options.requestedBy as User,
2021-06-14 18:50:36 +05:00
duration: Util.buildTimeCode(Util.parseMS(m.duration)),
playlist: playlist
})
);
if (playlist) playlist.tracks = tracks;
return { playlist: playlist, tracks: tracks };
}
}
2021-06-13 17:03:20 +05:00
const qt = options.searchEngine === QueryType.AUTO ? QueryResolver.resolve(query) : options.searchEngine;
2021-06-11 23:19:52 +05:00
switch (qt) {
2021-06-11 23:39:21 +05:00
case QueryType.YOUTUBE_SEARCH: {
const videos = await YouTube.search(query, {
2021-06-11 23:19:52 +05:00
type: "video"
2021-06-13 19:31:27 +05:00
}).catch(() => {});
2021-06-14 11:44:32 +05:00
if (!videos) return { playlist: null, tracks: [] };
2021-06-11 23:19:52 +05:00
2021-06-13 19:31:27 +05:00
const tracks = videos.map((m) => {
2021-06-12 00:18:53 +05:00
(m as any).source = "youtube";
return new Track(this, {
title: m.title,
description: m.description,
author: m.channel?.name,
url: m.url,
requestedBy: options.requestedBy as User,
2021-06-12 00:18:53 +05:00
thumbnail: m.thumbnail?.displayThumbnailURL("maxresdefault"),
views: m.views,
duration: m.durationFormatted,
raw: m
2021-06-11 23:39:21 +05:00
});
2021-06-12 00:18:53 +05:00
});
2021-06-13 19:31:27 +05:00
2021-06-14 11:44:32 +05:00
return { playlist: null, tracks };
2021-06-11 23:19:52 +05:00
}
2021-06-13 17:03:20 +05:00
case QueryType.SOUNDCLOUD_TRACK:
case QueryType.SOUNDCLOUD_SEARCH: {
const result: any[] = QueryResolver.resolve(query) === QueryType.SOUNDCLOUD_TRACK ? [{ url: query }] : await soundcloud.search(query, "track").catch(() => {});
2021-06-14 11:44:32 +05:00
if (!result || !result.length) return { playlist: null, tracks: [] };
2021-06-13 17:03:20 +05:00
const res: Track[] = [];
for (const r of result) {
const trackInfo = await soundcloud.getSongInfo(r.url).catch(() => {});
if (!trackInfo) continue;
const track = new Track(this, {
title: trackInfo.title,
url: trackInfo.url,
duration: Util.buildTimeCode(Util.parseMS(trackInfo.duration)),
description: trackInfo.description,
thumbnail: trackInfo.thumbnail,
views: trackInfo.playCount,
author: trackInfo.author.name,
requestedBy: options.requestedBy,
source: "soundcloud",
engine: trackInfo
});
res.push(track);
}
2021-06-14 11:44:32 +05:00
return { playlist: null, tracks: res };
2021-06-13 17:03:20 +05:00
}
2021-06-13 18:09:25 +05:00
case QueryType.SPOTIFY_SONG: {
const spotifyData = await Spotify.getData(query).catch(() => {});
2021-06-14 11:44:32 +05:00
if (!spotifyData) return { playlist: null, tracks: [] };
2021-06-13 18:09:25 +05:00
const spotifyTrack = new Track(this, {
title: spotifyData.name,
description: spotifyData.description ?? "",
author: spotifyData.artists[0]?.name ?? "Unknown Artist",
url: spotifyData.external_urls?.spotify ?? query,
thumbnail:
spotifyData.album?.images[0]?.url ?? spotifyData.preview_url?.length
? `https://i.scdn.co/image/${spotifyData.preview_url?.split("?cid=")[1]}`
: "https://www.scdn.co/i/_global/twitter_card-default.jpg",
duration: Util.buildTimeCode(Util.parseMS(spotifyData.duration_ms)),
views: 0,
requestedBy: options.requestedBy,
source: "spotify"
});
2021-06-14 11:44:32 +05:00
return { playlist: null, tracks: [spotifyTrack] };
2021-06-13 19:31:27 +05:00
}
case QueryType.SPOTIFY_PLAYLIST:
case QueryType.SPOTIFY_ALBUM: {
const spotifyPlaylist = await Spotify.getData(query).catch(() => {});
2021-06-14 11:44:32 +05:00
if (!spotifyPlaylist) return { playlist: null, tracks: [] };
2021-06-13 19:31:27 +05:00
const playlist = new Playlist(this, {
title: spotifyPlaylist.name ?? spotifyPlaylist.title,
description: spotifyPlaylist.description ?? "",
thumbnail: spotifyPlaylist.images[0]?.url ?? "https://www.scdn.co/i/_global/twitter_card-default.jpg",
type: spotifyPlaylist.type,
source: "spotify",
author:
spotifyPlaylist.type !== "playlist"
? {
name: spotifyPlaylist.artists[0]?.name ?? "Unknown Artist",
url: spotifyPlaylist.artists[0]?.external_urls?.spotify ?? null
}
: {
name: spotifyPlaylist.owner?.display_name ?? spotifyPlaylist.owner?.id ?? "Unknown Artist",
url: spotifyPlaylist.owner?.external_urls?.spotify ?? null
},
tracks: [],
id: spotifyPlaylist.id,
2021-06-14 11:44:32 +05:00
url: spotifyPlaylist.external_urls?.spotify ?? query,
rawPlaylist: spotifyPlaylist
2021-06-13 19:31:27 +05:00
});
if (spotifyPlaylist.type !== "playlist") {
playlist.tracks = spotifyPlaylist.tracks.items.map((m: any) => {
const data = new Track(this, {
title: m.name ?? "",
description: m.description ?? "",
author: m.artists[0]?.name ?? "Unknown Artist",
url: m.external_urls?.spotify ?? query,
thumbnail: spotifyPlaylist.images[0]?.url ?? "https://www.scdn.co/i/_global/twitter_card-default.jpg",
duration: Util.buildTimeCode(Util.parseMS(m.duration_ms)),
views: 0,
requestedBy: options.requestedBy as User,
2021-06-13 19:31:27 +05:00
playlist,
source: "spotify"
});
return data;
}) as Track[];
} else {
playlist.tracks = spotifyPlaylist.tracks.items.map((m: any) => {
const data = new Track(this, {
title: m.track.name ?? "",
description: m.track.description ?? "",
author: m.track.artists[0]?.name ?? "Unknown Artist",
url: m.track.external_urls?.spotify ?? query,
thumbnail: m.track.album?.images[0]?.url ?? "https://www.scdn.co/i/_global/twitter_card-default.jpg",
duration: Util.buildTimeCode(Util.parseMS(m.track.duration_ms)),
views: 0,
requestedBy: options.requestedBy as User,
2021-06-13 19:31:27 +05:00
playlist,
source: "spotify"
});
return data;
}) as Track[];
}
2021-06-14 11:44:32 +05:00
return { playlist: playlist, tracks: playlist.tracks };
}
case QueryType.SOUNDCLOUD_PLAYLIST: {
2021-06-14 11:45:03 +05:00
const data = await SoundCloud.getPlaylist(query).catch(() => {});
2021-06-14 11:44:32 +05:00
if (!data) return { playlist: null, tracks: [] };
const res = new Playlist(this, {
title: data.title,
description: data.description ?? "",
thumbnail: data.thumbnail ?? "https://soundcloud.com/pwa-icon-192.png",
type: "playlist",
source: "soundcloud",
author: {
name: data.author?.name ?? data.author?.username ?? "Unknown Artist",
url: data.author?.profile
},
tracks: [],
id: `${data.id}`, // stringified
url: data.url,
rawPlaylist: data
});
for (const song of data) {
const track = new Track(this, {
title: song.title,
description: song.description ?? "",
author: song.author?.username ?? song.author?.name ?? "Unknown Artist",
url: song.url,
thumbnail: song.thumbnail,
duration: Util.buildTimeCode(Util.parseMS(song.duration)),
views: song.playCount ?? 0,
requestedBy: options.requestedBy,
playlist: res,
source: "soundcloud",
engine: song
});
res.tracks.push(track);
}
return { playlist: res, tracks: res.tracks };
}
case QueryType.YOUTUBE_PLAYLIST: {
const ytpl = await YouTube.getPlaylist(query).catch(() => {});
if (!ytpl) return { playlist: null, tracks: [] };
// @todo: better way of handling large playlists
await ytpl.fetch().catch(() => {});
2021-06-14 12:28:31 +05:00
const playlist: Playlist = new Playlist(this, {
2021-06-14 11:44:32 +05:00
title: ytpl.title,
2021-06-14 12:28:31 +05:00
thumbnail: ytpl.thumbnail as unknown as string,
2021-06-14 11:44:32 +05:00
description: "",
type: "playlist",
source: "youtube",
author: {
name: ytpl.channel.name,
url: ytpl.channel.url
},
tracks: [],
id: ytpl.id,
url: ytpl.url,
rawPlaylist: ytpl
});
2021-06-14 12:28:31 +05:00
playlist.tracks = ytpl.videos.map(
(video) =>
2021-06-14 11:45:03 +05:00
new Track(this, {
title: video.title,
description: video.description,
author: video.channel?.name,
url: video.url,
requestedBy: options.requestedBy as User,
2021-06-14 12:28:31 +05:00
thumbnail: video.thumbnail.url,
2021-06-14 11:45:03 +05:00
views: video.views,
duration: video.durationFormatted,
raw: video,
2021-06-14 12:28:31 +05:00
playlist: playlist,
source: "youtube"
2021-06-14 11:45:03 +05:00
})
2021-06-14 12:28:31 +05:00
);
2021-06-14 11:48:32 +05:00
return { playlist: playlist, tracks: playlist.tracks };
2021-06-13 18:09:25 +05:00
}
2021-06-11 23:39:21 +05:00
default:
2021-06-14 11:44:32 +05:00
return { playlist: null, tracks: [] };
2021-06-11 23:19:52 +05:00
}
}
use(extractorName: string, extractor: ExtractorModel | any, force = false): ExtractorModel {
2021-06-14 18:50:36 +05:00
if (!extractorName) throw new Error("Cannot use unknown extractor!");
if (this.extractors.has(extractorName) && !force) return this.extractors.get(extractorName);
2021-06-14 18:50:36 +05:00
if (extractor instanceof ExtractorModel) {
this.extractors.set(extractorName, extractor);
return extractor;
2021-06-14 18:50:36 +05:00
}
for (const method of ["validate", "getInfo"]) {
if (typeof extractor[method] !== "function") throw new Error("Invalid extractor data!");
}
const model = new ExtractorModel(extractorName, extractor);
this.extractors.set(model.name, model);
return model;
2021-06-14 18:50:36 +05:00
}
unuse(extractorName: string) {
if (!this.extractors.has(extractorName)) throw new Error(`Cannot find extractor "${extractorName}"`);
const prev = this.extractors.get(extractorName);
2021-06-14 18:50:36 +05:00
this.extractors.delete(extractorName);
return prev;
2021-06-14 18:50:36 +05:00
}
2021-06-11 19:57:49 +05:00
*[Symbol.iterator]() {
yield* Array.from(this.queues.values());
}
2021-06-11 15:32:22 +05:00
}
2021-06-13 23:28:37 +05:00
export { DiscordPlayer as Player };