UNPKG

spotitube

Version:

A Converter That Will Convert Your Spotify To YT with LavaLink.

573 lines (528 loc) 21.4 kB
const Util = new require('./util'); const SpotifyWebApi = require('spotify-web-api-node'); const LavaLinkManager = require("./managers/LavaLinkManager"); const EventEmmiter = require('@tbnritzdoge/events'); /** * Converts Spotify To YT with the help of LavaLink * @class SpotiTube */ class SpotiTube extends EventEmmiter { /** * @description The options that SpotiTube will use to convert and link with lavalink. * * @param {Object} options The Options Object * @param {Object} options.spotify The Object for Spotify * @param {String} options.spotify.clientID Client ID of Spotify App * @param {String} options.spotify.secretKey Client ID of Spotify App * @param {String} [options.spotify.clientAccessToken=null] Client Access Token. This will automaticlly generate * @param {Number} [options.spotify.clientAccessExpire=null] Client Access Token Expire Time. This will automaticlly generate * @param {RegExp} [options.spotify.regex=/(https?:\/\/open\.spotify\.com\/(playlist|track)\/[a-zA-Z0-9]+|spotify:(playlist|track):[a-zA-Z0-9])/g] The regex to vaildate spotify Strings * @param {Object[]} options.lavalink The Object for LavaLink * @param {String} options.lavalink.url The lavalink url w/ http:// or https:// * @param {String} options.lavalink.password Lavalink password * @param {String} [options.lavalink.name] Lavalink node name * @param {Object=} options.redis The Object for Redis (To use redis put host & port) * @param {String=} options.lavalink.host The ip of redis (Redis default is 127.0.0.1) * @param {String=} options.lavalink.password The password of redis if one exist * @param {Number=} [options.lavalink.port=6379] The port of redis defaults to 6379 * @param {Number=} options.lavalink.db The db to use on redis * @returns {Object} * * @example * const STYT = new SpotiTube({ * spotify: { * clientID: 'CLIENTID', * secretKey: 'SECRETKEY' * }, * lavalink: [{ * url: 'http://localhost:2869', * password: 'password' * }], * redis: { * host: "127.0.0.1", * post: 6379, * db: null * } * }) */ constructor(options = {}) { super() this.options = Util.mergeDefault({ debug: false, spotify: { clientID: null, secretKey: null, clientAccessToken: null, clientAccessExpire: null, regex: /(https?:\/\/open\.spotify\.com\/(playlist|track)\/[a-zA-Z0-9]+|spotify:(playlist|track):[a-zA-Z0-9])/g }, lavalink: [{ url: null, password: null }], redis: { host: null, password: null, post: 6379, db: null } }, options) /** * The list of supported spotify links we support * @type {String[]} */ this.supportedTypes = ['playlist', 'track']; // Check for correct strings if (!this.options.spotify.clientID || !this.options.spotify.secretKey) throw new Error('Missing Spotify Client ID Or Spotify Secrect Key'); /** * The Manager that stores all the lavalinks * @type {LavaLinkManager} */ this.lavalinks = new LavaLinkManager(this.options.lavalink); // Load Creds /** * The Spotify Web API * @type {SpotifyWebApi} */ this.spotifySearch = new SpotifyWebApi({ clientId: this.options.spotify.clientID, clientSecret: this.options.spotify.secretKey }); // Load Redis if (this.options.redis.host && this.options.redis.port) { this.redis = require("redis").createClient(this.options.redis); // Redis Error Event Reconnect this.redis.on("error", err => { if (err?.toString()?.includes('ECONNRESET')) { setTimeout(() => { this.redis = this.redis.createClient(this.options.redis); }, 15000); } }); // Redis Error Event this.redis.on("error", (...args) => this.emit('error', ...args)); // Redis Ready Event this.redis.on("ready", (...args) => this.emit('debug', ...args)); // Redis Connect Event this.redis.on("connect", (...args) => this.emit('debug', ...args)); // Redis Reconnecting Event this.redis.on("reconnecting", (...args) => this.emit('debug', ...args)); } else { this.redis = null; this.emit("debug", "No Redis Host & Port. Redis will not be used.") } // Load Any Unknown Errors process.on('uncaughtException', (...args) => this.emit('error', ...args)); // Retrieve an access token. if (this.options.spotify.clientAccessToken && this.options.spotify.clientAccessExpire) this.initCreds({force: true, access_token: this.options.spotify.clientAccessToken, expires_in: this.options.spotify.clientAccessExpire}) // else this.spotifySearch.clientCredentialsGrant().then(res => this.initCreds(res.body)).catch(e => console.log(e)) } /** * @description Debug related events * This will include Redis Events * * @event SpotiTube#debug * @param {String} info The debug info * @memberof SpotiTube * * @example * SpotiTube.on("debug", console.log); */ /** * @description When the process has an error event. * This will include Redis Error Event * * @event SpotiTube#error * @param {Error} error The error object * @memberof SpotiTube * * @example * SpotiTube.on("error", console.log); */ /** * @description Loads/Creates/Checks the auth token to the Spotify Web API. * * @param {Object} settings The object of the data. * @param {String} settings.access_token The access token to use. * @param {String} [settings.token_type=Bearer] The token type. * @param {Number} settings.expires_in The time of when it will expire in seconds. * @returns {Object} * @memberof SpotiTube */ initCreds (settings = {}) { settings = Util.mergeDefault({ access_token: null, token_type: "Bearer", expires_in: null, force: false }, settings) if ((!this.options.spotify.clientAccessExpire || !this.options.spotify.clientAccessExpire) && (!settings.access_token || !settings.expires_in) && !settings.force) { this.emit("debug", "There is no login detect and none were sent with function. Creating login and restarting function") return this.spotifySearch.clientCredentialsGrant().then(res => this.initCreds(res.body)).catch(e => this.emit("error", e)); } else if ((new Date(new Date().getTime() + (1000 * 120)) >= this.options.spotify.clientAccessExpire) && (!settings.access_token || !settings.expires_in) && !settings.force) { this.emit("debug", "Current access token is expires or is within the 2 min mark. Creating new login and restarting function"); return this.spotifySearch.clientCredentialsGrant().then(res => this.initCreds(res.body)).catch(e => this.emit("error", e)); } else if (settings.access_token || settings.expires_in) { this.spotifySearch.setAccessToken(settings.access_token) this.options.spotify.clientAccessToken = settings.access_token; this.options.spotify.clientAccessExpire = new Date(new Date().getTime() + (1000 * settings.expires_in)); this.emit("debug", `Added Access token & Expire Time (${this.options.spotify.clientAccessExpire.getTime()})`); return this.spotifySearch } else { return this.spotifySearch } } /** * @description Validate that the URL is a Spotify URL * * @param {Url} url The URL of the Spotify Track Or Playlist * @returns {Boolean} * @memberof SpotiTube * * @example * (async () => { * const result = await STYT.validateURL('https://open.spotify.com/track/5nTtCOCds6I0PHMNtqelas'); * console.log(result) * })(); * * @example * (async () => { * const result = await STYT.validateURL('https://open.spotify.com/playlist/5B30UzmQQ6exwcZPwA8tbF?si=9df28e96ebf34267'); * console.log(result) * })(); * * @example * (async () => { * const result = await STYT.validateURL('spotify:track:4aDSp2TuP7OSPvN9wrwcs5'); * console.log(result) * })(); * * @example * (async () => { * const result = await STYT.validateURL('spotify:playlist:5B30UzmQQ6exwcZPwA8tbF'); * console.log(result); * })(); */ async validateURL (url) { if (!url) throw new Error('You did not specify the URL of Spotify!'); if (typeof url !== 'string') return false; if (this.options.spotify.regex.test(url)) { let parsedURL = {} try { parsedURL = require('spotify-uri')?.parse(url) || null; if (!this.supportedTypes.includes(parsedURL?.type)) return false; if (!parsedURL) return false; return true; } catch (e) { return false; } } else return false; } /** * @description Get info like type, name, and authors on the given url. * * @param {URL} url The url you want to check. * @returns {Object} * @memberof SpotiTube * * @example * (async () => { * const result = await STYT.getInfo('https://open.spotify.com/track/5nTtCOCds6I0PHMNtqelas'); * console.log(result); * })(); */ async getInfo(url) { if (!url) throw new Error('You did not specify the URL of Spotify!'); if (!this.validateURL(url)) throw new Error('Url is not a match to the regex!') ; try { let data = await require('spotify-url-info')?.getData(url) || null; this.emit("debug", `${url} = ${data.type} from getInfo`) return data; } catch (error) { this.emit("error", error); return null } } /** * @description Gets the data off a playlist. * * @param {String} id The url you want to check. * @param {Object} options Addtional options to add * @param {Number} [options.offset=0] How many tracks to skip * @param {Number} [options.limit=100] How many tracks to skip (100 Max with 0 Min) * @returns {Object} * @private * @memberof SpotiTube */ async getPlaylist (id, options = {}) { if (!id) throw new Error("Missing playlist ID") options = Util.mergeDefault({ offset: 0, limit: 100 }, options) await this.initCreds(); // Make sure creds are correct let data = await this.spotifySearch.getPlaylistTracks(id, {offset: options.offset, limit: options.limit}) || null; return data?.body || null } /** * @description Checks Redis Cache. * * @param {String} key The url you want to check. * @returns {Object} * @private * @memberof SpotiTube */ async getRedisCache (key) { if (!key) throw new Error("Reddis Key"); if (!this.redis) return null; return new Promise(async (resolve, reject) => { this.redis.get(key, async (error, reply) => { if (error) { this.emit("error", error); return resolve(null) } else { if (reply) { try { if (JSON.parse(reply)) { return resolve(JSON.parse(reply)) } else { return resolve(reply) } } catch (e) { return resolve(reply) } } else return resolve(null) } }) }) } /** * @description Set Redis Cache. * * @param {String} key The url you want to check. * @param {*} data The data to be cached. * @returns {*} * @private * @memberof SpotiTube */ async setRedisCache (key, data) { if (!key) throw new Error("Reddis Key"); if (!data) throw new Error("Needs Value"); if (!this.redis) return null; try { if (JSON.stringify(data)) { data = JSON.stringify(data) } } catch (e) { console.log(e) } return new Promise(async (resolve, reject) => { this.redis.set(key, data, async (error) =>{ if (error) { this.emit("error", error) return resolve(false) } else { this.emit("debug", `Key ${key} was set to ${data}`); return resolve(true) } }); }) } /** * @description Converts the spotify url(s) to a youtube result. * The Longer the playlist on spotify the longer it will take * * @param {URL} url The url you want to convert. * @param {Number} [limit=20] Limit how many songs we should convert. Use Infinity to allow the entire playlist, but the bigger the playlist the longer it will take to convert * @param {Boolean} [failedLimit=true] Let failed song searches include in the overall limit checks. * @returns {Object} * @memberof SpotiTube * * @example * (async () => { * const result = await STYT.convert('https://open.spotify.com/track/5nTtCOCds6I0PHMNtqelas', 200, true); * console.log(result); * })(); * * @example * (async () => { * const result = await STYT.convert('https://open.spotify.com/track/5nTtCOCds6I0PHMNtqelas', Infinity, true); // Infinity will allow you to get all results * console.log(result); * })(); * * @example * (async () => { * const result = await STYT.convert('spotify:playlist:5SRG4654V94q7ao2X1hUU1', Infinity, true); // Infinity will allow you to get all results * console.log(result); * })(); * * @example * (async () => { * const result = await STYT.convert('spotify:track:4aDSp2TuP7OSPvN9wrwcs5', Infinity, true); // Infinity will allow you to get all results * console.log(result); * })(); */ async convert(url, limit = 20, failedLimit = true) { if (!url) throw new Error('You did not specify the URL of Spotify!'); if (!this.validateURL(url)) throw new Error('Url is not a match to the regex!'); var start = new Date(); await this.initCreds() // Make sure creds are correct if (!limit || limit < 1) limit = 20 if (failedLimit !== false && failedLimit !== true) failedLimit = false this.emit("debug", `Getting ${url} with limit of ${limit} with ${failedLimit ? 'failed searches being included with limit' : 'failed searches not being included with limit'}`) let getInfo = await this.getInfo(url); if (!getInfo) { this.emit("debug", `${url} was not found`) return null } this.emit("debug", `${url} = ${getInfo.type} in convert`) if (!this.supportedTypes.includes(getInfo.type)) throw new Error(`${getInfo.type} is not a support format only ${this.supportedTypes.join()}`); const node = await this.lavalinks.getBestNode(); this.emit("debug", `Using node ${node.name}`); if (getInfo.type === "track") { // track this.emit("debug", `${url} is an ${getInfo.type} so limits will not work on this`) let result; try { if (this.redis) { let check = await this.getRedisCache(`${getInfo.uri || 'spotify:' + getInfo?.external_urls?.spotify}`); if (!check) { result = await node.search(`${getInfo.name} ${getInfo.artists.map(x => x.name).join(' ')}`); await this.setRedisCache(`${getInfo?.uri || 'spotify:' + getInfo?.external_urls?.spotify}`, result); this.emit("debug", `${getInfo?.external_urls?.spotify || getInfo?.uri} => ${result.uri} (Not From Cache)`) } else { result = check; this.emit("debug", `${getInfo?.external_urls?.spotify || getInfo?.uri} => ${result.uri} (From Cache)`) } } else { const node = await this.lavalinks.getBestNode(); this.emit("debug", `Using node ${node.name}`) result = await node.search(`${getInfo.name} ${getInfo.artists.map(x => x.name).join(' ')}`); this.emit("debug", `${getInfo?.external_urls?.spotify || getInfo?.uri} => ${result.uri} (Not From Cache. Redis not being used)`) } } catch (error) { result = null; this.emit("debug", `${url} was not found`) this.emit("error", error) } return { songs: { failed: !result ? [`${getInfo?.external_urls?.spotify || getInfo.uri}`] : [], completed: result ? [{ url: result.uri, info: result }] : [] }, info: getInfo, limit: limit, converted: { failed: !result ? 1 : 0, completed: result ? 1 : 0, total: 1 }, time: { start: start, end: new Date(), executionMS: new Date() - start, executionSec: (new Date() - start) / 1000 } } } else if (getInfo.type === "playlist") { // playlist let data = await this.getPlaylist(getInfo.id); let tracks = []; for (const song of data.items) { if (tracks.length >= limit) break; else tracks.push(song) } let next = data?.next ? true : false; while (next === true && (tracks.length < limit)) { let data2 = await this.getPlaylist(getInfo.id, {offset: tracks.length, limit: (data.total - tracks.length) >= 100 ? 100 : data.total - tracks.length }) for (const song of data2.items) { if (tracks.length >= limit) break; tracks.push(song) } if (tracks.length >= limit) next = false; else next = data2?.next ? true : false } this.emit("debug", `Gathered a total of ${tracks.length} with a limitter set to ${limit}`) var songs = []; var failed = []; for (let song of tracks) { song = song.track if ((failedLimit ? failed.length + songs.length : songs.length) >= limit) break; this.emit("debug", `${url} Current searches: ${(failedLimit ? failed.length + songs.length : songs.length)} w/ limit of ${limit}.`) let result; if (!song?.uri || !song?.external_urls?.spotify) { failed.push(song?.external_urls?.spotify || song?.uri || song?.name || "Unknown song") } else { try { if (this.redis) { let check = await this.getRedisCache(`${song.uri || 'spotify:' + song?.external_urls?.spotify}`); if (!check) { result = await node.search(`${song.name} ${song.artists.map(x => x.name).join(' ')}`); await this.setRedisCache(`${song?.uri || 'spotify:' + song?.external_urls?.spotify}`, result); this.emit("debug", `${song?.external_urls?.spotify || song?.uri} => ${result.uri} (Not From Cache)`) } else { result = check; this.emit("debug", `${song?.external_urls?.spotify || song?.uri} => ${result.uri} (From Cache)`) } } else { result = await node.search(`${song.name} ${song.artists.map(x => x.name).join(' ')}`); this.emit("debug", `${song?.external_urls?.spotify || song?.uri} => ${result.uri} (Not From Cache. Redis not being used)`) } } catch (error) { result = null; this.emit("debug", `${song?.external_urls?.spotify || song?.uri || song?.name || "Unknown song"} was not found`) this.emit("error", error) } if (!result) failed.push(song?.external_urls?.spotify || song?.uri || song?.name || "Unknown song") else songs.push({ url: result.uri, info: result }) } } this.emit("debug", `${getInfo?.external_urls?.spotify || getInfo.uri} was converted to ${songs.length} with ${failed?.length} with limit ${limit}`) return { songs: { failed: failed, completed: songs }, info: {...getInfo, tracks: tracks.filter(g => !g?.track?.uri)?.map(g => { return `${g?.track?.uri || 'spotify:' + g?.track?.external_urls?.spotify}` }) || []}, limit: limit, converted: { failed: failed?.length || 0, completed: songs?.length || 0, total: (failed?.length + songs?.length) || 0 }, time: { start: start, end: new Date(), executionMS: new Date() - start, executionSec: (new Date() - start) / 1000 } } } else throw new Error(`${getInfo.type} is not a support format only ${this.supportedTypes.join()}`) } /** * @description Search on lavalink. * @deprecated Use {@link LavaLink.search} instead * * @param {String} query The search query to send to lavalink. * @returns {Object} * * @example * (async () => { * const result = await STYT.searchLavaLink('say something'); * console.log(result); * })(); */ searchLavaLink () { process.emitWarning('This function event is deprecated. Use lavalink search instead', 'DeprecationWarning'); } } module.exports = SpotiTube