| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696 | import mongoose from "mongoose";import async from "async";import config from "config";import sckey from "soundcloud-key-fetch";import * as rax from "retry-axios";import axios from "axios";import CoreClass from "../core";let SoundCloudModule;let DBModule;let CacheModule;let MediaModule;const soundcloudTrackObjectToMusareTrackObject = soundcloudTrackObject => {	const {		id,		title,		artwork_url: artworkUrl,		created_at: createdAt,		duration,		genre,		kind,		license,		likes_count: likesCount,		playback_count: playbackCount,		public: _public,		tag_list: tagList,		user_id: userId,		user,		track_format: trackFormat,		permalink,		monetization_model: monetizationModel,		policy,		streamable,		sharing,		state,		embeddable_by: embeddableBy	} = soundcloudTrackObject;	return {		trackId: id,		title,		artworkUrl,		soundcloudCreatedAt: new Date(createdAt),		duration: duration / 1000,		genre,		kind,		license,		likesCount,		playbackCount,		public: _public,		tagList,		userId,		username: user.username,		userPermalink: user.permalink,		trackFormat,		permalink,		monetizationModel,		policy,		streamable,		sharing,		state,		embeddableBy	};};class RateLimitter {	/**	 * Constructor	 * @param {number} timeBetween - The time between each allowed YouTube request	 */	constructor(timeBetween) {		this.dateStarted = Date.now();		this.timeBetween = timeBetween;	}	/**	 * Returns a promise that resolves whenever the ratelimit of a YouTube request is done	 * @returns {Promise} - promise that gets resolved when the rate limit allows it	 */	continue() {		return new Promise(resolve => {			if (Date.now() - this.dateStarted >= this.timeBetween) resolve();			else setTimeout(resolve, this.dateStarted + this.timeBetween - Date.now());		});	}	/**	 * Restart the rate limit timer	 */	restart() {		this.dateStarted = Date.now();	}}class _SoundCloudModule extends CoreClass {	// eslint-disable-next-line require-jsdoc	constructor() {		super("soundcloud");		SoundCloudModule = this;	}	/**	 * Initialises the soundcloud module	 * @returns {Promise} - returns promise (reject, resolve)	 */	async initialize() {		DBModule = this.moduleManager.modules.db;		CacheModule = this.moduleManager.modules.cache;		MediaModule = this.moduleManager.modules.media;		this.soundcloudTrackModel = this.SoundCloudTrackModel = await DBModule.runJob("GET_MODEL", {			modelName: "soundcloudTrack"		});		return new Promise((resolve, reject) => {			this.rateLimiter = new RateLimitter(config.get("apis.soundcloud.rateLimit"));			this.requestTimeout = config.get("apis.soundcloud.requestTimeout");			this.axios = axios.create();			this.axios.defaults.raxConfig = {				instance: this.axios,				retry: config.get("apis.soundcloud.retryAmount"),				noResponseRetries: config.get("apis.soundcloud.retryAmount")			};			rax.attach(this.axios);			this.apiKey = null;			SoundCloudModule.runJob("TEST_SOUNDCLOUD_API_KEY", {}, null, -1)				.then(result => {					if (result) {						resolve();						return;					}					SoundCloudModule.runJob("GENERATE_SOUNDCLOUD_API_KEY", {}, null, -1)						.then(() => {							resolve();						})						.catch(reject);				})				.catch(reject);		});	}	/**	 * Generates/fetches a new SoundCloud API key	 * @returns {Promise} - returns promise (reject, resolve)	 */	GENERATE_SOUNDCLOUD_API_KEY() {		return new Promise((resolve, reject) => {			this.log("INFO", "Fetching new SoundCloud API key.");			sckey				.fetchKey()				.then(soundcloudApiKey => {					if (!soundcloudApiKey) {						this.log("ERROR", "Couldn't fetch new SoundCloud API key.");						reject(new Error("Couldn't fetch SoundCloud key."));						return;					}					SoundCloudModule.soundcloudApiKey = soundcloudApiKey;					CacheModule.runJob("SET", { key: "soundcloudApiKey", value: soundcloudApiKey }, this)						.then(() => {							SoundCloudModule.runJob("TEST_SOUNDCLOUD_API_KEY", {}, this).then(result => {								if (!result) {									this.log("ERROR", "Fetched SoundCloud API key is invalid.");									reject(new Error("SoundCloud key isn't valid."));								} else {									this.log("INFO", "Fetched new valid SoundCloud API key.");									resolve();								}							});						})						.catch(err => {							this.log("ERROR", `Couldn't set new SoundCloud API key in cache. Error: ${err.message}`);							reject(err);						});				})				.catch(err => {					this.log("ERROR", `Couldn't fetch new SoundCloud API key. Error: ${err.message}`);					reject(new Error("Couldn't fetch SoundCloud key."));				});		});	}	/**	 * Tests the stored SoundCloud API key	 * @returns {Promise} - returns promise (reject, resolve)	 */	TEST_SOUNDCLOUD_API_KEY() {		return new Promise((resolve, reject) => {			this.log("INFO", "Testing SoundCloud API key.");			CacheModule.runJob("GET", { key: "soundcloudApiKey" }, this).then(soundcloudApiKey => {				if (!soundcloudApiKey) {					this.log("ERROR", "No SoundCloud API key found in cache.");					resolve(false);					return;				}				SoundCloudModule.soundcloudApiKey = soundcloudApiKey;				sckey					.testKey(soundcloudApiKey)					.then(res => {						this.log("INFO", `Tested SoundCloud API key. Result: ${res}`);						resolve(res);					})					.catch(err => {						this.log("ERROR", `Testing SoundCloud API key error: ${err.message}`);						reject(err);					});			});		});	}	/**	 * Perform SoundCloud API get track request	 * @param {object} payload - object that contains the payload	 * @param {string} payload.trackId - the SoundCloud track id to get	 * @returns {Promise} - returns promise (reject, resolve)	 */	API_GET_TRACK(payload) {		return new Promise((resolve, reject) => {			const { trackId } = payload;			SoundCloudModule.runJob(				"API_CALL",				{					url: `https://api-v2.soundcloud.com/tracks/${trackId}`				},				this			)				.then(response => {					resolve(response);				})				.catch(err => {					reject(err);				});		});	}	/**	 * Perform SoundCloud API call	 * @param {object} payload - object that contains the payload	 * @param {string} payload.url - request url	 * @param {object} payload.params - request parameters	 * @returns {Promise} - returns promise (reject, resolve)	 */	API_CALL(payload) {		return new Promise((resolve, reject) => {			const { url } = payload;			const { soundcloudApiKey } = SoundCloudModule;			const params = {				client_id: soundcloudApiKey			};			SoundCloudModule.axios				.get(url, {					params,					timeout: SoundCloudModule.requestTimeout				})				.then(response => {					if (response.data.error) {						reject(new Error(response.data.error));					} else {						resolve({ response });					}				})				.catch(err => {					reject(err);				});			// }		});	}	/**	 * Create SoundCloud track	 * @param {object} payload - an object containing the payload	 * @param {object} payload.soundcloudTrack - the soundcloudTrack object	 * @returns {Promise} - returns a promise (resolve, reject)	 */	CREATE_TRACK(payload) {		return new Promise((resolve, reject) => {			async.waterfall(				[					next => {						const { soundcloudTrack } = payload;						if (typeof soundcloudTrack !== "object") next("Invalid soundcloudTrack type");						else {							SoundCloudModule.soundcloudTrackModel.insertMany(soundcloudTrack, next);						}					},					(soundcloudTracks, next) => {						const mediaSources = soundcloudTracks.map(							soundcloudTrack => `soundcloud:${soundcloudTrack.trackId}`						);						async.eachLimit(							mediaSources,							2,							(mediaSource, next) => {								MediaModule.runJob("RECALCULATE_RATINGS", { mediaSource }, this)									.then(() => next())									.catch(next);							},							err => {								if (err) next(err);								else next(null, soundcloudTracks);							}						);					}				],				(err, soundcloudTracks) => {					if (err) reject(new Error(err));					else resolve({ soundcloudTracks });				}			);		});	}	/**	 * Get SoundCloud track	 * @param {object} payload - an object containing the payload	 * @param {string} payload.identifier - the soundcloud track ObjectId or track id	 * @param {boolean} payload.createMissing - attempt to fetch and create track if not in db	 * @returns {Promise} - returns a promise (resolve, reject)	 */	GET_TRACK(payload) {		return new Promise((resolve, reject) => {			async.waterfall(				[					next => {						const query = mongoose.isObjectIdOrHexString(payload.identifier)							? { _id: payload.identifier }							: { trackId: payload.identifier };						return SoundCloudModule.soundcloudTrackModel.findOne(query, next);					},					(track, next) => {						if (track) return next(null, track, false);						if (mongoose.isObjectIdOrHexString(payload.identifier) || !payload.createMissing)							return next("SoundCloud track not found.");						return SoundCloudModule.runJob("API_GET_TRACK", { trackId: payload.identifier }, this)							.then(({ response }) => {								const { data } = response;								if (!data || !data.id)									return next("The specified track does not exist or cannot be publicly accessed.");								const soundcloudTrack = soundcloudTrackObjectToMusareTrackObject(data);								return next(null, false, soundcloudTrack);							})							.catch(next);					},					(track, soundcloudTrack, next) => {						if (track) return next(null, track, true);						return SoundCloudModule.runJob("CREATE_TRACK", { soundcloudTrack }, this)							.then(res => {								if (res.soundcloudTracks.length === 1) next(null, res.soundcloudTracks[0], false);								else next("SoundCloud track not found.");							})							.catch(next);					}				],				(err, track, existing) => {					if (err) reject(new Error(err));					else if (track.policy === "SNIP") reject(new Error("Track is premium-only."));					else resolve({ track, existing });				}			);		});	}	/**	 * Tries to get a SoundCloud track from a URL	 * @param {object} payload - object that contains the payload	 * @param {string} payload.identifier - the SoundCloud track URL	 * @returns {Promise} - returns promise (reject, resolve)	 */	GET_TRACK_FROM_URL(payload) {		return new Promise((resolve, reject) => {			const scRegex =				/soundcloud\.com\/(?<userPermalink>[A-Za-z0-9]+([-_][A-Za-z0-9]+)*)\/(?<permalink>[A-Za-z0-9]+(?:[-_][A-Za-z0-9]+)*)/;			async.waterfall(				[					next => {						const match = scRegex.exec(payload.identifier);						if (!match || !match.groups) {							next("Invalid SoundCloud URL.");							return;						}						const { userPermalink, permalink } = match.groups;						SoundCloudModule.soundcloudTrackModel.findOne({ userPermalink, permalink }, next);					},					(_dbTrack, next) => {						if (_dbTrack) {							next(null, _dbTrack, true);							return;						}						SoundCloudModule.runJob("API_RESOLVE", { url: payload.identifier }, this)							.then(({ response }) => {								const { data } = response;								if (!data || !data.id) {									next("The provided URL does not exist or cannot be accessed.");									return;								}								if (data.kind !== "track") {									next(`Invalid URL provided. Kind got: ${data.kind}.`);									return;								}								// TODO get more data here								const { id: trackId } = data;								SoundCloudModule.soundcloudTrackModel.findOne({ trackId }, (err, dbTrack) => {									if (err) next(err);									else if (dbTrack) {										next(null, dbTrack, true);									} else {										const soundcloudTrack = soundcloudTrackObjectToMusareTrackObject(data);										SoundCloudModule.runJob("CREATE_TRACK", { soundcloudTrack }, this)											.then(res => {												if (res.soundcloudTracks.length === 1)													next(null, res.soundcloudTracks[0], false);												else next("SoundCloud track not found.");											})											.catch(next);									}								});							})							.catch(next);					}				],				(err, track, existing) => {					if (err) reject(new Error(err));					else if (track.policy === "SNIP") reject(new Error("Track is premium-only."));					else resolve({ track, existing });				}			);		});	}	/**	 * Returns an array of songs taken from a SoundCloud playlist	 * @param {object} payload - object that contains the payload	 * @param {string} payload.url - the url of the SoundCloud playlist	 * @returns {Promise} - returns promise (reject, resolve)	 */	GET_PLAYLIST(payload) {		return new Promise((resolve, reject) => {			async.waterfall(				[					next => {						SoundCloudModule.runJob("API_RESOLVE", { url: payload.url }, this)							.then(async ({ response }) => {								const { data } = response;								if (!data || !data.id)									return next("The provided URL does not exist or cannot be accessed.");								let tracks;								if (data.kind === "user")									tracks = (										await SoundCloudModule.runJob(											"GET_ARTIST_TRACKS",											{												artistId: data.id											},											this										)									).tracks;								else if (data.kind !== "playlist" && data.kind !== "system-playlist")									return next(`Invalid URL provided. Kind got: ${data.kind}.`);								else tracks = data.tracks;								const soundcloudTrackIds = tracks.map(track => track.id);								return next(null, soundcloudTrackIds);							})							.catch(next);					}				],				(err, soundcloudTrackIds) => {					if (err && err !== true) {						SoundCloudModule.log(							"ERROR",							"GET_PLAYLIST",							"Some error has occurred.",							typeof err === "string" ? err : err.message						);						reject(new Error(typeof err === "string" ? err : err.message));					} else {						resolve({ songs: soundcloudTrackIds });					}				}			);			// kind;		});	}	/**	 * Returns an array of songs taken from a SoundCloud artist	 * @param {object} payload - object that contains the payload	 * @param {string} payload.artistId - the id of the SoundCloud artist	 * @returns {Promise} - returns promise (reject, resolve)	 */	GET_ARTIST_TRACKS(payload) {		return new Promise((resolve, reject) => {			async.waterfall(				[					next => {						let first = true;						let nextHref = null;						let tracks = [];						async.whilst(							next => {								if (nextHref || first) next(null, true);								else next(null, false);							},							next => {								let job;								if (first) {									job = SoundCloudModule.runJob(										"API_GET_ARTIST_TRACKS",										{ artistId: payload.artistId },										this									);									first = false;								} else job = SoundCloudModule.runJob("API_GET_ARTIST_TRACKS", { nextHref }, this);								job.then(({ response }) => {									const { data } = response;									const { collection, next_href: _nextHref } = data;									nextHref = _nextHref;									tracks = tracks.concat(collection);									setTimeout(() => {										next();									}, 500);								}).catch(err => {									next(err);								});							},							err => {								if (err) return next(err);								return next(null, tracks);							}						);					}				],				(err, tracks) => {					if (err && err !== true) {						SoundCloudModule.log(							"ERROR",							"GET_ARTIST_TRACKS",							"Some error has occurred.",							typeof err === "string" ? err : err.message						);						reject(new Error(typeof err === "string" ? err : err.message));					} else {						resolve({ tracks });					}				}			);		});	}	/**	 * Get Soundcloud artists	 * @param {object} payload - an object containing the payload	 * @param {Array} payload.userPermalinks - an array of Soundcloud user permalinks	 * @returns {Promise} - returns a promise (resolve, reject)	 */	async GET_ARTISTS_FROM_PERMALINKS(payload) {		const getArtists = async userPermalinks => {			const jobsToRun = [];			userPermalinks.forEach(userPermalink => {				const url = `https://soundcloud.com/${userPermalink}`;				jobsToRun.push(SoundCloudModule.runJob("API_RESOLVE", { url }, this));			});			const jobResponses = await Promise.all(jobsToRun);			return jobResponses				.map(jobResponse => jobResponse.response.data)				.map(artist => ({					artistId: artist.id,					username: artist.username,					avatarUrl: artist.avatar_url,					permalink: artist.permalink,					rawData: artist				}));		};		const { userPermalinks } = payload;		const existingArtists = [];		const existingUserPermalinks = existingArtists.map(existingArtists => existingArtists.userPermalink);		// const existingArtistsObjectIds = existingArtists.map(existingArtists => existingArtists._id.toString());		if (userPermalinks.length === existingArtists.length) return { artists: existingArtists };		const missingUserPermalinks = userPermalinks.filter(			userPermalink => existingUserPermalinks.indexOf(userPermalink) === -1		);		if (missingUserPermalinks.length === 0) return { videos: existingArtists };		const newArtists = await getArtists(missingUserPermalinks);		// await SoundcloudModule.soundcloudArtistsModel.insertMany(newArtists);		return { artists: existingArtists.concat(newArtists) };	}	/**	 * @param {object} payload - object that contains the payload	 * @param {string} payload.url - the url of the SoundCloud resource	 * @returns {Promise} - returns a promise (resolve, reject)	 */	API_RESOLVE(payload) {		return new Promise((resolve, reject) => {			const { url } = payload;			SoundCloudModule.runJob(				"API_CALL",				{					url: `https://api-v2.soundcloud.com/resolve?url=${encodeURIComponent(url)}`				},				this			)				.then(response => {					resolve(response);				})				.catch(err => {					reject(err);				});		});	}	/**	 * Calls the API_CALL with the proper URL to get artist/user tracks	 * @param {object} payload - object that contains the payload	 * @param {string} payload.artistId - the id of the SoundCloud artist	 * @param {string} payload.nextHref - the next url to call	 * @returns {Promise} - returns a promise (resolve, reject)	 */	API_GET_ARTIST_TRACKS(payload) {		return new Promise((resolve, reject) => {			const { artistId, nextHref } = payload;			SoundCloudModule.runJob(				"API_CALL",				{					url: artistId						? `https://api-v2.soundcloud.com/users/${artistId}/tracks?access=playable&limit=50`						: nextHref				},				this			)				.then(response => {					resolve(response);				})				.catch(err => {					reject(err);				});		});	}}export default new _SoundCloudModule();
 |