spotify.js 18 KB

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