soundcloud.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440
  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. CacheModule = this.moduleManager.modules.cache;
  90. DBModule = this.moduleManager.modules.db;
  91. MediaModule = this.moduleManager.modules.media;
  92. SongsModule = this.moduleManager.modules.songs;
  93. StationsModule = this.moduleManager.modules.stations;
  94. PlaylistsModule = this.moduleManager.modules.playlists;
  95. WSModule = this.moduleManager.modules.ws;
  96. // this.youtubeApiRequestModel = this.YoutubeApiRequestModel = await DBModule.runJob("GET_MODEL", {
  97. // modelName: "youtubeApiRequest"
  98. // });
  99. this.soundcloudTrackModel = this.SoundCloudTrackModel = await DBModule.runJob("GET_MODEL", {
  100. modelName: "soundcloudTrack"
  101. });
  102. return new Promise(resolve => {
  103. this.rateLimiter = new RateLimitter(config.get("apis.soundcloud.rateLimit"));
  104. this.requestTimeout = config.get("apis.soundcloud.requestTimeout");
  105. this.axios = axios.create();
  106. this.axios.defaults.raxConfig = {
  107. instance: this.axios,
  108. retry: config.get("apis.soundcloud.retryAmount"),
  109. noResponseRetries: config.get("apis.soundcloud.retryAmount")
  110. };
  111. rax.attach(this.axios);
  112. // SoundCloudModule.runJob("GET_TRACK", { identifier: 469902882, createMissing: false })
  113. // .then(res => {
  114. // console.log(57567, res);
  115. // })
  116. // .catch(err => {
  117. // console.log(78768, err);
  118. // });
  119. resolve();
  120. });
  121. }
  122. /**
  123. * Perform SoundCloud API get track request
  124. *
  125. * @param {object} payload - object that contains the payload
  126. * @param {object} payload.params - request parameters
  127. * @returns {Promise} - returns promise (reject, resolve)
  128. */
  129. API_GET_TRACK(payload) {
  130. return new Promise((resolve, reject) => {
  131. const { trackId } = payload;
  132. SoundCloudModule.runJob(
  133. "API_CALL",
  134. {
  135. url: `https://api-v2.soundcloud.com/tracks/${trackId}`
  136. },
  137. this
  138. )
  139. .then(response => {
  140. resolve(response);
  141. })
  142. .catch(err => {
  143. reject(err);
  144. });
  145. });
  146. }
  147. /**
  148. * Perform SoundCloud API call
  149. *
  150. * @param {object} payload - object that contains the payload
  151. * @param {object} payload.url - request url
  152. * @param {object} payload.params - request parameters
  153. * @param {object} payload.quotaCost - request quotaCost
  154. * @returns {Promise} - returns promise (reject, resolve)
  155. */
  156. API_CALL(payload) {
  157. return new Promise((resolve, reject) => {
  158. // const { url, params, quotaCost } = payload;
  159. const { url } = payload;
  160. const params = {
  161. client_id: config.get("apis.soundcloud.key")
  162. };
  163. SoundCloudModule.axios
  164. .get(url, {
  165. params,
  166. timeout: SoundCloudModule.requestTimeout
  167. })
  168. .then(response => {
  169. if (response.data.error) {
  170. reject(new Error(response.data.error));
  171. } else {
  172. resolve({ response });
  173. }
  174. })
  175. .catch(err => {
  176. reject(err);
  177. });
  178. // }
  179. });
  180. }
  181. /**
  182. * Create SoundCloud track
  183. *
  184. * @param {object} payload - an object containing the payload
  185. * @param {string} payload.soundcloudTrack - the soundcloudTrack object
  186. * @returns {Promise} - returns a promise (resolve, reject)
  187. */
  188. CREATE_TRACK(payload) {
  189. return new Promise((resolve, reject) => {
  190. async.waterfall(
  191. [
  192. next => {
  193. const { soundcloudTrack } = payload;
  194. if (typeof soundcloudTrack !== "object") next("Invalid soundcloudTrack type");
  195. else {
  196. SoundCloudModule.soundcloudTrackModel.insertMany(soundcloudTrack, next);
  197. }
  198. },
  199. (soundcloudTracks, next) => {
  200. const mediaSources = soundcloudTracks.map(
  201. soundcloudTrack => `soundcloud:${soundcloudTrack.trackId}`
  202. );
  203. async.eachLimit(
  204. mediaSources,
  205. 2,
  206. (mediaSource, next) => {
  207. MediaModule.runJob("RECALCULATE_RATINGS", { mediaSource }, this)
  208. .then(() => next())
  209. .catch(next);
  210. },
  211. err => {
  212. if (err) next(err);
  213. else next(null, soundcloudTracks);
  214. }
  215. );
  216. }
  217. ],
  218. (err, soundcloudTracks) => {
  219. if (err) reject(new Error(err));
  220. else resolve({ soundcloudTracks });
  221. }
  222. );
  223. });
  224. }
  225. /**
  226. * Get SoundCloud track
  227. *
  228. * @param {object} payload - an object containing the payload
  229. * @param {string} payload.identifier - the soundcloud track ObjectId or track id
  230. * @param {string} payload.createMissing - attempt to fetch and create track if not in db
  231. * @returns {Promise} - returns a promise (resolve, reject)
  232. */
  233. GET_TRACK(payload) {
  234. return new Promise((resolve, reject) => {
  235. async.waterfall(
  236. [
  237. next => {
  238. const query = mongoose.isObjectIdOrHexString(payload.identifier)
  239. ? { _id: payload.identifier }
  240. : { trackId: payload.identifier };
  241. return SoundCloudModule.soundcloudTrackModel.findOne(query, next);
  242. },
  243. (track, next) => {
  244. if (track) return next(null, track, false);
  245. if (mongoose.isObjectIdOrHexString(payload.identifier) || !payload.createMissing)
  246. return next("SoundCloud track not found.");
  247. return SoundCloudModule.runJob("API_GET_TRACK", { trackId: payload.identifier }, this)
  248. .then(({ response }) => {
  249. const { data } = response;
  250. if (!data || !data.id)
  251. return next("The specified track does not exist or cannot be publicly accessed.");
  252. const soundcloudTrack = soundcloudTrackObjectToMusareTrackObject(data);
  253. return next(null, false, soundcloudTrack);
  254. })
  255. .catch(next);
  256. },
  257. (track, soundcloudTrack, next) => {
  258. if (track) return next(null, track, true);
  259. return SoundCloudModule.runJob("CREATE_TRACK", { soundcloudTrack }, this)
  260. .then(res => {
  261. if (res.soundcloudTracks.length === 1) next(null, res.soundcloudTracks[0], false);
  262. else next("SoundCloud track not found.");
  263. })
  264. .catch(next);
  265. }
  266. ],
  267. (err, track, existing) => {
  268. if (err) reject(new Error(err));
  269. else resolve({ track, existing });
  270. }
  271. );
  272. });
  273. }
  274. /**
  275. * Gets a track from a SoundCloud URL
  276. *
  277. * @param {*} payload
  278. * @returns {Promise}
  279. */
  280. GET_TRACK_FROM_URL(payload) {
  281. return new Promise((resolve, reject) => {
  282. const scRegex =
  283. /soundcloud\.com\/(?<userPermalink>[A-Za-z0-9]+([-_][A-Za-z0-9]+)*)\/(?<permalink>[A-Za-z0-9]+(?:[-_][A-Za-z0-9]+)*)/;
  284. async.waterfall(
  285. [
  286. next => {
  287. const match = scRegex.exec(payload.identifier);
  288. if (!match || !match.groups) return next("Invalid SoundCloud URL.");
  289. const { userPermalink, permalink } = match.groups;
  290. SoundCloudModule.soundcloudTrackModel.findOne({ userPermalink, permalink }, next);
  291. },
  292. (_dbTrack, next) => {
  293. if (_dbTrack) return next(null, _dbTrack, true);
  294. SoundCloudModule.runJob("API_RESOLVE", { url: payload.identifier }, this)
  295. .then(({ response }) => {
  296. const { data } = response;
  297. if (!data || !data.id)
  298. return next("The provided URL does not exist or cannot be accessed.");
  299. if (data.kind !== "track") return next(`Invalid URL provided. Kind got: ${data.kind}.`);
  300. // TODO get more data here
  301. const { id: trackId } = data;
  302. SoundCloudModule.soundcloudTrackModel.findOne({ trackId }, (err, dbTrack) => {
  303. if (err) next(err);
  304. else if (dbTrack) {
  305. next(null, dbTrack, true);
  306. } else {
  307. const soundcloudTrack = soundcloudTrackObjectToMusareTrackObject(data);
  308. SoundCloudModule.runJob("CREATE_TRACK", { soundcloudTrack }, this)
  309. .then(res => {
  310. if (res.soundcloudTracks.length === 1)
  311. next(null, res.soundcloudTracks[0], false);
  312. else next("SoundCloud track not found.");
  313. })
  314. .catch(next);
  315. }
  316. });
  317. })
  318. .catch(next);
  319. }
  320. ],
  321. (err, track, existing) => {
  322. if (err) reject(new Error(err));
  323. else resolve({ track, existing });
  324. }
  325. );
  326. });
  327. }
  328. /**
  329. * Returns an array of songs taken from a SoundCloud playlist
  330. *
  331. * @param {object} payload - object that contains the payload
  332. * @param {string} payload.url - the url of the SoundCloud playlist
  333. * @returns {Promise} - returns promise (reject, resolve)
  334. */
  335. GET_PLAYLIST(payload) {
  336. return new Promise((resolve, reject) => {
  337. async.waterfall(
  338. [
  339. next => {
  340. SoundCloudModule.runJob("API_RESOLVE", { url: payload.url }, this)
  341. .then(({ response }) => {
  342. const { data } = response;
  343. if (!data || !data.id)
  344. return next("The provided URL does not exist or cannot be accessed.");
  345. if (data.kind !== "playlist")
  346. return next(`Invalid URL provided. Kind got: ${data.kind}.`);
  347. const { tracks } = data;
  348. // TODO get more data here
  349. const soundcloudTrackIds = tracks.map(track => track.id);
  350. return next(null, soundcloudTrackIds);
  351. })
  352. .catch(next);
  353. }
  354. ],
  355. (err, soundcloudTrackIds) => {
  356. if (err && err !== true) {
  357. SoundCloudModule.log("ERROR", "GET_PLAYLIST", "Some error has occurred.", err.message);
  358. reject(new Error(err.message));
  359. } else {
  360. resolve({ songs: soundcloudTrackIds });
  361. }
  362. }
  363. );
  364. // kind;
  365. });
  366. }
  367. /**
  368. * @param {object} payload - object that contains the payload
  369. * @param {string} payload.url - the url of the SoundCloud resource
  370. */
  371. API_RESOLVE(payload) {
  372. return new Promise((resolve, reject) => {
  373. const { url } = payload;
  374. SoundCloudModule.runJob(
  375. "API_CALL",
  376. {
  377. url: `https://api-v2.soundcloud.com/resolve?url=${encodeURIComponent(url)}`
  378. },
  379. this
  380. )
  381. .then(response => {
  382. resolve(response);
  383. })
  384. .catch(err => {
  385. reject(err);
  386. });
  387. });
  388. }
  389. }
  390. export default new _SoundCloudModule();