From c6c6dc24bdcf9f8251a110d2d7e85d119496c9f4 Mon Sep 17 00:00:00 2001 From: LuanRT Date: Fri, 31 Dec 2021 03:15:59 -0300 Subject: [PATCH] feat: add support for music search --- lib/Actions.js | 165 ++++++++++++++++++++++++++++++++---------- lib/Constants.js | 105 +++++++-------------------- lib/Innertube.js | 182 +++++++++++++++++++++++----------------------- lib/Parser.js | 184 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 426 insertions(+), 210 deletions(-) create mode 100644 lib/Parser.js diff --git a/lib/Actions.js b/lib/Actions.js index ff0814d4..46cd5c2d 100644 --- a/lib/Actions.js +++ b/lib/Actions.js @@ -5,9 +5,17 @@ const Axios = require('axios'); const Utils = require('./Utils'); const Constants = require('./Constants'); +/** + * Performs direct interactions on YouTube. + * + * @param {object} session A valid Innertube session. + * @param {string} engagement_type Type of engagement. + * @param {object} args Engagement arguments. + * @returns {object} { success: boolean, status_code: number } | { success: boolean, status_code: number, message: string } + */ async function engage(session, engagement_type, args = {}) { if (!session.logged_in) throw new Error('You are not signed-in'); - + let data; switch (engagement_type) { case 'like/like': @@ -31,23 +39,30 @@ async function engage(session, engagement_type, args = {}) { data = { context: session.context, commentText: args.text, - createCommentParams: Utils.generateCommentParams(args.video_id) + createCommentParams: Utils.encodeCommentParams(args.video_id) }; break; default: } - const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/${engagement_type}${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`, - JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session, id: args.video_id, data })).catch((error) => error); - + const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/${engagement_type}${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`, + JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session, id: args.video_id, data })).catch((error) => error); + if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message }; - + return { success: true, status_code: response.status }; } +/** + * Accesses YouTube's various sections. + * + * @param {object} session A valid Innertube session. + * @param {string} action_type Type of action. + * @returns {object} { success: boolean, status_code: number, data: object } | { success: boolean, status_code: number, message: string } + */ async function browse(session, action_type) { if (!session.logged_in) throw new Error('You are not signed-in'); @@ -62,11 +77,11 @@ async function browse(session, action_type) { default: } - const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/browse${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`, - JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session })).catch((error) => error); - + const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/browse${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`, + JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session })).catch((error) => error); + if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message }; - + return { success: true, status_code: response.status, @@ -74,17 +89,46 @@ async function browse(session, action_type) { }; } -async function search(session, args = {}) { +/** + * Performs searches on YouTube. + * + * @param {object} session A valid Innertube session. + * @param {string} client YouTube client: YOUTUBE | YTMUSIC + * @param {object} args Search arguments. + * @returns {object} { success: boolean, status_code: number, data: object } | { success: boolean, status_code: number, message: string } + */ +async function search(session, client, args = {}) { if (!args.query) throw new Error('No query was provided'); - const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/search${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`, - JSON.stringify({ - context: session.context, - params: Utils.encodeFilter(args.options.period, args.options.duration, args.options.order), - query: args.query - }), Constants.INNERTUBE_REQOPTS({ session })).catch((error) => error); + let data; + switch (client) { + case 'YOUTUBE': + data = { + context: session.context, + params: Utils.encodeFilter(args.options.period, args.options.duration, args.options.order), + query: args.query + }; + break; + case 'YTMUSIC': + const yt_music_context = JSON.parse(JSON.stringify(session.context)); // deep copy the context obj so we don't accidentally change it + + yt_music_context.client.originalUrl = Constants.URLS.YT_MUSIC_URL; + yt_music_context.client.clientVersion = '1.20211213.00.00'; + yt_music_context.client.clientName = 'WEB_REMIX'; + + data = { + context: yt_music_context, + query: args.query + }; + break; + default: + break; + } + + const response = await Axios.post(`${client === 'YOUTUBE' && Constants.URLS.YT_BASE_URL || Constants.URLS.YT_MUSIC_URL}/youtubei/v1/search${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`, + JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session, ytmusic: client === 'YTMUSIC' })).catch((error) => error); if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message }; - + return { success: true, status_code: response.status, @@ -92,9 +136,18 @@ async function search(session, args = {}) { }; } + +/** + * Interacts with YouTube's notification system. + * + * @param {object} session A valid Innertube session. + * @param {string} action_type Type of action. + * @param {object} args Action arguments. + * @returns {object} { success: boolean, status_code: number, data: object } | { success: boolean, status_code: number, message: string } + */ async function notifications(session, action_type, args = {}) { if (!session.logged_in) throw new Error('You are not signed-in'); - + let data; switch (action_type) { @@ -119,11 +172,11 @@ async function notifications(session, action_type, args = {}) { default: } - const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/notification/${action_type}${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`, - JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session })).catch((error) => error); + const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/notification/${action_type}${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`, + JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session })).catch((error) => error); if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message }; if (action_type === 'modify_channel_preference') return { success: true, status_code: response.status }; - + return { success: true, status_code: response.status, @@ -131,13 +184,28 @@ async function notifications(session, action_type, args = {}) { }; } + +/** + * Interacts with YouTube's livechat system. + * + * @param {object} session A valid Innertube session. + * @param {string} action_type Type of action. + * @param {object} args Action arguments. + * @returns {object} { success: boolean, status_code: number, data: object } | { success: boolean, status_code: number, message: string } + */ async function livechat(session, action_type, args = {}) { let data; switch (action_type) { + case 'live_chat/get_live_chat': + data = { + context: session.context, + continuation: args.ctoken + }; + break; case 'live_chat/send_message': data = { context: session.context, - params: Utils.generateMessageParams(args.channel_id, args.video_id), + params: Utils.encodeMessageParams(args.channel_id, args.video_id), clientMessageId: `ytjs-${Uuid.v4()}`, richMessage: { textSegments: [{ text: args.text }] @@ -155,13 +223,20 @@ async function livechat(session, action_type, args = {}) { params: args.cmd_params }; break; + case 'updated_metadata': + data = { + context: session.context, + videoId: args.video_id + }; + args.continuation && (data.continuation = args.continuation); + break; default: } - const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/${action_type}${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`, - JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session, params: args.params })).catch((error) => error); + const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/${action_type}${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`, + JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session, params: args.params })).catch((error) => error); if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message }; - + return { success: true, status_code: response.status, @@ -169,23 +244,39 @@ async function livechat(session, action_type, args = {}) { }; } + +/** + * Gets detailed data for a video. + * + * @param {object} session A valid Innertube session. + * @param {object} args Request arguments. + * @returns {object} Video data. + */ async function getVideoInfo(session, args = {}) { let response; - + !args.is_desktop && (response = await Axios.get(`${Constants.URLS.YT_WATCH_PAGE}?v=${args.id}&t=8s&pbj=1`, Constants.INNERTUBE_REQOPTS({ session, id: args.id, desktop: false })).catch((error) => error)) || - (response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/player${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`, - JSON.stringify(Constants.VIDEO_INFO_REQBODY(args.id, session.sts, session.context)), Constants.INNERTUBE_REQOPTS({ session, id: args.id, desktop: true })).catch((error) => error)); + (response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/player${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`, + JSON.stringify(Constants.VIDEO_INFO_REQBODY(args.id, session.sts, session.context)), Constants.INNERTUBE_REQOPTS({ session, id: args.id, desktop: true })).catch((error) => error)); if (response instanceof Error) throw new Error(`Could not get video info: ${response.message}`); return response.data; } -async function getContinuation(session, info = {}) { - let data = { context: session.context }; - info.continuation_token && (data.continuation = info.continuation_token); - if (info.video_id) { - data.videoId = info.video_id; +/** + * Requests continuation for previously performed actions. + * + * @param {object} session A valid Innertube session. + * @param {object} args Continuation arguments. + * @returns {object} { success: boolean, status_code: number, data: object } | { success: boolean, status_code: number, message: string } + */ +async function getContinuation(session, args = {}) { + let data = { context: session.context }; + args.continuation_token && (data.continuation = args.continuation_token); + + if (args.video_id) { + data.videoId = args.video_id; data.racyCheckOk = true; data.contentCheckOk = false; data.autonavState = 'STATE_NONE'; @@ -196,10 +287,10 @@ async function getContinuation(session, info = {}) { data.captionsRequested = false; } - const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/next${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`, - JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session })).catch((error) => error); + const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/next${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`, + JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session })).catch((error) => error); if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message }; - + return { success: true, status_code: response.status, diff --git a/lib/Constants.js b/lib/Constants.js index 8328cf9a..4f79e3bb 100644 --- a/lib/Constants.js +++ b/lib/Constants.js @@ -5,6 +5,7 @@ const Utils = require('./Utils'); module.exports = { URLS: { YT_BASE_URL: 'https://www.youtube.com', + YT_MUSIC_URL: 'https://music.youtube.com', YT_MOBILE_URL: 'https://m.youtube.com', YT_WATCH_PAGE: 'https://m.youtube.com/watch' }, @@ -46,6 +47,9 @@ module.exports = { }, INNERTUBE_REQOPTS: (info) => { info.desktop === undefined && (info.desktop = true); + const origin = info.ytmusic && 'https://music.youtube.com' || + info.desktop && 'https://www.youtube.com' || 'https://m.youtube.com'; + let req_opts = { params: info.params || {}, headers: { @@ -58,8 +62,8 @@ module.exports = { 'x-youtube-client-name': info.desktop ? 1 : 2, 'x-youtube-client-version': info.session.context.client.clientVersion, 'x-youtube-chrome-connected': 'source=Chrome,mode=0,enable_account_consistency=true,supervised=false,consistency_enabled_by_default=false', - 'x-origin': info.desktop ? 'https://www.youtube.com' : 'https://m.youtube.com', - 'origin': info.desktop ? 'https://www.youtube.com' : 'https://m.youtube.com', + 'x-origin': origin, + 'origin': origin, } }; @@ -91,6 +95,19 @@ module.exports = { videoId: id }; }, + METADATA_KEYS: [ + 'embed', 'view_count', 'average_rating', + 'length_seconds', 'channel_id', 'channel_url', + 'external_channel_id', 'is_live_content', 'is_family_safe', + 'is_unlisted', 'is_private', 'has_ypc_metadata', + 'category', 'owner_channel_name', 'publish_date', + 'upload_date', 'keywords', 'available_countries', + 'owner_profile_url' + ], + BLACKLISTED_KEYS: [ + 'is_owner_viewing', 'is_unplugged_corpus', + 'is_crawlable', 'allow_ratings', 'author' + ], BASE64_DIALECT: { NORMAL: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'.split(''), REVERSE: '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_'.split('') @@ -109,88 +126,14 @@ module.exports = { TRANSLATE_1: 'function(d,e){for(var f', TRANSLATE_2: 'function(d,e,f){var h=f' }, - // Helper functions, felt like Utils.js wasn't the right place for them: + // Just a helper function, felt like Utils.js wasn't the right place for it: formatNTransformData: (data) => { return data .replace(/function\(d,e\)/g, '"function(d,e)').replace(/function\(d\)/g, '"function(d)') .replace(/function\(\)/g, '"function()').replace(/function\(d,e,f\)/g, '"function(d,e,f)') - .replace(/\[function\(d,e,f\)/g, '["function(d,e,f)') - .replace(/,b,/g, ',"b",').replace(/,b/g, ',"b"').replace(/b,/g, '"b",').replace(/b]/g, '"b"]') - .replace(/\[b/g, '["b"').replace(/}]/g, '"]').replace(/},/g, '}",').replace(/""/g, '') - .replace(/length]\)}"/g, 'length])}'); - }, - formatVideoData: (data, context, is_desktop) => { - let video_details = {}; - let metadata = {}; - - if (is_desktop) { - metadata.embed = data.microformat.playerMicroformatRenderer.embed; - metadata.view_count = parseInt(data.videoDetails.viewCount); - metadata.average_rating = data.videoDetails.averageRating; - metadata.length_seconds = data.microformat.playerMicroformatRenderer.lengthSeconds; - metadata.channel_id = data.videoDetails.channelId; - metadata.channel_url = data.microformat.playerMicroformatRenderer.ownerProfileUrl; - metadata.external_channel_id = data.microformat.playerMicroformatRenderer.externalChannelId; - metadata.is_live_content = data.videoDetails.isLiveContent; - metadata.is_family_safe = data.microformat.playerMicroformatRenderer.isFamilySafe; - metadata.is_unlisted = data.microformat.playerMicroformatRenderer.isUnlisted; - metadata.is_private = data.videoDetails.isPrivate; - metadata.has_ypc_metadata = data.microformat.playerMicroformatRenderer.hasYpcMetadata; - metadata.category = data.microformat.playerMicroformatRenderer.category; - metadata.channel_name = data.microformat.playerMicroformatRenderer.ownerChannelName; - metadata.publish_date = data.microformat.playerMicroformatRenderer.publishDate || 'N/A'; - metadata.upload_date = data.microformat.playerMicroformatRenderer.uploadDate || 'N/A'; - metadata.keywords = data.videoDetails.keywords || []; - metadata.available_qualities = [...new Set(data.streamingData.adaptiveFormats.filter(v => v.qualityLabel).map(v => v.qualityLabel).sort((a, b) => +a.replace(/\D/gi, '') - +b.replace(/\D/gi, '')))]; - - video_details.id = data.videoDetails.videoId; - video_details.title = data.videoDetails.title; - video_details.description = data.videoDetails.shortDescription; - video_details.thumbnail = data.videoDetails.thumbnail.thumbnails.slice(-1)[0]; - video_details.metadata = metadata; - } else { - const is_dislike_available = data[3].response.contents.singleColumnWatchNextResults.results.results.contents[1].slimVideoMetadataSectionRenderer.contents[1].slimVideoActionBarRenderer.buttons[1].slimMetadataToggleButtonRenderer.button.toggleButtonRenderer.defaultText.accessibility && true || false; - - metadata.embed = data[2].playerResponse.microformat.playerMicroformatRenderer.embed; - metadata.likes = parseInt(data[3].response.contents.singleColumnWatchNextResults.results.results.contents[1].slimVideoMetadataSectionRenderer.contents[1].slimVideoActionBarRenderer.buttons[0].slimMetadataToggleButtonRenderer.button.toggleButtonRenderer.defaultText.accessibility.accessibilityData.label.replace(/\D/g, '')); - metadata.dislikes = is_dislike_available && parseInt(data[3].response.contents.singleColumnWatchNextResults.results.results.contents[1].slimVideoMetadataSectionRenderer.contents[1].slimVideoActionBarRenderer.buttons[1].slimMetadataToggleButtonRenderer.button.toggleButtonRenderer.defaultText.accessibility.accessibilityData.label.replace(/\D/g, '')) || 0; - metadata.view_count = parseInt(data[2].playerResponse.videoDetails.viewCount); - metadata.average_rating = data[2].playerResponse.videoDetails.averageRating; - metadata.length_seconds = data[2].playerResponse.microformat.playerMicroformatRenderer.lengthSeconds; - metadata.channel_id = data[2].playerResponse.videoDetails.channelId; - metadata.channel_url = data[2].playerResponse.microformat.playerMicroformatRenderer.ownerProfileUrl; - metadata.external_channel_id = data[2].playerResponse.microformat.playerMicroformatRenderer.externalChannelId; - metadata.is_live_content = data[2].playerResponse.videoDetails.isLiveContent; - metadata.is_family_safe = data[2].playerResponse.microformat.playerMicroformatRenderer.isFamilySafe; - metadata.is_unlisted = data[2].playerResponse.microformat.playerMicroformatRenderer.isUnlisted; - metadata.is_private = data[2].playerResponse.videoDetails.isPrivate; - metadata.has_ypc_metadata = data[2].playerResponse.microformat.playerMicroformatRenderer.hasYpcMetadata; - metadata.category = data[2].playerResponse.microformat.playerMicroformatRenderer.category; - metadata.channel_name = data[2].playerResponse.microformat.playerMicroformatRenderer.ownerChannelName; - metadata.publish_date = data[2].playerResponse.microformat.playerMicroformatRenderer.publishDate; - metadata.upload_date = data[2].playerResponse.microformat.playerMicroformatRenderer.uploadDate; - metadata.keywords = data[2].playerResponse.videoDetails.keywords; - metadata.available_qualities = [...new Set(data[2].playerResponse.streamingData.adaptiveFormats.filter(v => v.qualityLabel).map(v => v.qualityLabel).sort((a, b) => +a.replace(/\D/gi, '') - +b.replace(/\D/gi, '')))]; - - video_details.id = data[2].playerResponse.videoDetails.videoId; - video_details.title = data[2].playerResponse.videoDetails.title; - video_details.description = data[2].playerResponse.videoDetails.shortDescription; - video_details.thumbnail = data[2].playerResponse.videoDetails.thumbnail.thumbnails.slice(-1)[0]; - - // Placeholders for functions - video_details.like = () => {}; - video_details.dislike = () => {}; - video_details.removeLike = () => {}; - video_details.subscribe = () => {}; - video_details.unsubscribe = () => {}; - video_details.comment = () => {}; - video_details.getComments = () => {}; - video_details.setNotificationPref = () => {}; - video_details.getLivechat = () => {}; - - // Additional metadata - video_details.metadata = metadata; - } - return video_details; + .replace(/\[function\(d,e,f\)/g, '["function(d,e,f)').replace(/,b,/g, ',"b",') + .replace(/,b/g, ',"b"').replace(/b,/g, '"b",').replace(/b]/g, '"b"]') + .replace(/\[b/g, '["b"').replace(/}]/g, '"]').replace(/},/g, '}",') + .replace(/""/g, '').replace(/length]\)}"/g, 'length])}'); } }; \ No newline at end of file diff --git a/lib/Innertube.js b/lib/Innertube.js index b031df55..3b5c58d3 100644 --- a/lib/Innertube.js +++ b/lib/Innertube.js @@ -5,6 +5,7 @@ const Stream = require('stream'); const OAuth = require('./OAuth'); const Utils = require('./Utils'); const Player = require('./Player'); +const Parser = require('./Parser'); const NToken = require('./NToken'); const Actions = require('./Actions'); const Livechat = require('./Livechat'); @@ -18,26 +19,28 @@ class Innertube { constructor(cookie) { this.cookie = cookie || ''; this.retry_count = 0; - return this.init(); + return this.#init(); } - async init() { + async #init() { const response = await Axios.get(Constants.URLS.YT_BASE_URL, Constants.DEFAULT_HEADERS(this)).catch((error) => error); - if (response instanceof Error) throw new Error(`Could not extract Innertube data: ${response.message}`); + if (response instanceof Error) throw new Error(`Could not retrieve Innertube session: ${response.message}`); try { - const innertube_data = JSON.parse(`{${Utils.getStringBetweenStrings(response.data, 'ytcfg.set({', '});')}}`); - if (innertube_data.INNERTUBE_CONTEXT) { - this.context = innertube_data.INNERTUBE_CONTEXT; - this.key = innertube_data.INNERTUBE_API_KEY; - this.id_token = innertube_data.ID_TOKEN; - this.session_token = innertube_data.XSRF_TOKEN; - this.player_url = innertube_data.PLAYER_JS_URL; - this.logged_in = innertube_data.LOGGED_IN; - this.sts = innertube_data.STS; + const data = JSON.parse(`{${Utils.getStringBetweenStrings(response.data, 'ytcfg.set({', '});')}}`); + if (data.INNERTUBE_CONTEXT) { + this.context = data.INNERTUBE_CONTEXT; + this.key = data.INNERTUBE_API_KEY; + this.id_token = data.ID_TOKEN; + this.session_token = data.XSRF_TOKEN; + this.player_url = data.PLAYER_JS_URL; + this.logged_in = data.LOGGED_IN; + this.sts = data.STS; this.context.client.hl = 'en'; this.context.client.gl = 'US'; + this.ev = new EventEmitter(); + this.player = new Player(this); await this.player.init(); @@ -45,36 +48,32 @@ class Innertube { this.auth_apisid = Utils.getStringBetweenStrings(this.cookie, 'PAPISID=', ';'); this.auth_apisid = Utils.generateSidAuth(this.auth_apisid); } - - this.ev = new EventEmitter(); } else { - this.retry_count += 1; - if (this.retry_count >= 10) throw new Error('Could not retrieve Innertube data'); - return this.init(); + throw new Error('Could not retrieve Innertube session due to unknown reasons'); } } catch (err) { this.retry_count += 1; - if (this.retry_count >= 10) throw new Error('Could not retrieve Innertube data'); - return this.init(); + if (this.retry_count >= 10) throw new Error(`Could not retrieve Innertube session: ${err.message}`); + return this.#init(); } return this; } + /** + * Signs-in to a google account. + * + * @param {object} auth_info { refresh_token: string, access_token: string, expires: string } + * @returns {Promise} + */ signIn(auth_info = {}) { return new Promise(async (resolve, reject) => { const oauth = new OAuth(auth_info); if (auth_info.access_token) { - const is_valid = await oauth.isTokenValid(auth_info.expires); - - if (!is_valid) { - const new_tokens = await oauth.refreshAccessToken(auth_info.refresh_token); + if (!oauth.isTokenValid()) { + const new_tokens = await oauth.refreshAccessToken(); auth_info.refresh_token = new_tokens.credentials.refresh_token; auth_info.access_token = new_tokens.credentials.access_token; - - this.ev.emit('update-credentials', { - credentials: new_tokens.credentials, - status: new_tokens.status - }); + this.ev.emit('update-credentials', { credentials: new_tokens.credentials, status: new_tokens.status }); } this.access_token = auth_info.access_token; @@ -85,15 +84,10 @@ class Innertube { } else { oauth.on('auth', (data) => { if (data.status === 'SUCCESS') { + this.ev.emit('auth', { credentials: data.credentials, status: data.status }); this.access_token = data.credentials.access_token; this.refresh_token = data.credentials.refresh_token; this.logged_in = true; - - this.ev.emit('auth', { - credentials: data.credentials, - status: data.status - }); - resolve(); } else { this.ev.emit('auth', data); @@ -103,74 +97,63 @@ class Innertube { }); } - async search(query, options = { period: 'any', order: 'relevance', duration: 'any' }) { - const response = await Actions.search(this, { query, options }); + /** + * Searches on YouTube. + * + * @param {string} query Search query. + * @param {object} options { client: YOUTUBE | YTMUSIC, period: any | hour | day | week | month | year , order: relevance | rating | age | views, duration: any | short | long } + * @returns {Promise} Search results. + */ + async search(query, options = { client: 'YOUTUBE', period: 'any', order: 'relevance', duration: 'any' }) { + const response = await Actions.search(this, options.client, { query, options }); if (!response.success) throw new Error(`Could not search on YouTube: ${response.message}`); - const content = response.data.contents.twoColumnSearchResultsRenderer.primaryContents.sectionListRenderer.contents[0].itemSectionRenderer.contents; - const search = {}; + const refined_data = new Parser(response.data, { + client: options.client, + data_type: 'SEARCH', + query + }).parse(); - search.search_metadata = {}; - search.search_metadata.query = content[0].showingResultsForRenderer ? content[0].showingResultsForRenderer.originalQuery.simpleText : query; - search.search_metadata.corrected_query = content[0].showingResultsForRenderer ? content[0].showingResultsForRenderer.correctedQueryEndpoint.searchEndpoint.query : query; - search.search_metadata.estimated_results = parseInt(response.data.estimatedResults); - search.videos = content.map((data) => { - if (!data.videoRenderer) return; - const video = data.videoRenderer; - return { - title: video.title.runs[0].text, - description: video.detailedMetadataSnippets && video.detailedMetadataSnippets[0].snippetText.runs.map((item) => item.text).join('') || 'N/A', - author: video.ownerText.runs[0].text, - id: video.videoId, - url: `https://youtu.be/${video.videoId}`, - channel_url: `${Constants.URLS.YT_BASE_URL}${video.ownerText.runs[0].navigationEndpoint.commandMetadata.webCommandMetadata.url}`, - metadata: { - view_count: video.viewCountText && video.viewCountText.simpleText || 'N/A', - short_view_count_text: { - simple_text: video.shortViewCountText && video.shortViewCountText.simpleText || 'N/A', - accessibility_label: video.shortViewCountText && (video.shortViewCountText.accessibility && video.shortViewCountText.accessibility.accessibilityData.label || 'N/A') || 'N/A', - }, - thumbnails: video.thumbnail.thumbnails, - duration: { - seconds: TimeToSeconds(video.lengthText && video.lengthText.simpleText || '0'), - simple_text: video.lengthText && video.lengthText.simpleText || 'N/A', - accessibility_label: video.lengthText && video.lengthText.accessibility.accessibilityData.label || 'N/A' - }, - published: video.publishedTimeText && video.publishedTimeText.simpleText || 'N/A', - badges: video.badges && video.badges.map((item) => item.metadataBadgeRenderer.label) || 'N/A', - owner_badges: video.ownerBadges && video.ownerBadges.map((item) => item.metadataBadgeRenderer.tooltip) || 'N/A ' - } - }; - }).filter((video_block) => video_block !== undefined); - return search; + return refined_data; } + /** + * Gets details for a video. + * + * @param {string} id The id of the video. + */ async getDetails(id) { if (!id) throw new Error('You must provide a video id'); const data = await Actions.getVideoInfo(this, { id, is_desktop: false }); - const video_data = Constants.formatVideoData(data, this, false); + const refined_data = new Parser(data, { client: 'YOUTUBE', data_type: 'VIDEO_INFO', desktop_v: false }).parse(); - if (video_data.metadata.is_live_content) { + if (refined_data.metadata.is_live_content) { const data_continuation = await Actions.getContinuation(this, { video_id: id }); if (!data_continuation.data.contents.twoColumnWatchNextResults.conversationBar) return; - video_data.getLivechat = () => new Livechat(this, data_continuation.data.contents.twoColumnWatchNextResults.conversationBar.liveChatRenderer.continuations[0].reloadContinuationData.continuation, video_data.metadata.channel_id, id); + refined_data.getLivechat = () => new Livechat(this, data_continuation.data.contents.twoColumnWatchNextResults.conversationBar.liveChatRenderer.continuations[0].reloadContinuationData.continuation, refined_data.metadata.channel_id, id); } else { - video_data.getLivechat = () => {}; + refined_data.getLivechat = () => { }; } - video_data.like = () => Actions.engage(this, 'like/like', { video_id: id }); - video_data.dislike = () => Actions.engage(this, 'like/dislike', { video_id: id }); - video_data.removeLike = () => Actions.engage(this, 'like/removelike', { video_id: id }); - video_data.subscribe = () => Actions.engage(this, 'subscription/subscribe', { video_id: id, channel_id: video_data.metadata.channel_id }); - video_data.unsubscribe = () => Actions.engage(this, 'subscription/unsubscribe', { video_id: id, channel_id: video_data.metadata.channel_id }); - video_data.comment = text => Actions.engage(this, 'comment/create_comment', { video_id: id, text }); - video_data.getComments = () => this.getComments(id); - video_data.setNotificationPref = pref => Actions.notifications(this, 'modify_channel_preference', { channel_id: video_data.metadata.channel_id, pref: pref || 'NONE' }); + refined_data.like = () => Actions.engage(this, 'like/like', { video_id: id }); + refined_data.dislike = () => Actions.engage(this, 'like/dislike', { video_id: id }); + refined_data.removeLike = () => Actions.engage(this, 'like/removelike', { video_id: id }); + refined_data.subscribe = () => Actions.engage(this, 'subscription/subscribe', { video_id: id, channel_id: refined_data.metadata.channel_id }); + refined_data.unsubscribe = () => Actions.engage(this, 'subscription/unsubscribe', { video_id: id, channel_id: refined_data.metadata.channel_id }); + refined_data.comment = text => Actions.engage(this, 'comment/create_comment', { video_id: id, text }); + refined_data.getComments = () => this.getComments(id); + refined_data.setNotificationPref = pref => Actions.notifications(this, 'modify_channel_preference', { channel_id: refined_data.metadata.channel_id, pref: pref || 'NONE' }); - return video_data; + return refined_data; } + /** + * Gets the comments section of a video. + * + * @param {string} video_id The id of the video. + * @param {string} token Continuation token (optional). + */ async getComments(video_id, token) { let comment_section_token; @@ -220,6 +203,10 @@ class Innertube { return comments_section; } + /** + * Returns your subscription feed. + * @returns {Promise} subs feed. + */ async getSubscriptionsFeed() { const response = await Actions.browse(this, 'subscriptions_feed'); if (!response.success) throw new Error('Could not fetch subscriptions feed'); @@ -232,9 +219,7 @@ class Innertube { const section_contents = section.itemSectionRenderer.contents[0]; const section_items = section_contents.shelfRenderer.content.gridRenderer.items; - const key = section_contents.shelfRenderer.title.runs[0].text; - subscriptions_feed[key.toLowerCase().replace(/ +/g, '_')] = []; section_items.forEach((item) => { const content = { @@ -257,6 +242,10 @@ class Innertube { return subscriptions_feed; } + /** + * Returns your notifications. + * @returns {Promise} notifications. + */ async getNotifications() { const response = await Actions.notifications(this, 'get_notification_menu'); if (!response.success) throw new Error('Could not fetch notifications'); @@ -279,12 +268,22 @@ class Innertube { }).filter((notification_block) => notification_block); } + /** + * Returns the amount of notifications you haven't seen. + * @returns {Promise} unseen notifications count. + */ async getUnseenNotificationsCount() { const response = await Actions.notifications(this, 'get_unseen_count'); - if (!response.success) throw new Error('Could not fetch unseen notifications count'); + if (!response.success) throw new Error('Could not get unseen notifications count'); return response.data.unseenCount; } + /** + * Downloads a video from YouTube. + * + * @param {string} id The id of the video. + * @param {object} options Download options: { quality?: string, type?: string, format?: string } + */ download(id, options = {}) { if (!id) throw new Error('Missing video id'); @@ -326,8 +325,6 @@ class Innertube { formats.hls_manifest_url = video_data.streamingData.hlsManifestUrl || undefined; formats.dash_manifest_url = video_data.streamingData.dashManifestUrl || undefined; - const video_details = Constants.formatVideoData(video_data, this, true); - let url; let bitrates; let filtered_streams; @@ -363,7 +360,8 @@ class Innertube { if (!selected_format) { return stream.emit('error', { message: 'Could not find any suitable format.', type: 'FORMAT_UNAVAILABLE' }); } else { - stream.emit('info', { video_details, selected_format, formats }); + const refined_data = new Parser(video_data, { client: 'YOUTUBE', data_type: 'VIDEO_INFO', desktop_v: true }).parse(); + stream.emit('info', { video_details: refined_data, selected_format, formats }); } if (options.type == 'videoandaudio' && !options.range) { @@ -390,8 +388,8 @@ class Innertube { }); response.data.on('error', (err) => { - cancelled && stream.emit('error', { message: 'The download was cancelled.', type: 'DOWNLOAD_CANCELLED' }) || - stream.emit('error', { message: err.message, type: 'DOWNLOAD_ABORTED' }); + cancelled && stream.emit('error', { message: 'The download was cancelled.', type: 'DOWNLOAD_CANCELLED' }) + || stream.emit('error', { message: err.message, type: 'DOWNLOAD_ABORTED' }); }); response.data.pipe(stream, { end: true }); diff --git a/lib/Parser.js b/lib/Parser.js new file mode 100644 index 00000000..87eeac05 --- /dev/null +++ b/lib/Parser.js @@ -0,0 +1,184 @@ +'use strict'; + +const Utils = require('./Utils') +const Constants = require('./Constants'); +const TimeToSeconds = require('time-to-seconds'); + +/** + * Takes raw data from the Innertube API and refines it. + */ +class Parser { + constructor(data, args = {}) { + this.data = data; + this.args = args; + } + + parse() { + return this.args.client === 'YOUTUBE' ? ({ + SEARCH: () => this.#parseVideoSearch(), + VIDEO_INFO: () => this.#parseVideoInfo() + })[this.args.data_type]() : ({ + SEARCH: () => this.#parseMusicSearch(), + SONG_INFO: () => { } + })[this.args.data_type](); + } + + #parseVideoSearch() { + const response = {}; + + const contents = this.data.contents.twoColumnSearchResultsRenderer + .primaryContents.sectionListRenderer.contents[0].itemSectionRenderer + .contents; + + const continuation_token = this.data.contents.twoColumnSearchResultsRenderer + .primaryContents.sectionListRenderer.contents[1].continuationItemRenderer + .continuationEndpoint.continuationCommand.token; + + response.search_metadata = {}; + response.search_metadata.query = contents[0].showingResultsForRenderer && contents[0].showingResultsForRenderer.originalQuery.simpleText || this.args.query; + response.search_metadata.corrected_query = contents[0].showingResultsForRenderer && contents[0].showingResultsForRenderer.correctedQueryEndpoint.searchEndpoint.query || this.args.query; + response.search_metadata.estimated_results = parseInt(this.data.estimatedResults); + + response.videos = contents.map((data) => { + if (!data.videoRenderer) return; + const video = data.videoRenderer; + return { + title: video.title.runs[0].text, + description: video.detailedMetadataSnippets && video.detailedMetadataSnippets[0].snippetText.runs.map((item) => item.text).join('') || 'N/A', + author: video.ownerText.runs[0].text, + id: video.videoId, + url: `https://youtu.be/${video.videoId}`, + channel_url: `${Constants.URLS.YT_BASE_URL}${video.ownerText.runs[0].navigationEndpoint.commandMetadata.webCommandMetadata.url}`, + metadata: { + view_count: video.viewCountText && video.viewCountText.simpleText || 'N/A', + short_view_count_text: { + simple_text: video.shortViewCountText && video.shortViewCountText.simpleText || 'N/A', + accessibility_label: video.shortViewCountText && (video.shortViewCountText.accessibility && video.shortViewCountText.accessibility.accessibilityData.label || 'N/A') || 'N/A', + }, + thumbnails: video.thumbnail.thumbnails, + duration: { + seconds: TimeToSeconds(video.lengthText && video.lengthText.simpleText || '0'), + simple_text: video.lengthText && video.lengthText.simpleText || 'N/A', + accessibility_label: video.lengthText && video.lengthText.accessibility.accessibilityData.label || 'N/A' + }, + published: video.publishedTimeText && video.publishedTimeText.simpleText || 'N/A', + badges: video.badges && video.badges.map((item) => item.metadataBadgeRenderer.label) || 'N/A', + owner_badges: video.ownerBadges && video.ownerBadges.map((item) => item.metadataBadgeRenderer.tooltip) || 'N/A ' + } + }; + }); + + return response; + } + + #parseMusicSearch() { + const tabs = this.data.contents.tabbedSearchResultsRenderer.tabs; + const contents = tabs[0].tabRenderer.content.sectionListRenderer.contents; + + const songs_ms = contents.find((content) => content.musicShelfRenderer.title.runs[0].text == 'Songs'); + const songs = songs_ms.musicShelfRenderer.contents.map((item) => { + const list_item = item.musicResponsiveListItemRenderer; + return { + id: list_item.playlistItemData.videoId, + title: list_item.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text, + artist: list_item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[2].text, + album: list_item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[4].text, + duration: list_item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[6].text, + thumbnail: list_item.thumbnail.musicThumbnailRenderer.thumbnail + }; + }); + + const videos_ms = contents.find((content) => content.musicShelfRenderer.title.runs[0].text == 'Videos'); + const videos = videos_ms.musicShelfRenderer.contents.map((item) => { + const list_item = item.musicResponsiveListItemRenderer; + return { + id: list_item.playlistItemData.videoId, + title: list_item.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text, + author: list_item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[2].text, + views: list_item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[4].text, + duration: list_item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[6].text, + thumbnail: list_item.thumbnail.musicThumbnailRenderer.thumbnail + }; + }); + + const albums_ms = contents.find((content) => content.musicShelfRenderer.title.runs[0].text == 'Albums'); + const albums = albums_ms.musicShelfRenderer.contents.map((item) => { + const list_item = item.musicResponsiveListItemRenderer; + return { + title: list_item.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text, + author: list_item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[2].text, + year: list_item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs.find((run) => /^[12][0-9]{3}$/.test(run.text)).text, + thumbnail: list_item.thumbnail.musicThumbnailRenderer.thumbnail + }; + }); + + return { songs, videos, albums }; + } + + #parseVideoInfo() { + const desktop_v = this.args.desktop_v; + + const details = desktop_v && this.data.videoDetails || + this.data[2].playerResponse.videoDetails; + + const microformat = desktop_v && this.data.microformat.playerMicroformatRenderer || + this.data[2].playerResponse.microformat.playerMicroformatRenderer; + + const streaming_data = desktop_v && this.data.streamingData || + this.data[2].playerResponse.streamingData; + + const response = { metadata: {} }; + const mf_raw_data = Object.entries(microformat); + const dt_raw_data = Object.entries(details); + + mf_raw_data.forEach((entry) => { + const key = Utils.camelToSnake(entry[0]); + if (Constants.METADATA_KEYS.includes(key)) { + key == 'view_count' && (response.metadata[key] = parseInt(entry[1])) || + key == 'owner_profile_url' && (response.metadata.channel_url = entry[1]) || + key == 'owner_channel_name' && (response.metadata.channel_name = entry[1]) || + (response.metadata[key] = entry[1]); + } else { + response[key] = entry[1]; + } + }); + + dt_raw_data.forEach((entry) => { + const key = Utils.camelToSnake(entry[0]); + if (Constants.BLACKLISTED_KEYS.includes(key)) return; + if (Constants.METADATA_KEYS.includes(key)) { + key == 'view_count' && (response.metadata[key] = parseInt(entry[1])) || + (response.metadata[key] = entry[1]); + } else { + response[key] = entry[1]; + key == 'short_description' && (response.description = entry[1]); + key == 'thumbnail' && (response.thumbnail = entry[1].thumbnails.slice(-1)[0]); + key == 'video_id' && (response.id = entry[1]); + } + }); + + if (!desktop_v) { + const dislike_available = this.data[3].response.contents.singleColumnWatchNextResults.results.results.contents[1] + .slimVideoMetadataSectionRenderer.contents[1].slimVideoActionBarRenderer.buttons[1].slimMetadataToggleButtonRenderer + .button.toggleButtonRenderer.defaultText.accessibility && true || false; + + response.metadata.likes = parseInt(this.data[3].response.contents.singleColumnWatchNextResults.results.results.contents[1] + .slimVideoMetadataSectionRenderer.contents[1].slimVideoActionBarRenderer.buttons[0].slimMetadataToggleButtonRenderer + .button.toggleButtonRenderer.defaultText.accessibility.accessibilityData.label.replace(/\D/g, '')); + + response.metadata.dislikes = dislike_available && parseInt(this.data[3].response.contents.singleColumnWatchNextResults.results.results.contents[1] + .slimVideoMetadataSectionRenderer.contents[1].slimVideoActionBarRenderer.buttons[1].slimMetadataToggleButtonRenderer + .button.toggleButtonRenderer.defaultText.accessibility.accessibilityData.label.replace(/\D/g, '')) || 0; + } + + response.metadata.available_qualities = [...new Set(streaming_data.adaptiveFormats.filter(v => v.qualityLabel) + .map(v => v.qualityLabel).sort((a, b) => + a.replace(/\D/gi, '') - + b.replace(/\D/gi, '')))]; + + delete response.video_id; + delete response.short_description; + + return response; + } +} + +module.exports = Parser; \ No newline at end of file