spotify.js 36 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238
  1. import mongoose from "mongoose";
  2. import async from "async";
  3. import config from "config";
  4. import * as rax from "retry-axios";
  5. import axios from "axios";
  6. import url from "url";
  7. import CoreClass from "../core";
  8. let SpotifyModule;
  9. let SoundcloudModule;
  10. let DBModule;
  11. let CacheModule;
  12. let MediaModule;
  13. let MusicBrainzModule;
  14. let WikiDataModule;
  15. const youtubeVideoUrlRegex =
  16. /^(https?:\/\/)?(www\.)?(m\.)?(music\.)?(youtube\.com|youtu\.be)\/(watch\?v=)?(?<youtubeId>[\w-]{11})((&([A-Za-z0-9]+)?)*)?$/;
  17. const youtubeVideoIdRegex = /^([\w-]{11})$/;
  18. const spotifyTrackObjectToMusareTrackObject = spotifyTrackObject => {
  19. return {
  20. trackId: spotifyTrackObject.id,
  21. name: spotifyTrackObject.name,
  22. albumId: spotifyTrackObject.album.id,
  23. albumTitle: spotifyTrackObject.album.title,
  24. albumImageUrl: spotifyTrackObject.album.images[0].url,
  25. artists: spotifyTrackObject.artists.map(artist => artist.name),
  26. artistIds: spotifyTrackObject.artists.map(artist => artist.id),
  27. duration: spotifyTrackObject.duration_ms / 1000,
  28. explicit: spotifyTrackObject.explicit,
  29. externalIds: spotifyTrackObject.external_ids,
  30. popularity: spotifyTrackObject.popularity,
  31. isLocal: spotifyTrackObject.is_local
  32. };
  33. };
  34. class RateLimitter {
  35. /**
  36. * Constructor
  37. *
  38. * @param {number} timeBetween - The time between each allowed YouTube request
  39. */
  40. constructor(timeBetween) {
  41. this.dateStarted = Date.now();
  42. this.timeBetween = timeBetween;
  43. }
  44. /**
  45. * Returns a promise that resolves whenever the ratelimit of a YouTube request is done
  46. *
  47. * @returns {Promise} - promise that gets resolved when the rate limit allows it
  48. */
  49. continue() {
  50. return new Promise(resolve => {
  51. if (Date.now() - this.dateStarted >= this.timeBetween) resolve();
  52. else setTimeout(resolve, this.dateStarted + this.timeBetween - Date.now());
  53. });
  54. }
  55. /**
  56. * Restart the rate limit timer
  57. */
  58. restart() {
  59. this.dateStarted = Date.now();
  60. }
  61. }
  62. class _SpotifyModule extends CoreClass {
  63. // eslint-disable-next-line require-jsdoc
  64. constructor() {
  65. super("spotify");
  66. SpotifyModule = this;
  67. }
  68. /**
  69. * Initialises the spotify module
  70. *
  71. * @returns {Promise} - returns promise (reject, resolve)
  72. */
  73. async initialize() {
  74. DBModule = this.moduleManager.modules.db;
  75. CacheModule = this.moduleManager.modules.cache;
  76. MediaModule = this.moduleManager.modules.media;
  77. MusicBrainzModule = this.moduleManager.modules.musicbrainz;
  78. SoundcloudModule = this.moduleManager.modules.soundcloud;
  79. WikiDataModule = this.moduleManager.modules.wikidata;
  80. // this.youtubeApiRequestModel = this.YoutubeApiRequestModel = await DBModule.runJob("GET_MODEL", {
  81. // modelName: "youtubeApiRequest"
  82. // });
  83. this.spotifyTrackModel = this.SpotifyTrackModel = await DBModule.runJob("GET_MODEL", {
  84. modelName: "spotifyTrack"
  85. });
  86. this.spotifyAlbumModel = this.SpotifyAlbumModel = await DBModule.runJob("GET_MODEL", {
  87. modelName: "spotifyAlbum"
  88. });
  89. return new Promise((resolve, reject) => {
  90. if (!config.has("apis.spotify") || !config.get("apis.spotify.enabled")) {
  91. reject(new Error("Spotify is not enabled."));
  92. return;
  93. }
  94. this.rateLimiter = new RateLimitter(config.get("apis.spotify.rateLimit"));
  95. this.requestTimeout = config.get("apis.spotify.requestTimeout");
  96. this.axios = axios.create();
  97. this.axios.defaults.raxConfig = {
  98. instance: this.axios,
  99. retry: config.get("apis.spotify.retryAmount"),
  100. noResponseRetries: config.get("apis.spotify.retryAmount")
  101. };
  102. rax.attach(this.axios);
  103. resolve();
  104. });
  105. }
  106. /**
  107. *
  108. * @returns
  109. */
  110. GET_API_TOKEN() {
  111. return new Promise((resolve, reject) => {
  112. CacheModule.runJob("GET", { key: "spotifyApiKey" }, this).then(spotifyApiKey => {
  113. if (spotifyApiKey) {
  114. resolve(spotifyApiKey);
  115. return;
  116. }
  117. this.log("INFO", `No Spotify API token stored in cache, requesting new token.`);
  118. const clientId = config.get("apis.spotify.clientId");
  119. const clientSecret = config.get("apis.spotify.clientSecret");
  120. const unencoded = `${clientId}:${clientSecret}`;
  121. const encoded = Buffer.from(unencoded).toString("base64");
  122. const params = new url.URLSearchParams({ grant_type: "client_credentials" });
  123. SpotifyModule.axios
  124. .post("https://accounts.spotify.com/api/token", params.toString(), {
  125. headers: {
  126. Authorization: `Basic ${encoded}`,
  127. "Content-Type": "application/x-www-form-urlencoded"
  128. }
  129. })
  130. .then(res => {
  131. const { access_token: accessToken, expires_in: expiresIn } = res.data;
  132. // TODO TTL can be later if stuck in queue
  133. CacheModule.runJob(
  134. "SET",
  135. { key: "spotifyApiKey", value: accessToken, ttl: expiresIn - 30 },
  136. this
  137. )
  138. .then(spotifyApiKey => {
  139. this.log(
  140. "SUCCESS",
  141. `Stored new Spotify API token in cache. Expires in ${expiresIn - 30}`
  142. );
  143. resolve(spotifyApiKey);
  144. })
  145. .catch(err => {
  146. this.log(
  147. "ERROR",
  148. `Failed to store new Spotify API token in cache.`,
  149. typeof err === "string" ? err : err.message
  150. );
  151. reject(err);
  152. });
  153. })
  154. .catch(err => {
  155. this.log(
  156. "ERROR",
  157. `Failed to get new Spotify API token.`,
  158. typeof err === "string" ? err : err.message
  159. );
  160. reject(err);
  161. });
  162. });
  163. });
  164. }
  165. /**
  166. * Perform Spotify API get albums request
  167. *
  168. * @param {object} payload - object that contains the payload
  169. * @param {object} payload.albumIds - the album ids to get
  170. * @returns {Promise} - returns promise (reject, resolve)
  171. */
  172. API_GET_ALBUMS(payload) {
  173. return new Promise((resolve, reject) => {
  174. const { albumIds } = payload;
  175. SpotifyModule.runJob(
  176. "API_CALL",
  177. {
  178. url: `https://api.spotify.com/v1/albums`,
  179. params: {
  180. ids: albumIds.join(",")
  181. }
  182. },
  183. this
  184. )
  185. .then(response => {
  186. resolve(response);
  187. })
  188. .catch(err => {
  189. reject(err);
  190. });
  191. });
  192. }
  193. /**
  194. * Perform Spotify API get track request
  195. *
  196. * @param {object} payload - object that contains the payload
  197. * @param {object} payload.params - request parameters
  198. * @returns {Promise} - returns promise (reject, resolve)
  199. */
  200. API_GET_TRACK(payload) {
  201. return new Promise((resolve, reject) => {
  202. const { trackId } = payload;
  203. SpotifyModule.runJob(
  204. "API_CALL",
  205. {
  206. url: `https://api.spotify.com/v1/tracks/${trackId}`
  207. },
  208. this
  209. )
  210. .then(response => {
  211. resolve(response);
  212. })
  213. .catch(err => {
  214. reject(err);
  215. });
  216. });
  217. }
  218. /**
  219. * Perform Spotify API get playlist request
  220. *
  221. * @param {object} payload - object that contains the payload
  222. * @param {object} payload.params - request parameters
  223. * @returns {Promise} - returns promise (reject, resolve)
  224. */
  225. API_GET_PLAYLIST(payload) {
  226. return new Promise((resolve, reject) => {
  227. const { playlistId, nextUrl } = payload;
  228. SpotifyModule.runJob(
  229. "API_CALL",
  230. {
  231. url: nextUrl || `https://api.spotify.com/v1/playlists/${playlistId}/tracks`
  232. },
  233. this
  234. )
  235. .then(response => {
  236. resolve(response);
  237. })
  238. .catch(err => {
  239. reject(err);
  240. });
  241. });
  242. }
  243. /**
  244. * Perform Spotify API call
  245. *
  246. * @param {object} payload - object that contains the payload
  247. * @param {object} payload.url - request url
  248. * @param {object} payload.params - request parameters
  249. * @param {object} payload.quotaCost - request quotaCost
  250. * @returns {Promise} - returns promise (reject, resolve)
  251. */
  252. API_CALL(payload) {
  253. return new Promise((resolve, reject) => {
  254. // const { url, params, quotaCost } = payload;
  255. const { url, params } = payload;
  256. SpotifyModule.runJob("GET_API_TOKEN", {}, this)
  257. .then(spotifyApiToken => {
  258. SpotifyModule.axios
  259. .get(url, {
  260. headers: {
  261. Authorization: `Bearer ${spotifyApiToken}`
  262. },
  263. timeout: SpotifyModule.requestTimeout,
  264. params
  265. })
  266. .then(response => {
  267. if (response.data.error) {
  268. reject(new Error(response.data.error));
  269. } else {
  270. resolve({ response });
  271. }
  272. })
  273. .catch(err => {
  274. console.log(4443311, err);
  275. reject(err);
  276. });
  277. })
  278. .catch(err => {
  279. this.log(
  280. "ERROR",
  281. `Spotify API call failed as an error occured whilst getting the API token`,
  282. typeof err === "string" ? err : err.message
  283. );
  284. resolve(err);
  285. });
  286. });
  287. }
  288. /**
  289. * Create Spotify track
  290. *
  291. * @param {object} payload - an object containing the payload
  292. * @param {string} payload.spotifyTracks - the spotifyTracks
  293. * @returns {Promise} - returns a promise (resolve, reject)
  294. */
  295. CREATE_TRACKS(payload) {
  296. return new Promise((resolve, reject) => {
  297. async.waterfall(
  298. [
  299. next => {
  300. const { spotifyTracks } = payload;
  301. if (!Array.isArray(spotifyTracks)) next("Invalid spotifyTracks type");
  302. else {
  303. const trackIds = spotifyTracks.map(spotifyTrack => spotifyTrack.trackId);
  304. SpotifyModule.spotifyTrackModel.find({ trackId: trackIds }, (err, existingTracks) => {
  305. if (err) return next(err);
  306. const existingTrackIds = existingTracks.map(existingTrack => existingTrack.trackId);
  307. const newSpotifyTracks = spotifyTracks.filter(
  308. spotifyTrack => existingTrackIds.indexOf(spotifyTrack.trackId) === -1
  309. );
  310. SpotifyModule.spotifyTrackModel.insertMany(newSpotifyTracks, next);
  311. });
  312. }
  313. },
  314. (spotifyTracks, next) => {
  315. const mediaSources = spotifyTracks.map(spotifyTrack => `spotify:${spotifyTrack.trackId}`);
  316. async.eachLimit(
  317. mediaSources,
  318. 2,
  319. (mediaSource, next) => {
  320. MediaModule.runJob("RECALCULATE_RATINGS", { mediaSource }, this)
  321. .then(() => next())
  322. .catch(next);
  323. },
  324. err => {
  325. if (err) next(err);
  326. else next(null, spotifyTracks);
  327. }
  328. );
  329. }
  330. ],
  331. (err, spotifyTracks) => {
  332. if (err) reject(new Error(err));
  333. else resolve({ spotifyTracks });
  334. }
  335. );
  336. });
  337. }
  338. /**
  339. * Create Spotify albums
  340. *
  341. * @param {object} payload - an object containing the payload
  342. * @param {string} payload.spotifyAlbums - the Spotify albums
  343. * @returns {Promise} - returns a promise (resolve, reject)
  344. */
  345. async CREATE_ALBUMS(payload) {
  346. const { spotifyAlbums } = payload;
  347. if (!Array.isArray(spotifyAlbums)) throw new Error("Invalid spotifyAlbums type");
  348. const albumIds = spotifyAlbums.map(spotifyAlbum => spotifyAlbum.albumId);
  349. const existingAlbums = (await SpotifyModule.spotifyAlbumModel.find({ albumId: albumIds })).map(
  350. album => album._doc
  351. );
  352. const existingAlbumIds = existingAlbums.map(existingAlbum => existingAlbum.albumId);
  353. const newSpotifyAlbums = spotifyAlbums.filter(
  354. spotifyAlbum => existingAlbumIds.indexOf(spotifyAlbum.albumId) === -1
  355. );
  356. if (newSpotifyAlbums.length === 0) return existingAlbums;
  357. await SpotifyModule.spotifyAlbumModel.insertMany(newSpotifyAlbums);
  358. return existingAlbums.concat(newSpotifyAlbums);
  359. }
  360. /**
  361. * Gets tracks from media sources
  362. *
  363. * @param {object} payload
  364. * @returns {Promise}
  365. */
  366. async GET_TRACKS_FROM_MEDIA_SOURCES(payload) {
  367. return new Promise((resolve, reject) => {
  368. const { mediaSources } = payload;
  369. const responses = {};
  370. const promises = [];
  371. mediaSources.forEach(mediaSource => {
  372. promises.push(
  373. new Promise(resolve => {
  374. const trackId = mediaSource.split(":")[1];
  375. SpotifyModule.runJob("GET_TRACK", { identifier: trackId, createMissing: true }, this)
  376. .then(({ track }) => {
  377. responses[mediaSource] = track;
  378. })
  379. .catch(err => {
  380. SpotifyModule.log(
  381. "ERROR",
  382. `Getting tracked with media source ${mediaSource} failed.`,
  383. typeof err === "string" ? err : err.message
  384. );
  385. responses[mediaSource] = typeof err === "string" ? err : err.message;
  386. })
  387. .finally(() => {
  388. resolve();
  389. });
  390. })
  391. );
  392. });
  393. Promise.all(promises)
  394. .then(() => {
  395. SpotifyModule.log("SUCCESS", `Got all tracks.`);
  396. resolve({ tracks: responses });
  397. })
  398. .catch(reject);
  399. });
  400. }
  401. /**
  402. * Gets albums from ids
  403. *
  404. * @param {object} payload
  405. * @returns {Promise}
  406. */
  407. async GET_ALBUMS_FROM_IDS(payload) {
  408. const { albumIds } = payload;
  409. console.log(albumIds);
  410. const existingAlbums = (await SpotifyModule.spotifyAlbumModel.find({ albumId: albumIds })).map(
  411. album => album._doc
  412. );
  413. const existingAlbumIds = existingAlbums.map(existingAlbum => existingAlbum.albumId);
  414. console.log(existingAlbums);
  415. const missingAlbumIds = albumIds.filter(albumId => existingAlbumIds.indexOf(albumId) === -1);
  416. if (missingAlbumIds.length === 0) return existingAlbums;
  417. console.log(missingAlbumIds);
  418. const jobsToRun = [];
  419. const chunkSize = 2;
  420. while (missingAlbumIds.length > 0) {
  421. const chunkedMissingAlbumIds = missingAlbumIds.splice(0, chunkSize);
  422. jobsToRun.push(SpotifyModule.runJob("API_GET_ALBUMS", { albumIds: chunkedMissingAlbumIds }, this));
  423. }
  424. const jobResponses = await Promise.all(jobsToRun);
  425. console.log(jobResponses);
  426. const newAlbums = jobResponses
  427. .map(jobResponse => jobResponse.response.data.albums)
  428. .flat()
  429. .map(album => ({
  430. albumId: album.id,
  431. rawData: album
  432. }));
  433. console.log(newAlbums);
  434. await SpotifyModule.runJob("CREATE_ALBUMS", { spotifyAlbums: newAlbums }, this);
  435. return existingAlbums.concat(newAlbums);
  436. }
  437. /**
  438. * Get Spotify track
  439. *
  440. * @param {object} payload - an object containing the payload
  441. * @param {string} payload.identifier - the spotify track ObjectId or track id
  442. * @param {string} payload.createMissing - attempt to fetch and create track if not in db
  443. * @returns {Promise} - returns a promise (resolve, reject)
  444. */
  445. GET_TRACK(payload) {
  446. return new Promise((resolve, reject) => {
  447. async.waterfall(
  448. [
  449. next => {
  450. const query = mongoose.isObjectIdOrHexString(payload.identifier)
  451. ? { _id: payload.identifier }
  452. : { trackId: payload.identifier };
  453. return SpotifyModule.spotifyTrackModel.findOne(query, next);
  454. },
  455. (track, next) => {
  456. if (track) return next(null, track, false);
  457. if (mongoose.isObjectIdOrHexString(payload.identifier) || !payload.createMissing)
  458. return next("Spotify track not found.");
  459. return SpotifyModule.runJob("API_GET_TRACK", { trackId: payload.identifier }, this)
  460. .then(({ response }) => {
  461. const { data } = response;
  462. if (!data || !data.id)
  463. return next("The specified track does not exist or cannot be publicly accessed.");
  464. const spotifyTrack = spotifyTrackObjectToMusareTrackObject(data);
  465. return next(null, false, spotifyTrack);
  466. })
  467. .catch(next);
  468. },
  469. (track, spotifyTrack, next) => {
  470. if (track) return next(null, track, true);
  471. return SpotifyModule.runJob("CREATE_TRACKS", { spotifyTracks: [spotifyTrack] }, this)
  472. .then(res => {
  473. if (res.spotifyTracks.length === 1) next(null, res.spotifyTracks[0], false);
  474. else next("Spotify track not found.");
  475. })
  476. .catch(next);
  477. }
  478. ],
  479. (err, track, existing) => {
  480. if (err) reject(new Error(err));
  481. else if (track.isLocal) reject(new Error("Track is local."));
  482. else resolve({ track, existing });
  483. }
  484. );
  485. });
  486. }
  487. /**
  488. * Returns an array of songs taken from a Spotify playlist
  489. *
  490. * @param {object} payload - object that contains the payload
  491. * @param {string} payload.url - the id of the Spotify playlist
  492. * @returns {Promise} - returns promise (reject, resolve)
  493. */
  494. GET_PLAYLIST(payload) {
  495. return new Promise((resolve, reject) => {
  496. const spotifyPlaylistUrlRegex = /.+open\.spotify\.com\/playlist\/(?<playlistId>[A-Za-z0-9]+)/;
  497. const match = spotifyPlaylistUrlRegex.exec(payload.url);
  498. if (!match || !match.groups) {
  499. SpotifyModule.log("ERROR", "GET_PLAYLIST", "Invalid Spotify playlist URL query.");
  500. reject(new Error("Invalid playlist URL."));
  501. return;
  502. }
  503. const { playlistId } = match.groups;
  504. async.waterfall(
  505. [
  506. next => {
  507. let spotifyTracks = [];
  508. let total = -1;
  509. let nextUrl = "";
  510. async.whilst(
  511. next => {
  512. SpotifyModule.log(
  513. "INFO",
  514. `Getting playlist progress for job (${this.toString()}): ${
  515. spotifyTracks.length
  516. } tracks gotten so far. Total tracks: ${total}.`
  517. );
  518. next(null, nextUrl !== null);
  519. },
  520. next => {
  521. // Add 250ms delay between each job request
  522. setTimeout(() => {
  523. SpotifyModule.runJob("API_GET_PLAYLIST", { playlistId, nextUrl }, this)
  524. .then(({ response }) => {
  525. const { data } = response;
  526. if (!data)
  527. return next("The provided URL does not exist or cannot be accessed.");
  528. total = data.total;
  529. nextUrl = data.next;
  530. const { items } = data;
  531. const trackObjects = items.map(item => item.track);
  532. const newSpotifyTracks = trackObjects.map(trackObject =>
  533. spotifyTrackObjectToMusareTrackObject(trackObject)
  534. );
  535. spotifyTracks = spotifyTracks.concat(newSpotifyTracks);
  536. next();
  537. })
  538. .catch(err => next(err));
  539. }, 1000);
  540. },
  541. err => {
  542. if (err) next(err);
  543. else {
  544. return SpotifyModule.runJob("CREATE_TRACKS", { spotifyTracks }, this)
  545. .then(() => {
  546. next(
  547. null,
  548. spotifyTracks.map(spotifyTrack => spotifyTrack.trackId)
  549. );
  550. })
  551. .catch(next);
  552. }
  553. }
  554. );
  555. }
  556. ],
  557. (err, soundcloudTrackIds) => {
  558. if (err && err !== true) {
  559. SpotifyModule.log(
  560. "ERROR",
  561. "GET_PLAYLIST",
  562. "Some error has occurred.",
  563. typeof err === "string" ? err : err.message
  564. );
  565. reject(new Error(typeof err === "string" ? err : err.message));
  566. } else {
  567. resolve({ songs: soundcloudTrackIds });
  568. }
  569. }
  570. );
  571. // kind;
  572. });
  573. }
  574. /**
  575. *
  576. * @param {*} payload
  577. * @returns
  578. */
  579. async GET_ALTERNATIVE_MEDIA_SOURCES_FOR_TRACKS(payload) {
  580. const { mediaSources, collectAlternativeMediaSourcesOrigins } = payload;
  581. // console.log("KR*S94955", mediaSources);
  582. // this.pub
  583. await async.eachLimit(mediaSources, 1, async mediaSource => {
  584. try {
  585. const result = await SpotifyModule.runJob(
  586. "GET_ALTERNATIVE_MEDIA_SOURCES_FOR_TRACK",
  587. { mediaSource, collectAlternativeMediaSourcesOrigins },
  588. this
  589. );
  590. this.publishProgress({
  591. status: "working",
  592. message: `Got alternative media for ${mediaSource}`,
  593. data: {
  594. mediaSource,
  595. status: "success",
  596. result
  597. }
  598. });
  599. } catch (err) {
  600. console.log("ERROR", err);
  601. this.publishProgress({
  602. status: "working",
  603. message: `Failed to get alternative media for ${mediaSource}`,
  604. data: {
  605. mediaSource,
  606. status: "error"
  607. }
  608. });
  609. }
  610. });
  611. console.log("Done!");
  612. this.publishProgress({
  613. status: "finished",
  614. message: `Finished getting alternative media`
  615. });
  616. }
  617. /**
  618. *
  619. * @param {*} payload
  620. * @returns
  621. */
  622. async GET_ALTERNATIVE_MEDIA_SOURCES_FOR_TRACK(payload) {
  623. const { mediaSource, collectAlternativeMediaSourcesOrigins } = payload;
  624. if (!mediaSource || !mediaSource.startsWith("spotify:"))
  625. throw new Error("Media source provided is not a valid Spotify media source.");
  626. const spotifyTrackId = mediaSource.split(":")[1];
  627. const { track: spotifyTrack } = await SpotifyModule.runJob(
  628. "GET_TRACK",
  629. {
  630. identifier: spotifyTrackId,
  631. createMissing: true
  632. },
  633. this
  634. );
  635. const ISRC = spotifyTrack.externalIds.isrc;
  636. if (!ISRC) throw new Error(`ISRC not found for Spotify track ${mediaSource}.`);
  637. const mediaSources = new Set();
  638. const mediaSourcesOrigins = {};
  639. const jobsToRun = [];
  640. try {
  641. const ISRCApiResponse = await MusicBrainzModule.runJob(
  642. "API_CALL",
  643. {
  644. url: `https://musicbrainz.org/ws/2/isrc/${ISRC}`,
  645. params: {
  646. fmt: "json",
  647. inc: "url-rels+work-rels"
  648. }
  649. },
  650. this
  651. );
  652. // console.log("ISRCApiResponse");
  653. // console.dir(ISRCApiResponse, { depth: 5 });
  654. ISRCApiResponse.recordings.forEach(recording => {
  655. recording.relations.forEach(relation => {
  656. if (relation["target-type"] === "url" && relation.url) {
  657. // relation["type-id"] === "7e41ef12-a124-4324-afdb-fdbae687a89c"
  658. const { resource } = relation.url;
  659. if (resource.indexOf("soundcloud.com") !== -1) {
  660. // throw new Error(`Unable to parse SoundCloud resource ${resource}.`);
  661. const promise = new Promise(resolve => {
  662. SoundcloudModule.runJob(
  663. "GET_TRACK_FROM_URL",
  664. { identifier: resource, createMissing: true },
  665. this
  666. )
  667. .then(response => {
  668. const { trackId } = response.track;
  669. const mediaSource = `soundcloud:${trackId}`;
  670. mediaSources.add(mediaSource);
  671. if (collectAlternativeMediaSourcesOrigins) {
  672. const mediaSourceOrigins = [
  673. `Spotify track ${spotifyTrackId}`,
  674. `ISRC ${ISRC}`,
  675. `MusicBrainz recordings`,
  676. `MusicBrainz recording ${recording.id}`,
  677. `MusicBrainz relations`,
  678. `MusicBrainz relation target-type url`,
  679. `MusicBrainz relation resource ${resource}`,
  680. `SoundCloud ID ${trackId}`
  681. ];
  682. if (!mediaSourcesOrigins[mediaSource])
  683. mediaSourcesOrigins[mediaSource] = [];
  684. mediaSourcesOrigins[mediaSource].push(mediaSourceOrigins);
  685. }
  686. resolve();
  687. })
  688. .catch(() => {
  689. resolve();
  690. });
  691. });
  692. jobsToRun.push(promise);
  693. return;
  694. }
  695. if (resource.indexOf("youtube.com") !== -1 || resource.indexOf("youtu.be") !== -1) {
  696. const match = youtubeVideoUrlRegex.exec(resource);
  697. if (!match) throw new Error(`Unable to parse YouTube resource ${resource}.`);
  698. const { youtubeId } = match.groups;
  699. if (!youtubeId) throw new Error(`Unable to parse YouTube resource ${resource}.`);
  700. const mediaSource = `youtube:${youtubeId}`;
  701. mediaSources.add(mediaSource);
  702. if (collectAlternativeMediaSourcesOrigins) {
  703. const mediaSourceOrigins = [
  704. `Spotify track ${spotifyTrackId}`,
  705. `ISRC ${ISRC}`,
  706. `MusicBrainz recordings`,
  707. `MusicBrainz recording ${recording.id}`,
  708. `MusicBrainz relations`,
  709. `MusicBrainz relation target-type url`,
  710. `MusicBrainz relation resource ${resource}`,
  711. `YouTube ID ${youtubeId}`
  712. ];
  713. if (!mediaSourcesOrigins[mediaSource]) mediaSourcesOrigins[mediaSource] = [];
  714. mediaSourcesOrigins[mediaSource].push(mediaSourceOrigins);
  715. }
  716. return;
  717. }
  718. return;
  719. }
  720. if (relation["target-type"] === "work") {
  721. const promise = new Promise(resolve => {
  722. WikiDataModule.runJob(
  723. "API_GET_DATA_FROM_MUSICBRAINZ_WORK",
  724. { workId: relation.work.id },
  725. this
  726. )
  727. .then(resultBody => {
  728. const youtubeIds = Array.from(
  729. new Set(
  730. resultBody.results.bindings
  731. .filter(binding => !!binding.YouTube_video_ID)
  732. .map(binding => binding.YouTube_video_ID.value)
  733. )
  734. );
  735. // const soundcloudIds = Array.from(
  736. // new Set(
  737. // resultBody.results.bindings
  738. // .filter(binding => !!binding["SoundCloud_track_ID"])
  739. // .map(binding => binding["SoundCloud_track_ID"].value)
  740. // )
  741. // );
  742. const musicVideoEntityUrls = Array.from(
  743. new Set(
  744. resultBody.results.bindings
  745. .filter(binding => !!binding.Music_video_entity_URL)
  746. .map(binding => binding.Music_video_entity_URL.value)
  747. )
  748. );
  749. youtubeIds.forEach(youtubeId => {
  750. const mediaSource = `youtube:${youtubeId}`;
  751. mediaSources.add(mediaSource);
  752. if (collectAlternativeMediaSourcesOrigins) {
  753. const mediaSourceOrigins = [
  754. `Spotify track ${spotifyTrackId}`,
  755. `ISRC ${ISRC}`,
  756. `MusicBrainz recordings`,
  757. `MusicBrainz recording ${recording.id}`,
  758. `MusicBrainz relations`,
  759. `MusicBrainz relation target-type work`,
  760. `MusicBrainz relation work id ${relation.work.id}`,
  761. `WikiData select from MusicBrainz work id ${relation.work.id}`,
  762. `YouTube ID ${youtubeId}`
  763. ];
  764. if (!mediaSourcesOrigins[mediaSource])
  765. mediaSourcesOrigins[mediaSource] = [];
  766. mediaSourcesOrigins[mediaSource].push(mediaSourceOrigins);
  767. }
  768. });
  769. // soundcloudIds.forEach(soundcloudId => {
  770. // const mediaSource = `soundcloud:${soundcloudId}`;
  771. // mediaSources.add(mediaSource);
  772. // if (collectAlternativeMediaSourcesOrigins) {
  773. // const mediaSourceOrigins = [
  774. // `Spotify track ${spotifyTrackId}`,
  775. // `ISRC ${ISRC}`,
  776. // `MusicBrainz recordings`,
  777. // `MusicBrainz recording ${recording.id}`,
  778. // `MusicBrainz relations`,
  779. // `MusicBrainz relation target-type work`,
  780. // `MusicBrainz relation work id ${relation.work.id}`,
  781. // `WikiData select from MusicBrainz work id ${relation.work.id}`,
  782. // `SoundCloud ID ${soundcloudId}`
  783. // ];
  784. // if (!mediaSourcesOrigins[mediaSource]) mediaSourcesOrigins[mediaSource] = [];
  785. // mediaSourcesOrigins[mediaSource].push(mediaSourceOrigins);
  786. // }
  787. // });
  788. const promisesToRun2 = [];
  789. musicVideoEntityUrls.forEach(musicVideoEntityUrl => {
  790. promisesToRun2.push(
  791. new Promise(resolve => {
  792. WikiDataModule.runJob(
  793. "API_GET_DATA_FROM_ENTITY_URL",
  794. { entityUrl: musicVideoEntityUrl },
  795. this
  796. ).then(resultBody => {
  797. const youtubeIds = Array.from(
  798. new Set(
  799. resultBody.results.bindings
  800. .filter(binding => !!binding.YouTube_video_ID)
  801. .map(binding => binding.YouTube_video_ID.value)
  802. )
  803. );
  804. // const soundcloudIds = Array.from(
  805. // new Set(
  806. // resultBody.results.bindings
  807. // .filter(binding => !!binding["SoundCloud_track_ID"])
  808. // .map(binding => binding["SoundCloud_track_ID"].value)
  809. // )
  810. // );
  811. youtubeIds.forEach(youtubeId => {
  812. const mediaSource = `youtube:${youtubeId}`;
  813. mediaSources.add(mediaSource);
  814. // if (collectAlternativeMediaSourcesOrigins) {
  815. // const mediaSourceOrigins = [
  816. // `Spotify track ${spotifyTrackId}`,
  817. // `ISRC ${ISRC}`,
  818. // `MusicBrainz recordings`,
  819. // `MusicBrainz recording ${recording.id}`,
  820. // `MusicBrainz relations`,
  821. // `MusicBrainz relation target-type work`,
  822. // `MusicBrainz relation work id ${relation.work.id}`,
  823. // `WikiData select from MusicBrainz work id ${relation.work.id}`,
  824. // `YouTube ID ${youtubeId}`
  825. // ];
  826. // if (!mediaSourcesOrigins[mediaSource]) mediaSourcesOrigins[mediaSource] = [];
  827. // mediaSourcesOrigins[mediaSource].push(mediaSourceOrigins);
  828. // }
  829. });
  830. // soundcloudIds.forEach(soundcloudId => {
  831. // const mediaSource = `soundcloud:${soundcloudId}`;
  832. // mediaSources.add(mediaSource);
  833. // // if (collectAlternativeMediaSourcesOrigins) {
  834. // // const mediaSourceOrigins = [
  835. // // `Spotify track ${spotifyTrackId}`,
  836. // // `ISRC ${ISRC}`,
  837. // // `MusicBrainz recordings`,
  838. // // `MusicBrainz recording ${recording.id}`,
  839. // // `MusicBrainz relations`,
  840. // // `MusicBrainz relation target-type work`,
  841. // // `MusicBrainz relation work id ${relation.work.id}`,
  842. // // `WikiData select from MusicBrainz work id ${relation.work.id}`,
  843. // // `SoundCloud ID ${soundcloudId}`
  844. // // ];
  845. // // if (!mediaSourcesOrigins[mediaSource]) mediaSourcesOrigins[mediaSource] = [];
  846. // // mediaSourcesOrigins[mediaSource].push(mediaSourceOrigins);
  847. // // }
  848. // });
  849. resolve();
  850. });
  851. })
  852. );
  853. });
  854. Promise.allSettled(promisesToRun2).then(resolve);
  855. })
  856. .catch(err => {
  857. console.log("KRISWORKERR", err);
  858. resolve();
  859. });
  860. });
  861. jobsToRun.push(promise);
  862. //WikiDataModule.runJob("API_GET_DATA_FROM_MUSICBRAINZ_WORK", { workId: relation.work.id }, this));
  863. return;
  864. }
  865. });
  866. });
  867. } catch (err) {
  868. console.log("Error during initial ISRC getting/parsing", err);
  869. }
  870. try {
  871. const RecordingApiResponse = await MusicBrainzModule.runJob(
  872. "API_CALL",
  873. {
  874. url: `https://musicbrainz.org/ws/2/recording/`,
  875. params: {
  876. fmt: "json",
  877. query: `isrc:${ISRC}`
  878. }
  879. },
  880. this
  881. );
  882. const releaseIds = new Set();
  883. const releaseGroupIds = new Set();
  884. RecordingApiResponse.recordings.forEach(recording => {
  885. const recordingId = recording.id;
  886. // console.log("Recording:", recording.id);
  887. recording.releases.forEach(release => {
  888. const releaseId = release.id;
  889. // console.log("Release:", releaseId);
  890. const releaseGroupId = release["release-group"].id;
  891. // console.log("Release group:", release["release-group"]);
  892. // console.log("Release group id:", release["release-group"].id);
  893. // console.log("Release group type id:", release["release-group"]["type-id"]);
  894. // console.log("Release group primary type id:", release["release-group"]["primary-type-id"]);
  895. // console.log("Release group primary type:", release["release-group"]["primary-type"]);
  896. // d6038452-8ee0-3f68-affc-2de9a1ede0b9 = single
  897. // 6d0c5bf6-7a33-3420-a519-44fc63eedebf = EP
  898. if (
  899. release["release-group"]["type-id"] === "d6038452-8ee0-3f68-affc-2de9a1ede0b9" ||
  900. release["release-group"]["type-id"] === "6d0c5bf6-7a33-3420-a519-44fc63eedebf"
  901. ) {
  902. releaseIds.add(releaseId);
  903. releaseGroupIds.add(releaseGroupId);
  904. }
  905. });
  906. });
  907. Array.from(releaseGroupIds).forEach(releaseGroupId => {
  908. const promise = new Promise(resolve => {
  909. WikiDataModule.runJob("API_GET_DATA_FROM_MUSICBRAINZ_RELEASE_GROUP", { releaseGroupId }, this)
  910. .then(resultBody => {
  911. const youtubeIds = Array.from(
  912. new Set(
  913. resultBody.results.bindings
  914. .filter(binding => !!binding.YouTube_video_ID)
  915. .map(binding => binding.YouTube_video_ID.value)
  916. )
  917. );
  918. // const soundcloudIds = Array.from(
  919. // new Set(
  920. // resultBody.results.bindings
  921. // .filter(binding => !!binding["SoundCloud_track_ID"])
  922. // .map(binding => binding["SoundCloud_track_ID"].value)
  923. // )
  924. // );
  925. const musicVideoEntityUrls = Array.from(
  926. new Set(
  927. resultBody.results.bindings
  928. .filter(binding => !!binding.Music_video_entity_URL)
  929. .map(binding => binding.Music_video_entity_URL.value)
  930. )
  931. );
  932. youtubeIds.forEach(youtubeId => {
  933. const mediaSource = `youtube:${youtubeId}`;
  934. mediaSources.add(mediaSource);
  935. // if (collectAlternativeMediaSourcesOrigins) {
  936. // const mediaSourceOrigins = [
  937. // `Spotify track ${spotifyTrackId}`,
  938. // `ISRC ${ISRC}`,
  939. // `MusicBrainz recordings`,
  940. // `MusicBrainz recording ${recording.id}`,
  941. // `MusicBrainz relations`,
  942. // `MusicBrainz relation target-type work`,
  943. // `MusicBrainz relation work id ${relation.work.id}`,
  944. // `WikiData select from MusicBrainz work id ${relation.work.id}`,
  945. // `YouTube ID ${youtubeId}`
  946. // ];
  947. // if (!mediaSourcesOrigins[mediaSource]) mediaSourcesOrigins[mediaSource] = [];
  948. // mediaSourcesOrigins[mediaSource].push(mediaSourceOrigins);
  949. // }
  950. });
  951. // soundcloudIds.forEach(soundcloudId => {
  952. // const mediaSource = `soundcloud:${soundcloudId}`;
  953. // mediaSources.add(mediaSource);
  954. // // if (collectAlternativeMediaSourcesOrigins) {
  955. // // const mediaSourceOrigins = [
  956. // // `Spotify track ${spotifyTrackId}`,
  957. // // `ISRC ${ISRC}`,
  958. // // `MusicBrainz recordings`,
  959. // // `MusicBrainz recording ${recording.id}`,
  960. // // `MusicBrainz relations`,
  961. // // `MusicBrainz relation target-type work`,
  962. // // `MusicBrainz relation work id ${relation.work.id}`,
  963. // // `WikiData select from MusicBrainz work id ${relation.work.id}`,
  964. // // `SoundCloud ID ${soundcloudId}`
  965. // // ];
  966. // // if (!mediaSourcesOrigins[mediaSource]) mediaSourcesOrigins[mediaSource] = [];
  967. // // mediaSourcesOrigins[mediaSource].push(mediaSourceOrigins);
  968. // // }
  969. // });
  970. const promisesToRun2 = [];
  971. musicVideoEntityUrls.forEach(musicVideoEntityUrl => {
  972. promisesToRun2.push(
  973. new Promise(resolve => {
  974. WikiDataModule.runJob(
  975. "API_GET_DATA_FROM_ENTITY_URL",
  976. { entityUrl: musicVideoEntityUrl },
  977. this
  978. ).then(resultBody => {
  979. const youtubeIds = Array.from(
  980. new Set(
  981. resultBody.results.bindings
  982. .filter(binding => !!binding.YouTube_video_ID)
  983. .map(binding => binding.YouTube_video_ID.value)
  984. )
  985. );
  986. // const soundcloudIds = Array.from(
  987. // new Set(
  988. // resultBody.results.bindings
  989. // .filter(binding => !!binding["SoundCloud_track_ID"])
  990. // .map(binding => binding["SoundCloud_track_ID"].value)
  991. // )
  992. // );
  993. youtubeIds.forEach(youtubeId => {
  994. const mediaSource = `youtube:${youtubeId}`;
  995. mediaSources.add(mediaSource);
  996. // if (collectAlternativeMediaSourcesOrigins) {
  997. // const mediaSourceOrigins = [
  998. // `Spotify track ${spotifyTrackId}`,
  999. // `ISRC ${ISRC}`,
  1000. // `MusicBrainz recordings`,
  1001. // `MusicBrainz recording ${recording.id}`,
  1002. // `MusicBrainz relations`,
  1003. // `MusicBrainz relation target-type work`,
  1004. // `MusicBrainz relation work id ${relation.work.id}`,
  1005. // `WikiData select from MusicBrainz work id ${relation.work.id}`,
  1006. // `YouTube ID ${youtubeId}`
  1007. // ];
  1008. // if (!mediaSourcesOrigins[mediaSource]) mediaSourcesOrigins[mediaSource] = [];
  1009. // mediaSourcesOrigins[mediaSource].push(mediaSourceOrigins);
  1010. // }
  1011. });
  1012. // soundcloudIds.forEach(soundcloudId => {
  1013. // const mediaSource = `soundcloud:${soundcloudId}`;
  1014. // mediaSources.add(mediaSource);
  1015. // // if (collectAlternativeMediaSourcesOrigins) {
  1016. // // const mediaSourceOrigins = [
  1017. // // `Spotify track ${spotifyTrackId}`,
  1018. // // `ISRC ${ISRC}`,
  1019. // // `MusicBrainz recordings`,
  1020. // // `MusicBrainz recording ${recording.id}`,
  1021. // // `MusicBrainz relations`,
  1022. // // `MusicBrainz relation target-type work`,
  1023. // // `MusicBrainz relation work id ${relation.work.id}`,
  1024. // // `WikiData select from MusicBrainz work id ${relation.work.id}`,
  1025. // // `SoundCloud ID ${soundcloudId}`
  1026. // // ];
  1027. // // if (!mediaSourcesOrigins[mediaSource]) mediaSourcesOrigins[mediaSource] = [];
  1028. // // mediaSourcesOrigins[mediaSource].push(mediaSourceOrigins);
  1029. // // }
  1030. // });
  1031. resolve();
  1032. });
  1033. })
  1034. );
  1035. });
  1036. Promise.allSettled(promisesToRun2).then(resolve);
  1037. })
  1038. .catch(err => {
  1039. console.log("KRISWORKERR", err);
  1040. resolve();
  1041. });
  1042. });
  1043. jobsToRun.push(promise);
  1044. });
  1045. } catch (err) {
  1046. console.log("Error during getting releases from ISRC", err);
  1047. }
  1048. // console.log("RecordingApiResponse");
  1049. // console.dir(RecordingApiResponse, { depth: 10 });
  1050. // console.dir(RecordingApiResponse.recordings[0].releases[0], { depth: 10 });
  1051. await Promise.allSettled(jobsToRun);
  1052. return {
  1053. mediaSources: Array.from(mediaSources),
  1054. mediaSourcesOrigins
  1055. };
  1056. }
  1057. }
  1058. export default new _SpotifyModule();