youtube.js 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230
  1. /* eslint-disable */
  2. import mongoose from "mongoose";
  3. import async from "async";
  4. import config from "config";
  5. import * as rax from "retry-axios";
  6. import axios from "axios";
  7. import CoreClass from "../core";
  8. class RateLimitter {
  9. /**
  10. * Constructor
  11. *
  12. * @param {number} timeBetween - The time between each allowed YouTube request
  13. */
  14. constructor(timeBetween) {
  15. this.dateStarted = Date.now();
  16. this.timeBetween = timeBetween;
  17. }
  18. /**
  19. * Returns a promise that resolves whenever the ratelimit of a YouTube request is done
  20. *
  21. * @returns {Promise} - promise that gets resolved when the rate limit allows it
  22. */
  23. continue() {
  24. return new Promise(resolve => {
  25. if (Date.now() - this.dateStarted >= this.timeBetween) resolve();
  26. else setTimeout(resolve, this.dateStarted + this.timeBetween - Date.now());
  27. });
  28. }
  29. /**
  30. * Restart the rate limit timer
  31. */
  32. restart() {
  33. this.dateStarted = Date.now();
  34. }
  35. }
  36. let YouTubeModule;
  37. let CacheModule;
  38. let DBModule;
  39. let RatingsModule;
  40. const isQuotaExceeded = apiCalls => {
  41. const reversedApiCalls = apiCalls.slice().reverse();
  42. const quotas = config.get("apis.youtube.quotas").slice();
  43. const sortedQuotas = quotas.sort((a, b) => a.limit > b.limit);
  44. let quotaExceeded = false;
  45. for (const quota of sortedQuotas) {
  46. let quotaUsed = 0;
  47. let dateCutoff = null;
  48. if (quota.type === "QUERIES_PER_MINUTE") dateCutoff = new Date() - 1000 * 60;
  49. else if (quota.type === "QUERIES_PER_100_SECONDS") dateCutoff = new Date() - 1000 * 100;
  50. else if (quota.type === "QUERIES_PER_DAY") {
  51. // Quota resets at midnight PT, this is my best guess to convert the current date to the last midnight PT
  52. dateCutoff = new Date();
  53. dateCutoff.setUTCMilliseconds(0);
  54. dateCutoff.setUTCSeconds(0);
  55. dateCutoff.setUTCMinutes(0);
  56. dateCutoff.setUTCHours(dateCutoff.getUTCHours() - 7);
  57. dateCutoff.setUTCHours(0);
  58. }
  59. for (const apiCall of reversedApiCalls) {
  60. if (apiCall.date >= dateCutoff) quotaUsed += apiCall.quotaCost;
  61. else break;
  62. }
  63. if (quotaUsed >= quota.limit) {
  64. quotaExceeded = true;
  65. break;
  66. }
  67. }
  68. return quotaExceeded;
  69. };
  70. class _YouTubeModule extends CoreClass {
  71. // eslint-disable-next-line require-jsdoc
  72. constructor() {
  73. super("youtube", {
  74. concurrency: 10,
  75. priorities: {
  76. GET_PLAYLIST: 11
  77. }
  78. });
  79. YouTubeModule = this;
  80. }
  81. /**
  82. * Initialises the activities module
  83. *
  84. * @returns {Promise} - returns promise (reject, resolve)
  85. */
  86. initialize() {
  87. return new Promise(async resolve => {
  88. CacheModule = this.moduleManager.modules.cache;
  89. DBModule = this.moduleManager.modules.db;
  90. RatingsModule = this.moduleManager.modules.ratings;
  91. this.youtubeApiRequestModel = this.YoutubeApiRequestModel = await DBModule.runJob("GET_MODEL", {
  92. modelName: "youtubeApiRequest"
  93. });
  94. this.youtubeVideoModel = this.YoutubeVideoModel = await DBModule.runJob("GET_MODEL", {
  95. modelName: "youtubeVideo"
  96. });
  97. this.rateLimiter = new RateLimitter(config.get("apis.youtube.rateLimit"));
  98. this.requestTimeout = config.get("apis.youtube.requestTimeout");
  99. this.axios = axios.create();
  100. this.axios.defaults.raxConfig = {
  101. instance: this.axios,
  102. retry: config.get("apis.youtube.retryAmount"),
  103. noResponseRetries: config.get("apis.youtube.retryAmount")
  104. };
  105. rax.attach(this.axios);
  106. this.youtubeApiRequestModel
  107. .find({ date: { $gte: new Date() - 2 * 24 * 60 * 60 * 1000 } }, { date: true, quotaCost: true, _id: false })
  108. .sort({ date: 1 })
  109. .exec((err, youtubeApiRequests) => {
  110. if (err) console.log("Couldn't load YouTube API requests.");
  111. else {
  112. this.apiCalls = youtubeApiRequests;
  113. resolve();
  114. }
  115. });
  116. });
  117. }
  118. /**
  119. * Fetches a list of songs from Youtube's API
  120. *
  121. * @param {object} payload - object that contains the payload
  122. * @param {string} payload.query - the query we'll pass to youtubes api
  123. * @param {string} payload.pageToken - (optional) if this exists, will search search youtube for a specific page reference
  124. * @returns {Promise} - returns promise (reject, resolve)
  125. */
  126. SEARCH(payload) {
  127. const params = {
  128. part: "snippet",
  129. q: payload.query,
  130. type: "video",
  131. maxResults: 10
  132. };
  133. if (payload.pageToken) params.pageToken = payload.pageToken;
  134. return new Promise((resolve, reject) => {
  135. YouTubeModule.runJob(
  136. "API_SEARCH",
  137. {
  138. params
  139. },
  140. this
  141. )
  142. .then(({ response }) => {
  143. const { data } = response;
  144. return resolve(data);
  145. })
  146. .catch(err => {
  147. YouTubeModule.log("ERROR", "SEARCH", `${err.message}`);
  148. return reject(new Error("An error has occured. Please try again later."));
  149. });
  150. });
  151. }
  152. GET_QUOTA_STATUS(payload) {
  153. return new Promise((resolve, reject) => {
  154. const fromDate = payload.fromDate ? new Date(payload.fromDate) : new Date();
  155. YouTubeModule.youtubeApiRequestModel
  156. .find({ date: { $gte: fromDate - 2 * 24 * 60 * 60 * 1000, $lte: fromDate } }, { date: true, quotaCost: true, _id: false })
  157. .sort({ date: 1 })
  158. .exec((err, youtubeApiRequests) => {
  159. if (err) reject(new Error("Couldn't load YouTube API requests."));
  160. else {
  161. const reversedApiCalls = youtubeApiRequests.slice().reverse();
  162. const quotas = config.get("apis.youtube.quotas").slice();
  163. const sortedQuotas = quotas.sort((a, b) => a.limit > b.limit);
  164. const status = {};
  165. for (const quota of sortedQuotas) {
  166. status[quota.type] = {
  167. title: quota.title,
  168. quotaUsed: 0,
  169. limit: quota.limit,
  170. quotaExceeded: false
  171. };
  172. let dateCutoff = null;
  173. if (quota.type === "QUERIES_PER_MINUTE") dateCutoff = new Date(fromDate) - 1000 * 60;
  174. else if (quota.type === "QUERIES_PER_100_SECONDS") dateCutoff = new Date(fromDate) - 1000 * 100;
  175. else if (quota.type === "QUERIES_PER_DAY") {
  176. // Quota resets at midnight PT, this is my best guess to convert the current date to the last midnight PT
  177. dateCutoff = new Date(fromDate);
  178. dateCutoff.setUTCMilliseconds(0);
  179. dateCutoff.setUTCSeconds(0);
  180. dateCutoff.setUTCMinutes(0);
  181. dateCutoff.setUTCHours(dateCutoff.getUTCHours() - 7);
  182. dateCutoff.setUTCHours(0);
  183. }
  184. for (const apiCall of reversedApiCalls) {
  185. if (apiCall.date >= dateCutoff) status[quota.type].quotaUsed += apiCall.quotaCost;
  186. else break;
  187. }
  188. if (status[quota.type].quotaUsed >= quota.limit && !status[quota.type].quotaExceeded)
  189. status[quota.type].quotaExceeded = true;
  190. }
  191. resolve({ status });
  192. }
  193. });
  194. });
  195. }
  196. /**
  197. * Gets the id of the channel upload playlist
  198. *
  199. * @param {object} payload - object that contains the payload
  200. * @param {string} payload.id - the id of the YouTube channel. Optional: can be left out if specifying a username.
  201. * @param {string} payload.username - the username of the YouTube channel. Only gets used if no id is specified.
  202. * @returns {Promise} - returns promise (reject, resolve)
  203. */
  204. GET_CHANNEL_UPLOADS_PLAYLIST_ID(payload) {
  205. return new Promise((resolve, reject) => {
  206. const params = {
  207. part: "id,contentDetails"
  208. };
  209. if (payload.id) params.id = payload.id;
  210. else params.forUsername = payload.username;
  211. YouTubeModule.runJob(
  212. "API_GET_CHANNELS",
  213. {
  214. params
  215. },
  216. this
  217. )
  218. .then(({ response }) => {
  219. const { data } = response;
  220. if (data.pageInfo.totalResults === 0) return reject(new Error("Channel not found."));
  221. const playlistId = data.items[0].contentDetails.relatedPlaylists.uploads;
  222. return resolve({ playlistId });
  223. })
  224. .catch(err => {
  225. YouTubeModule.log("ERROR", "GET_CHANNEL_UPLOADS_PLAYLIST_ID", `${err.message}`);
  226. if (err.message === "Request failed with status code 404") {
  227. return reject(new Error("Channel not found. Is the channel public/unlisted?"));
  228. }
  229. return reject(new Error("An error has occured. Please try again later."));
  230. });
  231. });
  232. }
  233. /**
  234. * Gets the id of the channel from the custom URL
  235. *
  236. * @param {object} payload - object that contains the payload
  237. * @param {string} payload.customUrl - the customUrl of the YouTube channel
  238. * @returns {Promise} - returns promise (reject, resolve)
  239. */
  240. GET_CHANNEL_ID_FROM_CUSTOM_URL(payload) {
  241. return new Promise((resolve, reject) => {
  242. async.waterfall(
  243. [
  244. next => {
  245. const params = {
  246. part: "snippet",
  247. type: "channel",
  248. maxResults: 50
  249. };
  250. params.q = payload.customUrl;
  251. YouTubeModule.runJob(
  252. "API_SEARCH",
  253. {
  254. params
  255. },
  256. this
  257. )
  258. .then(({ response }) => {
  259. const { data } = response;
  260. if (data.pageInfo.totalResults === 0) return next("Channel not found.");
  261. const channelIds = data.items.map(item => item.id.channelId);
  262. return next(null, channelIds);
  263. })
  264. .catch(err => {
  265. next(err);
  266. });
  267. },
  268. (channelIds, next) => {
  269. const params = {
  270. part: "snippet",
  271. id: channelIds.join(","),
  272. maxResults: 50
  273. };
  274. YouTubeModule.runJob(
  275. "API_GET_CHANNELS",
  276. {
  277. params
  278. },
  279. this
  280. )
  281. .then(({ response }) => {
  282. const { data } = response;
  283. if (data.pageInfo.totalResults === 0) return next("Channel not found.");
  284. let channelId = null;
  285. for (const item of data.items) {
  286. if (
  287. item.snippet.customUrl &&
  288. item.snippet.customUrl.toLowerCase() === payload.customUrl.toLowerCase()
  289. ) {
  290. channelId = item.id;
  291. break;
  292. }
  293. }
  294. if (!channelId) return next("Channel not found.");
  295. return next(null, channelId);
  296. })
  297. .catch(err => {
  298. next(err);
  299. });
  300. }
  301. ],
  302. (err, channelId) => {
  303. if (err) {
  304. YouTubeModule.log("ERROR", "GET_CHANNEL_ID_FROM_CUSTOM_URL", `${err.message}`);
  305. if (err.message === "Request failed with status code 404") {
  306. return reject(new Error("Channel not found. Is the channel public/unlisted?"));
  307. }
  308. return reject(new Error("An error has occured. Please try again later."));
  309. }
  310. return resolve({ channelId });
  311. }
  312. );
  313. });
  314. }
  315. /**
  316. * Returns an array of songs taken from a YouTube playlist
  317. *
  318. * @param {object} payload - object that contains the payload
  319. * @param {boolean} payload.musicOnly - whether to return music videos or all videos in the playlist
  320. * @param {string} payload.url - the url of the YouTube playlist
  321. * @returns {Promise} - returns promise (reject, resolve)
  322. */
  323. GET_PLAYLIST(payload) {
  324. return new Promise((resolve, reject) => {
  325. const regex = /[\\?&]list=([^&#]*)/;
  326. const splitQuery = regex.exec(payload.url);
  327. if (!splitQuery) {
  328. YouTubeModule.log("ERROR", "GET_PLAYLIST", "Invalid YouTube playlist URL query.");
  329. reject(new Error("Invalid playlist URL."));
  330. return;
  331. }
  332. const playlistId = splitQuery[1];
  333. async.waterfall(
  334. [
  335. next => {
  336. let songs = [];
  337. let nextPageToken = "";
  338. async.whilst(
  339. next => {
  340. YouTubeModule.log(
  341. "INFO",
  342. `Getting playlist progress for job (${this.toString()}): ${songs.length
  343. } songs gotten so far. Is there a next page: ${nextPageToken !== undefined}.`
  344. );
  345. next(null, nextPageToken !== undefined);
  346. },
  347. next => {
  348. // Add 250ms delay between each job request
  349. setTimeout(() => {
  350. YouTubeModule.runJob("GET_PLAYLIST_PAGE", { playlistId, nextPageToken }, this)
  351. .then(response => {
  352. songs = songs.concat(response.songs);
  353. nextPageToken = response.nextPageToken;
  354. next();
  355. })
  356. .catch(err => next(err));
  357. }, 250);
  358. },
  359. err => next(err, songs)
  360. );
  361. },
  362. (songs, next) =>
  363. next(
  364. null,
  365. songs.map(song => song.contentDetails.videoId)
  366. ),
  367. (songs, next) => {
  368. if (!payload.musicOnly) return next(true, { songs });
  369. return YouTubeModule.runJob("FILTER_MUSIC_VIDEOS", { videoIds: songs.slice() }, this)
  370. .then(filteredSongs => next(null, { filteredSongs, songs }))
  371. .catch(next);
  372. }
  373. ],
  374. (err, response) => {
  375. if (err && err !== true) {
  376. YouTubeModule.log("ERROR", "GET_PLAYLIST", "Some error has occurred.", err.message);
  377. reject(new Error(err.message));
  378. } else {
  379. resolve({ songs: response.filteredSongs ? response.filteredSongs.videoIds : response.songs });
  380. }
  381. }
  382. );
  383. });
  384. }
  385. /**
  386. * Returns a a page from a YouTube playlist. Is used internally by GET_PLAYLIST and GET_CHANNEL.
  387. *
  388. * @param {object} payload - object that contains the payload
  389. * @param {boolean} payload.playlistId - the playlist id to get videos from
  390. * @param {boolean} payload.nextPageToken - the nextPageToken to use
  391. * @param {string} payload.url - the url of the YouTube playlist
  392. * @returns {Promise} - returns promise (reject, resolve)
  393. */
  394. GET_PLAYLIST_PAGE(payload) {
  395. return new Promise((resolve, reject) => {
  396. const params = {
  397. part: "contentDetails",
  398. playlistId: payload.playlistId,
  399. maxResults: 50
  400. };
  401. if (payload.nextPageToken) params.pageToken = payload.nextPageToken;
  402. YouTubeModule.runJob(
  403. "API_GET_PLAYLIST_ITEMS",
  404. {
  405. params
  406. },
  407. this
  408. )
  409. .then(({ response }) => {
  410. const { data } = response;
  411. const songs = data.items;
  412. if (data.nextPageToken) return resolve({ nextPageToken: data.nextPageToken, songs });
  413. return resolve({ songs });
  414. })
  415. .catch(err => {
  416. YouTubeModule.log("ERROR", "GET_PLAYLIST_PAGE", `${err.message}`);
  417. if (err.message === "Request failed with status code 404") {
  418. return reject(new Error("Playlist not found. Is the playlist public/unlisted?"));
  419. }
  420. return reject(new Error("An error has occured. Please try again later."));
  421. });
  422. });
  423. }
  424. /**
  425. * Filters a list of YouTube videos so that they only contains videos with music. Is used internally by GET_PLAYLIST
  426. *
  427. * @param {object} payload - object that contains the payload
  428. * @param {Array} payload.videoIds - an array of YouTube videoIds to filter through
  429. * @param {Array} payload.page - the current page/set of video's to get, starting at 0. If left null, 0 is assumed. Will recurse.
  430. * @returns {Promise} - returns promise (reject, resolve)
  431. */
  432. FILTER_MUSIC_VIDEOS(payload) {
  433. return new Promise((resolve, reject) => {
  434. const page = payload.page ? payload.page : 0;
  435. const videosPerPage = 50;
  436. const localVideoIds = payload.videoIds.splice(page * 50, videosPerPage);
  437. if (localVideoIds.length === 0) {
  438. resolve({ videoIds: [] });
  439. return;
  440. }
  441. const params = {
  442. part: "topicDetails",
  443. id: localVideoIds.join(","),
  444. maxResults: videosPerPage
  445. };
  446. YouTubeModule.runJob("API_GET_VIDEOS", { params }, this)
  447. .then(({ response }) => {
  448. const { data } = response;
  449. const videoIds = [];
  450. data.items.forEach(item => {
  451. const videoId = item.id;
  452. if (!item.topicDetails) return;
  453. if (item.topicDetails.topicCategories.indexOf("https://en.wikipedia.org/wiki/Music") !== -1)
  454. videoIds.push(videoId);
  455. });
  456. return YouTubeModule.runJob(
  457. "FILTER_MUSIC_VIDEOS",
  458. { videoIds: payload.videoIds, page: page + 1 },
  459. this
  460. )
  461. .then(result => resolve({ videoIds: videoIds.concat(result.videoIds) }))
  462. .catch(err => reject(err));
  463. })
  464. .catch(err => {
  465. YouTubeModule.log("ERROR", "FILTER_MUSIC_VIDEOS", `${err.message}`);
  466. return reject(new Error("Failed to find playlist from YouTube"));
  467. });
  468. });
  469. }
  470. /**
  471. * Returns an array of songs taken from a YouTube channel
  472. *
  473. * @param {object} payload - object that contains the payload
  474. * @param {boolean} payload.musicOnly - whether to return music videos or all videos in the channel
  475. * @param {string} payload.url - the url of the YouTube channel
  476. * @returns {Promise} - returns promise (reject, resolve)
  477. */
  478. GET_CHANNEL(payload) {
  479. return new Promise((resolve, reject) => {
  480. const regex =
  481. /\.[\w]+\/(?:(?:channel\/(UC[0-9A-Za-z_-]{21}[AQgw]))|(?:user\/?([\w-]+))|(?:c\/?([\w-]+))|(?:\/?([\w-]+)))/;
  482. const splitQuery = regex.exec(payload.url);
  483. if (!splitQuery) {
  484. YouTubeModule.log("ERROR", "GET_CHANNEL", "Invalid YouTube channel URL query.");
  485. reject(new Error("Invalid playlist URL."));
  486. return;
  487. }
  488. const channelId = splitQuery[1];
  489. const channelUsername = splitQuery[2];
  490. const channelCustomUrl = splitQuery[3];
  491. const channelUsernameOrCustomUrl = splitQuery[4];
  492. console.log(`Channel id: ${channelId}`);
  493. console.log(`Channel username: ${channelUsername}`);
  494. console.log(`Channel custom URL: ${channelCustomUrl}`);
  495. console.log(`Channel username or custom URL: ${channelUsernameOrCustomUrl}`);
  496. async.waterfall(
  497. [
  498. next => {
  499. const payload = {};
  500. if (channelId) payload.id = channelId;
  501. else if (channelUsername) payload.username = channelUsername;
  502. else return next(null, true, null);
  503. return YouTubeModule.runJob("GET_CHANNEL_UPLOADS_PLAYLIST_ID", payload, this)
  504. .then(({ playlistId }) => {
  505. next(null, false, playlistId);
  506. })
  507. .catch(err => {
  508. if (err.message === "Channel not found. Is the channel public/unlisted?") next(null, true, null);
  509. else next(err);
  510. });
  511. },
  512. (getUsernameFromCustomUrl, playlistId, next) => {
  513. if (!getUsernameFromCustomUrl) return next(null, playlistId);
  514. const payload = {};
  515. if (channelCustomUrl) payload.customUrl = channelCustomUrl;
  516. else if (channelUsernameOrCustomUrl) payload.customUrl = channelUsernameOrCustomUrl;
  517. else next("No proper URL provided.");
  518. YouTubeModule.runJob("GET_CHANNEL_ID_FROM_CUSTOM_URL", payload, this)
  519. .then(({ channelId }) => {
  520. YouTubeModule.runJob("GET_CHANNEL_UPLOADS_PLAYLIST_ID", { id: channelId }, this)
  521. .then(({ playlistId }) => {
  522. next(null, playlistId);
  523. })
  524. .catch(err => next(err));
  525. })
  526. .catch(err => next(err));
  527. },
  528. (playlistId, next) => {
  529. let songs = [];
  530. let nextPageToken = "";
  531. async.whilst(
  532. next => {
  533. YouTubeModule.log(
  534. "INFO",
  535. `Getting channel progress for job (${this.toString()}): ${songs.length
  536. } songs gotten so far. Is there a next page: ${nextPageToken !== undefined}.`
  537. );
  538. next(null, nextPageToken !== undefined);
  539. },
  540. next => {
  541. // Add 250ms delay between each job request
  542. setTimeout(() => {
  543. YouTubeModule.runJob("GET_PLAYLIST_PAGE", { playlistId, nextPageToken }, this)
  544. .then(response => {
  545. songs = songs.concat(response.songs);
  546. nextPageToken = response.nextPageToken;
  547. next();
  548. })
  549. .catch(err => next(err));
  550. }, 250);
  551. },
  552. err => next(err, songs)
  553. );
  554. },
  555. (songs, next) =>
  556. next(
  557. null,
  558. songs.map(song => song.contentDetails.videoId)
  559. ),
  560. (songs, next) => {
  561. if (!payload.musicOnly) return next(true, { songs });
  562. return YouTubeModule.runJob("FILTER_MUSIC_VIDEOS", { videoIds: songs.slice() }, this)
  563. .then(filteredSongs => next(null, { filteredSongs, songs }))
  564. .catch(next);
  565. }
  566. ],
  567. (err, response) => {
  568. if (err && err !== true) {
  569. YouTubeModule.log("ERROR", "GET_CHANNEL", "Some error has occurred.", err.message);
  570. reject(new Error(err.message));
  571. } else {
  572. resolve({ songs: response.filteredSongs ? response.filteredSongs.videoIds : response.songs });
  573. }
  574. }
  575. );
  576. });
  577. }
  578. API_GET_VIDEOS(payload) {
  579. return new Promise((resolve, reject) => {
  580. const { params } = payload;
  581. YouTubeModule.runJob(
  582. "API_CALL",
  583. {
  584. url: "https://www.googleapis.com/youtube/v3/videos",
  585. params: {
  586. key: config.get("apis.youtube.key"),
  587. ...params
  588. },
  589. quotaCost: 1
  590. },
  591. this
  592. )
  593. .then(response => {
  594. resolve(response);
  595. })
  596. .catch(err => {
  597. reject(err);
  598. });
  599. });
  600. }
  601. API_GET_PLAYLIST_ITEMS(payload) {
  602. return new Promise((resolve, reject) => {
  603. const { params } = payload;
  604. YouTubeModule.runJob(
  605. "API_CALL",
  606. {
  607. url: "https://www.googleapis.com/youtube/v3/playlistItems",
  608. params: {
  609. key: config.get("apis.youtube.key"),
  610. ...params
  611. },
  612. quotaCost: 1
  613. },
  614. this
  615. )
  616. .then(response => {
  617. resolve(response);
  618. })
  619. .catch(err => {
  620. reject(err);
  621. });
  622. });
  623. }
  624. API_GET_CHANNELS(payload) {
  625. return new Promise((resolve, reject) => {
  626. const { params } = payload;
  627. YouTubeModule.runJob(
  628. "API_CALL",
  629. {
  630. url: "https://www.googleapis.com/youtube/v3/channels",
  631. params: {
  632. key: config.get("apis.youtube.key"),
  633. ...params
  634. },
  635. quotaCost: 1
  636. },
  637. this
  638. )
  639. .then(response => {
  640. resolve(response);
  641. })
  642. .catch(err => {
  643. reject(err);
  644. });
  645. });
  646. }
  647. API_SEARCH(payload) {
  648. return new Promise((resolve, reject) => {
  649. const { params } = payload;
  650. YouTubeModule.runJob(
  651. "API_CALL",
  652. {
  653. url: "https://www.googleapis.com/youtube/v3/search",
  654. params: {
  655. key: config.get("apis.youtube.key"),
  656. ...params
  657. },
  658. quotaCost: 100
  659. },
  660. this
  661. )
  662. .then(response => {
  663. resolve(response);
  664. })
  665. .catch(err => {
  666. reject(err);
  667. });
  668. });
  669. }
  670. API_CALL(payload) {
  671. return new Promise((resolve, reject) => {
  672. const { url, params, quotaCost } = payload;
  673. const quotaExceeded = isQuotaExceeded(YouTubeModule.apiCalls);
  674. if (quotaExceeded) reject(new Error("Quota has been exceeded. Please wait a while."));
  675. else {
  676. const youtubeApiRequest = new YouTubeModule.YoutubeApiRequestModel({
  677. url,
  678. date: Date.now(),
  679. quotaCost
  680. });
  681. youtubeApiRequest.save();
  682. const { key, ...keylessParams } = payload.params;
  683. CacheModule.runJob(
  684. "HSET",
  685. {
  686. table: "youtubeApiRequestParams",
  687. key: youtubeApiRequest._id.toString(),
  688. value: JSON.stringify(keylessParams)
  689. },
  690. this
  691. ).then();
  692. YouTubeModule.apiCalls.push({ date: youtubeApiRequest.date, quotaCost });
  693. YouTubeModule.axios
  694. .get(url, {
  695. params,
  696. timeout: YouTubeModule.requestTimeout
  697. })
  698. .then(response => {
  699. if (response.data.error) {
  700. reject(new Error(response.data.error));
  701. } else {
  702. CacheModule.runJob(
  703. "HSET",
  704. {
  705. table: "youtubeApiRequestResults",
  706. key: youtubeApiRequest._id.toString(),
  707. value: JSON.stringify(response.data)
  708. },
  709. this
  710. ).then();
  711. resolve({ response });
  712. }
  713. })
  714. .catch(err => {
  715. reject(err);
  716. });
  717. }
  718. });
  719. }
  720. GET_API_REQUESTS(payload) {
  721. return new Promise((resolve, reject) => {
  722. const fromDate = payload.fromDate ? new Date(payload.fromDate) : new Date();
  723. YouTubeModule.youtubeApiRequestModel
  724. .find({ date: { $lte: fromDate } })
  725. .sort({ date: -1 })
  726. .exec((err, youtubeApiRequests) => {
  727. if (err) reject(new Error("Couldn't load YouTube API requests."));
  728. else {
  729. resolve({ apiRequests: youtubeApiRequests });
  730. }
  731. });
  732. });
  733. }
  734. GET_API_REQUEST(payload) {
  735. return new Promise((resolve, reject) => {
  736. const { apiRequestId } = payload;
  737. async.waterfall(
  738. [
  739. next => {
  740. YouTubeModule.youtubeApiRequestModel
  741. .findOne({ _id: apiRequestId })
  742. .exec(next);
  743. },
  744. (apiRequest, next) => {
  745. CacheModule.runJob(
  746. "HGET",
  747. {
  748. table: "youtubeApiRequestParams",
  749. key: apiRequestId.toString()
  750. },
  751. this
  752. ).then(apiRequestParams => {
  753. next(null, {
  754. ...apiRequest._doc,
  755. params: apiRequestParams
  756. });
  757. }
  758. ).catch(err => next(err));
  759. },
  760. (apiRequest, next) => {
  761. CacheModule.runJob(
  762. "HGET",
  763. {
  764. table: "youtubeApiRequestResults",
  765. key: apiRequestId.toString()
  766. },
  767. this
  768. ).then(apiRequestResults => {
  769. next(null, {
  770. ...apiRequest,
  771. results: apiRequestResults
  772. });
  773. }).catch(err => next(err));
  774. }
  775. ],
  776. (err, apiRequest) => {
  777. if (err) reject(new Error(err));
  778. else resolve({ apiRequest });
  779. }
  780. );
  781. });
  782. }
  783. RESET_STORED_API_REQUESTS(payload) {
  784. return new Promise((resolve, reject) => {
  785. async.waterfall(
  786. [
  787. next => {
  788. YouTubeModule.youtubeApiRequestModel.deleteMany({}, err => {
  789. if (err) next("Couldn't reset stored YouTube API requests.");
  790. else {
  791. next();
  792. }
  793. });
  794. },
  795. next => {
  796. CacheModule.runJob(
  797. "DEL",
  798. {key: "youtubeApiRequestParams"},
  799. this
  800. ).then(next).catch(err => next(err));
  801. },
  802. next => {
  803. CacheModule.runJob(
  804. "DEL",
  805. {key: "youtubeApiRequestResults"},
  806. this
  807. ).then(next).catch(err => next(err));
  808. }
  809. ],
  810. err => {
  811. if (err) reject(new Error(err));
  812. else resolve();
  813. }
  814. );
  815. });
  816. }
  817. REMOVE_STORED_API_REQUEST(payload) {
  818. return new Promise((resolve, reject) => {
  819. async.waterfall(
  820. [
  821. next => {
  822. YouTubeModule.youtubeApiRequestModel.deleteOne({_id: payload.requestId}, err => {
  823. if (err) next("Couldn't remove stored YouTube API request.");
  824. else {
  825. next();
  826. }
  827. });
  828. },
  829. next => {
  830. CacheModule.runJob(
  831. "HDEL",
  832. {
  833. table: "youtubeApiRequestParams",
  834. key: payload.requestId.toString()
  835. },
  836. this
  837. ).then(next).catch(err => next(err));
  838. },
  839. next => {
  840. CacheModule.runJob(
  841. "HDEL",
  842. {
  843. table: "youtubeApiRequestResults",
  844. key: payload.requestId.toString()
  845. },
  846. this
  847. ).then(next).catch(err => next(err));
  848. }
  849. ],
  850. err => {
  851. if (err) reject(new Error(err));
  852. else resolve();
  853. }
  854. );
  855. });
  856. }
  857. /**
  858. * Create YouTube videos
  859. *
  860. * @param {object} payload - an object containing the payload
  861. * @param {string} payload.youtubeVideos - the youtubeVideo object or array of
  862. * @returns {Promise} - returns a promise (resolve, reject)
  863. */
  864. CREATE_VIDEOS(payload) {
  865. return new Promise((resolve, reject) => {
  866. async.waterfall(
  867. [
  868. next => {
  869. let youtubeVideos = payload.youtubeVideos;
  870. if (typeof youtubeVideos !== "object") next("Invalid youtubeVideos type");
  871. else {
  872. if (!Array.isArray(youtubeVideos)) youtubeVideos = [youtubeVideos];
  873. YouTubeModule.youtubeVideoModel.insertMany(youtubeVideos, next);
  874. }
  875. },
  876. (youtubeVideos, next) => {
  877. const youtubeIds = youtubeVideos.map(video => video.youtubeId);
  878. async.eachLimit(
  879. youtubeIds,
  880. 2,
  881. (youtubeId, next) => {
  882. RatingsModule.runJob("RECALCULATE_RATINGS", { youtubeId }, this)
  883. .then(() => next())
  884. .catch(next);
  885. },
  886. err => {
  887. if (err) next(err);
  888. else next(null, youtubeVideos);
  889. }
  890. );
  891. }
  892. ],
  893. (err, youtubeVideos) => {
  894. if (err) reject(new Error(err));
  895. else resolve({ youtubeVideos });
  896. }
  897. )
  898. });
  899. }
  900. /**
  901. * Get YouTube video
  902. *
  903. * @param {object} payload - an object containing the payload
  904. * @param {string} payload.identifier - the youtube video ObjectId or YouTube ID
  905. * @param {string} payload.createMissing - attempt to fetch and create video if not in db
  906. * @returns {Promise} - returns a promise (resolve, reject)
  907. */
  908. GET_VIDEO(payload) {
  909. return new Promise((resolve, reject) => {
  910. async.waterfall(
  911. [
  912. next => {
  913. const query = mongoose.Types.ObjectId.isValid(payload.identifier) ?
  914. { _id: payload.identifier } :
  915. { youtubeId: payload.identifier };
  916. return YouTubeModule.youtubeVideoModel.findOne(query, next);
  917. },
  918. (video, next) => {
  919. if (video) return next(null, video, false);
  920. if (mongoose.Types.ObjectId.isValid(payload.identifier) || !payload.createMissing) return next("YouTube video not found.");
  921. const params = {
  922. part: "snippet,contentDetails,statistics,status",
  923. id: payload.identifier
  924. };
  925. return YouTubeModule.runJob("API_GET_VIDEOS", { params }, this)
  926. .then(({ response }) => {
  927. const { data } = response;
  928. if (data.items[0] === undefined)
  929. return next("The specified video does not exist or cannot be publicly accessed.");
  930. // TODO Clean up duration converter
  931. let dur = data.items[0].contentDetails.duration;
  932. dur = dur.replace("PT", "");
  933. let duration = 0;
  934. dur = dur.replace(/([\d]*)H/, (v, v2) => {
  935. v2 = Number(v2);
  936. duration = v2 * 60 * 60;
  937. return "";
  938. });
  939. dur = dur.replace(/([\d]*)M/, (v, v2) => {
  940. v2 = Number(v2);
  941. duration += v2 * 60;
  942. return "";
  943. });
  944. // eslint-disable-next-line no-unused-vars
  945. dur = dur.replace(/([\d]*)S/, (v, v2) => {
  946. v2 = Number(v2);
  947. duration += v2;
  948. return "";
  949. });
  950. const youtubeVideo = {
  951. youtubeId: data.items[0].id,
  952. title: data.items[0].snippet.title,
  953. author: data.items[0].snippet.channelTitle,
  954. thumbnail: data.items[0].snippet.thumbnails.default.url,
  955. duration
  956. };
  957. return next(null, false, youtubeVideo);
  958. })
  959. .catch(next);
  960. },
  961. (video, youtubeVideo, next) => {
  962. if (video) return next(null, video, true);
  963. return YouTubeModule.runJob("CREATE_VIDEOS", { youtubeVideos: youtubeVideo }, this)
  964. .then(res => {
  965. if (res.youtubeVideos.length === 1) next(null, res.youtubeVideos[0], false)
  966. else next("YouTube video not found.")
  967. })
  968. .catch(next);
  969. }
  970. ],
  971. (err, video, existing) => {
  972. if (err) reject(new Error(err));
  973. else resolve({ video, existing });
  974. }
  975. )
  976. });
  977. }
  978. /**
  979. * Remove YouTube videos
  980. *
  981. * @param {object} payload - an object containing the payload
  982. * @param {string} payload.videoIds - Array of youtubeVideo ObjectIds
  983. * @returns {Promise} - returns a promise (resolve, reject)
  984. */
  985. REMOVE_VIDEOS(payload) {
  986. return new Promise((resolve, reject) => {
  987. let videoIds = payload.videoIds;
  988. if (!Array.isArray(videoIds)) videoIds = [videoIds];
  989. async.waterfall(
  990. [
  991. next => {
  992. if (!videoIds.every(videoId => mongoose.Types.ObjectId.isValid(videoId)))
  993. next("One or more videoIds are not a valid ObjectId.");
  994. else {
  995. YouTubeModule.youtubeVideoModel.find({_id: { $in: videoIds }}, next);
  996. }
  997. },
  998. (videos, next) => {
  999. const youtubeIds = videos.map(video => video.youtubeId);
  1000. RatingsModule.runJob("REMOVE_RATINGS", { youtubeIds }, this)
  1001. .then(() => next())
  1002. .catch(next);
  1003. },
  1004. next => {
  1005. YouTubeModule.youtubeVideoModel.deleteMany({_id: { $in: videoIds }}, next);
  1006. }
  1007. ],
  1008. err => {
  1009. if (err) reject(new Error(err));
  1010. else resolve();
  1011. }
  1012. )
  1013. });
  1014. }
  1015. /**
  1016. * Request a set of YouTube videos
  1017. *
  1018. * @param {object} payload - an object containing the payload
  1019. * @param {string} payload.url - the url of the the YouTube playlist or channel
  1020. * @param {boolean} payload.musicOnly - whether to only get music from the playlist/channel
  1021. * @param {boolean} payload.returnVideos - whether to return videos
  1022. * @returns {Promise} - returns a promise (resolve, reject)
  1023. */
  1024. REQUEST_SET(payload) {
  1025. return new Promise((resolve, reject) => {
  1026. async.waterfall(
  1027. [
  1028. next => {
  1029. const playlistRegex = /[\\?&]list=([^&#]*)/;
  1030. const channelRegex =
  1031. /\.[\w]+\/(?:(?:channel\/(UC[0-9A-Za-z_-]{21}[AQgw]))|(?:user\/?([\w-]+))|(?:c\/?([\w-]+))|(?:\/?([\w-]+)))/;
  1032. if (playlistRegex.exec(payload.url) || channelRegex.exec(payload.url))
  1033. YouTubeModule.runJob(
  1034. playlistRegex.exec(payload.url) ? "GET_PLAYLIST" : "GET_CHANNEL",
  1035. {
  1036. url: payload.url,
  1037. musicOnly: payload.musicOnly
  1038. },
  1039. this
  1040. )
  1041. .then(res => {
  1042. next(null, res.songs);
  1043. })
  1044. .catch(next);
  1045. else next("Invalid YouTube URL.");
  1046. },
  1047. (youtubeIds, next) => {
  1048. let successful = 0;
  1049. let videos = {};
  1050. let failed = 0;
  1051. let alreadyInDatabase = 0;
  1052. if (youtubeIds.length === 0) next();
  1053. async.eachOfLimit(
  1054. youtubeIds,
  1055. 1,
  1056. (youtubeId, index, next2) => {
  1057. YouTubeModule.runJob("GET_VIDEO", { identifier: youtubeId, createMissing: true }, this)
  1058. .then(res => {
  1059. successful += 1;
  1060. if (res.existing) alreadyInDatabase += 1;
  1061. if (res.video) videos[index] = res.video;
  1062. })
  1063. .catch(() => {
  1064. failed += 1;
  1065. })
  1066. .finally(() => {
  1067. next2();
  1068. });
  1069. },
  1070. () => {
  1071. if (payload.returnVideos)
  1072. videos = Object.keys(videos)
  1073. .sort()
  1074. .map(key => videos[key]);
  1075. next(null, { successful, failed, alreadyInDatabase, videos });
  1076. }
  1077. );
  1078. }
  1079. ],
  1080. (err, response) => {
  1081. if (err) reject(new Error(err));
  1082. else resolve(response);
  1083. }
  1084. )
  1085. });
  1086. }
  1087. }
  1088. export default new _YouTubeModule();