soundcloud.js 19 KB

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