youtube.js 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330
  1. import async from "async";
  2. import config from "config";
  3. import axios from "axios";
  4. import CoreClass from "../core";
  5. let YouTubeModule;
  6. class _YouTubeModule extends CoreClass {
  7. // eslint-disable-next-line require-jsdoc
  8. constructor() {
  9. super("youtube", {
  10. concurrency: 1,
  11. priorities: {
  12. GET_PLAYLIST: 11
  13. }
  14. });
  15. YouTubeModule = this;
  16. }
  17. /**
  18. * Initialises the activities module
  19. *
  20. * @returns {Promise} - returns promise (reject, resolve)
  21. */
  22. initialize() {
  23. return new Promise(resolve => {
  24. resolve();
  25. });
  26. }
  27. /**
  28. * Fetches a list of songs from Youtube's API
  29. *
  30. * @param {object} payload - object that contains the payload
  31. * @param {string} payload.query - the query we'll pass to youtubes api
  32. * @param {string} payload.pageToken - (optional) if this exists, will search search youtube for a specific page reference
  33. * @returns {Promise} - returns promise (reject, resolve)
  34. */
  35. SEARCH(payload) {
  36. const params = {
  37. part: "snippet",
  38. q: payload.query,
  39. key: config.get("apis.youtube.key"),
  40. type: "video",
  41. maxResults: 10
  42. };
  43. if (payload.pageToken) params.pageToken = payload.pageToken;
  44. return new Promise((resolve, reject) => {
  45. axios
  46. .get("https://www.googleapis.com/youtube/v3/search", { params })
  47. .then(res => {
  48. if (res.data.err) {
  49. YouTubeModule.log("ERROR", "SEARCH", `${res.data.error.message}`);
  50. return reject(new Error("An error has occured. Please try again later."));
  51. }
  52. return resolve(res.data);
  53. })
  54. .catch(err => {
  55. YouTubeModule.log("ERROR", "SEARCH", `${err.message}`);
  56. return reject(new Error("An error has occured. Please try again later."));
  57. });
  58. });
  59. }
  60. /**
  61. * Gets the details of a song using the YouTube API
  62. *
  63. * @param {object} payload - object that contains the payload
  64. * @param {string} payload.songId - the YouTube API id of the song
  65. * @returns {Promise} - returns promise (reject, resolve)
  66. */
  67. GET_SONG(payload) {
  68. return new Promise((resolve, reject) => {
  69. const params = {
  70. part: "snippet,contentDetails,statistics,status",
  71. id: payload.songId,
  72. key: config.get("apis.youtube.key")
  73. };
  74. if (payload.pageToken) params.pageToken = payload.pageToken;
  75. axios
  76. .get("https://www.googleapis.com/youtube/v3/videos", {
  77. params,
  78. timeout: 30000
  79. })
  80. .then(res => {
  81. if (res.data.error) {
  82. YouTubeModule.log("ERROR", "GET_SONG", `${res.data.error.message}`);
  83. return reject(new Error("An error has occured. Please try again later."));
  84. }
  85. if (res.data.items[0] === undefined)
  86. return reject(new Error("The specified video does not exist or cannot be publicly accessed."));
  87. // TODO Clean up duration converter
  88. let dur = res.data.items[0].contentDetails.duration;
  89. dur = dur.replace("PT", "");
  90. let duration = 0;
  91. dur = dur.replace(/([\d]*)H/, (v, v2) => {
  92. v2 = Number(v2);
  93. duration = v2 * 60 * 60;
  94. return "";
  95. });
  96. dur = dur.replace(/([\d]*)M/, (v, v2) => {
  97. v2 = Number(v2);
  98. duration += v2 * 60;
  99. return "";
  100. });
  101. // eslint-disable-next-line no-unused-vars
  102. dur = dur.replace(/([\d]*)S/, (v, v2) => {
  103. v2 = Number(v2);
  104. duration += v2;
  105. return "";
  106. });
  107. const song = {
  108. songId: res.data.items[0].id,
  109. title: res.data.items[0].snippet.title,
  110. thumbnail: res.data.items[0].snippet.thumbnails.default.url,
  111. duration
  112. };
  113. return resolve({ song });
  114. })
  115. .catch(err => {
  116. YouTubeModule.log("ERROR", "GET_SONG", `${err.message}`);
  117. return reject(new Error("An error has occured. Please try again later."));
  118. });
  119. });
  120. }
  121. /**
  122. * Returns an array of songs taken from a YouTube playlist
  123. *
  124. * @param {object} payload - object that contains the payload
  125. * @param {boolean} payload.musicOnly - whether to return music videos or all videos in the playlist
  126. * @param {string} payload.url - the url of the YouTube playlist
  127. * @returns {Promise} - returns promise (reject, resolve)
  128. */
  129. GET_PLAYLIST(payload) {
  130. return new Promise((resolve, reject) => {
  131. const name = "list".replace(/[\\[]/, "\\[").replace(/[\]]/, "\\]");
  132. const regex = new RegExp(`[\\?&]${name}=([^&#]*)`);
  133. const splitQuery = regex.exec(payload.url);
  134. if (!splitQuery) {
  135. YouTubeModule.log("ERROR", "GET_PLAYLIST", "Invalid YouTube playlist URL query.");
  136. return reject(new Error("Invalid playlist URL."));
  137. }
  138. const playlistId = splitQuery[1];
  139. return async.waterfall(
  140. [
  141. next => {
  142. let songs = [];
  143. let nextPageToken = "";
  144. async.whilst(
  145. next => {
  146. YouTubeModule.log(
  147. "INFO",
  148. `Getting playlist progress for job (${this.toString()}): ${
  149. songs.length
  150. } songs gotten so far. Is there a next page: ${nextPageToken !== undefined}.`
  151. );
  152. next(null, nextPageToken !== undefined);
  153. },
  154. next => {
  155. // Add 250ms delay between each job request
  156. setTimeout(() => {
  157. YouTubeModule.runJob("GET_PLAYLIST_PAGE", { playlistId, nextPageToken }, this)
  158. .then(response => {
  159. songs = songs.concat(response.songs);
  160. nextPageToken = response.nextPageToken;
  161. next();
  162. })
  163. .catch(err => next(err));
  164. }, 250);
  165. },
  166. err => next(err, songs)
  167. );
  168. },
  169. (songs, next) =>
  170. next(
  171. null,
  172. songs.map(song => song.contentDetails.videoId)
  173. ),
  174. (songs, next) => {
  175. if (!payload.musicOnly) return next(true, { songs });
  176. return YouTubeModule.runJob("FILTER_MUSIC_VIDEOS", { videoIds: songs.slice() }, this)
  177. .then(filteredSongs => next(null, { filteredSongs, songs }))
  178. .catch(next);
  179. }
  180. ],
  181. (err, response) => {
  182. if (err && err !== true) {
  183. YouTubeModule.log("ERROR", "GET_PLAYLIST", "Some error has occurred.", err.message);
  184. reject(new Error("Some error has occurred."));
  185. } else {
  186. resolve({ songs: response.filteredSongs ? response.filteredSongs.songIds : response.songs });
  187. }
  188. }
  189. );
  190. });
  191. }
  192. /**
  193. * Returns a a page from a YouTube playlist. Is used internally by GET_PLAYLIST.
  194. *
  195. * @param {object} payload - object that contains the payload
  196. * @param {boolean} payload.playlistId - the playlist id to get videos from
  197. * @param {boolean} payload.nextPageToken - the nextPageToken to use
  198. * @param {string} payload.url - the url of the YouTube playlist
  199. * @returns {Promise} - returns promise (reject, resolve)
  200. */
  201. GET_PLAYLIST_PAGE(payload) {
  202. return new Promise((resolve, reject) => {
  203. const params = {
  204. part: "contentDetails",
  205. playlistId: payload.playlistId,
  206. key: config.get("apis.youtube.key"),
  207. maxResults: 50
  208. };
  209. if (payload.nextPageToken) params.pageToken = payload.nextPageToken;
  210. axios
  211. .get("https://www.googleapis.com/youtube/v3/playlistItems", {
  212. params,
  213. timeout: 30000
  214. })
  215. .then(res => {
  216. if (res.data.err) {
  217. YouTubeModule.log("ERROR", "GET_PLAYLIST_PAGE", `${res.data.error.message}`);
  218. return reject(new Error("An error has occured. Please try again later."));
  219. }
  220. const songs = res.data.items;
  221. if (res.data.nextPageToken) return resolve({ nextPageToken: res.data.nextPageToken, songs });
  222. return resolve({ songs });
  223. })
  224. .catch(err => {
  225. YouTubeModule.log("ERROR", "GET_PLAYLIST_PAGE", `${err.message}`);
  226. return reject(new Error("An error has occured. Please try again later."));
  227. });
  228. });
  229. }
  230. /**
  231. * Filters a list of YouTube videos so that they only contains videos with music. Is used internally by GET_PLAYLIST
  232. *
  233. * @param {object} payload - object that contains the payload
  234. * @param {Array} payload.videoIds - an array of YouTube videoIds to filter through
  235. * @param {Array} payload.page - the current page/set of video's to get, starting at 0. If left null, 0 is assumed. Will recurse.
  236. * @returns {Promise} - returns promise (reject, resolve)
  237. */
  238. FILTER_MUSIC_VIDEOS(payload) {
  239. return new Promise((resolve, reject) => {
  240. const page = payload.page ? payload.page : 0;
  241. const videosPerPage = 50;
  242. const localVideoIds = payload.videoIds.splice(page * 50, videosPerPage);
  243. if (localVideoIds.length === 0) {
  244. return resolve({ videoIds: [] });
  245. }
  246. const params = {
  247. part: "topicDetails",
  248. id: localVideoIds.join(","),
  249. key: config.get("apis.youtube.key"),
  250. maxResults: videosPerPage
  251. };
  252. return axios
  253. .get("https://www.googleapis.com/youtube/v3/videos", {
  254. params,
  255. timeout: 30000
  256. })
  257. .then(res => {
  258. if (res.data.err) {
  259. YouTubeModule.log("ERROR", "FILTER_MUSIC_VIDEOS", `${res.data.error.message}`);
  260. return reject(new Error("An error has occured. Please try again later."));
  261. }
  262. const songIds = [];
  263. res.data.items.forEach(item => {
  264. const songId = item.id;
  265. if (!item.topicDetails) return;
  266. if (item.topicDetails.topicCategories.indexOf("https://en.wikipedia.org/wiki/Music") !== -1)
  267. songIds.push(songId);
  268. });
  269. return YouTubeModule.runJob(
  270. "FILTER_MUSIC_VIDEOS",
  271. { videoIds: payload.videoIds, page: page + 1 },
  272. this
  273. )
  274. .then(result => resolve({ songIds: songIds.concat(result.songIds) }))
  275. .catch(err => reject(err));
  276. })
  277. .catch(err => {
  278. YouTubeModule.log("ERROR", "FILTER_MUSIC_VIDEOS", `${err.message}`);
  279. return reject(new Error("Failed to find playlist from YouTube"));
  280. });
  281. });
  282. }
  283. }
  284. export default new _YouTubeModule();