|
@@ -0,0 +1,285 @@
|
|
|
+import async from "async";
|
|
|
+import config from "config";
|
|
|
+
|
|
|
+import request from "request";
|
|
|
+
|
|
|
+import CoreClass from "../core";
|
|
|
+
|
|
|
+let YouTubeModule;
|
|
|
+
|
|
|
+class _YouTubeModule extends CoreClass {
|
|
|
+ // eslint-disable-next-line require-jsdoc
|
|
|
+ constructor() {
|
|
|
+ super("youtube");
|
|
|
+
|
|
|
+ YouTubeModule = this;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Initialises the activities module
|
|
|
+ *
|
|
|
+ * @returns {Promise} - returns promise (reject, resolve)
|
|
|
+ */
|
|
|
+ initialize() {
|
|
|
+ return new Promise(resolve => {
|
|
|
+ resolve();
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Gets the details of a song using the YouTube API
|
|
|
+ *
|
|
|
+ * @param {object} payload - object that contains the payload
|
|
|
+ * @param {string} payload.songId - the YouTube API id of the song
|
|
|
+ * @returns {Promise} - returns promise (reject, resolve)
|
|
|
+ */
|
|
|
+ GET_SONG(payload) {
|
|
|
+ // songId, cb
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
+ const youtubeParams = [
|
|
|
+ "part=snippet,contentDetails,statistics,status",
|
|
|
+ `id=${encodeURIComponent(payload.songId)}`,
|
|
|
+ `key=${config.get("apis.youtube.key")}`
|
|
|
+ ].join("&");
|
|
|
+
|
|
|
+ request(`https://www.googleapis.com/youtube/v3/videos?${youtubeParams}`, (err, res, body) => {
|
|
|
+ if (err) {
|
|
|
+ YouTubeModule.log("ERROR", "GET_SONG", `${err.message}`);
|
|
|
+ return reject(new Error("An error has occured. Please try again later."));
|
|
|
+ }
|
|
|
+
|
|
|
+ body = JSON.parse(body);
|
|
|
+
|
|
|
+ if (body.error) {
|
|
|
+ YouTubeModule.log("ERROR", "GET_SONG", `${body.error.message}`);
|
|
|
+ return reject(new Error("An error has occured. Please try again later."));
|
|
|
+ }
|
|
|
+
|
|
|
+ if (body.items[0] === undefined)
|
|
|
+ return reject(new Error("The specified video does not exist or cannot be publicly accessed."));
|
|
|
+
|
|
|
+ // TODO Clean up duration converter
|
|
|
+ let dur = body.items[0].contentDetails.duration;
|
|
|
+
|
|
|
+ dur = dur.replace("PT", "");
|
|
|
+
|
|
|
+ let duration = 0;
|
|
|
+
|
|
|
+ dur = dur.replace(/([\d]*)H/, (v, v2) => {
|
|
|
+ v2 = Number(v2);
|
|
|
+ duration = v2 * 60 * 60;
|
|
|
+ return "";
|
|
|
+ });
|
|
|
+
|
|
|
+ dur = dur.replace(/([\d]*)M/, (v, v2) => {
|
|
|
+ v2 = Number(v2);
|
|
|
+ duration += v2 * 60;
|
|
|
+ return "";
|
|
|
+ });
|
|
|
+
|
|
|
+ // eslint-disable-next-line no-unused-vars
|
|
|
+ dur = dur.replace(/([\d]*)S/, (v, v2) => {
|
|
|
+ v2 = Number(v2);
|
|
|
+ duration += v2;
|
|
|
+ return "";
|
|
|
+ });
|
|
|
+
|
|
|
+ const song = {
|
|
|
+ songId: body.items[0].id,
|
|
|
+ title: body.items[0].snippet.title,
|
|
|
+ duration
|
|
|
+ };
|
|
|
+
|
|
|
+ return resolve({ song });
|
|
|
+ });
|
|
|
+ // songId: payload.songId
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Returns an array of songs taken from a YouTube playlist
|
|
|
+ *
|
|
|
+ * @param {object} payload - object that contains the payload
|
|
|
+ * @param {boolean} payload.musicOnly - whether to return music videos or all videos in the playlist
|
|
|
+ * @param {string} payload.url - the url of the YouTube playlist
|
|
|
+ * @returns {Promise} - returns promise (reject, resolve)
|
|
|
+ */
|
|
|
+ GET_PLAYLIST(payload) {
|
|
|
+ // payload includes: url, musicOnly
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
+ const name = "list".replace(/[\\[]/, "\\[").replace(/[\]]/, "\\]");
|
|
|
+
|
|
|
+ const regex = new RegExp(`[\\?&]${name}=([^&#]*)`);
|
|
|
+ const splitQuery = regex.exec(payload.url);
|
|
|
+
|
|
|
+ if (!splitQuery) {
|
|
|
+ YouTubeModule.log("ERROR", "GET_PLAYLIST", "Invalid YouTube playlist URL query.");
|
|
|
+ return reject(new Error("Invalid playlist URL."));
|
|
|
+ }
|
|
|
+ const playlistId = splitQuery[1];
|
|
|
+
|
|
|
+ return async.waterfall(
|
|
|
+ [
|
|
|
+ next => {
|
|
|
+ let songs = [];
|
|
|
+ let nextPageToken = "";
|
|
|
+
|
|
|
+ async.whilst(
|
|
|
+ next => {
|
|
|
+ YouTubeModule.log(
|
|
|
+ "INFO",
|
|
|
+ `Getting playlist progress for job (${this.toString()}): ${
|
|
|
+ songs.length
|
|
|
+ } songs gotten so far. Is there a next page: ${nextPageToken !== undefined}.`
|
|
|
+ );
|
|
|
+ next(null, nextPageToken !== undefined);
|
|
|
+ },
|
|
|
+ next => {
|
|
|
+ YouTubeModule.runJob("GET_PLAYLIST_PAGE", { playlistId, nextPageToken }, this)
|
|
|
+ // eslint-disable-next-line no-loop-func
|
|
|
+ .then(response => {
|
|
|
+ songs = songs.concat(response.songs);
|
|
|
+ nextPageToken = response.nextPageToken;
|
|
|
+ next();
|
|
|
+ })
|
|
|
+ // eslint-disable-next-line no-loop-func
|
|
|
+ .catch(err => {
|
|
|
+ next(err);
|
|
|
+ });
|
|
|
+ },
|
|
|
+ err => {
|
|
|
+ next(err, songs);
|
|
|
+ }
|
|
|
+ );
|
|
|
+ },
|
|
|
+
|
|
|
+ (songs, next) => {
|
|
|
+ if (!payload.musicOnly) return next(true, { songs });
|
|
|
+ return YouTubeModule.runJob(
|
|
|
+ "FILTER_MUSIC_VIDEOS_YOUTUBE",
|
|
|
+ {
|
|
|
+ videoIds: songs.slice()
|
|
|
+ },
|
|
|
+ this
|
|
|
+ )
|
|
|
+ .then(filteredSongs => {
|
|
|
+ next(null, { filteredSongs, songs });
|
|
|
+ })
|
|
|
+ .catch(next);
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ (err, response) => {
|
|
|
+ if (err && err !== true) {
|
|
|
+ YouTubeModule.log("ERROR", "GET_PLAYLIST", "Some error has occurred.", err.message);
|
|
|
+ reject(new Error("Some error has occurred."));
|
|
|
+ } else {
|
|
|
+ resolve(response);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ );
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Returns a a page from a YouTube playlist. Is used internally by GET_PLAYLIST.
|
|
|
+ *
|
|
|
+ * @param {object} payload - object that contains the payload
|
|
|
+ * @param {boolean} payload.playlistId - the playlist id to get videos from
|
|
|
+ * @param {boolean} payload.nextPageToken - the nextPageToken to use
|
|
|
+ * @param {string} payload.url - the url of the YouTube playlist
|
|
|
+ * @returns {Promise} - returns promise (reject, resolve)
|
|
|
+ */
|
|
|
+ GET_PLAYLIST_PAGE(payload) {
|
|
|
+ // payload includes: playlistId, nextPageToken
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
+ const nextPageToken = payload.nextPageToken ? `pageToken=${payload.nextPageToken}` : "";
|
|
|
+ const videosPerPage = 50;
|
|
|
+ const youtubeParams = [
|
|
|
+ "part=contentDetails",
|
|
|
+ `playlistId=${encodeURIComponent(payload.playlistId)}`,
|
|
|
+ `maxResults=${videosPerPage}`,
|
|
|
+ `key=${config.get("apis.youtube.key")}`,
|
|
|
+ nextPageToken
|
|
|
+ ].join("&");
|
|
|
+
|
|
|
+ request(`https://www.googleapis.com/youtube/v3/playlistItems?${youtubeParams}`, async (err, res, body) => {
|
|
|
+ if (err) {
|
|
|
+ YouTubeModule.log("ERROR", "GET_PLAYLIST_PAGE", `${err.message}`);
|
|
|
+ return reject(new Error("An error has occured. Please try again later."));
|
|
|
+ }
|
|
|
+
|
|
|
+ body = JSON.parse(body);
|
|
|
+
|
|
|
+ if (body.error) {
|
|
|
+ YouTubeModule.log("ERROR", "GET_PLAYLIST_PAGE", `${body.error.message}`);
|
|
|
+ return reject(new Error("An error has occured. Please try again later."));
|
|
|
+ }
|
|
|
+
|
|
|
+ const songs = body.items;
|
|
|
+
|
|
|
+ if (body.nextPageToken) return resolve({ nextPageToken: body.nextPageToken, songs });
|
|
|
+ return resolve({ songs });
|
|
|
+ });
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Filters a list of YouTube videos so that they only contains videos with music. Is used internally by GET_PLAYLIST
|
|
|
+ *
|
|
|
+ * @param {object} payload - object that contains the payload
|
|
|
+ * @param {Array} payload.videoIds - an array of YouTube videoIds to filter through
|
|
|
+ * @param {Array} payload.page - the current page/set of video's to get, starting at 0. If left null, 0 is assumed. Will recurse.
|
|
|
+ * @returns {Promise} - returns promise (reject, resolve)
|
|
|
+ */
|
|
|
+ FILTER_MUSIC_VIDEOS(payload) {
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
+ const page = payload.page ? payload.page : 0;
|
|
|
+ const videosPerPage = 50; // 50 is the max I believe
|
|
|
+ const localVideoIds = payload.videoIds.splice(page * 50, videosPerPage);
|
|
|
+
|
|
|
+ if (localVideoIds.length === 0) {
|
|
|
+ return resolve({ videoIds: [] });
|
|
|
+ }
|
|
|
+
|
|
|
+ const youtubeParams = [
|
|
|
+ "part=topicDetails",
|
|
|
+ `id=${encodeURIComponent(localVideoIds.join(","))}`,
|
|
|
+ `maxResults=${videosPerPage}`,
|
|
|
+ `key=${config.get("apis.youtube.key")}`
|
|
|
+ ].join("&");
|
|
|
+
|
|
|
+ return request(`https://www.googleapis.com/youtube/v3/videos?${youtubeParams}`, (err, res, body) => {
|
|
|
+ if (err) {
|
|
|
+ YouTubeModule.log("ERROR", "FILTER_MUSIC_VIDEOS", `${err.message}`);
|
|
|
+ return reject(new Error("Failed to find playlist from YouTube"));
|
|
|
+ }
|
|
|
+
|
|
|
+ body = JSON.parse(body);
|
|
|
+
|
|
|
+ if (body.error) {
|
|
|
+ YouTubeModule.log("ERROR", "FILTER_MUSIC_VIDEOS", `${body.error.message}`);
|
|
|
+ return reject(new Error("An error has occured. Please try again later."));
|
|
|
+ }
|
|
|
+
|
|
|
+ const songIds = [];
|
|
|
+ body.items.forEach(item => {
|
|
|
+ const songId = item.id;
|
|
|
+ if (!item.topicDetails) return;
|
|
|
+ if (item.topicDetails.relevantTopicIds.indexOf("/m/04rlf") !== -1) {
|
|
|
+ songIds.push(songId);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ return YouTubeModule.runJob("FILTER_MUSIC_VIDEOS", { videoIds: payload.videoIds, page: page + 1 })
|
|
|
+ .then(result => {
|
|
|
+ resolve({ songIds: songIds.concat(result.songIds) });
|
|
|
+ })
|
|
|
+ .catch(err => {
|
|
|
+ reject(err);
|
|
|
+ });
|
|
|
+ });
|
|
|
+ });
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+export default new _YouTubeModule();
|