| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479 | import mongoose from "mongoose";import async from "async";import config from "config";import * as rax from "retry-axios";import axios from "axios";import url from "url";import CoreClass from "../core";let SpotifyModule;let SoundcloudModule;let DBModule;let CacheModule;let MediaModule;let MusicBrainzModule;let WikiDataModule;const youtubeVideoUrlRegex =	/^(https?:\/\/)?(www\.)?(m\.)?(music\.)?(youtube\.com|youtu\.be)\/(watch\?v=)?(?<youtubeId>[\w-]{11})((&([A-Za-z0-9]+)?)*)?$/;const spotifyTrackObjectToMusareTrackObject = spotifyTrackObject => ({	trackId: spotifyTrackObject.id,	name: spotifyTrackObject.name,	albumId: spotifyTrackObject.album.id,	albumTitle: spotifyTrackObject.album.title,	albumImageUrl: spotifyTrackObject.album.images[0].url,	artists: spotifyTrackObject.artists.map(artist => artist.name),	artistIds: spotifyTrackObject.artists.map(artist => artist.id),	duration: spotifyTrackObject.duration_ms / 1000,	explicit: spotifyTrackObject.explicit,	externalIds: spotifyTrackObject.external_ids,	popularity: spotifyTrackObject.popularity,	isLocal: spotifyTrackObject.is_local});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 _SpotifyModule extends CoreClass {	// eslint-disable-next-line require-jsdoc	constructor() {		super("spotify");		SpotifyModule = this;	}	/**	 * Initialises the spotify module	 * @returns {Promise} - returns promise (reject, resolve)	 */	async initialize() {		DBModule = this.moduleManager.modules.db;		CacheModule = this.moduleManager.modules.cache;		MediaModule = this.moduleManager.modules.media;		MusicBrainzModule = this.moduleManager.modules.musicbrainz;		SoundcloudModule = this.moduleManager.modules.soundcloud;		WikiDataModule = this.moduleManager.modules.wikidata;		this.spotifyTrackModel = this.SpotifyTrackModel = await DBModule.runJob("GET_MODEL", {			modelName: "spotifyTrack"		});		this.spotifyAlbumModel = this.SpotifyAlbumModel = await DBModule.runJob("GET_MODEL", {			modelName: "spotifyAlbum"		});		this.spotifyArtistModel = this.SpotifyArtistModel = await DBModule.runJob("GET_MODEL", {			modelName: "spotifyArtist"		});		return new Promise((resolve, reject) => {			if (!config.get("apis.spotify.enabled")) {				reject(new Error("Spotify is not enabled."));				return;			}			this.rateLimiter = new RateLimitter(config.get("apis.spotify.rateLimit"));			this.requestTimeout = config.get("apis.spotify.requestTimeout");			this.axios = axios.create();			this.axios.defaults.raxConfig = {				instance: this.axios,				retry: config.get("apis.spotify.retryAmount"),				noResponseRetries: config.get("apis.spotify.retryAmount")			};			rax.attach(this.axios);			resolve();		});	}	/**	 * Fetches a Spotify API token from either the cache, or Spotify using the client id and secret from the config	 * @returns {Promise} - returns promise (reject, resolve)	 */	GET_API_TOKEN() {		return new Promise((resolve, reject) => {			CacheModule.runJob("GET", { key: "spotifyApiKey" }, this).then(spotifyApiKey => {				if (spotifyApiKey) {					resolve(spotifyApiKey);					return;				}				this.log("INFO", `No Spotify API token stored in cache, requesting new token.`);				const clientId = config.get("apis.spotify.clientId");				const clientSecret = config.get("apis.spotify.clientSecret");				const unencoded = `${clientId}:${clientSecret}`;				const encoded = Buffer.from(unencoded).toString("base64");				const params = new url.URLSearchParams({ grant_type: "client_credentials" });				SpotifyModule.axios					.post("https://accounts.spotify.com/api/token", params.toString(), {						headers: {							Authorization: `Basic ${encoded}`,							"Content-Type": "application/x-www-form-urlencoded"						}					})					.then(res => {						const { access_token: accessToken, expires_in: expiresIn } = res.data;						// TODO TTL can be later if stuck in queue						CacheModule.runJob(							"SET",							{ key: "spotifyApiKey", value: accessToken, ttl: expiresIn - 30 },							this						)							.then(spotifyApiKey => {								this.log(									"SUCCESS",									`Stored new Spotify API token in cache. Expires in ${expiresIn - 30}`								);								resolve(spotifyApiKey);							})							.catch(err => {								this.log(									"ERROR",									`Failed to store new Spotify API token in cache.`,									typeof err === "string" ? err : err.message								);								reject(err);							});					})					.catch(err => {						this.log(							"ERROR",							`Failed to get new Spotify API token.`,							typeof err === "string" ? err : err.message						);						reject(err);					});			});		});	}	/**	 * Perform Spotify API get albums request	 * @param {object} payload - object that contains the payload	 * @param {Array} payload.albumIds - the album ids to get	 * @returns {Promise} - returns promise (reject, resolve)	 */	API_GET_ALBUMS(payload) {		return new Promise((resolve, reject) => {			const { albumIds } = payload;			SpotifyModule.runJob(				"API_CALL",				{					url: `https://api.spotify.com/v1/albums`,					params: {						ids: albumIds.join(",")					}				},				this			)				.then(response => {					resolve(response);				})				.catch(err => {					reject(err);				});		});	}	/**	 * Perform Spotify API get artists request	 * @param {object} payload - object that contains the payload	 * @param {Array} payload.artistIds - the artist ids to get	 * @returns {Promise} - returns promise (reject, resolve)	 */	API_GET_ARTISTS(payload) {		return new Promise((resolve, reject) => {			const { artistIds } = payload;			SpotifyModule.runJob(				"API_CALL",				{					url: `https://api.spotify.com/v1/artists`,					params: {						ids: artistIds.join(",")					}				},				this			)				.then(response => {					resolve(response);				})				.catch(err => {					reject(err);				});		});	}	/**	 * Perform Spotify API get track request	 * @param {object} payload - object that contains the payload	 * @param {string} payload.trackId - the Spotify track id to get	 * @returns {Promise} - returns promise (reject, resolve)	 */	API_GET_TRACK(payload) {		return new Promise((resolve, reject) => {			const { trackId } = payload;			SpotifyModule.runJob(				"API_CALL",				{					url: `https://api.spotify.com/v1/tracks/${trackId}`				},				this			)				.then(response => {					resolve(response);				})				.catch(err => {					reject(err);				});		});	}	/**	 * Perform Spotify API get playlist request	 * @param {object} payload - object that contains the payload	 * @param {string} payload.playlistId - the Spotify playlist id to get songs from	 * @param {string} payload.nextUrl - the next URL to use	 * @returns {Promise} - returns promise (reject, resolve)	 */	API_GET_PLAYLIST(payload) {		return new Promise((resolve, reject) => {			const { playlistId, nextUrl } = payload;			SpotifyModule.runJob(				"API_CALL",				{					url: nextUrl || `https://api.spotify.com/v1/playlists/${playlistId}/tracks`				},				this			)				.then(response => {					resolve(response);				})				.catch(err => {					reject(err);				});		});	}	/**	 * Perform Spotify 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, params } = payload;			SpotifyModule.runJob("GET_API_TOKEN", {}, this)				.then(spotifyApiToken => {					SpotifyModule.axios						.get(url, {							headers: {								Authorization: `Bearer ${spotifyApiToken}`							},							timeout: SpotifyModule.requestTimeout,							params						})						.then(response => {							if (response.data.error) {								reject(new Error(response.data.error));							} else {								resolve({ response });							}						})						.catch(err => {							reject(err);						});				})				.catch(err => {					this.log(						"ERROR",						`Spotify API call failed as an error occured whilst getting the API token`,						typeof err === "string" ? err : err.message					);					resolve(err);				});		});	}	/**	 * Create Spotify track	 * @param {object} payload - an object containing the payload	 * @param {Array} payload.spotifyTracks - the spotifyTracks	 * @returns {Promise} - returns a promise (resolve, reject)	 */	CREATE_TRACKS(payload) {		return new Promise((resolve, reject) => {			async.waterfall(				[					next => {						const { spotifyTracks } = payload;						if (!Array.isArray(spotifyTracks)) next("Invalid spotifyTracks type");						else {							const trackIds = spotifyTracks.map(spotifyTrack => spotifyTrack.trackId);							SpotifyModule.spotifyTrackModel.find({ trackId: trackIds }, (err, existingTracks) => {								if (err) {									next(err);									return;								}								const existingTrackIds = existingTracks.map(existingTrack => existingTrack.trackId);								const newSpotifyTracks = spotifyTracks.filter(									spotifyTrack => existingTrackIds.indexOf(spotifyTrack.trackId) === -1								);								SpotifyModule.spotifyTrackModel.insertMany(newSpotifyTracks, next);							});						}					},					(spotifyTracks, next) => {						const mediaSources = spotifyTracks.map(spotifyTrack => `spotify:${spotifyTrack.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, spotifyTracks);							}						);					}				],				(err, spotifyTracks) => {					if (err) reject(new Error(err));					else resolve({ spotifyTracks });				}			);		});	}	/**	 * Create Spotify albums	 * @param {object} payload - an object containing the payload	 * @param {Array} payload.spotifyAlbums - the Spotify albums	 * @returns {Promise} - returns a promise (resolve, reject)	 */	async CREATE_ALBUMS(payload) {		const { spotifyAlbums } = payload;		if (!Array.isArray(spotifyAlbums)) throw new Error("Invalid spotifyAlbums type");		const albumIds = spotifyAlbums.map(spotifyAlbum => spotifyAlbum.albumId);		const existingAlbums = (await SpotifyModule.spotifyAlbumModel.find({ albumId: albumIds })).map(			album => album._doc		);		const existingAlbumIds = existingAlbums.map(existingAlbum => existingAlbum.albumId);		const newSpotifyAlbums = spotifyAlbums.filter(			spotifyAlbum => existingAlbumIds.indexOf(spotifyAlbum.albumId) === -1		);		if (newSpotifyAlbums.length === 0) return existingAlbums;		await SpotifyModule.spotifyAlbumModel.insertMany(newSpotifyAlbums);		return existingAlbums.concat(newSpotifyAlbums);	}	/**	 * Create Spotify artists	 * @param {object} payload - an object containing the payload	 * @param {Array} payload.spotifyArtists - the Spotify artists	 * @returns {Promise} - returns a promise (resolve, reject)	 */	async CREATE_ARTISTS(payload) {		const { spotifyArtists } = payload;		if (!Array.isArray(spotifyArtists)) throw new Error("Invalid spotifyArtists type");		const artistIds = spotifyArtists.map(spotifyArtist => spotifyArtist.artistId);		const existingArtists = (await SpotifyModule.spotifyArtistModel.find({ artistId: artistIds })).map(			artist => artist._doc		);		const existingArtistIds = existingArtists.map(existingArtist => existingArtist.artistId);		const newSpotifyArtists = spotifyArtists.filter(			spotifyArtist => existingArtistIds.indexOf(spotifyArtist.artistId) === -1		);		if (newSpotifyArtists.length === 0) return existingArtists;		await SpotifyModule.spotifyArtistModel.insertMany(newSpotifyArtists);		return existingArtists.concat(newSpotifyArtists);	}	/**	 * Gets tracks from media sources	 * @param {object} payload - object that contains the payload	 * @param {Array} payload.mediaSources - the media sources to get tracks from	 * @returns {Promise} - returns promise (reject, resolve)	 */	async GET_TRACKS_FROM_MEDIA_SOURCES(payload) {		return new Promise((resolve, reject) => {			const { mediaSources } = payload;			const responses = {};			const promises = [];			mediaSources.forEach(mediaSource => {				promises.push(					new Promise(resolve => {						const trackId = mediaSource.split(":")[1];						SpotifyModule.runJob("GET_TRACK", { identifier: trackId, createMissing: true }, this)							.then(({ track }) => {								responses[mediaSource] = track;							})							.catch(err => {								SpotifyModule.log(									"ERROR",									`Getting tracked with media source ${mediaSource} failed.`,									typeof err === "string" ? err : err.message								);								responses[mediaSource] = typeof err === "string" ? err : err.message;							})							.finally(() => {								resolve();							});					})				);			});			Promise.all(promises)				.then(() => {					SpotifyModule.log("SUCCESS", `Got all tracks.`);					resolve({ tracks: responses });				})				.catch(reject);		});	}	/**	 * Gets albums from Spotify album ids	 * @param {object} payload - object that contains the payload	 * @param {Array} payload.albumIds - the Spotify album ids	 * @returns {Promise} - returns promise (reject, resolve)	 */	async GET_ALBUMS_FROM_IDS(payload) {		const { albumIds } = payload;		const existingAlbums = (await SpotifyModule.spotifyAlbumModel.find({ albumId: albumIds })).map(			album => album._doc		);		const existingAlbumIds = existingAlbums.map(existingAlbum => existingAlbum.albumId);		const missingAlbumIds = albumIds.filter(albumId => existingAlbumIds.indexOf(albumId) === -1);		if (missingAlbumIds.length === 0) return existingAlbums;		const jobsToRun = [];		const chunkSize = 2;		while (missingAlbumIds.length > 0) {			const chunkedMissingAlbumIds = missingAlbumIds.splice(0, chunkSize);			jobsToRun.push(SpotifyModule.runJob("API_GET_ALBUMS", { albumIds: chunkedMissingAlbumIds }, this));		}		const jobResponses = await Promise.all(jobsToRun);		const newAlbums = jobResponses			.map(jobResponse => jobResponse.response.data.albums)			.flat()			.map(album => ({				albumId: album.id,				rawData: album			}));		await SpotifyModule.runJob("CREATE_ALBUMS", { spotifyAlbums: newAlbums }, this);		return existingAlbums.concat(newAlbums);	}	/**	 * Gets Spotify artists from Spotify artist ids	 * @param {object} payload - object that contains the payload	 * @param {Array} payload.artistIds - the Spotify artist ids	 * @returns {Promise} - returns promise (reject, resolve)	 */	async GET_ARTISTS_FROM_IDS(payload) {		const { artistIds } = payload;		const existingArtists = (await SpotifyModule.spotifyArtistModel.find({ artistId: artistIds })).map(			artist => artist._doc		);		const existingArtistIds = existingArtists.map(existingArtist => existingArtist.artistId);		const missingArtistIds = artistIds.filter(artistId => existingArtistIds.indexOf(artistId) === -1);		if (missingArtistIds.length === 0) return existingArtists;		const jobsToRun = [];		const chunkSize = 50;		while (missingArtistIds.length > 0) {			const chunkedMissingArtistIds = missingArtistIds.splice(0, chunkSize);			jobsToRun.push(SpotifyModule.runJob("API_GET_ARTISTS", { artistIds: chunkedMissingArtistIds }, this));		}		const jobResponses = await Promise.all(jobsToRun);		const newArtists = jobResponses			.map(jobResponse => jobResponse.response.data.artists)			.flat()			.map(artist => ({				artistId: artist.id,				rawData: artist			}));		await SpotifyModule.runJob("CREATE_ARTISTS", { spotifyArtists: newArtists }, this);		return existingArtists.concat(newArtists);	}	/**	 * Get Spotify track	 * @param {object} payload - an object containing the payload	 * @param {string} payload.identifier - the spotify 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 SpotifyModule.spotifyTrackModel.findOne(query, next);					},					(track, next) => {						if (track) return next(null, track, false);						if (mongoose.isObjectIdOrHexString(payload.identifier) || !payload.createMissing)							return next("Spotify track not found.");						return SpotifyModule.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 spotifyTrack = spotifyTrackObjectToMusareTrackObject(data);								return next(null, false, spotifyTrack);							})							.catch(next);					},					(track, spotifyTrack, next) => {						if (track) return next(null, track, true);						return SpotifyModule.runJob("CREATE_TRACKS", { spotifyTracks: [spotifyTrack] }, this)							.then(res => {								if (res.spotifyTracks.length === 1) next(null, res.spotifyTracks[0], false);								else next("Spotify track not found.");							})							.catch(next);					}				],				(err, track, existing) => {					if (err) reject(new Error(err));					else if (track.isLocal) reject(new Error("Track is local."));					else resolve({ track, existing });				}			);		});	}	/**	 * Get Spotify album	 * @param {object} payload - an object containing the payload	 * @param {string} payload.identifier - the spotify album ObjectId or track id	 * @returns {Promise} - returns a promise (resolve, reject)	 */	async GET_ALBUM(payload) {		const query = mongoose.isObjectIdOrHexString(payload.identifier)			? { _id: payload.identifier }			: { albumId: payload.identifier };		const album = await SpotifyModule.spotifyAlbumModel.findOne(query);		if (album) return album._doc;		return null;	}	/**	 * Returns an array of songs taken from a Spotify playlist	 * @param {object} payload - object that contains the payload	 * @param {string} payload.url - the id of the Spotify playlist	 * @returns {Promise} - returns promise (reject, resolve)	 */	GET_PLAYLIST(payload) {		return new Promise((resolve, reject) => {			const spotifyPlaylistUrlRegex = /.+open\.spotify\.com\/playlist\/(?<playlistId>[A-Za-z0-9]+)/;			const match = spotifyPlaylistUrlRegex.exec(payload.url);			if (!match || !match.groups) {				SpotifyModule.log("ERROR", "GET_PLAYLIST", "Invalid Spotify playlist URL query.");				reject(new Error("Invalid playlist URL."));				return;			}			const { playlistId } = match.groups;			async.waterfall(				[					next => {						let spotifyTracks = [];						let total = -1;						let nextUrl = "";						async.whilst(							next => {								SpotifyModule.log(									"INFO",									`Getting playlist progress for job (${this.toString()}): ${										spotifyTracks.length									} tracks gotten so far. Total tracks: ${total}.`								);								next(null, nextUrl !== null);							},							next => {								// Add 250ms delay between each job request								setTimeout(() => {									SpotifyModule.runJob("API_GET_PLAYLIST", { playlistId, nextUrl }, this)										.then(({ response }) => {											const { data } = response;											if (!data) {												next("The provided URL does not exist or cannot be accessed.");												return;											}											total = data.total;											nextUrl = data.next;											const { items } = data;											const trackObjects = items.map(item => item.track);											const newSpotifyTracks = trackObjects.map(trackObject =>												spotifyTrackObjectToMusareTrackObject(trackObject)											);											spotifyTracks = spotifyTracks.concat(newSpotifyTracks);											next();										})										.catch(err => next(err));								}, 1000);							},							err => {								if (err) next(err);								else									SpotifyModule.runJob("CREATE_TRACKS", { spotifyTracks }, this)										.then(() => {											next(												null,												spotifyTracks.map(spotifyTrack => spotifyTrack.trackId)											);										})										.catch(next);							}						);					}				],				(err, soundcloudTrackIds) => {					if (err && err !== true) {						SpotifyModule.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 });					}				}			);		});	}	/**	 * Tries to get alternative artists sources for a list of Spotify artist ids	 * @param {object} payload - object that contains the payload	 * @param {string} payload.artistIds - the Spotify artist ids to try and get alternative artist sources for	 * @param {boolean} payload.collectAlternativeArtistSourcesOrigins - whether to collect the origin of any alternative artist sources found	 * @returns {Promise} - returns promise (reject, resolve)	 */	async GET_ALTERNATIVE_ARTIST_SOURCES_FOR_ARTISTS(payload) {		const { artistIds, collectAlternativeArtistSourcesOrigins } = payload;		await async.eachLimit(artistIds, 1, async artistId => {			try {				const result = await SpotifyModule.runJob(					"GET_ALTERNATIVE_ARTIST_SOURCES_FOR_ARTIST",					{ artistId, collectAlternativeArtistSourcesOrigins },					this				);				this.publishProgress({					status: "working",					message: `Got alternative artist source for ${artistId}`,					data: {						artistId,						status: "success",						result					}				});			} catch (err) {				this.publishProgress({					status: "working",					message: `Failed to get alternative artist source for ${artistId}`,					data: {						artistId,						status: "error"					}				});			}		});		this.publishProgress({			status: "finished",			message: `Finished getting alternative artist sources`		});	}	/**	 * Tries to get alternative artist sources for a Spotify artist id	 * @param {object} payload - object that contains the payload	 * @param {string} payload.artistId - the Spotify artist id to try and get alternative artist sources for	 * @param {boolean} payload.collectAlternativeArtistSourcesOrigins - whether to collect the origin of any alternative artist sources found	 * @returns {Promise} - returns promise (reject, resolve)	 */	async GET_ALTERNATIVE_ARTIST_SOURCES_FOR_ARTIST(payload) {		const { artistId /* , collectAlternativeArtistSourcesOrigins */ } = payload;		if (!artistId) throw new Error("Artist id provided is not valid.");		const wikiDataResponse = await WikiDataModule.runJob(			"API_GET_DATA_FROM_SPOTIFY_ARTIST",			{ spotifyArtistId: artistId },			this		);		const youtubeChannelIds = Array.from(			new Set(				wikiDataResponse.results.bindings					.filter(binding => !!binding.YouTube_channel_ID)					.map(binding => binding.YouTube_channel_ID.value)			)		);		// const soundcloudIds = Array.from(		// 	new Set(		// 		wikiDataResponse.results.bindings		// 			.filter(binding => !!binding.SoundCloud_ID)		// 			.map(binding => binding.SoundCloud_ID.value)		// 	)		// );		// const musicbrainzArtistIds = Array.from(		// 	new Set(		// 		wikiDataResponse.results.bindings		// 			.filter(binding => !!binding.MusicBrainz_artist_ID)		// 			.map(binding => binding.MusicBrainz_artist_ID.value)		// 	)		// );		return youtubeChannelIds;	}	/**	 * Tries to get alternative album sources for a list of Spotify album ids	 * @param {object} payload - object that contains the payload	 * @param {string} payload.albumIds - the Spotify album ids to try and get alternative album sources for	 * @param {boolean} payload.collectAlternativeAlbumSourcesOrigins - whether to collect the origin of any alternative album sources found	 * @returns {Promise} - returns promise (reject, resolve)	 */	async GET_ALTERNATIVE_ALBUM_SOURCES_FOR_ALBUMS(payload) {		const { albumIds, collectAlternativeAlbumSourcesOrigins } = payload;		await async.eachLimit(albumIds, 1, async albumId => {			try {				const result = await SpotifyModule.runJob(					"GET_ALTERNATIVE_ALBUM_SOURCES_FOR_ALBUM",					{ albumId, collectAlternativeAlbumSourcesOrigins },					this				);				this.publishProgress({					status: "working",					message: `Got alternative album source for ${albumId}`,					data: {						albumId,						status: "success",						result					}				});			} catch (err) {				this.publishProgress({					status: "working",					message: `Failed to get alternative album source for ${albumId}`,					data: {						albumId,						status: "error"					}				});			}		});		this.publishProgress({			status: "finished",			message: `Finished getting alternative album sources`		});	}	/**	 * Tries to get alternative album sources for a Spotify album id	 * @param {object} payload - object that contains the payload	 * @param {string} payload.albumId - the Spotify album id to try and get alternative album sources for	 * @param {boolean} payload.collectAlternativeAlbumSourcesOrigins - whether to collect the origin of any alternative album sources found	 * @returns {Promise} - returns promise (reject, resolve)	 */	async GET_ALTERNATIVE_ALBUM_SOURCES_FOR_ALBUM(payload) {		const { albumId /* , collectAlternativeAlbumSourcesOrigins */ } = payload;		if (!albumId) throw new Error("Album id provided is not valid.");		const wikiDataResponse = await WikiDataModule.runJob(			"API_GET_DATA_FROM_SPOTIFY_ALBUM",			{ spotifyAlbumId: albumId },			this		);		const youtubePlaylistIds = Array.from(			new Set(				wikiDataResponse.results.bindings					.filter(binding => !!binding.YouTube_playlist_ID)					.map(binding => binding.YouTube_playlist_ID.value)			)		);		return youtubePlaylistIds;	}	/**	 * Tries to get alternative track sources for a list of Spotify track media sources	 * @param {object} payload - object that contains the payload	 * @param {string} payload.mediaSources - the Spotify media sources to try and get alternative track sources for	 * @param {boolean} payload.collectAlternativeMediaSourcesOrigins - whether to collect the origin of any alternative track sources found	 * @returns {Promise} - returns promise (reject, resolve)	 */	async GET_ALTERNATIVE_MEDIA_SOURCES_FOR_TRACKS(payload) {		const { mediaSources, collectAlternativeMediaSourcesOrigins } = payload;		await async.eachLimit(mediaSources, 1, async mediaSource => {			try {				const result = await SpotifyModule.runJob(					"GET_ALTERNATIVE_MEDIA_SOURCES_FOR_TRACK",					{ mediaSource, collectAlternativeMediaSourcesOrigins },					this				);				this.publishProgress({					status: "working",					message: `Got alternative media for ${mediaSource}`,					data: {						mediaSource,						status: "success",						result					}				});			} catch (err) {				this.publishProgress({					status: "working",					message: `Failed to get alternative media for ${mediaSource}`,					data: {						mediaSource,						status: "error"					}				});			}		});		this.publishProgress({			status: "finished",			message: `Finished getting alternative media`		});	}	/**	 * Tries to get alternative track sources for a Spotify track media source	 * @param {object} payload - object that contains the payload	 * @param {string} payload.mediaSource - the Spotify media source to try and get alternative track sources for	 * @param {boolean} payload.collectAlternativeMediaSourcesOrigins - whether to collect the origin of any alternative track sources found	 * @returns {Promise} - returns promise (reject, resolve)	 */	async GET_ALTERNATIVE_MEDIA_SOURCES_FOR_TRACK(payload) {		const { mediaSource, collectAlternativeMediaSourcesOrigins } = payload;		if (!mediaSource || !mediaSource.startsWith("spotify:"))			throw new Error("Media source provided is not a valid Spotify media source.");		const spotifyTrackId = mediaSource.split(":")[1];		const { track: spotifyTrack } = await SpotifyModule.runJob(			"GET_TRACK",			{				identifier: spotifyTrackId,				createMissing: true			},			this		);		const ISRC = spotifyTrack.externalIds.isrc;		if (!ISRC) throw new Error(`ISRC not found for Spotify track ${mediaSource}.`);		const mediaSources = new Set();		const mediaSourcesOrigins = {};		const jobsToRun = [];		try {			const ISRCApiResponse = await MusicBrainzModule.runJob(				"API_CALL",				{					url: `https://musicbrainz.org/ws/2/isrc/${ISRC}`,					params: {						fmt: "json",						inc: "url-rels+work-rels"					}				},				this			);			ISRCApiResponse.recordings.forEach(recording => {				recording.relations.forEach(relation => {					if (relation["target-type"] === "url" && relation.url) {						// relation["type-id"] === "7e41ef12-a124-4324-afdb-fdbae687a89c"						const { resource } = relation.url;						if (config.get("experimental.soundcloud") && resource.indexOf("soundcloud.com") !== -1) {							const promise = new Promise(resolve => {								SoundcloudModule.runJob(									"GET_TRACK_FROM_URL",									{ identifier: resource, createMissing: true },									this								)									.then(response => {										const { trackId } = response.track;										const mediaSource = `soundcloud:${trackId}`;										mediaSources.add(mediaSource);										if (collectAlternativeMediaSourcesOrigins) {											const mediaSourceOrigins = [												`Spotify track ${spotifyTrackId}`,												`ISRC ${ISRC}`,												`MusicBrainz recordings`,												`MusicBrainz recording ${recording.id}`,												`MusicBrainz relations`,												`MusicBrainz relation target-type url`,												`MusicBrainz relation resource ${resource}`,												`SoundCloud ID ${trackId}`											];											if (!mediaSourcesOrigins[mediaSource])												mediaSourcesOrigins[mediaSource] = [];											mediaSourcesOrigins[mediaSource].push(mediaSourceOrigins);										}										resolve();									})									.catch(() => {										resolve();									});							});							jobsToRun.push(promise);							return;						}						if (resource.indexOf("youtube.com") !== -1 || resource.indexOf("youtu.be") !== -1) {							const match = youtubeVideoUrlRegex.exec(resource);							if (!match) throw new Error(`Unable to parse YouTube resource ${resource}.`);							const { youtubeId } = match.groups;							if (!youtubeId) throw new Error(`Unable to parse YouTube resource ${resource}.`);							const mediaSource = `youtube:${youtubeId}`;							mediaSources.add(mediaSource);							if (collectAlternativeMediaSourcesOrigins) {								const mediaSourceOrigins = [									`Spotify track ${spotifyTrackId}`,									`ISRC ${ISRC}`,									`MusicBrainz recordings`,									`MusicBrainz recording ${recording.id}`,									`MusicBrainz relations`,									`MusicBrainz relation target-type url`,									`MusicBrainz relation resource ${resource}`,									`YouTube ID ${youtubeId}`								];								if (!mediaSourcesOrigins[mediaSource]) mediaSourcesOrigins[mediaSource] = [];								mediaSourcesOrigins[mediaSource].push(mediaSourceOrigins);							}							return;						}						return;					}					if (relation["target-type"] === "work") {						const promise = new Promise(resolve => {							WikiDataModule.runJob(								"API_GET_DATA_FROM_MUSICBRAINZ_WORK",								{ workId: relation.work.id },								this							)								.then(resultBody => {									const youtubeIds = Array.from(										new Set(											resultBody.results.bindings												.filter(binding => !!binding.YouTube_video_ID)												.map(binding => binding.YouTube_video_ID.value)										)									);									// const soundcloudIds = Array.from(									// 	new Set(									// 		resultBody.results.bindings									// 			.filter(binding => !!binding["SoundCloud_track_ID"])									// 			.map(binding => binding["SoundCloud_track_ID"].value)									// 	)									// );									const musicVideoEntityUrls = Array.from(										new Set(											resultBody.results.bindings												.filter(binding => !!binding.Music_video_entity_URL)												.map(binding => binding.Music_video_entity_URL.value)										)									);									youtubeIds.forEach(youtubeId => {										const mediaSource = `youtube:${youtubeId}`;										mediaSources.add(mediaSource);										if (collectAlternativeMediaSourcesOrigins) {											const mediaSourceOrigins = [												`Spotify track ${spotifyTrackId}`,												`ISRC ${ISRC}`,												`MusicBrainz recordings`,												`MusicBrainz recording ${recording.id}`,												`MusicBrainz relations`,												`MusicBrainz relation target-type work`,												`MusicBrainz relation work id ${relation.work.id}`,												`WikiData select from MusicBrainz work id ${relation.work.id}`,												`YouTube ID ${youtubeId}`											];											if (!mediaSourcesOrigins[mediaSource])												mediaSourcesOrigins[mediaSource] = [];											mediaSourcesOrigins[mediaSource].push(mediaSourceOrigins);										}									});									// soundcloudIds.forEach(soundcloudId => {									// 	const mediaSource = `soundcloud:${soundcloudId}`;									// 	mediaSources.add(mediaSource);									// 	if (collectAlternativeMediaSourcesOrigins) {									// 		const mediaSourceOrigins = [									// 			`Spotify track ${spotifyTrackId}`,									// 			`ISRC ${ISRC}`,									// 			`MusicBrainz recordings`,									// 			`MusicBrainz recording ${recording.id}`,									// 			`MusicBrainz relations`,									// 			`MusicBrainz relation target-type work`,									// 			`MusicBrainz relation work id ${relation.work.id}`,									// 			`WikiData select from MusicBrainz work id ${relation.work.id}`,									// 			`SoundCloud ID ${soundcloudId}`									// 		];									// 		if (!mediaSourcesOrigins[mediaSource]) mediaSourcesOrigins[mediaSource] = [];									// 		mediaSourcesOrigins[mediaSource].push(mediaSourceOrigins);									// 	}									// });									const promisesToRun2 = [];									musicVideoEntityUrls.forEach(musicVideoEntityUrl => {										promisesToRun2.push(											new Promise(resolve => {												WikiDataModule.runJob(													"API_GET_DATA_FROM_ENTITY_URL",													{ entityUrl: musicVideoEntityUrl },													this												).then(resultBody => {													const youtubeIds = Array.from(														new Set(															resultBody.results.bindings																.filter(binding => !!binding.YouTube_video_ID)																.map(binding => binding.YouTube_video_ID.value)														)													);													// const soundcloudIds = Array.from(													// 	new Set(													// 		resultBody.results.bindings													// 			.filter(binding => !!binding["SoundCloud_track_ID"])													// 			.map(binding => binding["SoundCloud_track_ID"].value)													// 	)													// );													youtubeIds.forEach(youtubeId => {														const mediaSource = `youtube:${youtubeId}`;														mediaSources.add(mediaSource);														// if (collectAlternativeMediaSourcesOrigins) {														// 	const mediaSourceOrigins = [														// 		`Spotify track ${spotifyTrackId}`,														// 		`ISRC ${ISRC}`,														// 		`MusicBrainz recordings`,														// 		`MusicBrainz recording ${recording.id}`,														// 		`MusicBrainz relations`,														// 		`MusicBrainz relation target-type work`,														// 		`MusicBrainz relation work id ${relation.work.id}`,														// 		`WikiData select from MusicBrainz work id ${relation.work.id}`,														// 		`YouTube ID ${youtubeId}`														// 	];														// 	if (!mediaSourcesOrigins[mediaSource]) mediaSourcesOrigins[mediaSource] = [];														// 	mediaSourcesOrigins[mediaSource].push(mediaSourceOrigins);														// }													});													// soundcloudIds.forEach(soundcloudId => {													// 	const mediaSource = `soundcloud:${soundcloudId}`;													// 	mediaSources.add(mediaSource);													// 	// if (collectAlternativeMediaSourcesOrigins) {													// 	// 	const mediaSourceOrigins = [													// 	// 		`Spotify track ${spotifyTrackId}`,													// 	// 		`ISRC ${ISRC}`,													// 	// 		`MusicBrainz recordings`,													// 	// 		`MusicBrainz recording ${recording.id}`,													// 	// 		`MusicBrainz relations`,													// 	// 		`MusicBrainz relation target-type work`,													// 	// 		`MusicBrainz relation work id ${relation.work.id}`,													// 	// 		`WikiData select from MusicBrainz work id ${relation.work.id}`,													// 	// 		`SoundCloud ID ${soundcloudId}`													// 	// 	];													// 	// 	if (!mediaSourcesOrigins[mediaSource]) mediaSourcesOrigins[mediaSource] = [];													// 	// 	mediaSourcesOrigins[mediaSource].push(mediaSourceOrigins);													// 	// }													// });													resolve();												});											})										);									});									Promise.allSettled(promisesToRun2).then(resolve);								})								.catch(err => {									console.log(err);									resolve();								});						});						jobsToRun.push(promise);						// WikiDataModule.runJob("API_GET_DATA_FROM_MUSICBRAINZ_WORK", { workId: relation.work.id }, this));					}				});			});		} catch (err) {			console.log("Error during initial ISRC getting/parsing", err);		}		try {			const RecordingApiResponse = await MusicBrainzModule.runJob(				"API_CALL",				{					url: `https://musicbrainz.org/ws/2/recording/`,					params: {						fmt: "json",						query: `isrc:${ISRC}`					}				},				this			);			const releaseIds = new Set();			const releaseGroupIds = new Set();			RecordingApiResponse.recordings.forEach(recording => {				// const recordingId = recording.id;				// console.log("Recording:", recording.id);				recording.releases.forEach(release => {					const releaseId = release.id;					// console.log("Release:", releaseId);					const releaseGroupId = release["release-group"].id;					// console.log("Release group:", release["release-group"]);					// console.log("Release group id:", release["release-group"].id);					// console.log("Release group type id:", release["release-group"]["type-id"]);					// console.log("Release group primary type id:", release["release-group"]["primary-type-id"]);					// console.log("Release group primary type:", release["release-group"]["primary-type"]);					// d6038452-8ee0-3f68-affc-2de9a1ede0b9 = single					// 6d0c5bf6-7a33-3420-a519-44fc63eedebf = EP					if (						release["release-group"]["type-id"] === "d6038452-8ee0-3f68-affc-2de9a1ede0b9" ||						release["release-group"]["type-id"] === "6d0c5bf6-7a33-3420-a519-44fc63eedebf"					) {						releaseIds.add(releaseId);						releaseGroupIds.add(releaseGroupId);					}				});			});			Array.from(releaseGroupIds).forEach(releaseGroupId => {				const promise = new Promise(resolve => {					WikiDataModule.runJob("API_GET_DATA_FROM_MUSICBRAINZ_RELEASE_GROUP", { releaseGroupId }, this)						.then(resultBody => {							const youtubeIds = Array.from(								new Set(									resultBody.results.bindings										.filter(binding => !!binding.YouTube_video_ID)										.map(binding => binding.YouTube_video_ID.value)								)							);							// const soundcloudIds = Array.from(							// 	new Set(							// 		resultBody.results.bindings							// 			.filter(binding => !!binding["SoundCloud_track_ID"])							// 			.map(binding => binding["SoundCloud_track_ID"].value)							// 	)							// );							const musicVideoEntityUrls = Array.from(								new Set(									resultBody.results.bindings										.filter(binding => !!binding.Music_video_entity_URL)										.map(binding => binding.Music_video_entity_URL.value)								)							);							youtubeIds.forEach(youtubeId => {								const mediaSource = `youtube:${youtubeId}`;								mediaSources.add(mediaSource);								// if (collectAlternativeMediaSourcesOrigins) {								// 	const mediaSourceOrigins = [								// 		`Spotify track ${spotifyTrackId}`,								// 		`ISRC ${ISRC}`,								// 		`MusicBrainz recordings`,								// 		`MusicBrainz recording ${recording.id}`,								// 		`MusicBrainz relations`,								// 		`MusicBrainz relation target-type work`,								// 		`MusicBrainz relation work id ${relation.work.id}`,								// 		`WikiData select from MusicBrainz work id ${relation.work.id}`,								// 		`YouTube ID ${youtubeId}`								// 	];								// 	if (!mediaSourcesOrigins[mediaSource]) mediaSourcesOrigins[mediaSource] = [];								// 	mediaSourcesOrigins[mediaSource].push(mediaSourceOrigins);								// }							});							// soundcloudIds.forEach(soundcloudId => {							// 	const mediaSource = `soundcloud:${soundcloudId}`;							// 	mediaSources.add(mediaSource);							// 	// if (collectAlternativeMediaSourcesOrigins) {							// 	// 	const mediaSourceOrigins = [							// 	// 		`Spotify track ${spotifyTrackId}`,							// 	// 		`ISRC ${ISRC}`,							// 	// 		`MusicBrainz recordings`,							// 	// 		`MusicBrainz recording ${recording.id}`,							// 	// 		`MusicBrainz relations`,							// 	// 		`MusicBrainz relation target-type work`,							// 	// 		`MusicBrainz relation work id ${relation.work.id}`,							// 	// 		`WikiData select from MusicBrainz work id ${relation.work.id}`,							// 	// 		`SoundCloud ID ${soundcloudId}`							// 	// 	];							// 	// 	if (!mediaSourcesOrigins[mediaSource]) mediaSourcesOrigins[mediaSource] = [];							// 	// 	mediaSourcesOrigins[mediaSource].push(mediaSourceOrigins);							// 	// }							// });							const promisesToRun2 = [];							musicVideoEntityUrls.forEach(musicVideoEntityUrl => {								promisesToRun2.push(									new Promise(resolve => {										WikiDataModule.runJob(											"API_GET_DATA_FROM_ENTITY_URL",											{ entityUrl: musicVideoEntityUrl },											this										).then(resultBody => {											const youtubeIds = Array.from(												new Set(													resultBody.results.bindings														.filter(binding => !!binding.YouTube_video_ID)														.map(binding => binding.YouTube_video_ID.value)												)											);											// const soundcloudIds = Array.from(											// 	new Set(											// 		resultBody.results.bindings											// 			.filter(binding => !!binding["SoundCloud_track_ID"])											// 			.map(binding => binding["SoundCloud_track_ID"].value)											// 	)											// );											youtubeIds.forEach(youtubeId => {												const mediaSource = `youtube:${youtubeId}`;												mediaSources.add(mediaSource);												// if (collectAlternativeMediaSourcesOrigins) {												// 	const mediaSourceOrigins = [												// 		`Spotify track ${spotifyTrackId}`,												// 		`ISRC ${ISRC}`,												// 		`MusicBrainz recordings`,												// 		`MusicBrainz recording ${recording.id}`,												// 		`MusicBrainz relations`,												// 		`MusicBrainz relation target-type work`,												// 		`MusicBrainz relation work id ${relation.work.id}`,												// 		`WikiData select from MusicBrainz work id ${relation.work.id}`,												// 		`YouTube ID ${youtubeId}`												// 	];												// 	if (!mediaSourcesOrigins[mediaSource]) mediaSourcesOrigins[mediaSource] = [];												// 	mediaSourcesOrigins[mediaSource].push(mediaSourceOrigins);												// }											});											// soundcloudIds.forEach(soundcloudId => {											// 	const mediaSource = `soundcloud:${soundcloudId}`;											// 	mediaSources.add(mediaSource);											// 	// if (collectAlternativeMediaSourcesOrigins) {											// 	// 	const mediaSourceOrigins = [											// 	// 		`Spotify track ${spotifyTrackId}`,											// 	// 		`ISRC ${ISRC}`,											// 	// 		`MusicBrainz recordings`,											// 	// 		`MusicBrainz recording ${recording.id}`,											// 	// 		`MusicBrainz relations`,											// 	// 		`MusicBrainz relation target-type work`,											// 	// 		`MusicBrainz relation work id ${relation.work.id}`,											// 	// 		`WikiData select from MusicBrainz work id ${relation.work.id}`,											// 	// 		`SoundCloud ID ${soundcloudId}`											// 	// 	];											// 	// 	if (!mediaSourcesOrigins[mediaSource]) mediaSourcesOrigins[mediaSource] = [];											// 	// 	mediaSourcesOrigins[mediaSource].push(mediaSourceOrigins);											// 	// }											// });											resolve();										});									})								);							});							Promise.allSettled(promisesToRun2).then(resolve);						})						.catch(err => {							console.log(err);							resolve();						});				});				jobsToRun.push(promise);			});		} catch (err) {			console.log("Error during getting releases from ISRC", err);		}		await Promise.allSettled(jobsToRun);		return {			mediaSources: Array.from(mediaSources),			mediaSourcesOrigins		};	}}export default new _SpotifyModule();
 |