youtube.js 40 KB

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