spotify.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631
  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 DBModule;
  10. let CacheModule;
  11. let MediaModule;
  12. let MusicBrainzModule;
  13. const youtubeVideoUrlRegex =
  14. /^(https?:\/\/)?(www\.)?(m\.)?(music\.)?(youtube\.com|youtu\.be)\/(watch\?v=)?(?<youtubeId>[\w-]{11})((&([A-Za-z0-9]+)?)*)?$/;
  15. const youtubeVideoIdRegex = /^([\w-]{11})$/;
  16. const spotifyTrackObjectToMusareTrackObject = spotifyTrackObject => {
  17. return {
  18. trackId: spotifyTrackObject.id,
  19. name: spotifyTrackObject.name,
  20. albumId: spotifyTrackObject.album.id,
  21. albumTitle: spotifyTrackObject.album.title,
  22. albumImageUrl: spotifyTrackObject.album.images[0].url,
  23. artists: spotifyTrackObject.artists.map(artist => artist.name),
  24. artistIds: spotifyTrackObject.artists.map(artist => artist.id),
  25. duration: spotifyTrackObject.duration_ms / 1000,
  26. explicit: spotifyTrackObject.explicit,
  27. externalIds: spotifyTrackObject.external_ids,
  28. popularity: spotifyTrackObject.popularity
  29. };
  30. };
  31. class RateLimitter {
  32. /**
  33. * Constructor
  34. *
  35. * @param {number} timeBetween - The time between each allowed YouTube request
  36. */
  37. constructor(timeBetween) {
  38. this.dateStarted = Date.now();
  39. this.timeBetween = timeBetween;
  40. }
  41. /**
  42. * Returns a promise that resolves whenever the ratelimit of a YouTube request is done
  43. *
  44. * @returns {Promise} - promise that gets resolved when the rate limit allows it
  45. */
  46. continue() {
  47. return new Promise(resolve => {
  48. if (Date.now() - this.dateStarted >= this.timeBetween) resolve();
  49. else setTimeout(resolve, this.dateStarted + this.timeBetween - Date.now());
  50. });
  51. }
  52. /**
  53. * Restart the rate limit timer
  54. */
  55. restart() {
  56. this.dateStarted = Date.now();
  57. }
  58. }
  59. class _SpotifyModule extends CoreClass {
  60. // eslint-disable-next-line require-jsdoc
  61. constructor() {
  62. super("spotify");
  63. SpotifyModule = this;
  64. }
  65. /**
  66. * Initialises the spotify module
  67. *
  68. * @returns {Promise} - returns promise (reject, resolve)
  69. */
  70. async initialize() {
  71. DBModule = this.moduleManager.modules.db;
  72. CacheModule = this.moduleManager.modules.cache;
  73. MediaModule = this.moduleManager.modules.media;
  74. MusicBrainzModule = this.moduleManager.modules.musicbrainz;
  75. // this.youtubeApiRequestModel = this.YoutubeApiRequestModel = await DBModule.runJob("GET_MODEL", {
  76. // modelName: "youtubeApiRequest"
  77. // });
  78. this.spotifyTrackModel = this.SpotifyTrackModel = await DBModule.runJob("GET_MODEL", {
  79. modelName: "spotifyTrack"
  80. });
  81. return new Promise((resolve, reject) => {
  82. if (!config.has("apis.spotify") || !config.get("apis.spotify.enabled")) {
  83. reject(new Error("Spotify is not enabled."));
  84. return;
  85. }
  86. this.rateLimiter = new RateLimitter(config.get("apis.spotify.rateLimit"));
  87. this.requestTimeout = config.get("apis.spotify.requestTimeout");
  88. this.axios = axios.create();
  89. this.axios.defaults.raxConfig = {
  90. instance: this.axios,
  91. retry: config.get("apis.spotify.retryAmount"),
  92. noResponseRetries: config.get("apis.spotify.retryAmount")
  93. };
  94. rax.attach(this.axios);
  95. resolve();
  96. });
  97. }
  98. /**
  99. *
  100. * @returns
  101. */
  102. GET_API_TOKEN() {
  103. return new Promise((resolve, reject) => {
  104. CacheModule.runJob("GET", { key: "spotifyApiKey" }, this).then(spotifyApiKey => {
  105. if (spotifyApiKey) {
  106. resolve(spotifyApiKey);
  107. return;
  108. }
  109. this.log("INFO", `No Spotify API token stored in cache, requesting new token.`);
  110. const clientId = config.get("apis.spotify.clientId");
  111. const clientSecret = config.get("apis.spotify.clientSecret");
  112. const unencoded = `${clientId}:${clientSecret}`;
  113. const encoded = Buffer.from(unencoded).toString("base64");
  114. const params = new url.URLSearchParams({ grant_type: "client_credentials" });
  115. SpotifyModule.axios
  116. .post("https://accounts.spotify.com/api/token", params.toString(), {
  117. headers: {
  118. Authorization: `Basic ${encoded}`,
  119. "Content-Type": "application/x-www-form-urlencoded"
  120. }
  121. })
  122. .then(res => {
  123. const { access_token: accessToken, expires_in: expiresIn } = res.data;
  124. // TODO TTL can be later if stuck in queue
  125. CacheModule.runJob(
  126. "SET",
  127. { key: "spotifyApiKey", value: accessToken, ttl: expiresIn - 30 },
  128. this
  129. )
  130. .then(spotifyApiKey => {
  131. this.log(
  132. "SUCCESS",
  133. `Stored new Spotify API token in cache. Expires in ${expiresIn - 30}`
  134. );
  135. resolve(spotifyApiKey);
  136. })
  137. .catch(err => {
  138. this.log(
  139. "ERROR",
  140. `Failed to store new Spotify API token in cache.`,
  141. typeof err === "string" ? err : err.message
  142. );
  143. reject(err);
  144. });
  145. })
  146. .catch(err => {
  147. this.log(
  148. "ERROR",
  149. `Failed to get new Spotify API token.`,
  150. typeof err === "string" ? err : err.message
  151. );
  152. reject(err);
  153. });
  154. });
  155. });
  156. }
  157. /**
  158. * Perform Spotify API get track request
  159. *
  160. * @param {object} payload - object that contains the payload
  161. * @param {object} payload.params - request parameters
  162. * @returns {Promise} - returns promise (reject, resolve)
  163. */
  164. API_GET_TRACK(payload) {
  165. return new Promise((resolve, reject) => {
  166. const { trackId } = payload;
  167. SpotifyModule.runJob(
  168. "API_CALL",
  169. {
  170. url: `https://api.spotify.com/v1/tracks/${trackId}`
  171. },
  172. this
  173. )
  174. .then(response => {
  175. resolve(response);
  176. })
  177. .catch(err => {
  178. reject(err);
  179. });
  180. });
  181. }
  182. /**
  183. * Perform Spotify API get playlist request
  184. *
  185. * @param {object} payload - object that contains the payload
  186. * @param {object} payload.params - request parameters
  187. * @returns {Promise} - returns promise (reject, resolve)
  188. */
  189. API_GET_PLAYLIST(payload) {
  190. return new Promise((resolve, reject) => {
  191. const { playlistId, nextUrl } = payload;
  192. SpotifyModule.runJob(
  193. "API_CALL",
  194. {
  195. url: nextUrl || `https://api.spotify.com/v1/playlists/${playlistId}/tracks`
  196. },
  197. this
  198. )
  199. .then(response => {
  200. resolve(response);
  201. })
  202. .catch(err => {
  203. reject(err);
  204. });
  205. });
  206. }
  207. /**
  208. * Perform Spotify API call
  209. *
  210. * @param {object} payload - object that contains the payload
  211. * @param {object} payload.url - request url
  212. * @param {object} payload.params - request parameters
  213. * @param {object} payload.quotaCost - request quotaCost
  214. * @returns {Promise} - returns promise (reject, resolve)
  215. */
  216. API_CALL(payload) {
  217. return new Promise((resolve, reject) => {
  218. // const { url, params, quotaCost } = payload;
  219. const { url } = payload;
  220. SpotifyModule.runJob("GET_API_TOKEN", {}, this)
  221. .then(spotifyApiToken => {
  222. SpotifyModule.axios
  223. .get(url, {
  224. headers: {
  225. Authorization: `Bearer ${spotifyApiToken}`
  226. },
  227. timeout: SpotifyModule.requestTimeout
  228. })
  229. .then(response => {
  230. if (response.data.error) {
  231. reject(new Error(response.data.error));
  232. } else {
  233. resolve({ response });
  234. }
  235. })
  236. .catch(err => {
  237. reject(err);
  238. });
  239. })
  240. .catch(err => {
  241. this.log(
  242. "ERROR",
  243. `Spotify API call failed as an error occured whilst getting the API token`,
  244. typeof err === "string" ? err : err.message
  245. );
  246. resolve(err);
  247. });
  248. });
  249. }
  250. /**
  251. * Create Spotify track
  252. *
  253. * @param {object} payload - an object containing the payload
  254. * @param {string} payload.spotifyTracks - the spotifyTracks
  255. * @returns {Promise} - returns a promise (resolve, reject)
  256. */
  257. CREATE_TRACKS(payload) {
  258. return new Promise((resolve, reject) => {
  259. async.waterfall(
  260. [
  261. next => {
  262. const { spotifyTracks } = payload;
  263. if (!Array.isArray(spotifyTracks)) next("Invalid spotifyTracks type");
  264. else {
  265. const trackIds = spotifyTracks.map(spotifyTrack => spotifyTrack.trackId);
  266. SpotifyModule.spotifyTrackModel.find({ trackId: trackIds }, (err, existingTracks) => {
  267. if (err) return next(err);
  268. const existingTrackIds = existingTracks.map(existingTrack => existingTrack.trackId);
  269. const newSpotifyTracks = spotifyTracks.filter(
  270. spotifyTrack => existingTrackIds.indexOf(spotifyTrack.trackId) === -1
  271. );
  272. SpotifyModule.spotifyTrackModel.insertMany(newSpotifyTracks, next);
  273. });
  274. }
  275. },
  276. (spotifyTracks, next) => {
  277. const mediaSources = spotifyTracks.map(spotifyTrack => `spotify:${spotifyTrack.trackId}`);
  278. async.eachLimit(
  279. mediaSources,
  280. 2,
  281. (mediaSource, next) => {
  282. MediaModule.runJob("RECALCULATE_RATINGS", { mediaSource }, this)
  283. .then(() => next())
  284. .catch(next);
  285. },
  286. err => {
  287. if (err) next(err);
  288. else next(null, spotifyTracks);
  289. }
  290. );
  291. }
  292. ],
  293. (err, spotifyTracks) => {
  294. if (err) reject(new Error(err));
  295. else resolve({ spotifyTracks });
  296. }
  297. );
  298. });
  299. }
  300. /**
  301. * Gets tracks from media sources
  302. *
  303. * @param {object} payload
  304. * @returns {Promise}
  305. */
  306. async GET_TRACKS_FROM_MEDIA_SOURCES(payload) {
  307. return new Promise((resolve, reject) => {
  308. const { mediaSources } = payload;
  309. const responses = {};
  310. const promises = [];
  311. mediaSources.forEach(mediaSource => {
  312. promises.push(
  313. new Promise(resolve => {
  314. const trackId = mediaSource.split(":")[1];
  315. SpotifyModule.runJob("GET_TRACK", { identifier: trackId, createMissing: true }, this)
  316. .then(({ track }) => {
  317. responses[mediaSource] = track;
  318. })
  319. .catch(err => {
  320. SpotifyModule.log(
  321. "ERROR",
  322. `Getting tracked with media source ${mediaSource} failed.`,
  323. typeof err === "string" ? err : err.message
  324. );
  325. responses[mediaSource] = typeof err === "string" ? err : err.message;
  326. })
  327. .finally(() => {
  328. resolve();
  329. });
  330. })
  331. );
  332. });
  333. Promise.all(promises)
  334. .then(() => {
  335. SpotifyModule.log("SUCCESS", `Got all tracks.`);
  336. resolve({ tracks: responses });
  337. })
  338. .catch(reject);
  339. });
  340. }
  341. /**
  342. * Get Spotify track
  343. *
  344. * @param {object} payload - an object containing the payload
  345. * @param {string} payload.identifier - the spotify track ObjectId or track id
  346. * @param {string} payload.createMissing - attempt to fetch and create track if not in db
  347. * @returns {Promise} - returns a promise (resolve, reject)
  348. */
  349. GET_TRACK(payload) {
  350. return new Promise((resolve, reject) => {
  351. async.waterfall(
  352. [
  353. next => {
  354. const query = mongoose.isObjectIdOrHexString(payload.identifier)
  355. ? { _id: payload.identifier }
  356. : { trackId: payload.identifier };
  357. return SpotifyModule.spotifyTrackModel.findOne(query, next);
  358. },
  359. (track, next) => {
  360. if (track) return next(null, track, false);
  361. if (mongoose.isObjectIdOrHexString(payload.identifier) || !payload.createMissing)
  362. return next("Spotify track not found.");
  363. return SpotifyModule.runJob("API_GET_TRACK", { trackId: payload.identifier }, this)
  364. .then(({ response }) => {
  365. const { data } = response;
  366. if (!data || !data.id)
  367. return next("The specified track does not exist or cannot be publicly accessed.");
  368. const spotifyTrack = spotifyTrackObjectToMusareTrackObject(data);
  369. return next(null, false, spotifyTrack);
  370. })
  371. .catch(next);
  372. },
  373. (track, spotifyTrack, next) => {
  374. if (track) return next(null, track, true);
  375. return SpotifyModule.runJob("CREATE_TRACKS", { spotifyTracks: [spotifyTrack] }, this)
  376. .then(res => {
  377. if (res.spotifyTracks.length === 1) next(null, res.spotifyTracks[0], false);
  378. else next("Spotify track not found.");
  379. })
  380. .catch(next);
  381. }
  382. ],
  383. (err, track, existing) => {
  384. if (err) reject(new Error(err));
  385. else resolve({ track, existing });
  386. }
  387. );
  388. });
  389. }
  390. /**
  391. * Returns an array of songs taken from a Spotify playlist
  392. *
  393. * @param {object} payload - object that contains the payload
  394. * @param {string} payload.url - the id of the Spotify playlist
  395. * @returns {Promise} - returns promise (reject, resolve)
  396. */
  397. GET_PLAYLIST(payload) {
  398. return new Promise((resolve, reject) => {
  399. const spotifyPlaylistUrlRegex = /.+open\.spotify\.com\/playlist\/(?<playlistId>[A-Za-z0-9]+)/;
  400. const match = spotifyPlaylistUrlRegex.exec(payload.url);
  401. if (!match || !match.groups) {
  402. SpotifyModule.log("ERROR", "GET_PLAYLIST", "Invalid Spotify playlist URL query.");
  403. reject(new Error("Invalid playlist URL."));
  404. return;
  405. }
  406. const { playlistId } = match.groups;
  407. async.waterfall(
  408. [
  409. next => {
  410. let spotifyTracks = [];
  411. let total = -1;
  412. let nextUrl = "";
  413. async.whilst(
  414. next => {
  415. SpotifyModule.log(
  416. "INFO",
  417. `Getting playlist progress for job (${this.toString()}): ${
  418. spotifyTracks.length
  419. } tracks gotten so far. Total tracks: ${total}.`
  420. );
  421. next(null, nextUrl !== null);
  422. },
  423. next => {
  424. // Add 250ms delay between each job request
  425. setTimeout(() => {
  426. SpotifyModule.runJob("API_GET_PLAYLIST", { playlistId, nextUrl }, this)
  427. .then(({ response }) => {
  428. const { data } = response;
  429. if (!data)
  430. return next("The provided URL does not exist or cannot be accessed.");
  431. total = data.total;
  432. nextUrl = data.next;
  433. const { items } = data;
  434. const trackObjects = items.map(item => item.track);
  435. const newSpotifyTracks = trackObjects.map(trackObject =>
  436. spotifyTrackObjectToMusareTrackObject(trackObject)
  437. );
  438. spotifyTracks = spotifyTracks.concat(newSpotifyTracks);
  439. next();
  440. })
  441. .catch(err => next(err));
  442. }, 1000);
  443. },
  444. err => {
  445. if (err) next(err);
  446. else {
  447. return SpotifyModule.runJob("CREATE_TRACKS", { spotifyTracks }, this)
  448. .then(() => {
  449. next(
  450. null,
  451. spotifyTracks.map(spotifyTrack => spotifyTrack.trackId)
  452. );
  453. })
  454. .catch(next);
  455. }
  456. }
  457. );
  458. }
  459. ],
  460. (err, soundcloudTrackIds) => {
  461. if (err && err !== true) {
  462. SpotifyModule.log(
  463. "ERROR",
  464. "GET_PLAYLIST",
  465. "Some error has occurred.",
  466. typeof err === "string" ? err : err.message
  467. );
  468. reject(new Error(typeof err === "string" ? err : err.message));
  469. } else {
  470. resolve({ songs: soundcloudTrackIds });
  471. }
  472. }
  473. );
  474. // kind;
  475. });
  476. }
  477. /**
  478. *
  479. * @param {*} payload
  480. * @returns
  481. */
  482. async GET_ALTERNATIVE_MEDIA_SOURCES_FOR_TRACK(payload) {
  483. const { mediaSource } = payload;
  484. if (!mediaSource || !mediaSource.startsWith("spotify:"))
  485. throw new Error("Media source provided is not a valid Spotify media source.");
  486. const spotifyTrackId = mediaSource.split(":")[1];
  487. const { track: spotifyTrack } = await SpotifyModule.runJob(
  488. "GET_TRACK",
  489. {
  490. identifier: spotifyTrackId,
  491. createMissing: true
  492. },
  493. this
  494. );
  495. const ISRC = spotifyTrack.externalIds.isrc;
  496. if (!ISRC) throw new Error(`ISRC not found for Spotify track ${mediaSource}.`);
  497. const ISRCApiResponse = await MusicBrainzModule.runJob(
  498. "API_CALL",
  499. {
  500. url: `https://musicbrainz.org/ws/2/isrc/${ISRC}`,
  501. params: {
  502. fmt: "json",
  503. inc: "url-rels+work-rels"
  504. }
  505. },
  506. this
  507. );
  508. console.dir(ISRCApiResponse);
  509. const mediaSources = new Set();
  510. const mediaSourcesOrigins = {};
  511. ISRCApiResponse.recordings.forEach(recording => {
  512. recording.relations.forEach(relation => {
  513. if (relation["target-type"] === "url" && relation.url) {
  514. // relation["type-id"] === "7e41ef12-a124-4324-afdb-fdbae687a89c"
  515. const { resource } = relation.url;
  516. if (resource.indexOf("soundcloud.com") !== -1) {
  517. throw new Error(`Unable to parse SoundCloud resource ${resource}.`);
  518. return;
  519. }
  520. if (resource.indexOf("youtube.com") !== -1 || resource.indexOf("youtu.be") !== -1) {
  521. const match = youtubeVideoUrlRegex.exec(resource);
  522. if (!match) throw new Error(`Unable to parse YouTube resource ${resource}.`);
  523. const { youtubeId } = match.groups;
  524. if (!youtubeId) throw new Error(`Unable to parse YouTube resource ${resource}.`);
  525. const mediaSource = `youtube:${youtubeId}`;
  526. const mediaSourceOrigins = [
  527. `Spotify track ${spotifyTrackId}`,
  528. `ISRC ${ISRC}`,
  529. `MusicBrainz recordings`,
  530. `MusicBrainz recording ${recording.id}`,
  531. `MusicBrainz relations`,
  532. `MusicBrainz relation target-type url`,
  533. `MusicBrainz relation resource ${resource}`,
  534. `YouTube ID ${youtubeId}`
  535. ];
  536. mediaSources.add(mediaSource);
  537. if (!mediaSourcesOrigins[mediaSource]) mediaSourcesOrigins[mediaSource] = [];
  538. mediaSourcesOrigins[mediaSource].push([mediaSourceOrigins]);
  539. return;
  540. }
  541. return;
  542. }
  543. if (relation["target-type"] === "work") {
  544. return;
  545. }
  546. });
  547. });
  548. return {
  549. mediaSources: Array.from(mediaSources),
  550. mediaSourcesOrigins
  551. };
  552. }
  553. }
  554. export default new _SpotifyModule();