feat: extractors implementation
This commit is contained in:
parent
7f34fbd786
commit
62bf57ac74
7 changed files with 124 additions and 44 deletions
|
@ -1,8 +1,8 @@
|
||||||
import { Client, Collection, Guild, Snowflake, User, VoiceState } from "discord.js";
|
import { Client, Collection, Guild, Snowflake, VoiceState } 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 } from "./types/types";
|
import { PlayerEvents, PlayerOptions, QueryType, SearchOptions, DiscordPlayerInitOptions } 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";
|
||||||
|
@ -11,19 +11,24 @@ import Spotify from "spotify-url-info";
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { Client as SoundCloud } from "soundcloud-scraper";
|
import { Client as SoundCloud } from "soundcloud-scraper";
|
||||||
import { Playlist } from "./Structures/Playlist";
|
import { Playlist } from "./Structures/Playlist";
|
||||||
|
import { ExtractorModel } from "./Structures/ExtractorModel";
|
||||||
|
|
||||||
const soundcloud = new SoundCloud();
|
const soundcloud = new SoundCloud();
|
||||||
|
|
||||||
class DiscordPlayer extends EventEmitter<PlayerEvents> {
|
class DiscordPlayer extends EventEmitter<PlayerEvents> {
|
||||||
public readonly client: Client;
|
public readonly client: Client;
|
||||||
|
public readonly options: DiscordPlayerInitOptions = {
|
||||||
|
autoRegisterExtractor: true
|
||||||
|
};
|
||||||
public readonly queues = new Collection<Snowflake, Queue>();
|
public readonly queues = new Collection<Snowflake, Queue>();
|
||||||
public readonly voiceUtils = new VoiceUtils();
|
public readonly voiceUtils = new VoiceUtils();
|
||||||
|
public readonly extractors = new Collection<string, ExtractorModel>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates new Discord Player
|
* Creates new Discord Player
|
||||||
* @param {Discord.Client} client The Discord Client
|
* @param {Discord.Client} client The Discord Client
|
||||||
*/
|
*/
|
||||||
constructor(client: Client) {
|
constructor(client: Client, options: DiscordPlayerInitOptions = {}) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -32,7 +37,21 @@ class DiscordPlayer extends EventEmitter<PlayerEvents> {
|
||||||
*/
|
*/
|
||||||
this.client = client;
|
this.client = client;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The extractors collection
|
||||||
|
* @type {ExtractorModel}
|
||||||
|
*/
|
||||||
|
this.options = Object.assign(this.options, options);
|
||||||
|
|
||||||
this.client.on("voiceStateUpdate", this._handleVoiceState.bind(this));
|
this.client.on("voiceStateUpdate", this._handleVoiceState.bind(this));
|
||||||
|
|
||||||
|
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]));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _handleVoiceState(oldState: VoiceState, newState: VoiceState): void {
|
private _handleVoiceState(oldState: VoiceState, newState: VoiceState): void {
|
||||||
|
@ -120,7 +139,33 @@ class DiscordPlayer extends EventEmitter<PlayerEvents> {
|
||||||
if (!options) throw new Error("DiscordPlayer#search needs search options!");
|
if (!options) throw new Error("DiscordPlayer#search needs search options!");
|
||||||
if (!("searchEngine" in options)) options.searchEngine = QueryType.AUTO;
|
if (!("searchEngine" in options)) options.searchEngine = QueryType.AUTO;
|
||||||
|
|
||||||
// @todo: add extractors
|
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,
|
||||||
|
duration: Util.buildTimeCode(Util.parseMS(m.duration)),
|
||||||
|
playlist: playlist
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (playlist) playlist.tracks = tracks;
|
||||||
|
|
||||||
|
return { playlist: playlist, tracks: tracks };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const qt = options.searchEngine === QueryType.AUTO ? QueryResolver.resolve(query) : options.searchEngine;
|
const qt = options.searchEngine === QueryType.AUTO ? QueryResolver.resolve(query) : options.searchEngine;
|
||||||
switch (qt) {
|
switch (qt) {
|
||||||
case QueryType.YOUTUBE_SEARCH: {
|
case QueryType.YOUTUBE_SEARCH: {
|
||||||
|
@ -345,6 +390,29 @@ class DiscordPlayer extends EventEmitter<PlayerEvents> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
use(extractorName: string, extractor: ExtractorModel | any, force = false) {
|
||||||
|
if (!extractorName) throw new Error("Cannot use unknown extractor!");
|
||||||
|
if (this.extractors.has(extractorName) && !force) return this;
|
||||||
|
if (extractor instanceof ExtractorModel) {
|
||||||
|
this.extractors.set(extractorName, extractor);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 this;
|
||||||
|
}
|
||||||
|
|
||||||
|
unuse(extractorName: string) {
|
||||||
|
if (!this.extractors.has(extractorName)) throw new Error(`Cannot find extractor "${extractorName}"`);
|
||||||
|
this.extractors.delete(extractorName);
|
||||||
|
}
|
||||||
|
|
||||||
*[Symbol.iterator]() {
|
*[Symbol.iterator]() {
|
||||||
yield* Array.from(this.queues.values());
|
yield* Array.from(this.queues.values());
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,14 +29,18 @@ class ExtractorModel {
|
||||||
if (!data) return null;
|
if (!data) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: data.title,
|
playlist: data.playlist ?? null,
|
||||||
duration: data.duration,
|
data:
|
||||||
thumbnail: data.thumbnail,
|
data.info?.map((m: any) => ({
|
||||||
engine: data.engine,
|
title: m.title,
|
||||||
views: data.views,
|
duration: m.duration,
|
||||||
author: data.author,
|
thumbnail: m.thumbnail,
|
||||||
description: data.description,
|
engine: m.engine,
|
||||||
url: data.url
|
views: m.views,
|
||||||
|
author: m.author,
|
||||||
|
description: m.description,
|
||||||
|
url: m.url
|
||||||
|
})) ?? []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -56,14 +60,6 @@ class ExtractorModel {
|
||||||
get version(): string {
|
get version(): string {
|
||||||
return this._raw.version ?? "0.0.0";
|
return this._raw.version ?? "0.0.0";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* If player should mark this extractor as important
|
|
||||||
* @type {boolean}
|
|
||||||
*/
|
|
||||||
get important(): boolean {
|
|
||||||
return Boolean(this._raw.important);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export { ExtractorModel };
|
export { ExtractorModel };
|
||||||
|
|
|
@ -14,7 +14,6 @@ import { StageChannel, VoiceChannel } from "discord.js";
|
||||||
import { Duplex, Readable } from "stream";
|
import { Duplex, Readable } from "stream";
|
||||||
import { TypedEmitter as EventEmitter } from "tiny-typed-emitter";
|
import { TypedEmitter as EventEmitter } from "tiny-typed-emitter";
|
||||||
import Track from "../Structures/Track";
|
import Track from "../Structures/Track";
|
||||||
import PlayerError from "../utils/PlayerError";
|
|
||||||
|
|
||||||
export interface VoiceEvents {
|
export interface VoiceEvents {
|
||||||
error: (error: AudioPlayerError) => any;
|
error: (error: AudioPlayerError) => any;
|
||||||
|
@ -130,7 +129,7 @@ class BasicStreamDispatcher extends EventEmitter<VoiceEvents> {
|
||||||
* @param {AudioResource} resource The audio resource to play
|
* @param {AudioResource} resource The audio resource to play
|
||||||
*/
|
*/
|
||||||
async playStream(resource: AudioResource<Track> = this.audioResource) {
|
async playStream(resource: AudioResource<Track> = this.audioResource) {
|
||||||
if (!resource) throw new PlayerError("Audio resource is not available!");
|
if (!resource) throw new Error("Audio resource is not available!");
|
||||||
if (!this.audioResource) this.audioResource = resource;
|
if (!this.audioResource) this.audioResource = resource;
|
||||||
if (this.voiceConnection.state.status !== VoiceConnectionStatus.Ready) await entersState(this.voiceConnection, VoiceConnectionStatus.Ready, 20000);
|
if (this.voiceConnection.state.status !== VoiceConnectionStatus.Ready) await entersState(this.voiceConnection, VoiceConnectionStatus.Ready, 20000);
|
||||||
this.audioPlayer.play(resource);
|
this.audioPlayer.play(resource);
|
||||||
|
|
|
@ -4,6 +4,5 @@ export { Playlist } from "./Structures/Playlist";
|
||||||
export { Player } from "./Player";
|
export { Player } from "./Player";
|
||||||
export { Queue } from "./Structures/Queue";
|
export { Queue } from "./Structures/Queue";
|
||||||
export { Track } from "./Structures/Track";
|
export { Track } from "./Structures/Track";
|
||||||
export { PlayerError } from "./utils/PlayerError";
|
|
||||||
export { VoiceUtils } from "./VoiceInterface/VoiceUtils";
|
export { VoiceUtils } from "./VoiceInterface/VoiceUtils";
|
||||||
export { VoiceEvents, StreamDispatcher } from "./VoiceInterface/BasicStreamDispatcher";
|
export { VoiceEvents, StreamDispatcher } from "./VoiceInterface/BasicStreamDispatcher";
|
||||||
|
|
|
@ -88,17 +88,33 @@ export interface PlayerOptions {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExtractorModelData {
|
export interface ExtractorModelData {
|
||||||
title: string;
|
playlist?: {
|
||||||
duration: number;
|
title: string;
|
||||||
thumbnail: string;
|
description: string;
|
||||||
engine: string | Readable | Duplex;
|
thumbnail: string;
|
||||||
views: number;
|
type: "album" | "playlist";
|
||||||
author: string;
|
source: TrackSource;
|
||||||
description: string;
|
author: {
|
||||||
url: string;
|
name: string;
|
||||||
version?: string;
|
url: string;
|
||||||
important?: boolean;
|
};
|
||||||
source?: TrackSource;
|
id: string;
|
||||||
|
url: string;
|
||||||
|
rawPlaylist?: any;
|
||||||
|
};
|
||||||
|
data: {
|
||||||
|
title: string;
|
||||||
|
duration: number;
|
||||||
|
thumbnail: string;
|
||||||
|
engine: string | Readable | Duplex;
|
||||||
|
views: number;
|
||||||
|
author: string;
|
||||||
|
description: string;
|
||||||
|
url: string;
|
||||||
|
version?: string;
|
||||||
|
important?: boolean;
|
||||||
|
source?: TrackSource;
|
||||||
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum QueryType {
|
export enum QueryType {
|
||||||
|
@ -226,3 +242,7 @@ export interface PlaylistJSON {
|
||||||
};
|
};
|
||||||
tracks: TrackJSON[];
|
tracks: TrackJSON[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DiscordPlayerInitOptions {
|
||||||
|
autoRegisterExtractor?: boolean;
|
||||||
|
}
|
||||||
|
|
|
@ -1,10 +0,0 @@
|
||||||
export default class PlayerError extends Error {
|
|
||||||
constructor(msg: string, name?: string) {
|
|
||||||
super();
|
|
||||||
this.name = name ?? "PlayerError";
|
|
||||||
this.message = msg;
|
|
||||||
Error.captureStackTrace(this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export { PlayerError };
|
|
|
@ -39,6 +39,14 @@ class Util {
|
||||||
static isVoiceEmpty(channel: VoiceChannel | StageChannel) {
|
static isVoiceEmpty(channel: VoiceChannel | StageChannel) {
|
||||||
return channel.members.filter((member) => !member.user.bot).size === 0;
|
return channel.members.filter((member) => !member.user.bot).size === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static require(id: string) {
|
||||||
|
try {
|
||||||
|
return require(id);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Util };
|
export { Util };
|
||||||
|
|
Loading…
Reference in a new issue