New player features (#236)

Co-authored-by: Androz <androz2091@gmail.com>
This commit is contained in:
Snowflake 2021-02-19 03:25:31 +05:45 committed by GitHub
parent 00f77fbd67
commit c804bd671f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 884 additions and 64 deletions

1
.gitignore vendored
View file

@ -4,3 +4,4 @@ package-lock.json
yarn.lock
docs
.vscode
test

View file

@ -1,6 +1,7 @@
process.env.YTDL_NO_UPDATE = true;
process.env.YTDL_NO_UPDATE = true
module.exports = {
Extractors: require('./src/Extractors/Extractor'),
version: require('./package.json').version,
Player: require('./src/Player')
}

View file

@ -6,7 +6,7 @@
"types": "typings/index.d.ts",
"funding": "https://github.com/Androz2091/discord-player?sponsor=1",
"scripts": {
"test": "node index.js",
"test": "cd test && node index.js",
"generate-docs": "node_modules/.bin/jsdoc --configure .jsdoc.json --verbose"
},
"repository": {
@ -33,13 +33,16 @@
"@types/node": "^14.14.7",
"chalk": "^4.1.0",
"discord-ytdl-core": "^5.0.0",
"jsdom": "^16.4.0",
"merge-options": "^3.0.4",
"moment": "^2.27.0",
"node-fetch": "^2.6.0",
"soundcloud-scraper": "^4.0.0",
"parse-ms": "^2.1.0",
"reverbnation-scraper": "^2.0.0",
"soundcloud-scraper": "^4.0.3",
"spotify-url-info": "^2.2.0",
"xvdl": "^1.0.2",
"youtube-sr": "^3.0.0",
"ytdl-core": "^4.4.2"
"ytdl-core": "^4.4.3"
},
"devDependencies": {
"@discordjs/opus": "^0.3.2",

46
src/Extractors/Discord.js Normal file
View file

@ -0,0 +1,46 @@
const https = require('https')
class Discord {
constructor () {
throw new Error(`The ${this.constructor.name} class may not be instantiated!`)
}
/**
* @typedef {import('stream').Readable} Readable
*/
/**
* Downloads discord attachment
* @param {string} url Discord attachment url
* @returns {Promise<Readable>}
*/
static async download (url) {
const data = await Discord.getInfo(url)
return data.stream
}
/**
* Returns discord attachment info
* @param {string} url Attachment url
*/
static getInfo (url) {
return new Promise((resolve) => {
https.get(url, res => {
const data = {
title: res.req.path.split('/').pop(),
format: res.headers['content-type'],
size: !isNaN(res.headers['content-length']) ? Math.round((parseInt(res.headers['content-length']) / (1024 * 1024)) * 100) / 100 : 0,
sizeFormat: 'MB'
}
Object.defineProperty(data, 'stream', {
get: () => res
})
resolve(data)
})
})
}
}
module.exports = Discord

View file

@ -0,0 +1,7 @@
module.exports = {
DiscordExtractor: require('./Discord'),
FacebookExtractor: require('./Facebook'),
ReverbnationExtractor: require('reverbnation-scraper'),
VimeoExtractor: require('./Vimeo'),
XVideosExtractor: require('xvdl').XVDL
}

148
src/Extractors/Facebook.js Normal file
View file

@ -0,0 +1,148 @@
const fetch = require('node-fetch').default
const { JSDOM } = require('jsdom')
class Facebook {
constructor () {
throw new Error(`The ${this.constructor.name} class may not be instantiated!`)
}
/**
* Validates facebook url
* @param {string} url URL to validate
*/
static validateURL (url) {
const REGEX = /(https?:\/\/)(www\.|m\.)?(facebook|fb).com\/.*\/videos\/.*/
if (!url || typeof url !== 'string') return false
return REGEX.test(url)
}
/**
* @typedef {import('stream').Readable} Readable
*/
/**
* Downloads facebook video
* @param {string} url Video url to download
* @returns {Promise<Readable>}
*/
static download (url) {
return new Promise(async (resolve, reject) => {
if (!Facebook.validateURL(url)) reject(new Error('Invalid url.'))
const info = await Facebook.getInfo(url)
if (!info || !info.streamURL) return reject(new Error('video not found'))
const link = info.streamURL
let req = require('https')
if (link.startsWith('http://')) req = require('http')
req.get(link, res => {
resolve(res)
})
})
}
/**
* Fetches facebook video info
* @param {string} url Facebook video url
*/
static async getInfo (url) {
if (!Facebook.validateURL(url)) throw new Error('Invalid url.')
try {
const html = await Facebook._parseHTML(url)
const document = new JSDOM(html).window.document
const rawdata = document.querySelector('script[type="application/ld+json"]').innerHTML
const json = JSON.parse(rawdata)
const obj = {
name: json.name,
title: document.querySelector('meta[property="og:title"]').attributes.item(1).value,
description: json.description,
rawVideo: json.contentUrl,
thumbnail: json.thumbnailUrl,
uploadedAt: new Date(json.uploadDate),
duration: Facebook.parseTime(json.duration),
interactionCount: json.interactionCount,
streamURL: json.url,
publishedAt: new Date(json.datePublished),
width: json.width,
height: json.height,
live: !!json.publication[0].isLiveBroadcast,
nsfw: !json.isFamilyFriendly,
genre: json.genre,
keywords: json.keywords ? json.keywords.split(', ') : [],
comments: json.commentCount,
size: json.contentSize,
quality: json.videoQuality,
author: {
type: json.author['@type'],
name: json.author.name,
url: json.author.url
},
publisher: {
type: json.publisher['@type'],
name: json.publisher.name,
url: json.publisher.url,
avatar: json.publisher.logo.url
},
url: html.split('",page_uri:"')[1].split('",')[0],
shares: html.split(',share_count:{')[1].split('},')[0].split(':')[1],
views: html.split(',video_view_count:')[1].split(',')[0]
}
return obj
} catch {
return null
}
}
/**
* Parses time in ms
* @param {string} duration Raw duration to parse
* @returns {string}
*/
static parseTime (duration) {
if (typeof duration !== 'string') return duration
let a = duration.match(/\d+/g)
if (duration.indexOf('M') >= 0 && duration.indexOf('H') === -1 && duration.indexOf('S') === -1) {
a = [0, a[0], 0]
}
if (duration.indexOf('H') >= 0 && duration.indexOf('M') === -1) {
a = [a[0], 0, a[1]]
}
if (duration.indexOf('H') >= 0 && duration.indexOf('M') === -1 && duration.indexOf('S') === -1) {
a = [a[0], 0, 0]
}
duration = 0
if (a.length === 3) {
duration = duration + parseInt(a[0]) * 3600
duration = duration + parseInt(a[1]) * 60
duration = duration + parseInt(a[2])
}
if (a.length === 2) {
duration = duration + parseInt(a[0]) * 60
duration = duration + parseInt(a[1])
}
if (a.length === 1) {
duration = duration + parseInt(a[0])
}
return duration
}
/**
* @ignore
* @param {string} url website url to parse html
*/
static async _parseHTML (url) {
const res = await fetch(url.replace('/m.', '/'))
return await res.text()
}
}
module.exports = Facebook

68
src/Extractors/Vimeo.js Normal file
View file

@ -0,0 +1,68 @@
const fetch = require('node-fetch').default
class Vimeo {
constructor () {
throw new Error(`The ${this.constructor.name} class may not be instantiated!`)
}
/**
* @typedef {import('stream').Readable} Readable
*/
/**
* Downloads from vimeo
* @param {number} id Vimeo video id
* @returns {Promise<Readable>}
*/
static download (id) {
return new Promise(async (resolve) => {
const info = await Vimeo.getInfo(id)
if (!info) return null
const downloader = info.stream.url.startsWith('https://') ? require('https') : require('http')
downloader.get(info.stream.url, res => {
resolve(res)
})
})
}
/**
* Returns video info
* @param {number} id Video id
*/
static async getInfo (id) {
if (!id) throw new Error('Invalid id')
const url = `https://player.vimeo.com/video/${id}`
try {
const res = await fetch(url)
const data = await res.text()
const json = JSON.parse(data.split('<script> (function(document, player) { var config = ')[1].split(';')[0])
const obj = {
id: json.video.id,
duration: json.video.duration,
title: json.video.title,
url: json.video.url,
thumbnail: json.video.thumbs['1280'] || json.video.thumbs.base,
width: json.video.width,
height: json.video.height,
stream: json.request.files.progressive[0],
author: {
accountType: json.video.owner.account_type,
id: json.video.owner.id,
name: json.video.owner.name,
url: json.video.owner.url,
avatar: json.video.owner.img_2x || json.video.owner.img
}
}
return obj
} catch {
return null
}
}
}
module.exports = Vimeo

View file

@ -1,14 +1,15 @@
const ytdl = require('discord-ytdl-core')
const Discord = require('discord.js')
const ytsr = require('youtube-sr')
const ytsr = require('youtube-sr').default
const spotify = require('spotify-url-info')
const soundcloud = require('soundcloud-scraper')
const moment = require('moment')
const ms = require('parse-ms')
const Queue = require('./Queue')
const Track = require('./Track')
const Util = require('./Util')
const { EventEmitter } = require('events')
const Client = new soundcloud.Client()
const { VimeoExtractor, DiscordExtractor, FacebookExtractor, ReverbnationExtractor, XVideosExtractor } = require('./Extractors/Extractor')
/**
* @typedef Filters
@ -31,6 +32,15 @@ const Client = new soundcloud.Client()
* @property {boolean} [haas=false] Whether the haas filter is enabled.
* @property {boolean} [mcompand=false] Whether the mcompand filter is enabled.
* @property {boolean} [mono=false] Whether the mono output is enabled.
* @property {boolean} [mstlr=false] Whether M/S signal to L/R signal converter is enabled.
* @property {boolean} [mstrr=false] Whether M/S signal to R/R signal converter is enabled.
* @property {boolean} [compressor=false] Whether compressor filter is enabled.
* @property {boolean} [expander=false] Whether expander filter is enabled.
* @property {boolean} [softlimiter=false] Whether softlimiter filter is enabled.
* @property {boolean} [chorus=false] Whether chorus (single delay) filter is enabled.
* @property {boolean} [chorus2d=false] Whether chorus2d (two delays) filter is enabled.
* @property {boolean} [chorus3d=false] Whether chorus3d (three delays) filter is enabled.
* @property {boolean} [fadein=false] Whether fadein filter is enabled.
*/
const filters = {
@ -52,7 +62,16 @@ const filters = {
gate: 'agate',
haas: 'haas',
mcompand: 'mcompand',
mono: 'pan=mono|c0=.5*c0+.5*c1'
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'
}
/**
@ -64,6 +83,8 @@ const filters = {
* @property {number} [leaveOnEmptyCooldown=0] Used when leaveOnEmpty is enabled, to let the time to users to come back in the voice channel.
* @property {boolean} [autoSelfDeaf=true] Whether the bot should automatically turn off its headphones when joining a voice channel.
* @property {string} [quality='high'] Music quality (high or low)
* @property {boolean} [enableLive=false] If it should enable live contents
* @property {object} [ytdlRequestOptions={}] YTDL request options to use cookies, proxy etc..
*/
/**
@ -77,7 +98,9 @@ const defaultPlayerOptions = {
leaveOnEmpty: true,
leaveOnEmptyCooldown: 0,
autoSelfDeaf: true,
quality: 'high'
quality: 'high',
enableLive: false,
ytdlRequestOptions: {}
}
class Player extends EventEmitter {
@ -94,7 +117,7 @@ class Player extends EventEmitter {
* @type {Util}
*/
this.util = Util
this.util.checkFFMPEG();
this.util.checkFFMPEG()
/**
* Discord.js client instance
@ -130,11 +153,23 @@ class Player extends EventEmitter {
this._resultsCollectors = new Discord.Collection()
}
/**
* Returns all the available audio filters
* @type {Filters}
* @example const filters = require('discord-player').Player.AudioFilters
* console.log(`There are ${Object.keys(filters).length} filters!`)
*/
static get AudioFilters () {
return filters
}
/**
* @ignore
* @param {String} query
*/
resolveQueryType (query) {
resolveQueryType (query, forceType) {
if (forceType && typeof forceType === 'string') return forceType
if (this.util.isSpotifyLink(query)) {
return 'spotify-song'
} else if (this.util.isYTPlaylistLink(query)) {
@ -145,6 +180,16 @@ class Player extends EventEmitter {
return 'soundcloud-song'
} else if (this.util.isSpotifyPLLink(query)) {
return 'spotify-playlist'
} else if (this.util.isVimeoLink(query)) {
return 'vimeo'
} else if (FacebookExtractor.validateURL(query)) {
return 'facebook'
} else if (this.util.isReverbnationLink(query)) {
return 'reverbnation'
} else if (this.util.isDiscordAttachment(query)) {
return 'attachment'
} else if (this.util.isXVLink(query)) {
return 'xvlink'
} else {
return 'youtube-video-keywords'
}
@ -175,16 +220,154 @@ class Player extends EventEmitter {
}
}
} else if (queryType === 'soundcloud-song') {
const soundcloudData = await Client.getSongInfo(query).catch(() => {})
if (soundcloudData) {
updatedQuery = `${soundcloudData.author.name} - ${soundcloudData.title}`
queryType = 'youtube-video-keywords'
const data = await Client.getSongInfo(query).catch(() => { })
if (data) {
const track = new Track({
title: data.title,
url: data.url,
lengthSeconds: data.duration / 1000,
description: data.description,
thumbnail: data.thumbnail,
views: data.playCount,
author: data.author
}, message.author, this)
Object.defineProperty(track, 'soundcloud', {
get: () => data
})
tracks.push(track)
}
} else if (queryType === 'vimeo') {
const data = await VimeoExtractor.getInfo(this.util.getVimeoID(query)).catch(e => {})
if (!data) return this.emit('noResults', message, query)
const track = new Track({
title: data.title,
url: data.url,
thumbnail: data.thumbnail,
lengthSeconds: data.duration,
description: '',
views: 0,
author: data.author
}, message.author, this)
Object.defineProperties(track, {
arbitrary: {
get: () => true
},
stream: {
get: () => data.stream.url
}
})
tracks.push(track)
} else if (queryType === 'facebook') {
const data = await FacebookExtractor.getInfo(query).catch(e => {})
if (!data) return this.emit('noResults', message, query)
if (data.live && !this.options.enableLive) return this.emit('error', 'LiveVideo', message)
const track = new Track({
title: data.title,
url: data.url,
thumbnail: data.thumbnail,
lengthSeconds: data.duration,
description: data.description,
views: data.views || data.interactionCount,
author: data.author
}, message.author, this)
Object.defineProperties(track, {
arbitrary: {
get: () => true
},
stream: {
get: () => data.streamURL
}
})
tracks.push(track)
} else if (queryType === 'reverbnation') {
const data = await ReverbnationExtractor.getInfo(query).catch(() => {})
if (!data) return this.emit('noResults', message, query)
const track = new Track({
title: data.title,
url: data.url,
thumbnail: data.thumbnail,
lengthSeconds: data.duration / 1000,
description: '',
views: 0,
author: data.artist
}, message.author, this)
Object.defineProperties(track, {
arbitrary: {
get: () => true
},
stream: {
get: () => data.streamURL
}
})
tracks.push(track)
} else if (queryType === 'attachment') {
const data = await DiscordExtractor.getInfo(query).catch(() => {})
if (!data || !(data.format.startsWith('audio/') || data.format.startsWith('video/'))) return this.emit('noResults', message, query)
const track = new Track({
title: data.title,
url: query,
thumbnail: '',
lengthSeconds: 0,
description: '',
views: 0,
author: {
name: 'Media_Attachment'
}
}, message.author, this)
Object.defineProperties(track, {
arbitrary: {
get: () => true
},
stream: {
get: () => query
}
})
tracks.push(track)
} else if (queryType === 'xvlink') {
const data = await XVideosExtractor.getInfo(query).catch(() => {})
if (!data || !(data.streams.lq || data.streams.hq)) return this.emit('noResults', message, query)
const track = new Track({
title: data.title,
url: data.url,
thumbnail: data.thumbnail,
lengthSeconds: data.length,
description: '',
views: data.views,
author: {
name: data.channel.name
}
}, message.author, this)
Object.defineProperties(track, {
arbitrary: {
get: () => true
},
stream: {
get: () => data.streams.lq || data.streams.hq
}
})
tracks.push(track)
}
if (queryType === 'youtube-video-keywords') {
await ytsr.search(updatedQuery || query, { type: 'video' }).then((results) => {
if (results.length !== 0) {
if (results && results.length !== 0) {
tracks = results.map((r) => new Track(r, message.author, this))
}
}).catch(() => {})
@ -247,6 +430,37 @@ class Player extends EventEmitter {
})
}
/**
* Sets currently playing music duration
* @param {Discord.Message} message Discord message
* @param {number} time Time in ms
* @returns {Promise<void>}
*/
setPosition (message, time) {
return new Promise((resolve) => {
const queue = this.queues.find((g) => g.guildID === message.guild.id)
if (!queue) return this.emit('error', 'NotPlaying', message)
if (typeof time !== 'number' && !isNaN(time)) time = parseInt(time)
if (queue.playing.durationMS === time) return this.skip(message)
if (queue.voiceConnection.dispatcher.streamTime === time || (queue.voiceConnection.dispatcher.streamTime + queue.additionalStreamTime) === time) return resolve()
if (time < 0) this._playYTDLStream(queue, false).then(() => resolve())
this._playYTDLStream(queue, false, time)
.then(() => resolve())
})
}
/**
* Sets currently playing music duration
* @param {Discord.Message} message Discord message
* @param {number} time Time in ms
* @returns {Promise<void>}
*/
seek (message, time) {
return this.setPosition(message, time)
}
/**
* Check whether there is a music played in the server
* @param {Discord.Message} message
@ -255,6 +469,23 @@ class Player extends EventEmitter {
return this.queues.some((g) => g.guildID === message.guild.id)
}
/**
* Moves to new voice channel
* @param {Discord.Message} message Message
* @param {Discord.VoiceChannel} channel Voice channel
*/
moveTo (message, channel) {
if (!channel || channel.type !== 'voice') return
const queue = this.queues.find((g) => g.guildID === message.guild.id)
if (!queue) return this.emit('error', 'NotPlaying', message)
if (queue.voiceConnection.channel.id === channel.id) return
queue.voiceConnection.dispatcher.pause()
message.guild.voice.setChannel(channel)
.then(() => queue.voiceConnection.dispatcher.resume())
.catch(() => this.emit('error', 'UnableToJoin', message))
}
/**
* Add a track to the queue
* @ignore
@ -319,12 +550,16 @@ class Player extends EventEmitter {
* @param {String} query
*/
async _handlePlaylist (message, query) {
this.emit('playlistParseStart', {}, message)
const playlist = await ytsr.getPlaylist(query)
if (!playlist) return this.emit('noResults', message, query)
playlist.tracks = playlist.videos.map((item) => new Track(item, message.author, this, true))
playlist.duration = playlist.tracks.reduce((prev, next) => prev + next.duration, 0)
playlist.thumbnail = playlist.tracks[0].thumbnail
playlist.requestedBy = message.author
this.emit('playlistParseEnd', playlist, message)
if (this.isPlaying(message)) {
const queue = this._addTracksToQueue(message, playlist.tracks)
this.emit('playlistAdd', message, queue, playlist)
@ -335,17 +570,19 @@ class Player extends EventEmitter {
this._addTracksToQueue(message, playlist.tracks)
}
}
async _handleSpotifyPlaylist (message, query) {
this.emit('playlistParseStart', {}, message)
const playlist = await spotify.getData(query)
if (!playlist) return this.emit('noResults', message, query)
let tracks = []
let s;
for (var i = 0; i < playlist.tracks.items.length; i++) {
let query = `${playlist.tracks.items[i].track.artists[0].name} - ${playlist.tracks.items[i].track.name}`
let results = await ytsr.search(query, { type: 'video' })
const tracks = []
let s = 0
for (let i = 0; i < playlist.tracks.items.length; i++) {
const query = `${playlist.tracks.items[i].track.artists[0].name} - ${playlist.tracks.items[i].track.name}`
const results = await ytsr.search(query, { type: 'video', limit: 1 })
if (results.length < 1) {
s++ // could be used later for skipped tracks due to result not being found
continue;
s++ // could be used later for skipped tracks due to result not being found
continue
}
tracks.push(results[0])
}
@ -353,6 +590,8 @@ class Player extends EventEmitter {
playlist.duration = playlist.tracks.reduce((prev, next) => prev + next.duration, 0)
playlist.thumbnail = playlist.images[0].url
playlist.requestedBy = message.author
this.emit('playlistParseEnd', playlist, message)
if (this.isPlaying(message)) {
const queue = this._addTracksToQueue(message, playlist.tracks)
this.emit('playlistAdd', message, queue, playlist)
@ -363,21 +602,22 @@ class Player extends EventEmitter {
this._addTracksToQueue(message, playlist.tracks)
}
}
async _handleSpotifyAlbum (message, query) {
const album = await spotify.getData(query)
if (!album) return this.emit('noResults', message, query)
let tracks = []
let s;
for (var i = 0; i < album.tracks.items.length; i++) {
let query = `${album.tracks.items[i].artists[0].name} - ${album.tracks.items[i].name}`
let results = await ytsr.search(query, { type: 'video' })
const tracks = []
let s = 0
for (let i = 0; i < album.tracks.items.length; i++) {
const query = `${album.tracks.items[i].artists[0].name} - ${album.tracks.items[i].name}`
const results = await ytsr.search(query, { type: 'video' })
if (results.length < 1) {
s++ // could be used later for skipped tracks due to result not being found
continue;
s++ // could be used later for skipped tracks due to result not being found
continue
}
tracks.push(results[0])
}
album.tracks = tracks.map((item) => new Track(item, message.author))
album.duration = album.tracks.reduce((prev, next) => prev + next.duration, 0)
album.thumbnail = album.images[0].url
@ -392,6 +632,84 @@ class Player extends EventEmitter {
this._addTracksToQueue(message, album.tracks)
}
}
async _handleSoundCloudPlaylist (message, query) {
const data = await Client.getPlaylist(query).catch(() => {})
if (!data) return this.emit('noResults', message, query)
const res = {
id: data.id,
title: data.title,
tracks: [],
author: data.author,
duration: 0,
thumbnail: data.thumbnail,
requestedBy: message.author
}
this.emit('playlistParseStart', res, message)
for (let i = 0; i < data.tracks.length; i++) {
const song = data.tracks[i]
const r = new Track({
title: song.title,
url: song.url,
lengthSeconds: song.duration / 1000,
description: song.description,
thumbnail: song.thumbnail || 'https://soundcloud.com/pwa-icon-192.png',
views: song.playCount || 0,
author: song.author || data.author
}, message.author, this, true)
Object.defineProperty(r, 'soundcloud', {
get: () => song
})
res.tracks.push(r)
}
if (!res.tracks.length) {
this.emit('playlistParseEnd', res, message)
return this.emit('error', 'ParseError', message)
}
res.duration = res.tracks.reduce((a, c) => a + c.lengthSeconds, 0)
this.emit('playlistParseEnd', res, message)
if (this.isPlaying(message)) {
const queue = this._addTracksToQueue(message, res.tracks)
this.emit('playlistAdd', message, queue, res)
} else {
const track = res.tracks.shift()
const queue = await this._createQueue(message, track).catch((e) => this.emit('error', e, message))
this.emit('trackStart', message, queue.tracks[0])
this._addTracksToQueue(message, res.tracks)
}
}
/**
* Custom search function
* @param {string} query Search query
* @param {("youtube"|"soundcloud"|"xvideos")} type Search type
* @returns {Promise<any[]>}
*/
async search (query, type = 'youtube') {
if (!query || typeof query !== 'string') return []
switch (type.toLowerCase()) {
case 'soundcloud':
return await Client.search(query, 'track').catch(() => {}) || []
case 'xvideos':
// eslint-disable-next-line
let videos = await XVideosExtractor.search(query).catch(() => {})
if (!videos) return []
return videos.videos
default:
return ytsr.search(query, { type: 'video' }).catch(() => {}) || []
}
}
/**
* Play a track in the server. Supported query types are `keywords`, `YouTube video links`, `YouTube playlists links`, `Spotify track link` or `SoundCloud song link`.
* @param {Discord.Message} message Discord `message`
@ -403,6 +721,11 @@ class Player extends EventEmitter {
* client.player.play(message, "Despacito", true);
*/
async play (message, query, firstResult = false) {
if (!query || typeof query !== 'string') throw new Error('Play function requires search query but received none!')
// clean query
query = query.replace(/<(.+)>/g, '$1')
if (this.util.isYTPlaylistLink(query)) {
return this._handlePlaylist(message, query)
}
@ -412,12 +735,16 @@ class Player extends EventEmitter {
if (this.util.isSpotifyAlbumLink(query)) {
return this._handleSpotifyAlbum(message, query)
}
if (this.util.isSoundcloudPlaylist(query)) {
return this._handleSoundCloudPlaylist(message, query)
}
let trackToPlay
if (query instanceof Track) {
trackToPlay = query
} else if (this.util.isYTVideoLink(query)) {
const videoData = await ytdl.getBasicInfo(query)
if (videoData.videoDetails.isLiveContent) return this.emit('error', 'LiveVideo', message)
if (videoData.videoDetails.isLiveContent && !this.options.enableLive) return this.emit('error', 'LiveVideo', message)
const lastThumbnail = videoData.videoDetails.thumbnails.length - 1 /* get the highest quality thumbnail */
trackToPlay = new Track({
title: videoData.videoDetails.title,
@ -622,7 +949,13 @@ class Player extends EventEmitter {
if (!queue) return this.emit('error', 'NotPlaying', message)
// Shuffle the queue (except the first track)
const currentTrack = queue.tracks.shift()
queue.tracks = queue.tracks.sort(() => Math.random() - 0.5)
// Durstenfeld shuffle algorithm
for (let i = queue.tracks.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[queue.tracks[i], queue.tracks[j]] = [queue.tracks[j], queue.tracks[i]]
}
queue.tracks.unshift(currentTrack)
// Return the queue
return queue
@ -678,14 +1011,16 @@ class Player extends EventEmitter {
const bar = '▬▬▬▬▬▬▬▬▬▬▬▬▬▬'.split('')
bar.splice(index, 0, '🔘')
if (timecodes) {
const currentTimecode = (currentStreamTime >= 3600000 ? moment(currentStreamTime).format('H:mm:ss') : moment(currentStreamTime).format('m:ss'))
const parsed = ms(currentStreamTime)
const currentTimecode = Util.buildTimecode(parsed)
return `${currentTimecode}${bar.join('')}${queue.playing.duration}`
} else {
return `${bar.join('')}`
}
} else {
if (timecodes) {
const currentTimecode = (currentStreamTime >= 3600000 ? moment(currentStreamTime).format('H:mm:ss') : moment(currentStreamTime).format('m:ss'))
const parsed = ms(currentStreamTime)
const currentTimecode = Util.buildTimecode(parsed)
return `${currentTimecode} ┃ 🔘▬▬▬▬▬▬▬▬▬▬▬▬▬▬ ┃ ${queue.playing.duration}`
} else {
return '🔘▬▬▬▬▬▬▬▬▬▬▬▬▬▬'
@ -729,11 +1064,11 @@ class Player extends EventEmitter {
}, this.options.leaveOnEmptyCooldown || 0)
}
_playYTDLStream (queue, updateFilter) {
return new Promise((resolve) => {
const ffmeg = this.util.checkFFMPEG();
if (!ffmeg) return;
const seekTime = updateFilter ? queue.voiceConnection.dispatcher.streamTime + queue.additionalStreamTime : undefined
_playYTDLStream (queue, updateFilter, seek) {
return new Promise(async (resolve) => {
const ffmeg = this.util.checkFFMPEG()
if (!ffmeg) return
const seekTime = typeof seek === 'number' ? seek : updateFilter ? queue.voiceConnection.dispatcher.streamTime + queue.additionalStreamTime : undefined
const encoderArgsFilters = []
Object.keys(queue.filters).forEach((filterName) => {
if (queue.filters[filterName]) {
@ -746,14 +1081,26 @@ class Player extends EventEmitter {
} else {
encoderArgs = ['-af', encoderArgsFilters.join(',')]
}
const newStream = ytdl(queue.playing.url, {
quality: this.options.quality === 'low' ? 'lowestaudio' : 'highestaudio',
filter: 'audioonly',
opusEncoded: true,
encoderArgs,
seek: seekTime / 1000,
highWaterMark: 1 << 25
})
let newStream
if (!queue.playing.soundcloud && !queue.playing.arbitrary) {
newStream = ytdl(queue.playing.url, {
quality: this.options.quality === 'low' ? 'lowestaudio' : 'highestaudio',
filter: 'audioonly',
opusEncoded: true,
encoderArgs,
seek: seekTime / 1000,
highWaterMark: 1 << 25,
requestOptions: this.options.ytdlRequestOptions || {}
})
} else {
newStream = ytdl.arbitraryStream(queue.playing.soundcloud ? await queue.playing.soundcloud.downloadProgressive() : queue.playing.stream, {
opusEncoded: true,
encoderArgs,
seek: seekTime / 1000
})
}
setTimeout(() => {
if (queue.stream) queue.stream.destroy()
queue.stream = newStream
@ -793,10 +1140,10 @@ class Player extends EventEmitter {
if (this.options.leaveOnEnd && !queue.stopped) {
setTimeout(() => {
queue.voiceConnection.channel.leave()
// Remove the guild from the guilds list
this.queues.delete(queue.guildID)
}, this.options.leaveOnEndCooldown || 0)
}
// Remove the guild from the guilds list
this.queues.delete(queue.guildID)
// Emit stop event
if (queue.stopped) {
return this.emit('musicStop')
@ -909,6 +1256,20 @@ module.exports = Player
/**
* Emitted when an error is triggered
* @event Player#error
* @param {string} error It can be `NotConnected`, `UnableToJoin`, `NotPlaying` or `LiveVideo`.
* @param {string} error It can be `NotConnected`, `UnableToJoin`, `NotPlaying`, `ParseError` or `LiveVideo`.
* @param {Discord.Message} message
*/
/**
* Emitted when discord-player attempts to parse playlist contents (mostly soundcloud playlists)
* @event Player#playlistParseStart
* @param {Object} playlist Raw playlist (unparsed)
* @param {Discord.Message} message The message
*/
/**
* Emitted when discord-player finishes parsing playlist contents (mostly soundcloud playlists)
* @event Player#playlistParseEnd
* @param {Object} playlist The playlist data (parsed)
* @param {Discord.Message} message The message
*/

View file

@ -42,7 +42,7 @@ class Track {
* The video thumbnail
* @type {string}
*/
this.thumbnail = typeof videoData.thumbnail === 'object'
this.thumbnail = videoData.thumbnail && typeof videoData.thumbnail === 'object'
? videoData.thumbnail.url
: videoData.thumbnail
/**

View file

@ -1,10 +1,13 @@
const ytsr = require('youtube-sr')
const ytsr = require('youtube-sr').default
const soundcloud = require('soundcloud-scraper')
const chalk = require('chalk')
const spotifySongRegex = (/https?:\/\/(?:embed\.|open\.)(?:spotify\.com\/)(?:track\/|\?uri=spotify:track:)((\w|-){22})/)
const spotifyPlaylistRegex = (/https?:\/\/(?:embed\.|open\.)(?:spotify\.com\/)(?:playlist\/|\?uri=spotify:playlist:)((\w|-){22})/)
const spotifyAlbumRegex = (/https?:\/\/(?:embed\.|open\.)(?:spotify\.com\/)(?:album\/|\?uri=spotify:album:)((\w|-){22})/)
const vimeoRegex = (/(http|https)?:\/\/(www\.|player\.)?vimeo\.com\/(?:channels\/(?:\w+\/)?|groups\/([^/]*)\/videos\/|video\/|)(\d+)(?:|\/\?)/)
const facebookRegex = (/(https?:\/\/)(www\.|m\.)?(facebook|fb).com\/.*\/videos\/.*/)
const xvRegex = (/(http|https):\/\/(.+)?xvideos.com\/(.+)\/(.+)/)
module.exports = class Util {
constructor () {
@ -15,15 +18,15 @@ module.exports = class Util {
try {
const prism = require('prism-media')
prism.FFmpeg.getInfo()
return true;
} catch (e) {
this.alertFFMPEG();
return false;
return true
} catch {
this.alertFFMPEG()
return false
}
}
static alertFFMPEG () {
console.log(chalk.red('ERROR:'), 'FFMPEG is not installed. Install with "npm install ffmpeg-static" or download it here: https://ffmpeg.org/download.html.');
console.log(chalk.red('ERROR:'), 'FFMPEG is not installed. Install with "npm install ffmpeg-static" or download it here: https://ffmpeg.org/download.html.')
}
static isVoiceEmpty (channel) {
@ -41,16 +44,53 @@ module.exports = class Util {
static isSpotifyPLLink (query) {
return spotifyPlaylistRegex.test(query)
}
static isSpotifyAlbumLink(query) {
static isSpotifyAlbumLink (query) {
return spotifyAlbumRegex.test(query)
}
static isYTPlaylistLink (query) {
return ytsr.validate(query, 'PLAYLIST')
return ytsr.validate(query, 'PLAYLIST_ID')
}
static isYTVideoLink (query) {
return ytsr.validate(query, 'VIDEO')
}
static isSoundcloudPlaylist (query) {
return Util.isSoundcloudLink(query) && query.includes('/sets/')
}
static isVimeoLink (query) {
return vimeoRegex.test(query)
}
static getVimeoID (query) {
return Util.isVimeoLink(query) ? query.split('/').filter(x => !!x).pop() : null
}
static isFacebookLink (query) {
return facebookRegex.test(query)
}
static isReverbnationLink (query) {
return /https:\/\/(www.)?reverbnation.com\/(.+)\/song\/(.+)/.test(query)
}
static isDiscordAttachment (query) {
return /https:\/\/cdn.discordapp.com\/attachments\/(\d{17,19})\/(\d{17,19})\/(.+)/.test(query)
}
static isXVLink (query) {
return xvRegex.test(query)
}
static buildTimecode (data) {
const items = Object.keys(data)
const required = ['days', 'hours', 'minutes', 'seconds']
const parsed = items.filter(x => required.includes(x)).map(m => data[m] > 0 ? data[m] : '')
const final = parsed.filter(x => !!x).join(':')
return final.length <= 3 ? `0:${final.length === 1 ? `0${final}` : final || 0}` : final
}
}

151
typings/index.d.ts vendored
View file

@ -2,7 +2,8 @@ declare module 'discord-player' {
import { EventEmitter } from 'events';
import { Client, Collection, Message, MessageCollector, Snowflake, User, VoiceChannel, VoiceConnection } from 'discord.js';
import { Playlist as YTSRPlaylist } from 'youtube-sr';
import { Stream } from 'stream';
import { Stream, Readable } from 'stream';
import * as XVDL from 'xvdl';
export const version: string;
@ -12,6 +13,11 @@ declare module 'discord-player' {
static isSpotifyLink(query: string): boolean;
static isYTPlaylistLink(query: string): boolean;
static isYTVideoLink(query: string): boolean;
static isSoundcloudPlaylist(query: string): boolean;
static isVimeoLink(query: string): boolean;
static getVimeoID(query: string): string;
static isFacebookLink(query: string): boolean;
static buildTimecode(data: any): string;
}
export class Player extends EventEmitter {
@ -23,6 +29,7 @@ declare module 'discord-player' {
public queues: Collection<Snowflake, Queue>;
public filters: PlayerFilters;
public static get AudioFilters(): PlayerFilters;
public isPlaying(message: Message): boolean;
public setFilters(message: Message, newFilters: Partial<Filters>): Promise<void>;
public play(message: Message, query: string | Track, firstResult?: boolean): Promise<void>;
@ -55,7 +62,36 @@ declare module 'discord-player' {
autoSelfDeaf: boolean;
quality: MusicQuality;
}
type Filters = 'bassboost' | '8D' | 'vaporwave' | 'nightcore'| 'phaser' | 'tremolo' | 'vibrato' | 'reverse' | 'treble' | 'normalizer' | 'surrounding' | 'pulsator' | 'subboost' | 'karaoke' | 'flanger' | 'gate' | 'haas' | 'mcompand';
type Filters =
| 'bassboost'
| '8D'
| 'vaporwave'
| 'nightcore'
| 'phaser'
| 'tremolo'
| 'vibrato'
| 'reverse'
| 'treble'
| 'normalizer'
| 'surrounding'
| 'pulsator'
| 'subboost'
| 'karaoke'
| 'flanger'
| 'gate'
| 'haas'
| 'mcompand'
| 'mono'
| 'mstlr'
| 'mstrr'
| 'compressor'
| 'expander'
| 'softlimiter'
| 'chorus'
| 'chorus2d'
| 'chorus3d'
| 'fadein';
type FiltersStatuses = {
[key in Filters]: boolean;
}
@ -72,7 +108,7 @@ declare module 'discord-player' {
requestedBy: User;
}
type Playlist = YTSRPlaylist & CustomPlaylist;
type PlayerError = 'NotConnected' | 'UnableToJoin' | 'NotPlaying' | 'LiveVideo';
type PlayerError = 'NotConnected' | 'UnableToJoin' | 'NotPlaying' | 'LiveVideo' | 'ParseError';
interface PlayerEvents {
searchResults: [Message, string, Track[]];
searchInvalidResponse: [Message, string, Track[], string, MessageCollector];
@ -87,6 +123,8 @@ declare module 'discord-player' {
queueCreate: [Message, Queue];
queueEnd: [Message, Queue];
error: [PlayerError, Message];
playlistParseStart: [any, Message];
playlistParseEnd: [any, Message];
}
class Queue {
constructor(guildID: string, message: Message, filters: PlayerFilters);
@ -128,4 +166,111 @@ declare module 'discord-player' {
public durationMS: number;
public queue: Queue;
}
export interface RawExtractedData {
title: string;
format: string;
size: number;
sizeFormat: "MB";
stream: Readable;
}
export interface VimeoExtractedData {
id: number;
duration: number;
title: string;
url: string;
thumbnail: string;
width: number;
height: number;
stream: {
cdn: string;
fps: number;
width: number;
height: number;
id: string;
mime: string;
origin: string;
profile: number;
quality: string;
url: string;
};
author: {
accountType: string;
id: number;
name: string;
url: string;
avatar: string;
}
}
interface FacebookExtractedData {
name: string;
title: string;
description: string;
rawVideo: string;
thumbnail: string;
uploadedAt: Date;
duration: string;
interactionCount: number;
streamURL: string;
publishedAt: Date;
width: number;
height: number;
nsfw: boolean;
genre: string;
keywords: string[];
comments: number;
size: string;
quality: string;
author: {
type: string;
name: string;
url: string;
};
publisher: {
type: string;
name: string;
url: string;
avatar: string;
};
url: string;
reactions: {
total: number;
like: number;
love: number;
care: number;
wow: number;
haha: number;
sad: number;
angry: number;
};
shares: string;
views: string;
}
class Discord {
static getInfo(url: string): Promise<RawExtractedData>;
static download(url: string): Promise<Readable>;
}
class Facebook {
static validateURL(url: string): boolean;
static download(url: string): Promise<Readable>;
static getInfo(url: string): Promise<FacebookExtractedData>;
}
class Vimeo {
static getInfo(id: number): Promise<VimeoExtractedData>;
static download(id: number): Promise<Readable>;
}
interface Extractors {
DiscordExtractor: Discord;
FacebookExtractor: Facebook;
VimeoExtractor: Vimeo;
XVideosExtractor: XVDL.XVDL;
}
export const Extractors: Extractors;
}