soundcloud.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523
  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. } = soundcloudTrackObject;
  31. return {
  32. trackId: id,
  33. title,
  34. artworkUrl,
  35. soundcloudCreatedAt: new Date(createdAt),
  36. duration: duration / 1000,
  37. genre,
  38. kind,
  39. license,
  40. likesCount,
  41. playbackCount,
  42. public: _public,
  43. tagList,
  44. userId,
  45. username: user.username,
  46. userPermalink: user.permalink,
  47. trackFormat,
  48. permalink
  49. };
  50. };
  51. class RateLimitter {
  52. /**
  53. * Constructor
  54. *
  55. * @param {number} timeBetween - The time between each allowed YouTube request
  56. */
  57. constructor(timeBetween) {
  58. this.dateStarted = Date.now();
  59. this.timeBetween = timeBetween;
  60. }
  61. /**
  62. * Returns a promise that resolves whenever the ratelimit of a YouTube request is done
  63. *
  64. * @returns {Promise} - promise that gets resolved when the rate limit allows it
  65. */
  66. continue() {
  67. return new Promise(resolve => {
  68. if (Date.now() - this.dateStarted >= this.timeBetween) resolve();
  69. else setTimeout(resolve, this.dateStarted + this.timeBetween - Date.now());
  70. });
  71. }
  72. /**
  73. * Restart the rate limit timer
  74. */
  75. restart() {
  76. this.dateStarted = Date.now();
  77. }
  78. }
  79. class _SoundCloudModule extends CoreClass {
  80. // eslint-disable-next-line require-jsdoc
  81. constructor() {
  82. super("soundcloud");
  83. SoundCloudModule = this;
  84. }
  85. /**
  86. * Initialises the soundcloud module
  87. *
  88. * @returns {Promise} - returns promise (reject, resolve)
  89. */
  90. async initialize() {
  91. DBModule = this.moduleManager.modules.db;
  92. CacheModule = this.moduleManager.modules.cache;
  93. MediaModule = this.moduleManager.modules.media;
  94. // this.youtubeApiRequestModel = this.YoutubeApiRequestModel = await DBModule.runJob("GET_MODEL", {
  95. // modelName: "youtubeApiRequest"
  96. // });
  97. this.soundcloudTrackModel = this.SoundCloudTrackModel = await DBModule.runJob("GET_MODEL", {
  98. modelName: "soundcloudTrack"
  99. });
  100. return new Promise((resolve, reject) => {
  101. this.rateLimiter = new RateLimitter(config.get("apis.soundcloud.rateLimit"));
  102. this.requestTimeout = config.get("apis.soundcloud.requestTimeout");
  103. this.axios = axios.create();
  104. this.axios.defaults.raxConfig = {
  105. instance: this.axios,
  106. retry: config.get("apis.soundcloud.retryAmount"),
  107. noResponseRetries: config.get("apis.soundcloud.retryAmount")
  108. };
  109. rax.attach(this.axios);
  110. this.apiKey = null;
  111. SoundCloudModule.runJob("TEST_SOUNDCLOUD_API_KEY", {}, null, -1)
  112. .then(result => {
  113. if (result) {
  114. resolve();
  115. return;
  116. }
  117. SoundCloudModule.runJob("GENERATE_SOUNDCLOUD_API_KEY", {}, null, -1)
  118. .then(() => {
  119. resolve();
  120. })
  121. .catch(reject);
  122. })
  123. .catch(reject);
  124. });
  125. }
  126. /**
  127. *
  128. * @returns
  129. */
  130. GENERATE_SOUNDCLOUD_API_KEY() {
  131. return new Promise((resolve, reject) => {
  132. this.log("INFO", "Fetching new SoundCloud API key.");
  133. sckey
  134. .fetchKey()
  135. .then(soundcloudApiKey => {
  136. if (!soundcloudApiKey) {
  137. this.log("ERROR", "Couldn't fetch new SoundCloud API key.");
  138. reject(new Error("Couldn't fetch SoundCloud key."));
  139. return;
  140. }
  141. SoundCloudModule.soundcloudApiKey = soundcloudApiKey;
  142. CacheModule.runJob("SET", { key: "soundcloudApiKey", value: soundcloudApiKey }, this)
  143. .then(() => {
  144. SoundCloudModule.runJob("TEST_SOUNDCLOUD_API_KEY", {}, this).then(result => {
  145. if (!result) {
  146. this.log("ERROR", "Fetched SoundCloud API key is invalid.");
  147. reject(new Error("SoundCloud key isn't valid."));
  148. } else {
  149. this.log("INFO", "Fetched new valid SoundCloud API key.");
  150. resolve();
  151. }
  152. });
  153. })
  154. .catch(err => {
  155. this.log("ERROR", `Couldn't set new SoundCloud API key in cache. Error: ${err.message}`);
  156. reject(err);
  157. });
  158. })
  159. .catch(err => {
  160. this.log("ERROR", `Couldn't fetch new SoundCloud API key. Error: ${err.message}`);
  161. reject(new Error("Couldn't fetch SoundCloud key."));
  162. });
  163. });
  164. }
  165. /**
  166. *
  167. * @returns
  168. */
  169. TEST_SOUNDCLOUD_API_KEY() {
  170. return new Promise((resolve, reject) => {
  171. this.log("INFO", "Testing SoundCloud API key.");
  172. CacheModule.runJob("GET", { key: "soundcloudApiKey" }, this).then(soundcloudApiKey => {
  173. if (!soundcloudApiKey) {
  174. this.log("ERROR", "No SoundCloud API key found in cache.");
  175. return resolve(false);
  176. }
  177. SoundCloudModule.soundcloudApiKey = soundcloudApiKey;
  178. sckey
  179. .testKey(soundcloudApiKey)
  180. .then(res => {
  181. this.log("INFO", `Tested SoundCloud API key. Result: ${res}`);
  182. resolve(res);
  183. })
  184. .catch(err => {
  185. this.log("ERROR", `Testing SoundCloud API key error: ${err.message}`);
  186. reject(err);
  187. });
  188. });
  189. });
  190. }
  191. /**
  192. * Perform SoundCloud API get track request
  193. *
  194. * @param {object} payload - object that contains the payload
  195. * @param {object} payload.params - request parameters
  196. * @returns {Promise} - returns promise (reject, resolve)
  197. */
  198. API_GET_TRACK(payload) {
  199. return new Promise((resolve, reject) => {
  200. const { trackId } = payload;
  201. SoundCloudModule.runJob(
  202. "API_CALL",
  203. {
  204. url: `https://api-v2.soundcloud.com/tracks/${trackId}`
  205. },
  206. this
  207. )
  208. .then(response => {
  209. resolve(response);
  210. })
  211. .catch(err => {
  212. reject(err);
  213. });
  214. });
  215. }
  216. /**
  217. * Perform SoundCloud API call
  218. *
  219. * @param {object} payload - object that contains the payload
  220. * @param {object} payload.url - request url
  221. * @param {object} payload.params - request parameters
  222. * @param {object} payload.quotaCost - request quotaCost
  223. * @returns {Promise} - returns promise (reject, resolve)
  224. */
  225. API_CALL(payload) {
  226. return new Promise((resolve, reject) => {
  227. // const { url, params, quotaCost } = payload;
  228. const { url } = payload;
  229. const { soundcloudApiKey } = SoundCloudModule;
  230. const params = {
  231. client_id: soundcloudApiKey
  232. };
  233. SoundCloudModule.axios
  234. .get(url, {
  235. params,
  236. timeout: SoundCloudModule.requestTimeout
  237. })
  238. .then(response => {
  239. if (response.data.error) {
  240. reject(new Error(response.data.error));
  241. } else {
  242. resolve({ response });
  243. }
  244. })
  245. .catch(err => {
  246. reject(err);
  247. });
  248. // }
  249. });
  250. }
  251. /**
  252. * Create SoundCloud track
  253. *
  254. * @param {object} payload - an object containing the payload
  255. * @param {string} payload.soundcloudTrack - the soundcloudTrack object
  256. * @returns {Promise} - returns a promise (resolve, reject)
  257. */
  258. CREATE_TRACK(payload) {
  259. return new Promise((resolve, reject) => {
  260. async.waterfall(
  261. [
  262. next => {
  263. const { soundcloudTrack } = payload;
  264. if (typeof soundcloudTrack !== "object") next("Invalid soundcloudTrack type");
  265. else {
  266. SoundCloudModule.soundcloudTrackModel.insertMany(soundcloudTrack, next);
  267. }
  268. },
  269. (soundcloudTracks, next) => {
  270. const mediaSources = soundcloudTracks.map(
  271. soundcloudTrack => `soundcloud:${soundcloudTrack.trackId}`
  272. );
  273. async.eachLimit(
  274. mediaSources,
  275. 2,
  276. (mediaSource, next) => {
  277. MediaModule.runJob("RECALCULATE_RATINGS", { mediaSource }, this)
  278. .then(() => next())
  279. .catch(next);
  280. },
  281. err => {
  282. if (err) next(err);
  283. else next(null, soundcloudTracks);
  284. }
  285. );
  286. }
  287. ],
  288. (err, soundcloudTracks) => {
  289. if (err) reject(new Error(err));
  290. else resolve({ soundcloudTracks });
  291. }
  292. );
  293. });
  294. }
  295. /**
  296. * Get SoundCloud track
  297. *
  298. * @param {object} payload - an object containing the payload
  299. * @param {string} payload.identifier - the soundcloud track ObjectId or track id
  300. * @param {string} payload.createMissing - attempt to fetch and create track if not in db
  301. * @returns {Promise} - returns a promise (resolve, reject)
  302. */
  303. GET_TRACK(payload) {
  304. return new Promise((resolve, reject) => {
  305. async.waterfall(
  306. [
  307. next => {
  308. const query = mongoose.isObjectIdOrHexString(payload.identifier)
  309. ? { _id: payload.identifier }
  310. : { trackId: payload.identifier };
  311. return SoundCloudModule.soundcloudTrackModel.findOne(query, next);
  312. },
  313. (track, next) => {
  314. if (track) return next(null, track, false);
  315. if (mongoose.isObjectIdOrHexString(payload.identifier) || !payload.createMissing)
  316. return next("SoundCloud track not found.");
  317. return SoundCloudModule.runJob("API_GET_TRACK", { trackId: payload.identifier }, this)
  318. .then(({ response }) => {
  319. const { data } = response;
  320. if (!data || !data.id)
  321. return next("The specified track does not exist or cannot be publicly accessed.");
  322. const soundcloudTrack = soundcloudTrackObjectToMusareTrackObject(data);
  323. return next(null, false, soundcloudTrack);
  324. })
  325. .catch(next);
  326. },
  327. (track, soundcloudTrack, next) => {
  328. if (track) return next(null, track, true);
  329. return SoundCloudModule.runJob("CREATE_TRACK", { soundcloudTrack }, this)
  330. .then(res => {
  331. if (res.soundcloudTracks.length === 1) next(null, res.soundcloudTracks[0], false);
  332. else next("SoundCloud track not found.");
  333. })
  334. .catch(next);
  335. }
  336. ],
  337. (err, track, existing) => {
  338. if (err) reject(new Error(err));
  339. else resolve({ track, existing });
  340. }
  341. );
  342. });
  343. }
  344. /**
  345. * Gets a track from a SoundCloud URL
  346. *
  347. * @param {*} payload
  348. * @returns {Promise}
  349. */
  350. GET_TRACK_FROM_URL(payload) {
  351. return new Promise((resolve, reject) => {
  352. const scRegex =
  353. /soundcloud\.com\/(?<userPermalink>[A-Za-z0-9]+([-_][A-Za-z0-9]+)*)\/(?<permalink>[A-Za-z0-9]+(?:[-_][A-Za-z0-9]+)*)/;
  354. async.waterfall(
  355. [
  356. next => {
  357. const match = scRegex.exec(payload.identifier);
  358. if (!match || !match.groups) return next("Invalid SoundCloud URL.");
  359. const { userPermalink, permalink } = match.groups;
  360. SoundCloudModule.soundcloudTrackModel.findOne({ userPermalink, permalink }, next);
  361. },
  362. (_dbTrack, next) => {
  363. if (_dbTrack) return next(null, _dbTrack, true);
  364. SoundCloudModule.runJob("API_RESOLVE", { url: payload.identifier }, this)
  365. .then(({ response }) => {
  366. const { data } = response;
  367. if (!data || !data.id)
  368. return next("The provided URL does not exist or cannot be accessed.");
  369. if (data.kind !== "track") return next(`Invalid URL provided. Kind got: ${data.kind}.`);
  370. // TODO get more data here
  371. const { id: trackId } = data;
  372. SoundCloudModule.soundcloudTrackModel.findOne({ trackId }, (err, dbTrack) => {
  373. if (err) next(err);
  374. else if (dbTrack) {
  375. next(null, dbTrack, true);
  376. } else {
  377. const soundcloudTrack = soundcloudTrackObjectToMusareTrackObject(data);
  378. SoundCloudModule.runJob("CREATE_TRACK", { soundcloudTrack }, this)
  379. .then(res => {
  380. if (res.soundcloudTracks.length === 1)
  381. next(null, res.soundcloudTracks[0], false);
  382. else next("SoundCloud track not found.");
  383. })
  384. .catch(next);
  385. }
  386. });
  387. })
  388. .catch(next);
  389. }
  390. ],
  391. (err, track, existing) => {
  392. if (err) reject(new Error(err));
  393. else resolve({ track, existing });
  394. }
  395. );
  396. });
  397. }
  398. /**
  399. * Returns an array of songs taken from a SoundCloud playlist
  400. *
  401. * @param {object} payload - object that contains the payload
  402. * @param {string} payload.url - the url of the SoundCloud playlist
  403. * @returns {Promise} - returns promise (reject, resolve)
  404. */
  405. GET_PLAYLIST(payload) {
  406. return new Promise((resolve, reject) => {
  407. async.waterfall(
  408. [
  409. next => {
  410. SoundCloudModule.runJob("API_RESOLVE", { url: payload.url }, this)
  411. .then(({ response }) => {
  412. const { data } = response;
  413. if (!data || !data.id)
  414. return next("The provided URL does not exist or cannot be accessed.");
  415. if (data.kind !== "playlist" && data.kind !== "system-playlist")
  416. return next(`Invalid URL provided. Kind got: ${data.kind}.`);
  417. const { tracks } = data;
  418. // TODO get more data here
  419. const soundcloudTrackIds = tracks.map(track => track.id);
  420. return next(null, soundcloudTrackIds);
  421. })
  422. .catch(next);
  423. }
  424. ],
  425. (err, soundcloudTrackIds) => {
  426. if (err && err !== true) {
  427. SoundCloudModule.log(
  428. "ERROR",
  429. "GET_PLAYLIST",
  430. "Some error has occurred.",
  431. typeof err === "string" ? err : err.message
  432. );
  433. reject(new Error(typeof err === "string" ? err : err.message));
  434. } else {
  435. resolve({ songs: soundcloudTrackIds });
  436. }
  437. }
  438. );
  439. // kind;
  440. });
  441. }
  442. /**
  443. * @param {object} payload - object that contains the payload
  444. * @param {string} payload.url - the url of the SoundCloud resource
  445. */
  446. API_RESOLVE(payload) {
  447. return new Promise((resolve, reject) => {
  448. const { url } = payload;
  449. SoundCloudModule.runJob(
  450. "API_CALL",
  451. {
  452. url: `https://api-v2.soundcloud.com/resolve?url=${encodeURIComponent(url)}`
  453. },
  454. this
  455. )
  456. .then(response => {
  457. resolve(response);
  458. })
  459. .catch(err => {
  460. reject(err);
  461. });
  462. });
  463. }
  464. }
  465. export default new _SoundCloudModule();