soundcloud.js 11 KB

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