soundcloud.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696
  1. import mongoose from "mongoose";
  2. import async from "async";
  3. import config from "config";
  4. import sckey from "soundcloud-key-fetch";
  5. import * as rax from "retry-axios";
  6. import axios from "axios";
  7. import CoreClass from "../core";
  8. let SoundCloudModule;
  9. let DBModule;
  10. let CacheModule;
  11. let MediaModule;
  12. const soundcloudTrackObjectToMusareTrackObject = soundcloudTrackObject => {
  13. const {
  14. id,
  15. title,
  16. artwork_url: artworkUrl,
  17. created_at: createdAt,
  18. duration,
  19. genre,
  20. kind,
  21. license,
  22. likes_count: likesCount,
  23. playback_count: playbackCount,
  24. public: _public,
  25. tag_list: tagList,
  26. user_id: userId,
  27. user,
  28. track_format: trackFormat,
  29. permalink,
  30. monetization_model: monetizationModel,
  31. policy,
  32. streamable,
  33. sharing,
  34. state,
  35. embeddable_by: embeddableBy
  36. } = soundcloudTrackObject;
  37. return {
  38. trackId: id,
  39. title,
  40. artworkUrl,
  41. soundcloudCreatedAt: new Date(createdAt),
  42. duration: duration / 1000,
  43. genre,
  44. kind,
  45. license,
  46. likesCount,
  47. playbackCount,
  48. public: _public,
  49. tagList,
  50. userId,
  51. username: user.username,
  52. userPermalink: user.permalink,
  53. trackFormat,
  54. permalink,
  55. monetizationModel,
  56. policy,
  57. streamable,
  58. sharing,
  59. state,
  60. embeddableBy
  61. };
  62. };
  63. class RateLimitter {
  64. /**
  65. * Constructor
  66. * @param {number} timeBetween - The time between each allowed YouTube request
  67. */
  68. constructor(timeBetween) {
  69. this.dateStarted = Date.now();
  70. this.timeBetween = timeBetween;
  71. }
  72. /**
  73. * Returns a promise that resolves whenever the ratelimit of a YouTube request is done
  74. * @returns {Promise} - promise that gets resolved when the rate limit allows it
  75. */
  76. continue() {
  77. return new Promise(resolve => {
  78. if (Date.now() - this.dateStarted >= this.timeBetween) resolve();
  79. else setTimeout(resolve, this.dateStarted + this.timeBetween - Date.now());
  80. });
  81. }
  82. /**
  83. * Restart the rate limit timer
  84. */
  85. restart() {
  86. this.dateStarted = Date.now();
  87. }
  88. }
  89. class _SoundCloudModule extends CoreClass {
  90. // eslint-disable-next-line require-jsdoc
  91. constructor() {
  92. super("soundcloud");
  93. SoundCloudModule = this;
  94. }
  95. /**
  96. * Initialises the soundcloud module
  97. * @returns {Promise} - returns promise (reject, resolve)
  98. */
  99. async initialize() {
  100. DBModule = this.moduleManager.modules.db;
  101. CacheModule = this.moduleManager.modules.cache;
  102. MediaModule = this.moduleManager.modules.media;
  103. this.soundcloudTrackModel = this.SoundCloudTrackModel = await DBModule.runJob("GET_MODEL", {
  104. modelName: "soundcloudTrack"
  105. });
  106. return new Promise((resolve, reject) => {
  107. this.rateLimiter = new RateLimitter(config.get("apis.soundcloud.rateLimit"));
  108. this.requestTimeout = config.get("apis.soundcloud.requestTimeout");
  109. this.axios = axios.create();
  110. this.axios.defaults.raxConfig = {
  111. instance: this.axios,
  112. retry: config.get("apis.soundcloud.retryAmount"),
  113. noResponseRetries: config.get("apis.soundcloud.retryAmount")
  114. };
  115. rax.attach(this.axios);
  116. this.apiKey = null;
  117. SoundCloudModule.runJob("TEST_SOUNDCLOUD_API_KEY", {}, null, -1)
  118. .then(result => {
  119. if (result) {
  120. resolve();
  121. return;
  122. }
  123. SoundCloudModule.runJob("GENERATE_SOUNDCLOUD_API_KEY", {}, null, -1)
  124. .then(() => {
  125. resolve();
  126. })
  127. .catch(reject);
  128. })
  129. .catch(reject);
  130. });
  131. }
  132. /**
  133. * Generates/fetches a new SoundCloud API key
  134. * @returns {Promise} - returns promise (reject, resolve)
  135. */
  136. GENERATE_SOUNDCLOUD_API_KEY() {
  137. return new Promise((resolve, reject) => {
  138. this.log("INFO", "Fetching new SoundCloud API key.");
  139. sckey
  140. .fetchKey()
  141. .then(soundcloudApiKey => {
  142. if (!soundcloudApiKey) {
  143. this.log("ERROR", "Couldn't fetch new SoundCloud API key.");
  144. reject(new Error("Couldn't fetch SoundCloud key."));
  145. return;
  146. }
  147. SoundCloudModule.soundcloudApiKey = soundcloudApiKey;
  148. CacheModule.runJob("SET", { key: "soundcloudApiKey", value: soundcloudApiKey }, this)
  149. .then(() => {
  150. SoundCloudModule.runJob("TEST_SOUNDCLOUD_API_KEY", {}, this).then(result => {
  151. if (!result) {
  152. this.log("ERROR", "Fetched SoundCloud API key is invalid.");
  153. reject(new Error("SoundCloud key isn't valid."));
  154. } else {
  155. this.log("INFO", "Fetched new valid SoundCloud API key.");
  156. resolve();
  157. }
  158. });
  159. })
  160. .catch(err => {
  161. this.log("ERROR", `Couldn't set new SoundCloud API key in cache. Error: ${err.message}`);
  162. reject(err);
  163. });
  164. })
  165. .catch(err => {
  166. this.log("ERROR", `Couldn't fetch new SoundCloud API key. Error: ${err.message}`);
  167. reject(new Error("Couldn't fetch SoundCloud key."));
  168. });
  169. });
  170. }
  171. /**
  172. * Tests the stored SoundCloud API key
  173. * @returns {Promise} - returns promise (reject, resolve)
  174. */
  175. TEST_SOUNDCLOUD_API_KEY() {
  176. return new Promise((resolve, reject) => {
  177. this.log("INFO", "Testing SoundCloud API key.");
  178. CacheModule.runJob("GET", { key: "soundcloudApiKey" }, this).then(soundcloudApiKey => {
  179. if (!soundcloudApiKey) {
  180. this.log("ERROR", "No SoundCloud API key found in cache.");
  181. resolve(false);
  182. return;
  183. }
  184. SoundCloudModule.soundcloudApiKey = soundcloudApiKey;
  185. sckey
  186. .testKey(soundcloudApiKey)
  187. .then(res => {
  188. this.log("INFO", `Tested SoundCloud API key. Result: ${res}`);
  189. resolve(res);
  190. })
  191. .catch(err => {
  192. this.log("ERROR", `Testing SoundCloud API key error: ${err.message}`);
  193. reject(err);
  194. });
  195. });
  196. });
  197. }
  198. /**
  199. * Perform SoundCloud API get track request
  200. * @param {object} payload - object that contains the payload
  201. * @param {string} payload.trackId - the SoundCloud track id to get
  202. * @returns {Promise} - returns promise (reject, resolve)
  203. */
  204. API_GET_TRACK(payload) {
  205. return new Promise((resolve, reject) => {
  206. const { trackId } = payload;
  207. SoundCloudModule.runJob(
  208. "API_CALL",
  209. {
  210. url: `https://api-v2.soundcloud.com/tracks/${trackId}`
  211. },
  212. this
  213. )
  214. .then(response => {
  215. resolve(response);
  216. })
  217. .catch(err => {
  218. reject(err);
  219. });
  220. });
  221. }
  222. /**
  223. * Perform SoundCloud API call
  224. * @param {object} payload - object that contains the payload
  225. * @param {string} payload.url - request url
  226. * @param {object} payload.params - request parameters
  227. * @returns {Promise} - returns promise (reject, resolve)
  228. */
  229. API_CALL(payload) {
  230. return new Promise((resolve, reject) => {
  231. const { url } = payload;
  232. const { soundcloudApiKey } = SoundCloudModule;
  233. const params = {
  234. client_id: soundcloudApiKey
  235. };
  236. SoundCloudModule.axios
  237. .get(url, {
  238. params,
  239. timeout: SoundCloudModule.requestTimeout
  240. })
  241. .then(response => {
  242. if (response.data.error) {
  243. reject(new Error(response.data.error));
  244. } else {
  245. resolve({ response });
  246. }
  247. })
  248. .catch(err => {
  249. reject(err);
  250. });
  251. // }
  252. });
  253. }
  254. /**
  255. * Create SoundCloud track
  256. * @param {object} payload - an object containing the payload
  257. * @param {object} payload.soundcloudTrack - the soundcloudTrack object
  258. * @returns {Promise} - returns a promise (resolve, reject)
  259. */
  260. CREATE_TRACK(payload) {
  261. return new Promise((resolve, reject) => {
  262. async.waterfall(
  263. [
  264. next => {
  265. const { soundcloudTrack } = payload;
  266. if (typeof soundcloudTrack !== "object") next("Invalid soundcloudTrack type");
  267. else {
  268. SoundCloudModule.soundcloudTrackModel.insertMany(soundcloudTrack, next);
  269. }
  270. },
  271. (soundcloudTracks, next) => {
  272. const mediaSources = soundcloudTracks.map(
  273. soundcloudTrack => `soundcloud:${soundcloudTrack.trackId}`
  274. );
  275. async.eachLimit(
  276. mediaSources,
  277. 2,
  278. (mediaSource, next) => {
  279. MediaModule.runJob("RECALCULATE_RATINGS", { mediaSource }, this)
  280. .then(() => next())
  281. .catch(next);
  282. },
  283. err => {
  284. if (err) next(err);
  285. else next(null, soundcloudTracks);
  286. }
  287. );
  288. }
  289. ],
  290. (err, soundcloudTracks) => {
  291. if (err) reject(new Error(err));
  292. else resolve({ soundcloudTracks });
  293. }
  294. );
  295. });
  296. }
  297. /**
  298. * Get SoundCloud track
  299. * @param {object} payload - an object containing the payload
  300. * @param {string} payload.identifier - the soundcloud track ObjectId or track id
  301. * @param {boolean} payload.createMissing - attempt to fetch and create track if not in db
  302. * @returns {Promise} - returns a promise (resolve, reject)
  303. */
  304. GET_TRACK(payload) {
  305. return new Promise((resolve, reject) => {
  306. async.waterfall(
  307. [
  308. next => {
  309. const query = mongoose.isObjectIdOrHexString(payload.identifier)
  310. ? { _id: payload.identifier }
  311. : { trackId: payload.identifier };
  312. return SoundCloudModule.soundcloudTrackModel.findOne(query, next);
  313. },
  314. (track, next) => {
  315. if (track) return next(null, track, false);
  316. if (mongoose.isObjectIdOrHexString(payload.identifier) || !payload.createMissing)
  317. return next("SoundCloud track not found.");
  318. return SoundCloudModule.runJob("API_GET_TRACK", { trackId: payload.identifier }, this)
  319. .then(({ response }) => {
  320. const { data } = response;
  321. if (!data || !data.id)
  322. return next("The specified track does not exist or cannot be publicly accessed.");
  323. const soundcloudTrack = soundcloudTrackObjectToMusareTrackObject(data);
  324. return next(null, false, soundcloudTrack);
  325. })
  326. .catch(next);
  327. },
  328. (track, soundcloudTrack, next) => {
  329. if (track) return next(null, track, true);
  330. return SoundCloudModule.runJob("CREATE_TRACK", { soundcloudTrack }, this)
  331. .then(res => {
  332. if (res.soundcloudTracks.length === 1) next(null, res.soundcloudTracks[0], false);
  333. else next("SoundCloud track not found.");
  334. })
  335. .catch(next);
  336. }
  337. ],
  338. (err, track, existing) => {
  339. if (err) reject(new Error(err));
  340. else if (track.policy === "SNIP") reject(new Error("Track is premium-only."));
  341. else resolve({ track, existing });
  342. }
  343. );
  344. });
  345. }
  346. /**
  347. * Tries to get a SoundCloud track from a URL
  348. * @param {object} payload - object that contains the payload
  349. * @param {string} payload.identifier - the SoundCloud track URL
  350. * @returns {Promise} - returns promise (reject, resolve)
  351. */
  352. GET_TRACK_FROM_URL(payload) {
  353. return new Promise((resolve, reject) => {
  354. const scRegex =
  355. /soundcloud\.com\/(?<userPermalink>[A-Za-z0-9]+([-_][A-Za-z0-9]+)*)\/(?<permalink>[A-Za-z0-9]+(?:[-_][A-Za-z0-9]+)*)/;
  356. async.waterfall(
  357. [
  358. next => {
  359. const match = scRegex.exec(payload.identifier);
  360. if (!match || !match.groups) {
  361. next("Invalid SoundCloud URL.");
  362. return;
  363. }
  364. const { userPermalink, permalink } = match.groups;
  365. SoundCloudModule.soundcloudTrackModel.findOne({ userPermalink, permalink }, next);
  366. },
  367. (_dbTrack, next) => {
  368. if (_dbTrack) {
  369. next(null, _dbTrack, true);
  370. return;
  371. }
  372. SoundCloudModule.runJob("API_RESOLVE", { url: payload.identifier }, this)
  373. .then(({ response }) => {
  374. const { data } = response;
  375. if (!data || !data.id) {
  376. next("The provided URL does not exist or cannot be accessed.");
  377. return;
  378. }
  379. if (data.kind !== "track") {
  380. next(`Invalid URL provided. Kind got: ${data.kind}.`);
  381. return;
  382. }
  383. // TODO get more data here
  384. const { id: trackId } = data;
  385. SoundCloudModule.soundcloudTrackModel.findOne({ trackId }, (err, dbTrack) => {
  386. if (err) next(err);
  387. else if (dbTrack) {
  388. next(null, dbTrack, true);
  389. } else {
  390. const soundcloudTrack = soundcloudTrackObjectToMusareTrackObject(data);
  391. SoundCloudModule.runJob("CREATE_TRACK", { soundcloudTrack }, this)
  392. .then(res => {
  393. if (res.soundcloudTracks.length === 1)
  394. next(null, res.soundcloudTracks[0], false);
  395. else next("SoundCloud track not found.");
  396. })
  397. .catch(next);
  398. }
  399. });
  400. })
  401. .catch(next);
  402. }
  403. ],
  404. (err, track, existing) => {
  405. if (err) reject(new Error(err));
  406. else if (track.policy === "SNIP") reject(new Error("Track is premium-only."));
  407. else resolve({ track, existing });
  408. }
  409. );
  410. });
  411. }
  412. /**
  413. * Returns an array of songs taken from a SoundCloud playlist
  414. * @param {object} payload - object that contains the payload
  415. * @param {string} payload.url - the url of the SoundCloud playlist
  416. * @returns {Promise} - returns promise (reject, resolve)
  417. */
  418. GET_PLAYLIST(payload) {
  419. return new Promise((resolve, reject) => {
  420. async.waterfall(
  421. [
  422. next => {
  423. SoundCloudModule.runJob("API_RESOLVE", { url: payload.url }, this)
  424. .then(async ({ response }) => {
  425. const { data } = response;
  426. if (!data || !data.id)
  427. return next("The provided URL does not exist or cannot be accessed.");
  428. let tracks;
  429. if (data.kind === "user")
  430. tracks = (
  431. await SoundCloudModule.runJob(
  432. "GET_ARTIST_TRACKS",
  433. {
  434. artistId: data.id
  435. },
  436. this
  437. )
  438. ).tracks;
  439. else if (data.kind !== "playlist" && data.kind !== "system-playlist")
  440. return next(`Invalid URL provided. Kind got: ${data.kind}.`);
  441. else tracks = data.tracks;
  442. const soundcloudTrackIds = tracks.map(track => track.id);
  443. return next(null, soundcloudTrackIds);
  444. })
  445. .catch(next);
  446. }
  447. ],
  448. (err, soundcloudTrackIds) => {
  449. if (err && err !== true) {
  450. SoundCloudModule.log(
  451. "ERROR",
  452. "GET_PLAYLIST",
  453. "Some error has occurred.",
  454. typeof err === "string" ? err : err.message
  455. );
  456. reject(new Error(typeof err === "string" ? err : err.message));
  457. } else {
  458. resolve({ songs: soundcloudTrackIds });
  459. }
  460. }
  461. );
  462. // kind;
  463. });
  464. }
  465. /**
  466. * Returns an array of songs taken from a SoundCloud artist
  467. * @param {object} payload - object that contains the payload
  468. * @param {string} payload.artistId - the id of the SoundCloud artist
  469. * @returns {Promise} - returns promise (reject, resolve)
  470. */
  471. GET_ARTIST_TRACKS(payload) {
  472. return new Promise((resolve, reject) => {
  473. async.waterfall(
  474. [
  475. next => {
  476. let first = true;
  477. let nextHref = null;
  478. let tracks = [];
  479. async.whilst(
  480. next => {
  481. if (nextHref || first) next(null, true);
  482. else next(null, false);
  483. },
  484. next => {
  485. let job;
  486. if (first) {
  487. job = SoundCloudModule.runJob(
  488. "API_GET_ARTIST_TRACKS",
  489. { artistId: payload.artistId },
  490. this
  491. );
  492. first = false;
  493. } else job = SoundCloudModule.runJob("API_GET_ARTIST_TRACKS", { nextHref }, this);
  494. job.then(({ response }) => {
  495. const { data } = response;
  496. const { collection, next_href: _nextHref } = data;
  497. nextHref = _nextHref;
  498. tracks = tracks.concat(collection);
  499. setTimeout(() => {
  500. next();
  501. }, 500);
  502. }).catch(err => {
  503. next(err);
  504. });
  505. },
  506. err => {
  507. if (err) return next(err);
  508. return next(null, tracks);
  509. }
  510. );
  511. }
  512. ],
  513. (err, tracks) => {
  514. if (err && err !== true) {
  515. SoundCloudModule.log(
  516. "ERROR",
  517. "GET_ARTIST_TRACKS",
  518. "Some error has occurred.",
  519. typeof err === "string" ? err : err.message
  520. );
  521. reject(new Error(typeof err === "string" ? err : err.message));
  522. } else {
  523. resolve({ tracks });
  524. }
  525. }
  526. );
  527. });
  528. }
  529. /**
  530. * Get Soundcloud artists
  531. * @param {object} payload - an object containing the payload
  532. * @param {Array} payload.userPermalinks - an array of Soundcloud user permalinks
  533. * @returns {Promise} - returns a promise (resolve, reject)
  534. */
  535. async GET_ARTISTS_FROM_PERMALINKS(payload) {
  536. const getArtists = async userPermalinks => {
  537. const jobsToRun = [];
  538. userPermalinks.forEach(userPermalink => {
  539. const url = `https://soundcloud.com/${userPermalink}`;
  540. jobsToRun.push(SoundCloudModule.runJob("API_RESOLVE", { url }, this));
  541. });
  542. const jobResponses = await Promise.all(jobsToRun);
  543. return jobResponses
  544. .map(jobResponse => jobResponse.response.data)
  545. .map(artist => ({
  546. artistId: artist.id,
  547. username: artist.username,
  548. avatarUrl: artist.avatar_url,
  549. permalink: artist.permalink,
  550. rawData: artist
  551. }));
  552. };
  553. const { userPermalinks } = payload;
  554. const existingArtists = [];
  555. const existingUserPermalinks = existingArtists.map(existingArtists => existingArtists.userPermalink);
  556. // const existingArtistsObjectIds = existingArtists.map(existingArtists => existingArtists._id.toString());
  557. if (userPermalinks.length === existingArtists.length) return { artists: existingArtists };
  558. const missingUserPermalinks = userPermalinks.filter(
  559. userPermalink => existingUserPermalinks.indexOf(userPermalink) === -1
  560. );
  561. if (missingUserPermalinks.length === 0) return { videos: existingArtists };
  562. const newArtists = await getArtists(missingUserPermalinks);
  563. // await SoundcloudModule.soundcloudArtistsModel.insertMany(newArtists);
  564. return { artists: existingArtists.concat(newArtists) };
  565. }
  566. /**
  567. * @param {object} payload - object that contains the payload
  568. * @param {string} payload.url - the url of the SoundCloud resource
  569. * @returns {Promise} - returns a promise (resolve, reject)
  570. */
  571. API_RESOLVE(payload) {
  572. return new Promise((resolve, reject) => {
  573. const { url } = payload;
  574. SoundCloudModule.runJob(
  575. "API_CALL",
  576. {
  577. url: `https://api-v2.soundcloud.com/resolve?url=${encodeURIComponent(url)}`
  578. },
  579. this
  580. )
  581. .then(response => {
  582. resolve(response);
  583. })
  584. .catch(err => {
  585. reject(err);
  586. });
  587. });
  588. }
  589. /**
  590. * Calls the API_CALL with the proper URL to get artist/user tracks
  591. * @param {object} payload - object that contains the payload
  592. * @param {string} payload.artistId - the id of the SoundCloud artist
  593. * @param {string} payload.nextHref - the next url to call
  594. * @returns {Promise} - returns a promise (resolve, reject)
  595. */
  596. API_GET_ARTIST_TRACKS(payload) {
  597. return new Promise((resolve, reject) => {
  598. const { artistId, nextHref } = payload;
  599. SoundCloudModule.runJob(
  600. "API_CALL",
  601. {
  602. url: artistId
  603. ? `https://api-v2.soundcloud.com/users/${artistId}/tracks?access=playable&limit=50`
  604. : nextHref
  605. },
  606. this
  607. )
  608. .then(response => {
  609. resolve(response);
  610. })
  611. .catch(err => {
  612. reject(err);
  613. });
  614. });
  615. }
  616. }
  617. export default new _SoundCloudModule();