spotify.js 38 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337
  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. * Get Spotify album
  489. *
  490. * @param {object} payload - an object containing the payload
  491. * @param {string} payload.identifier - the spotify album ObjectId or track id
  492. * @returns {Promise} - returns a promise (resolve, reject)
  493. */
  494. async GET_ALBUM(payload) {
  495. const query = mongoose.isObjectIdOrHexString(payload.identifier)
  496. ? { _id: payload.identifier }
  497. : { albumId: payload.identifier };
  498. const album = await SpotifyModule.spotifyAlbumModel.findOne(query);
  499. if (album) return album._doc;
  500. return null;
  501. }
  502. /**
  503. * Returns an array of songs taken from a Spotify playlist
  504. *
  505. * @param {object} payload - object that contains the payload
  506. * @param {string} payload.url - the id of the Spotify playlist
  507. * @returns {Promise} - returns promise (reject, resolve)
  508. */
  509. GET_PLAYLIST(payload) {
  510. return new Promise((resolve, reject) => {
  511. const spotifyPlaylistUrlRegex = /.+open\.spotify\.com\/playlist\/(?<playlistId>[A-Za-z0-9]+)/;
  512. const match = spotifyPlaylistUrlRegex.exec(payload.url);
  513. if (!match || !match.groups) {
  514. SpotifyModule.log("ERROR", "GET_PLAYLIST", "Invalid Spotify playlist URL query.");
  515. reject(new Error("Invalid playlist URL."));
  516. return;
  517. }
  518. const { playlistId } = match.groups;
  519. async.waterfall(
  520. [
  521. next => {
  522. let spotifyTracks = [];
  523. let total = -1;
  524. let nextUrl = "";
  525. async.whilst(
  526. next => {
  527. SpotifyModule.log(
  528. "INFO",
  529. `Getting playlist progress for job (${this.toString()}): ${
  530. spotifyTracks.length
  531. } tracks gotten so far. Total tracks: ${total}.`
  532. );
  533. next(null, nextUrl !== null);
  534. },
  535. next => {
  536. // Add 250ms delay between each job request
  537. setTimeout(() => {
  538. SpotifyModule.runJob("API_GET_PLAYLIST", { playlistId, nextUrl }, this)
  539. .then(({ response }) => {
  540. const { data } = response;
  541. if (!data)
  542. return next("The provided URL does not exist or cannot be accessed.");
  543. total = data.total;
  544. nextUrl = data.next;
  545. const { items } = data;
  546. const trackObjects = items.map(item => item.track);
  547. const newSpotifyTracks = trackObjects.map(trackObject =>
  548. spotifyTrackObjectToMusareTrackObject(trackObject)
  549. );
  550. spotifyTracks = spotifyTracks.concat(newSpotifyTracks);
  551. next();
  552. })
  553. .catch(err => next(err));
  554. }, 1000);
  555. },
  556. err => {
  557. if (err) next(err);
  558. else {
  559. return SpotifyModule.runJob("CREATE_TRACKS", { spotifyTracks }, this)
  560. .then(() => {
  561. next(
  562. null,
  563. spotifyTracks.map(spotifyTrack => spotifyTrack.trackId)
  564. );
  565. })
  566. .catch(next);
  567. }
  568. }
  569. );
  570. }
  571. ],
  572. (err, soundcloudTrackIds) => {
  573. if (err && err !== true) {
  574. SpotifyModule.log(
  575. "ERROR",
  576. "GET_PLAYLIST",
  577. "Some error has occurred.",
  578. typeof err === "string" ? err : err.message
  579. );
  580. reject(new Error(typeof err === "string" ? err : err.message));
  581. } else {
  582. resolve({ songs: soundcloudTrackIds });
  583. }
  584. }
  585. );
  586. // kind;
  587. });
  588. }
  589. /**
  590. *
  591. * @param {*} payload
  592. * @returns
  593. */
  594. async GET_ALTERNATIVE_ALBUM_SOURCES_FOR_ALBUMS(payload) {
  595. const { albumIds, collectAlternativeAlbumSourcesOrigins } = payload;
  596. await async.eachLimit(albumIds, 1, async albumId => {
  597. try {
  598. const result = await SpotifyModule.runJob(
  599. "GET_ALTERNATIVE_ALBUM_SOURCES_FOR_ALBUM",
  600. { albumId, collectAlternativeAlbumSourcesOrigins },
  601. this
  602. );
  603. this.publishProgress({
  604. status: "working",
  605. message: `Got alternative album source for ${albumId}`,
  606. data: {
  607. albumId,
  608. status: "success",
  609. result
  610. }
  611. });
  612. } catch (err) {
  613. console.log("ERROR", err);
  614. this.publishProgress({
  615. status: "working",
  616. message: `Failed to get alternative album source for ${albumId}`,
  617. data: {
  618. albumId,
  619. status: "error"
  620. }
  621. });
  622. }
  623. });
  624. console.log("Done!");
  625. this.publishProgress({
  626. status: "finished",
  627. message: `Finished getting alternative album sources`
  628. });
  629. }
  630. /**
  631. *
  632. * @param {*} payload
  633. * @returns
  634. */
  635. async GET_ALTERNATIVE_ALBUM_SOURCES_FOR_ALBUM(payload) {
  636. const { albumId, collectAlternativeAlbumSourcesOrigins } = payload;
  637. if (!albumId) throw new Error("Album id provided is not valid.");
  638. // const album = await SpotifyModule.runJob(
  639. // "GET_ALBUM",
  640. // {
  641. // identifier: albumId
  642. // },
  643. // this
  644. // );
  645. const wikiDataResponse = await WikiDataModule.runJob(
  646. "API_GET_DATA_FROM_SPOTIFY_ALBUM",
  647. { spotifyAlbumId: albumId },
  648. this
  649. );
  650. const youtubePlaylistIds = Array.from(
  651. new Set(
  652. wikiDataResponse.results.bindings
  653. .filter(binding => !!binding.YouTube_playlist_ID)
  654. .map(binding => binding.YouTube_playlist_ID.value)
  655. )
  656. );
  657. return youtubePlaylistIds;
  658. }
  659. /**
  660. *
  661. * @param {*} payload
  662. * @returns
  663. */
  664. async GET_ALTERNATIVE_MEDIA_SOURCES_FOR_TRACKS(payload) {
  665. const { mediaSources, collectAlternativeMediaSourcesOrigins } = payload;
  666. // console.log("KR*S94955", mediaSources);
  667. // this.pub
  668. await async.eachLimit(mediaSources, 1, async mediaSource => {
  669. try {
  670. const result = await SpotifyModule.runJob(
  671. "GET_ALTERNATIVE_MEDIA_SOURCES_FOR_TRACK",
  672. { mediaSource, collectAlternativeMediaSourcesOrigins },
  673. this
  674. );
  675. this.publishProgress({
  676. status: "working",
  677. message: `Got alternative media for ${mediaSource}`,
  678. data: {
  679. mediaSource,
  680. status: "success",
  681. result
  682. }
  683. });
  684. } catch (err) {
  685. console.log("ERROR", err);
  686. this.publishProgress({
  687. status: "working",
  688. message: `Failed to get alternative media for ${mediaSource}`,
  689. data: {
  690. mediaSource,
  691. status: "error"
  692. }
  693. });
  694. }
  695. });
  696. console.log("Done!");
  697. this.publishProgress({
  698. status: "finished",
  699. message: `Finished getting alternative media`
  700. });
  701. }
  702. /**
  703. *
  704. * @param {*} payload
  705. * @returns
  706. */
  707. async GET_ALTERNATIVE_MEDIA_SOURCES_FOR_TRACK(payload) {
  708. const { mediaSource, collectAlternativeMediaSourcesOrigins } = payload;
  709. if (!mediaSource || !mediaSource.startsWith("spotify:"))
  710. throw new Error("Media source provided is not a valid Spotify media source.");
  711. const spotifyTrackId = mediaSource.split(":")[1];
  712. const { track: spotifyTrack } = await SpotifyModule.runJob(
  713. "GET_TRACK",
  714. {
  715. identifier: spotifyTrackId,
  716. createMissing: true
  717. },
  718. this
  719. );
  720. const ISRC = spotifyTrack.externalIds.isrc;
  721. if (!ISRC) throw new Error(`ISRC not found for Spotify track ${mediaSource}.`);
  722. const mediaSources = new Set();
  723. const mediaSourcesOrigins = {};
  724. const jobsToRun = [];
  725. try {
  726. const ISRCApiResponse = await MusicBrainzModule.runJob(
  727. "API_CALL",
  728. {
  729. url: `https://musicbrainz.org/ws/2/isrc/${ISRC}`,
  730. params: {
  731. fmt: "json",
  732. inc: "url-rels+work-rels"
  733. }
  734. },
  735. this
  736. );
  737. // console.log("ISRCApiResponse");
  738. // console.dir(ISRCApiResponse, { depth: 5 });
  739. ISRCApiResponse.recordings.forEach(recording => {
  740. recording.relations.forEach(relation => {
  741. if (relation["target-type"] === "url" && relation.url) {
  742. // relation["type-id"] === "7e41ef12-a124-4324-afdb-fdbae687a89c"
  743. const { resource } = relation.url;
  744. if (resource.indexOf("soundcloud.com") !== -1) {
  745. // throw new Error(`Unable to parse SoundCloud resource ${resource}.`);
  746. const promise = new Promise(resolve => {
  747. SoundcloudModule.runJob(
  748. "GET_TRACK_FROM_URL",
  749. { identifier: resource, createMissing: true },
  750. this
  751. )
  752. .then(response => {
  753. const { trackId } = response.track;
  754. const mediaSource = `soundcloud:${trackId}`;
  755. mediaSources.add(mediaSource);
  756. if (collectAlternativeMediaSourcesOrigins) {
  757. const mediaSourceOrigins = [
  758. `Spotify track ${spotifyTrackId}`,
  759. `ISRC ${ISRC}`,
  760. `MusicBrainz recordings`,
  761. `MusicBrainz recording ${recording.id}`,
  762. `MusicBrainz relations`,
  763. `MusicBrainz relation target-type url`,
  764. `MusicBrainz relation resource ${resource}`,
  765. `SoundCloud ID ${trackId}`
  766. ];
  767. if (!mediaSourcesOrigins[mediaSource])
  768. mediaSourcesOrigins[mediaSource] = [];
  769. mediaSourcesOrigins[mediaSource].push(mediaSourceOrigins);
  770. }
  771. resolve();
  772. })
  773. .catch(() => {
  774. resolve();
  775. });
  776. });
  777. jobsToRun.push(promise);
  778. return;
  779. }
  780. if (resource.indexOf("youtube.com") !== -1 || resource.indexOf("youtu.be") !== -1) {
  781. const match = youtubeVideoUrlRegex.exec(resource);
  782. if (!match) throw new Error(`Unable to parse YouTube resource ${resource}.`);
  783. const { youtubeId } = match.groups;
  784. if (!youtubeId) throw new Error(`Unable to parse YouTube resource ${resource}.`);
  785. const mediaSource = `youtube:${youtubeId}`;
  786. mediaSources.add(mediaSource);
  787. if (collectAlternativeMediaSourcesOrigins) {
  788. const mediaSourceOrigins = [
  789. `Spotify track ${spotifyTrackId}`,
  790. `ISRC ${ISRC}`,
  791. `MusicBrainz recordings`,
  792. `MusicBrainz recording ${recording.id}`,
  793. `MusicBrainz relations`,
  794. `MusicBrainz relation target-type url`,
  795. `MusicBrainz relation resource ${resource}`,
  796. `YouTube ID ${youtubeId}`
  797. ];
  798. if (!mediaSourcesOrigins[mediaSource]) mediaSourcesOrigins[mediaSource] = [];
  799. mediaSourcesOrigins[mediaSource].push(mediaSourceOrigins);
  800. }
  801. return;
  802. }
  803. return;
  804. }
  805. if (relation["target-type"] === "work") {
  806. const promise = new Promise(resolve => {
  807. WikiDataModule.runJob(
  808. "API_GET_DATA_FROM_MUSICBRAINZ_WORK",
  809. { workId: relation.work.id },
  810. this
  811. )
  812. .then(resultBody => {
  813. const youtubeIds = Array.from(
  814. new Set(
  815. resultBody.results.bindings
  816. .filter(binding => !!binding.YouTube_video_ID)
  817. .map(binding => binding.YouTube_video_ID.value)
  818. )
  819. );
  820. // const soundcloudIds = Array.from(
  821. // new Set(
  822. // resultBody.results.bindings
  823. // .filter(binding => !!binding["SoundCloud_track_ID"])
  824. // .map(binding => binding["SoundCloud_track_ID"].value)
  825. // )
  826. // );
  827. const musicVideoEntityUrls = Array.from(
  828. new Set(
  829. resultBody.results.bindings
  830. .filter(binding => !!binding.Music_video_entity_URL)
  831. .map(binding => binding.Music_video_entity_URL.value)
  832. )
  833. );
  834. youtubeIds.forEach(youtubeId => {
  835. const mediaSource = `youtube:${youtubeId}`;
  836. mediaSources.add(mediaSource);
  837. if (collectAlternativeMediaSourcesOrigins) {
  838. const mediaSourceOrigins = [
  839. `Spotify track ${spotifyTrackId}`,
  840. `ISRC ${ISRC}`,
  841. `MusicBrainz recordings`,
  842. `MusicBrainz recording ${recording.id}`,
  843. `MusicBrainz relations`,
  844. `MusicBrainz relation target-type work`,
  845. `MusicBrainz relation work id ${relation.work.id}`,
  846. `WikiData select from MusicBrainz work id ${relation.work.id}`,
  847. `YouTube ID ${youtubeId}`
  848. ];
  849. if (!mediaSourcesOrigins[mediaSource])
  850. mediaSourcesOrigins[mediaSource] = [];
  851. mediaSourcesOrigins[mediaSource].push(mediaSourceOrigins);
  852. }
  853. });
  854. // soundcloudIds.forEach(soundcloudId => {
  855. // const mediaSource = `soundcloud:${soundcloudId}`;
  856. // mediaSources.add(mediaSource);
  857. // if (collectAlternativeMediaSourcesOrigins) {
  858. // const mediaSourceOrigins = [
  859. // `Spotify track ${spotifyTrackId}`,
  860. // `ISRC ${ISRC}`,
  861. // `MusicBrainz recordings`,
  862. // `MusicBrainz recording ${recording.id}`,
  863. // `MusicBrainz relations`,
  864. // `MusicBrainz relation target-type work`,
  865. // `MusicBrainz relation work id ${relation.work.id}`,
  866. // `WikiData select from MusicBrainz work id ${relation.work.id}`,
  867. // `SoundCloud ID ${soundcloudId}`
  868. // ];
  869. // if (!mediaSourcesOrigins[mediaSource]) mediaSourcesOrigins[mediaSource] = [];
  870. // mediaSourcesOrigins[mediaSource].push(mediaSourceOrigins);
  871. // }
  872. // });
  873. const promisesToRun2 = [];
  874. musicVideoEntityUrls.forEach(musicVideoEntityUrl => {
  875. promisesToRun2.push(
  876. new Promise(resolve => {
  877. WikiDataModule.runJob(
  878. "API_GET_DATA_FROM_ENTITY_URL",
  879. { entityUrl: musicVideoEntityUrl },
  880. this
  881. ).then(resultBody => {
  882. const youtubeIds = Array.from(
  883. new Set(
  884. resultBody.results.bindings
  885. .filter(binding => !!binding.YouTube_video_ID)
  886. .map(binding => binding.YouTube_video_ID.value)
  887. )
  888. );
  889. // const soundcloudIds = Array.from(
  890. // new Set(
  891. // resultBody.results.bindings
  892. // .filter(binding => !!binding["SoundCloud_track_ID"])
  893. // .map(binding => binding["SoundCloud_track_ID"].value)
  894. // )
  895. // );
  896. youtubeIds.forEach(youtubeId => {
  897. const mediaSource = `youtube:${youtubeId}`;
  898. mediaSources.add(mediaSource);
  899. // if (collectAlternativeMediaSourcesOrigins) {
  900. // const mediaSourceOrigins = [
  901. // `Spotify track ${spotifyTrackId}`,
  902. // `ISRC ${ISRC}`,
  903. // `MusicBrainz recordings`,
  904. // `MusicBrainz recording ${recording.id}`,
  905. // `MusicBrainz relations`,
  906. // `MusicBrainz relation target-type work`,
  907. // `MusicBrainz relation work id ${relation.work.id}`,
  908. // `WikiData select from MusicBrainz work id ${relation.work.id}`,
  909. // `YouTube ID ${youtubeId}`
  910. // ];
  911. // if (!mediaSourcesOrigins[mediaSource]) mediaSourcesOrigins[mediaSource] = [];
  912. // mediaSourcesOrigins[mediaSource].push(mediaSourceOrigins);
  913. // }
  914. });
  915. // soundcloudIds.forEach(soundcloudId => {
  916. // const mediaSource = `soundcloud:${soundcloudId}`;
  917. // mediaSources.add(mediaSource);
  918. // // if (collectAlternativeMediaSourcesOrigins) {
  919. // // const mediaSourceOrigins = [
  920. // // `Spotify track ${spotifyTrackId}`,
  921. // // `ISRC ${ISRC}`,
  922. // // `MusicBrainz recordings`,
  923. // // `MusicBrainz recording ${recording.id}`,
  924. // // `MusicBrainz relations`,
  925. // // `MusicBrainz relation target-type work`,
  926. // // `MusicBrainz relation work id ${relation.work.id}`,
  927. // // `WikiData select from MusicBrainz work id ${relation.work.id}`,
  928. // // `SoundCloud ID ${soundcloudId}`
  929. // // ];
  930. // // if (!mediaSourcesOrigins[mediaSource]) mediaSourcesOrigins[mediaSource] = [];
  931. // // mediaSourcesOrigins[mediaSource].push(mediaSourceOrigins);
  932. // // }
  933. // });
  934. resolve();
  935. });
  936. })
  937. );
  938. });
  939. Promise.allSettled(promisesToRun2).then(resolve);
  940. })
  941. .catch(err => {
  942. console.log("KRISWORKERR", err);
  943. resolve();
  944. });
  945. });
  946. jobsToRun.push(promise);
  947. //WikiDataModule.runJob("API_GET_DATA_FROM_MUSICBRAINZ_WORK", { workId: relation.work.id }, this));
  948. return;
  949. }
  950. });
  951. });
  952. } catch (err) {
  953. console.log("Error during initial ISRC getting/parsing", err);
  954. }
  955. try {
  956. const RecordingApiResponse = await MusicBrainzModule.runJob(
  957. "API_CALL",
  958. {
  959. url: `https://musicbrainz.org/ws/2/recording/`,
  960. params: {
  961. fmt: "json",
  962. query: `isrc:${ISRC}`
  963. }
  964. },
  965. this
  966. );
  967. const releaseIds = new Set();
  968. const releaseGroupIds = new Set();
  969. RecordingApiResponse.recordings.forEach(recording => {
  970. const recordingId = recording.id;
  971. // console.log("Recording:", recording.id);
  972. recording.releases.forEach(release => {
  973. const releaseId = release.id;
  974. // console.log("Release:", releaseId);
  975. const releaseGroupId = release["release-group"].id;
  976. // console.log("Release group:", release["release-group"]);
  977. // console.log("Release group id:", release["release-group"].id);
  978. // console.log("Release group type id:", release["release-group"]["type-id"]);
  979. // console.log("Release group primary type id:", release["release-group"]["primary-type-id"]);
  980. // console.log("Release group primary type:", release["release-group"]["primary-type"]);
  981. // d6038452-8ee0-3f68-affc-2de9a1ede0b9 = single
  982. // 6d0c5bf6-7a33-3420-a519-44fc63eedebf = EP
  983. if (
  984. release["release-group"]["type-id"] === "d6038452-8ee0-3f68-affc-2de9a1ede0b9" ||
  985. release["release-group"]["type-id"] === "6d0c5bf6-7a33-3420-a519-44fc63eedebf"
  986. ) {
  987. releaseIds.add(releaseId);
  988. releaseGroupIds.add(releaseGroupId);
  989. }
  990. });
  991. });
  992. Array.from(releaseGroupIds).forEach(releaseGroupId => {
  993. const promise = new Promise(resolve => {
  994. WikiDataModule.runJob("API_GET_DATA_FROM_MUSICBRAINZ_RELEASE_GROUP", { releaseGroupId }, this)
  995. .then(resultBody => {
  996. const youtubeIds = Array.from(
  997. new Set(
  998. resultBody.results.bindings
  999. .filter(binding => !!binding.YouTube_video_ID)
  1000. .map(binding => binding.YouTube_video_ID.value)
  1001. )
  1002. );
  1003. // const soundcloudIds = Array.from(
  1004. // new Set(
  1005. // resultBody.results.bindings
  1006. // .filter(binding => !!binding["SoundCloud_track_ID"])
  1007. // .map(binding => binding["SoundCloud_track_ID"].value)
  1008. // )
  1009. // );
  1010. const musicVideoEntityUrls = Array.from(
  1011. new Set(
  1012. resultBody.results.bindings
  1013. .filter(binding => !!binding.Music_video_entity_URL)
  1014. .map(binding => binding.Music_video_entity_URL.value)
  1015. )
  1016. );
  1017. youtubeIds.forEach(youtubeId => {
  1018. const mediaSource = `youtube:${youtubeId}`;
  1019. mediaSources.add(mediaSource);
  1020. // if (collectAlternativeMediaSourcesOrigins) {
  1021. // const mediaSourceOrigins = [
  1022. // `Spotify track ${spotifyTrackId}`,
  1023. // `ISRC ${ISRC}`,
  1024. // `MusicBrainz recordings`,
  1025. // `MusicBrainz recording ${recording.id}`,
  1026. // `MusicBrainz relations`,
  1027. // `MusicBrainz relation target-type work`,
  1028. // `MusicBrainz relation work id ${relation.work.id}`,
  1029. // `WikiData select from MusicBrainz work id ${relation.work.id}`,
  1030. // `YouTube ID ${youtubeId}`
  1031. // ];
  1032. // if (!mediaSourcesOrigins[mediaSource]) mediaSourcesOrigins[mediaSource] = [];
  1033. // mediaSourcesOrigins[mediaSource].push(mediaSourceOrigins);
  1034. // }
  1035. });
  1036. // soundcloudIds.forEach(soundcloudId => {
  1037. // const mediaSource = `soundcloud:${soundcloudId}`;
  1038. // mediaSources.add(mediaSource);
  1039. // // if (collectAlternativeMediaSourcesOrigins) {
  1040. // // const mediaSourceOrigins = [
  1041. // // `Spotify track ${spotifyTrackId}`,
  1042. // // `ISRC ${ISRC}`,
  1043. // // `MusicBrainz recordings`,
  1044. // // `MusicBrainz recording ${recording.id}`,
  1045. // // `MusicBrainz relations`,
  1046. // // `MusicBrainz relation target-type work`,
  1047. // // `MusicBrainz relation work id ${relation.work.id}`,
  1048. // // `WikiData select from MusicBrainz work id ${relation.work.id}`,
  1049. // // `SoundCloud ID ${soundcloudId}`
  1050. // // ];
  1051. // // if (!mediaSourcesOrigins[mediaSource]) mediaSourcesOrigins[mediaSource] = [];
  1052. // // mediaSourcesOrigins[mediaSource].push(mediaSourceOrigins);
  1053. // // }
  1054. // });
  1055. const promisesToRun2 = [];
  1056. musicVideoEntityUrls.forEach(musicVideoEntityUrl => {
  1057. promisesToRun2.push(
  1058. new Promise(resolve => {
  1059. WikiDataModule.runJob(
  1060. "API_GET_DATA_FROM_ENTITY_URL",
  1061. { entityUrl: musicVideoEntityUrl },
  1062. this
  1063. ).then(resultBody => {
  1064. const youtubeIds = Array.from(
  1065. new Set(
  1066. resultBody.results.bindings
  1067. .filter(binding => !!binding.YouTube_video_ID)
  1068. .map(binding => binding.YouTube_video_ID.value)
  1069. )
  1070. );
  1071. // const soundcloudIds = Array.from(
  1072. // new Set(
  1073. // resultBody.results.bindings
  1074. // .filter(binding => !!binding["SoundCloud_track_ID"])
  1075. // .map(binding => binding["SoundCloud_track_ID"].value)
  1076. // )
  1077. // );
  1078. youtubeIds.forEach(youtubeId => {
  1079. const mediaSource = `youtube:${youtubeId}`;
  1080. mediaSources.add(mediaSource);
  1081. // if (collectAlternativeMediaSourcesOrigins) {
  1082. // const mediaSourceOrigins = [
  1083. // `Spotify track ${spotifyTrackId}`,
  1084. // `ISRC ${ISRC}`,
  1085. // `MusicBrainz recordings`,
  1086. // `MusicBrainz recording ${recording.id}`,
  1087. // `MusicBrainz relations`,
  1088. // `MusicBrainz relation target-type work`,
  1089. // `MusicBrainz relation work id ${relation.work.id}`,
  1090. // `WikiData select from MusicBrainz work id ${relation.work.id}`,
  1091. // `YouTube ID ${youtubeId}`
  1092. // ];
  1093. // if (!mediaSourcesOrigins[mediaSource]) mediaSourcesOrigins[mediaSource] = [];
  1094. // mediaSourcesOrigins[mediaSource].push(mediaSourceOrigins);
  1095. // }
  1096. });
  1097. // soundcloudIds.forEach(soundcloudId => {
  1098. // const mediaSource = `soundcloud:${soundcloudId}`;
  1099. // mediaSources.add(mediaSource);
  1100. // // if (collectAlternativeMediaSourcesOrigins) {
  1101. // // const mediaSourceOrigins = [
  1102. // // `Spotify track ${spotifyTrackId}`,
  1103. // // `ISRC ${ISRC}`,
  1104. // // `MusicBrainz recordings`,
  1105. // // `MusicBrainz recording ${recording.id}`,
  1106. // // `MusicBrainz relations`,
  1107. // // `MusicBrainz relation target-type work`,
  1108. // // `MusicBrainz relation work id ${relation.work.id}`,
  1109. // // `WikiData select from MusicBrainz work id ${relation.work.id}`,
  1110. // // `SoundCloud ID ${soundcloudId}`
  1111. // // ];
  1112. // // if (!mediaSourcesOrigins[mediaSource]) mediaSourcesOrigins[mediaSource] = [];
  1113. // // mediaSourcesOrigins[mediaSource].push(mediaSourceOrigins);
  1114. // // }
  1115. // });
  1116. resolve();
  1117. });
  1118. })
  1119. );
  1120. });
  1121. Promise.allSettled(promisesToRun2).then(resolve);
  1122. })
  1123. .catch(err => {
  1124. console.log("KRISWORKERR", err);
  1125. resolve();
  1126. });
  1127. });
  1128. jobsToRun.push(promise);
  1129. });
  1130. } catch (err) {
  1131. console.log("Error during getting releases from ISRC", err);
  1132. }
  1133. // console.log("RecordingApiResponse");
  1134. // console.dir(RecordingApiResponse, { depth: 10 });
  1135. // console.dir(RecordingApiResponse.recordings[0].releases[0], { depth: 10 });
  1136. await Promise.allSettled(jobsToRun);
  1137. return {
  1138. mediaSources: Array.from(mediaSources),
  1139. mediaSourcesOrigins
  1140. };
  1141. }
  1142. }
  1143. export default new _SpotifyModule();