From 4bbc2d50f47cbcd31369104ee39f9a06edc5fefa Mon Sep 17 00:00:00 2001 From: "luan.lrt4@gmail.com" Date: Sat, 16 Apr 2022 22:08:01 -0300 Subject: [PATCH] refactor!: move everything that needs parsing to `parser` and improve oauth system --- lib/Innertube.js | 538 +++++------------- lib/core/Actions.js | 49 +- lib/core/OAuth.js | 200 ++++--- lib/parser/index.js | 205 ++++++- lib/parser/youtube/index.js | 8 +- lib/parser/youtube/others/ChannelMetadata.js | 20 + lib/parser/youtube/others/GridPlaylistItem.js | 20 + lib/parser/youtube/others/GridVideoItem.js | 35 ++ lib/parser/youtube/others/NotificationItem.js | 25 + lib/parser/youtube/others/VideoItem.js | 46 ++ .../youtube/search/SearchSuggestionItem.js | 12 + lib/parser/ytmusic/index.js | 3 +- .../search/MusicSearchSuggestionItem.js | 22 + lib/parser/ytmusic/search/TopResultItem.js | 2 +- lib/utils/Constants.js | 11 +- lib/utils/Utils.js | 2 +- 16 files changed, 682 insertions(+), 516 deletions(-) create mode 100644 lib/parser/youtube/others/ChannelMetadata.js create mode 100644 lib/parser/youtube/others/GridPlaylistItem.js create mode 100644 lib/parser/youtube/others/GridVideoItem.js create mode 100644 lib/parser/youtube/others/NotificationItem.js create mode 100644 lib/parser/youtube/others/VideoItem.js create mode 100644 lib/parser/youtube/search/SearchSuggestionItem.js create mode 100644 lib/parser/ytmusic/search/MusicSearchSuggestionItem.js diff --git a/lib/Innertube.js b/lib/Innertube.js index f2ee82cd..85a184cf 100644 --- a/lib/Innertube.js +++ b/lib/Innertube.js @@ -18,6 +18,7 @@ const NToken = require('./deciphers/NToken'); const SigDecipher = require('./deciphers/Sig'); class Innertube { + #oauth; #player; #retry_count; @@ -41,7 +42,6 @@ class Innertube { if (response instanceof Error) throw new Utils.InnertubeError('Could not retrieve Innertube session', { status_code: response.status || 0 }); const data = JSON.parse(`{${Utils.getStringBetweenStrings(response.data, 'ytcfg.set({', '});') || ''}}`); - if (data.INNERTUBE_CONTEXT) { this.key = data.INNERTUBE_API_KEY; this.version = data.INNERTUBE_API_VERSION; @@ -60,7 +60,8 @@ class Innertube { * @type {EventEmitter} */ this.ev = new EventEmitter(); - + this.#oauth = new OAuth(this.ev); + this.#player = new Player(this); await this.#player.init(); @@ -106,7 +107,7 @@ class Innertube { * Notify about activity from the channels you're subscribed to. * * @param {boolean} new_value - * @returns {Promise<{success: boolean; status_code: string; }>} + * @returns {Promise<{ success: boolean; status_code: string; }>} */ setSubscriptions: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.SUBSCRIPTIONS, 'account_notifications', new_value), @@ -114,7 +115,7 @@ class Innertube { * Recommended content notifications. * * @param {boolean} new_value - * @returns {Promise<{success: boolean; status_code: string; }>} + * @returns {Promise<{ success: boolean; status_code: string; }>} */ setRecommendedVideos: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.RECOMMENDED_VIDEOS, 'account_notifications', new_value), @@ -122,7 +123,7 @@ class Innertube { * Notify about activity on your channel. * * @param {boolean} new_value - * @returns {Promise<{success: boolean; status_code: string; }>} + * @returns {Promise<{ success: boolean; status_code: string; }>} */ setChannelActivity: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.CHANNEL_ACTIVITY, 'account_notifications', new_value), @@ -130,7 +131,7 @@ class Innertube { * Notify about replies to your comments. * * @param {boolean} new_value - * @returns {Promise<{success: boolean; status_code: string; }>} + * @returns {Promise<{ success: boolean; status_code: string; }>} */ setCommentReplies: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.COMMENT_REPLIES, 'account_notifications', new_value), @@ -138,7 +139,7 @@ class Innertube { * Notify when others mention your channel. * * @param {boolean} new_value - * @returns {Promise<{success: boolean; status_code: string; }>} + * @returns {Promise<{ success: boolean; status_code: string; }>} */ setMentions: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.USER_MENTION, 'account_notifications', new_value), @@ -146,7 +147,7 @@ class Innertube { * Notify when others share your content on their channels. * * @param {boolean} new_value - * @returns {Promise<{success: boolean; status_code: string; }>} + * @returns {Promise<{ success: boolean; status_code: string; }>} */ setSharedContent: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.SHARED_CONTENT, 'account_notifications', new_value) }, @@ -155,7 +156,7 @@ class Innertube { * If set to true, your subscriptions won't be visible to others. * * @param {boolean} new_value - * @returns {Promise<{success: boolean; status_code: string; }>} + * @returns {Promise<{ success: boolean; status_code: string; }>} */ setSubscriptionsPrivate: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.SUBSCRIPTIONS_PRIVACY, 'account_privacy', new_value), @@ -163,7 +164,7 @@ class Innertube { * If set to true, saved playlists won't appear on your channel. * * @param {boolean} new_value - * @returns {Promise<{success: boolean; status_code: string; }>} + * @returns {Promise<{ success: boolean; status_code: string; }>} */ setSavedPlaylistsPrivate: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.PLAYLISTS_PRIVACY, 'account_privacy', new_value) } @@ -175,7 +176,7 @@ class Innertube { * Likes a given video. * * @param {string} video_id - * @returns {Promise<{success: boolean; status_code: string; }>} + * @returns {Promise<{ success: boolean; status_code: string; }>} */ like: (video_id) => Actions.engage(this, 'like/like', { video_id }), @@ -183,7 +184,7 @@ class Innertube { * Diskes a given video. * * @param {string} video_id - * @returns {Promise<{success: boolean; status_code: string; }>} + * @returns {Promise<{ success: boolean; status_code: string; }>} */ dislike: (video_id) => Actions.engage(this, 'like/dislike', { video_id }), @@ -191,7 +192,7 @@ class Innertube { * Removes a like/dislike. * * @param {string} video_id - * @returns {Promise<{success: boolean; status_code: string; }>} + * @returns {Promise<{ success: boolean; status_code: string; }>} */ removeLike: (video_id) => Actions.engage(this, 'like/removelike', { video_id }), @@ -200,7 +201,7 @@ class Innertube { * * @param {string} video_id * @param {string} text - * @returns {Promise<{success: boolean; status_code: string; }>} + * @returns {Promise<{ success: boolean; status_code: string; }>} */ comment: (video_id, text) => Actions.engage(this, 'comment/create_comment', { video_id, text }), @@ -208,7 +209,7 @@ class Innertube { * Subscribes to a given channel. * * @param {string} channel_id - * @returns {Promise<{success: boolean; status_code: string; }>} + * @returns {Promise<{ success: boolean; status_code: string; }>} */ subscribe: (channel_id) => Actions.engage(this, 'subscription/subscribe', { channel_id }), @@ -216,7 +217,7 @@ class Innertube { * Unsubscribes from a given channel. * * @param {string} channel_id - * @returns {Promise<{success: boolean; status_code: string; }>} + * @returns {Promise<{ success: boolean; status_code: string; }>} */ unsubscribe: (channel_id) => Actions.engage(this, 'subscription/unsubscribe', { channel_id }), @@ -226,7 +227,7 @@ class Innertube { * * @param {string} channel_id * @param {string} type PERSONALIZED | ALL | NONE - * @returns {Promise<{success: boolean; status_code: string; }>} + * @returns {Promise<{ success: boolean; status_code: string; }>} */ changeNotificationPreferences: (channel_id, type) => Actions.notifications(this, 'modify_channel_preference', { channel_id, pref: type || 'NONE' }), }; @@ -237,7 +238,7 @@ class Innertube { * * @param {string} title * @param {string} video_id - Note that a video must be supplied, empty playlists cannot be created. - * @returns {Promise<{success: boolean; status_code: string; }>} + * @returns {Promise<{ success: boolean; status_code: string; }>} */ create: (title, video_id) => Actions.engage(this, 'playlist/create', { title, video_id }), @@ -245,10 +246,10 @@ class Innertube { * Deletes a given playlist. * * @param {string} playlist_id - * @returns {Promise<{success: boolean; status_code: string; }>} + * @returns {Promise<{ success: boolean; status_code: string; }>} */ delete: (playlist_id) => Actions.engage(this, 'playlist/delete', { playlist_id }), - + /** * Adds videos to a given playlist. * @@ -265,11 +266,12 @@ class Innertube { * @param {string} setting_id * @param {string} type * @param {string} new_value - * @returns {Promise<{success: boolean; status_code: string; }>} + * @returns {Promise<{ success: boolean; status_code: string; }>} */ async #setSetting(setting_id, type, new_value) { const response = await Actions.browse(this, type); - + if (!response.success) return response; + const contents = ({ account_notifications: () => Utils.findNode(response.data, 'contents', 'Your preferences', 13, false).options, account_privacy: () => Utils.findNode(response.data, 'contents', 'settingsSwitchRenderer', 13, false).options @@ -282,7 +284,7 @@ class Innertube { return { success: set_setting.success, - status_code: response.status_code, + status_code: set_setting.status_code, } } @@ -297,50 +299,47 @@ class Innertube { */ signIn(auth_info = {}) { return new Promise(async (resolve) => { - const oauth = new OAuth(auth_info); - if (auth_info.access_token) { - if (!oauth.isTokenValid()) { - const tokens = await oauth.refreshAccessToken(); - auth_info.refresh_token = tokens.credentials.refresh_token; - auth_info.access_token = tokens.credentials.access_token; - this.ev.emit('update-credentials', { credentials: tokens.credentials, status: tokens.status }); - } + this.#oauth.init(auth_info); - this.access_token = auth_info.access_token; - this.refresh_token = auth_info.refresh_token; - this.logged_in = true; - - // API key is not needed if logged in via OAuth - delete this.YTRequester.defaults.params.key; - delete this.YTMRequester.defaults.params.key; - - // Update default headers - this.YTRequester.defaults.headers = Constants.INNERTUBE_HEADERS({ session: this, ytmusic: false }); - this.YTMRequester.defaults.headers = Constants.INNERTUBE_HEADERS({ session: this, ytmusic: true }); - - resolve(); - } 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; - - delete this.YTRequester.defaults.params.key; - delete this.YTMRequester.defaults.params.key; - - this.YTRequester.defaults.headers = Constants.INNERTUBE_HEADERS({ session: this, ytmusic: false }); - this.YTMRequester.defaults.headers = Constants.INNERTUBE_HEADERS({ session: this, ytmusic: true }); - - resolve(); - } else { - this.ev.emit('auth', data); - } - }); + if (this.#oauth.isValidAuthInfo()) { + await this.#oauth.checkTokenValidity(); + this.#updateCredentials(); + return resolve(); } + + this.ev.on('auth', (data) => { + if (data.status === 'SUCCESS') { + this.#updateCredentials(); + resolve(); + } + }); }); } + + #updateCredentials() { + this.access_token = this.#oauth.getAccessToken(); + this.refresh_token = this.#oauth.getRefreshToken(); + this.logged_in = true; + + // API key is not needed if logged in via OAuth + delete this.YTRequester.defaults.params.key; + delete this.YTMRequester.defaults.params.key; + + // Update default headers + this.YTRequester.defaults.headers = Constants.INNERTUBE_HEADERS({ session: this, ytmusic: false }); + this.YTMRequester.defaults.headers = Constants.INNERTUBE_HEADERS({ session: this, ytmusic: true }); + } + + /** + * Signs out of your account. + * @returns {Promise.<{ success: boolean; status_code: number }>} + */ + async signOut() { + if (!this.logged_in) throw new Utils.InnertubeError('You are not signed in'); + const response = await this.#oauth.revokeAccessToken(); + response.success && (this.logged_in = false); + return response; + } /** * Returns information about the account being used. @@ -370,19 +369,18 @@ class Innertube { * @param {string} options.order - Filter results by order, can be: relevance | rating | age | views * @param {string} options.duration - Filter video results by duration, can be: any | short | long * @returns {Promise.<{ query: string; corrected_query: string; estimated_results: number; videos: [] } | - * { songs: []; videos: []; albums: []; playlists: [] }>} + * { results: { songs: []; videos: []; albums: []; community_playlists: [] } }>} */ 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 Utils.InnertubeError('Could not search on YouTube', response); - - const parsed_data = new Parser(this, response.data, { - client: options.client, - data_type: 'SEARCH', - query + + const results = new Parser(this, response.data, { + query, client: options.client, + data_type: 'SEARCH' }).parse(); - return parsed_data; + return results; } /** @@ -394,52 +392,35 @@ class Innertube { * @returns {Promise.<[{ text: string; bold_text: string }]>} */ async getSearchSuggestions(input, options = { client: 'YOUTUBE' }) { - if (options.client == 'YOUTUBE') { - const response = await Actions.getYTSearchSuggestions(this, input); - if (!response.success) throw new Utils.InnertubeError('Could not get search suggestions', response); + const response = await Actions.getSearchSuggestions(this, options.client, input); + if (!response.success) throw new Utils.InnertubeError('Could not get search suggestions', response); + if (options.client === 'YTMUSIC' && !response.data.contents) return []; - return response.data[1].map((item) => { - return { - text: item.trim(), - bold_text: response.data[0].trim() - }; - }); - } else if (options.client == 'YTMUSIC') { - const response = await Actions.music(this, 'get_search_suggestions', { input }); - - if (!response.success) throw new Utils.InnertubeError('Could not get search suggestions', response); - if (!response.data.contents) return []; - - const contents = response.data.contents[0].searchSuggestionsSectionRenderer.contents; - return contents.map((item) => { - let suggestion; - - item.historySuggestionRenderer && - (suggestion = item.historySuggestionRenderer.suggestion) || - (suggestion = item.searchSuggestionRenderer.suggestion); - - return { - text: suggestion.runs.map((run) => run.text).join('').trim(), - bold_text: suggestion.runs[0].text.trim() - }; - }); - } + const suggestions = new Parser(this, response.data, { + input, client: options.client, + data_type: 'SEARCH_SUGGESTIONS' + }).parse(); + + return suggestions; } /** - * Gets details for a video. + * Gets video info. * * @param {string} video_id - The id of the video. - * @return {Promise.<{ title: string; description: string; thumbnail: []; metadata: {} }>} + * @return {Promise.<{ title: string; description: string; thumbnail: []; metadata: object }>} */ async getDetails(video_id) { if (!video_id) throw new Utils.MissingParamError('Video id is missing'); - const data = await Actions.getVideoInfo(this, { id: video_id }); + const response = await Actions.getVideoInfo(this, { id: video_id }); const continuation = await Actions.next(this, { video_id }); - data.continuation = continuation.data; + continuation.success && (response.continuation = continuation.data); - const details = new Parser(this, data, { client: 'YOUTUBE', data_type: 'VIDEO_INFO' }).parse(); + const details = new Parser(this, response, { + client: 'YOUTUBE', + data_type: 'VIDEO_INFO' + }).parse(); // Functions details.like = () => Actions.engage(this, 'like/like', { video_id }); @@ -464,83 +445,13 @@ class Innertube { async getChannel(id) { const response = await Actions.browse(this, 'channel', { browse_id: id }); if (!response.success) throw new Utils.InnertubeError('Could not retrieve channel info.', response); - - const tabs = response.data.contents.twoColumnBrowseResultsRenderer.tabs; - const metadata = response.data.metadata; - - const home_tab = tabs.find((tab) => tab.tabRenderer.title == 'Home'); - const home_contents = home_tab.tabRenderer.content.sectionListRenderer.contents; - const home_shelves = []; - - home_contents.forEach((content) => { - if (!content.itemSectionRenderer) return; - - const contents = content.itemSectionRenderer.contents[0]; - - const list = contents?.shelfRenderer?.content.horizontalListRenderer; - if (!list) return; // For now we'll support only videos & playlists; TODO: Handle featured channels - - const shelf = { - title: contents.shelfRenderer.title.runs[0].text, - content: [] - }; - - shelf.content = list.items.map((item) => { - const renderer = item.gridVideoRenderer || item.gridPlaylistRenderer; - if (renderer.videoId) { - return { - id: renderer?.videoId, - title: renderer?.title?.simpleText, - metadata: { - view_count: renderer?.viewCountText?.simpleText || 'N/A', - short_view_count_text: { - simple_text: renderer?.shortViewCountText?.simpleText || 'N/A', - accessibility_label: renderer?.shortViewCountText?.accessibility?.accessibilityData?.label || 'N/A', - }, - thumbnail: renderer?.thumbnail?.thumbnails?.slice(-1)[0] || {}, - moving_thumbnail: renderer?.richThumbnail?.movingThumbnailRenderer?.movingThumbnailDetails?.thumbnails[0] || {}, - published: renderer?.publishedTimeText?.simpleText || 'N/A', - badges: renderer?.badges?.map((badge) => badge.metadataBadgeRenderer.label) || [], - owner_badges: renderer?.ownerBadges?.map((badge) => badge.metadataBadgeRenderer.tooltip) || [] - } - } - } else { - return { - id: renderer?.playlistId, - title: renderer?.title?.runs?.map((run) => run.text).join(''), - metadata: { - thumbnail: renderer?.thumbnail?.thumbnails?.slice(-1)[0] || {}, - video_count: renderer?.videoCountShortText?.simpleText || 'N/A', - } - } - } - }); - home_shelves.push(shelf); - }); - - return { - title: metadata.channelMetadataRenderer.title, - description: metadata.channelMetadataRenderer.description, - metadata: { - url: metadata.channelMetadataRenderer?.channelUrl, - rss_urls: metadata.channelMetadataRenderer?.rssUrl, - vanity_channel_url: metadata.channelMetadataRenderer?.vanityChannelUrl, - external_id: metadata.channelMetadataRenderer?.externalId, - is_family_safe: metadata.channelMetadataRenderer?.isFamilySafe, - keywords: metadata.channelMetadataRenderer?.keywords - }, - content: { - // Home page of the channel, always available in the first request. - home_page: home_shelves, - - // Functions— these will need additional requests and will possibly use the parser. - getVideos: () => {}, - getPlaylists: () => {}, - getCommunity: () => {}, - getChannels: () => {}, - getAbout: () => {} - } - } + + const channel_info = new Parser(this, response.data, { + client: 'YOUTUBE', + data_type: 'CHANNEL' + }).parse(); + + return channel_info; } /** @@ -575,8 +486,14 @@ class Innertube { */ async getPlaylist(playlist_id, options = { client: 'YOUTUBE' }) { const response = await Actions.browse(this, options.client == 'YTMUSIC' ? 'music_playlist' : 'playlist', { ytmusic: options.client == 'YTMUSIC', browse_id: `VL${playlist_id}` }); - const data = new Parser(this, response.data, { client: options.client, data_type: 'PLAYLIST' }).parse(); - return data; + if (!response.success) throw new Utils.InnertubeError('Could not get playlist', response); + + const playlist = new Parser(this, response.data, { + client: options.client, + data_type: 'PLAYLIST' + }).parse(); + + return playlist; } /** @@ -588,7 +505,8 @@ class Innertube { */ async getComments(video_id, data = {}) { let comment_section_token; - + + //TODO: Refactor this and move it to the parser if (!data.token) { const continuation = await Actions.next(this, { video_id }); if (!continuation.success) throw new Utils.InnertubeError('Could not fetch comments section', continuation); @@ -603,7 +521,7 @@ class Innertube { const response = await Actions.next(this, { continuation_token: comment_section_token || data.token }); if (!response.success) throw new Utils.InnertubeError('Could not fetch comments section', response); - + const comments_section = { comments: [] }; !data.token && (comments_section.comment_count = response.data?.onResponseReceivedEndpoints[0]?.reloadContinuationItemsCommand?.continuationItems[0]?.commentsHeaderRenderer?.countText.runs[0]?.text || 'N/A'); @@ -612,11 +530,15 @@ class Innertube { (continuation_token = response.data?.onResponseReceivedEndpoints[1]?.reloadContinuationItemsCommand?.continuationItems ?.find((item) => item.continuationItemRenderer)?.continuationItemRenderer?.continuationEndpoint?.continuationCommand.token) || ((continuation_token = response.data?.onResponseReceivedEndpoints[0]?.appendContinuationItemsAction?.continuationItems - ?.find((item) => item.continuationItemRenderer)?.continuationItemRenderer?.continuationEndpoint?.continuationCommand.token) || - (continuation_token = response.data?.onResponseReceivedEndpoints[0]?.appendContinuationItemsAction?.continuationItems - ?.find((item) => item.continuationItemRenderer)?.continuationItemRenderer?.button.buttonRenderer.command.continuationCommand.token)); + ?.find((item) => item.continuationItemRenderer)?.continuationItemRenderer?.continuationEndpoint?.continuationCommand.token) || + (continuation_token = response.data?.onResponseReceivedEndpoints[0]?.appendContinuationItemsAction?.continuationItems + ?.find((item) => item.continuationItemRenderer)?.continuationItemRenderer?.button.buttonRenderer.command.continuationCommand.token)); - continuation_token && (comments_section.getContinuation = () => this.getComments(video_id, { token: continuation_token, channel_id: data.channel_id })); + continuation_token && (comments_section.getContinuation = + () => this.getComments(video_id, { + token: continuation_token, + channel_id: data.channel_id + })); let contents; !data.token && (contents = response.data.onResponseReceivedEndpoints[1].reloadContinuationItemsCommand.continuationItems) || @@ -625,10 +547,11 @@ class Innertube { contents.forEach((content) => { const thread = content?.commentThreadRenderer?.comment.commentRenderer || content?.commentRenderer; if (!thread) return; - - const replies_token = content?.commentThreadRenderer?.replies?.commentRepliesRenderer.contents - .find((content) => content.continuationItemRenderer.continuationEndpoint) - .continuationItemRenderer.continuationEndpoint.continuationCommand.token; + + // TODO: Reverse engineering this token so we can generate it manually (it's just protobuf). + const replies_token = content?.commentThreadRenderer?.replies?.commentRepliesRenderer?.contents + ?.find((content) => content.continuationItemRenderer.continuationEndpoint) + ?.continuationItemRenderer.continuationEndpoint.continuationCommand.token; const like_btn = thread?.actionButtons?.commentActionButtonsRenderer.likeButton; const dislike_btn = thread?.actionButtons?.commentActionButtonsRenderer.dislikeButton; @@ -672,69 +595,12 @@ class Innertube { const response = await Actions.browse(this, 'history'); if (!response.success) throw new Utils.InnertubeError('Could not retrieve watch history', response); - const contents = Utils.findNode(response, 'contents', 'videoRenderer', 9, false) - - const history = { items: [] }; - - const parseItems = (contents) => { - contents.forEach((section) => { - if (!section.itemSectionRenderer) return; - - const header = section.itemSectionRenderer.header.itemSectionHeaderRenderer.title; - const section_title = header?.simpleText || header?.runs.map((run) => run.text).join(''); - const contents = section.itemSectionRenderer.contents; - - const section_items = contents.map((item) => { - return { - id: item?.videoRenderer?.videoId, - title: item?.videoRenderer?.title?.runs?.map((run) => run.text).join(' '), - description: item?.videoRenderer?.descriptionSnippet?.runs[0]?.text || 'N/A', - channel: { - id: item?.videoRenderer?.shortBylineText?.runs[0]?.navigationEndpoint?.browseEndpoint?.browseId, - name: item?.videoRenderer?.shortBylineText?.runs[0]?.text || 'N/A', - url: `${Constants.URLS.YT_BASE}${item?.videoRenderer?.shortBylineText?.runs[0]?.navigationEndpoint?.browseEndpoint?.canonicalBaseUrl}` - }, - metadata: { - view_count: item?.videoRenderer?.viewCountText?.simpleText || 'N/A', - short_view_count_text: { - simple_text: item?.videoRenderer?.shortViewCountText?.simpleText || 'N/A', - accessibility_label: item?.videoRenderer?.shortViewCountText?.accessibility?.accessibilityData?.label, - }, - thumbnail: item?.videoRenderer?.thumbnail?.thumbnails?.slice(-1)[0] || [], - moving_thumbnail: item?.videoRenderer?.richThumbnail?.movingThumbnailRenderer?.movingThumbnailDetails?.thumbnails[0] || [], - duration: { - seconds: Utils.timeToSeconds(item?.videoRenderer?.lengthText?.simpleText || '0'), - simple_text: item?.videoRenderer?.lengthText?.simpleText || 'N/A', - accessibility_label: item?.videoRenderer?.lengthText?.accessibility?.accessibilityData?.label || 'N/A' - }, - badges: item?.videoRenderer?.badges?.map((badge) => badge.metadataBadgeRenderer.label) || [], - owner_badges: item?.videoRenderer?.ownerBadges?.map((badge) => badge.metadataBadgeRenderer.tooltip) || [] - } - }; - }); - - history.items.push({ - date: section_title, - videos: section_items - }); - }); - - history.getContinuation = async () => { - const citem = contents.find((item) => item.continuationItemRenderer); - const ctoken = citem.continuationItemRenderer.continuationEndpoint.continuationCommand.token; - - const response = await Actions.browse(this, 'continuation', { ctoken }); - if (!response.success) throw new Utils.InnertubeError('Could not retrieve continuation', response); - - history.items = []; - - return parseItems(response.data.onResponseReceivedActions[0].appendContinuationItemsAction.continuationItems); - } - - return history; - } - - return parseItems(contents); + const history = new Parser(this, response, { + client: 'YOUTUBE', + data_type: 'HISTORY' + }).parse(); + + return history; } /** @@ -745,56 +611,12 @@ class Innertube { const response = await Actions.browse(this, 'home_feed'); if (!response.success) throw new Utils.InnertubeError('Could not retrieve home feed', response); - const contents = Utils.findNode(response, 'contents', 'videoRenderer', 9, false) - - const parseItems = (contents) => { - const videos = contents.map((item) => { - const content = item.richItemRenderer && item.richItemRenderer.content.videoRenderer && - item.richItemRenderer.content; - - if (content) return { - id: content.videoRenderer.videoId, - title: content.videoRenderer.title.runs.map((run) => run.text).join(' '), - description: content?.videoRenderer?.descriptionSnippet?.runs[0]?.text || 'N/A', - channel: { - id: content?.videoRenderer?.shortBylineText?.runs[0]?.navigationEndpoint?.browseEndpoint?.browseId, - name: content?.videoRenderer?.shortBylineText?.runs[0]?.text || 'N/A', - url: `${Constants.URLS.YT_BASE}${content?.videoRenderer?.shortBylineText?.runs[0]?.navigationEndpoint?.browseEndpoint?.canonicalBaseUrl}` - }, - metadata: { - view_count: content?.videoRenderer?.viewCountText?.simpleText || 'N/A', - short_view_count_text: { - simple_text: content?.videoRenderer?.shortViewCountText?.simpleText || 'N/A', - accessibility_label: content?.videoRenderer?.shortViewCountText?.accessibility?.accessibilityData?.label || 'N/A', - }, - thumbnail: content?.videoRenderer?.thumbnail?.thumbnails.slice(-1)[0] || {}, - moving_thumbnail: content?.videoRenderer?.richThumbnail?.movingThumbnailRenderer?.movingThumbnailDetails?.thumbnails[0] || {}, - published: content?.videoRenderer?.publishedTimeText?.simpleText || 'N/A', - duration: { - seconds: Utils.timeToSeconds(content?.videoRenderer?.lengthText?.simpleText || '0'), - simple_text: content?.videoRenderer?.lengthText?.simpleText || 'N/A', - accessibility_label: content?.videoRenderer?.lengthText?.accessibility?.accessibilityData?.label || 'N/A' - }, - badges: content?.videoRenderer?.badges?.map((badge) => badge.metadataBadgeRenderer.label) || [], - owner_badges: content?.videoRenderer?.ownerBadges?.map((badge) => badge.metadataBadgeRenderer.tooltip) || [] - } - } - }).filter((item) => item); - - const getContinuation = async () => { - const citem = contents.find((item) => item.continuationItemRenderer); - const ctoken = citem.continuationItemRenderer.continuationEndpoint.continuationCommand.token; - - const response = await Actions.browse(this, 'continuation', { ctoken }); - if (!response.success) throw new Utils.InnertubeError('Could not retrieve continuation', response); - - return parseItems(response.data.onResponseReceivedActions[0].appendContinuationItemsAction.continuationItems); - } - - return { videos, getContinuation }; - } - - return parseItems(contents); + const homefeed = new Parser(this, response, { + client: 'YOUTUBE', + data_type: 'HOMEFEED' + }).parse(); + + return homefeed; } /** @@ -805,65 +627,12 @@ class Innertube { const response = await Actions.browse(this, 'subscriptions_feed'); if (!response.success) throw new Utils.InnertubeError('Could not retrieve subscriptions feed', response); - const contents = Utils.findNode(response, 'contents', 'contents', 9, false); + const subsfeed = new Parser(this, response, { + client: 'YOUTUBE', + data_type: 'SUBSFEED' + }).parse(); - const subsfeed = { items: [] }; - - const parseItems = (contents) => { - contents.forEach((section) => { - if (!section.itemSectionRenderer) return; - - const section_contents = section.itemSectionRenderer.contents[0]; - const section_title = section_contents.shelfRenderer.title.runs[0].text; - const section_items = section_contents.shelfRenderer.content.gridRenderer.items; - - const items = section_items.map((item) => { - return { - id: item.gridVideoRenderer.videoId, - title: item?.gridVideoRenderer?.title?.runs?.map((run) => run.text).join(' '), - channel: { - id: item?.gridVideoRenderer?.shortBylineText?.runs[0]?.navigationEndpoint?.browseEndpoint?.browseId, - name: item?.gridVideoRenderer?.shortBylineText?.runs[0]?.text || 'N/A', - url: `${Constants.URLS.YT_BASE}${item?.gridVideoRenderer?.shortBylineText?.runs[0]?.navigationEndpoint?.browseEndpoint?.canonicalBaseUrl}` - }, - metadata: { - view_count: item?.gridVideoRenderer?.viewCountText?.simpleText || 'N/A', - short_view_count_text: { - simple_text: item?.gridVideoRenderer?.shortViewCountText?.simpleText || 'N/A', - accessibility_label: item?.gridVideoRenderer?.shortViewCountText?.accessibility?.accessibilityData?.label || 'N/A', - }, - thumbnail: item?.gridVideoRenderer?.thumbnail?.thumbnails.slice(-1)[0] || [], - moving_thumbnail: item?.gridVideoRenderer?.richThumbnail?.movingThumbnailRenderer?.movingThumbnailDetails?.thumbnails[0] || {}, - published: item?.gridVideoRenderer?.publishedTimeText?.simpleText || 'N/A', - badges: item?.gridVideoRenderer?.badges?.map((badge) => badge.metadataBadgeRenderer.label) || [], - owner_badges: item?.gridVideoRenderer?.ownerBadges?.map((badge) => badge.metadataBadgeRenderer.tooltip) || [] - } - }; - }); - - subsfeed.items.push({ - date: section_title, - videos: items - }); - }); - - subsfeed.getContinuation = async () => { - const citem = contents.find((item) => item.continuationItemRenderer); - const ctoken = citem.continuationItemRenderer.continuationEndpoint.continuationCommand.token; - - const response = await Actions.browse(this, 'continuation', { ctoken }); - if (!response.success) throw new Utils.InnertubeError('Could not retrieve continuation', response); - - const ccontents = Utils.findNode(response.data, 'onResponseReceivedActions', 'itemSectionRenderer', 4, false); - subsfeed.items = []; - - return parseItems(ccontents); - } - - return subsfeed; - }; - - return parseItems(contents); + return subsfeed; } /** @@ -873,40 +642,13 @@ class Innertube { async getNotifications() { const response = await Actions.notifications(this, 'get_notification_menu'); if (!response.success) throw new Utils.InnertubeError('Could not fetch notifications', response); - - const contents = response.data.actions[0].openPopupAction.popup.multiPageMenuRenderer.sections[0]; - if (!contents.multiPageMenuNotificationSectionRenderer) throw new Utils.InnertubeError('No notifications', response); - - const parseItems = (items) => { - const parsed_items = items.map((notification) => { - if (!notification.notificationRenderer) return; - notification = notification.notificationRenderer; - return { - title: notification?.shortMessage?.simpleText, - sent_time: notification?.sentTimeText?.simpleText, - channel_name: notification?.contextualMenu?.menuRenderer?.items[1]?.menuServiceItemRenderer?.text?.runs[1]?.text || 'N/A', - channel_thumbnail: notification?.thumbnail?.thumbnails[0], - video_thumbnail: notification?.videoThumbnail?.thumbnails[0], - video_url: notification.navigationEndpoint.watchEndpoint && `https://youtu.be/${notification.navigationEndpoint.watchEndpoint.videoId}` || 'N/A', - read: notification.read, - notification_id: notification.notificationId, - }; - }).filter((notification) => notification); - - const getContinuation = async () => { - const citem = items.find((item) => item.continuationItemRenderer); - const ctoken = citem?.continuationItemRenderer?.continuationEndpoint?.getNotificationMenuEndpoint?.ctoken; - - const response = await Actions.notifications(this, 'get_notification_menu', { ctoken }); - if (!response.success) throw new Utils.InnertubeError('Could not retrieve continuation', response); - - return parseItems(response.data.actions[0].appendContinuationItemsAction.continuationItems); - } - - return { items: parsed_items, getContinuation }; - } - - return parseItems(contents.multiPageMenuNotificationSectionRenderer.items); + + const notifications = new Parser(this, response.data, { + client: 'YOUTUBE', + data_type: 'NOTIFICATIONS' + }).parse(); + + return notifications; } /** diff --git a/lib/core/Actions.js b/lib/core/Actions.js index 33720ed7..66188307 100644 --- a/lib/core/Actions.js +++ b/lib/core/Actions.js @@ -15,7 +15,7 @@ const Constants = require('../utils/Constants'); * @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>} */ async function engage(session, engagement_type, args = {}) { - if (!session.logged_in) throw new Error('You are not signed in'); + if (!session.logged_in) throw new Utils.InnertubeError('You are not signed in'); const data = { context: session.context }; switch (engagement_type) { @@ -85,7 +85,7 @@ async function browse(session, action, args = {}) { if (!session.logged_in && action != 'home_feed' && action !== 'lyrics' && action !== 'music_playlist' && action !== 'playlist') - throw new Error('You are not signed in'); + throw new Utils.InnertubeError('You are not signed in'); const data = { context: session.context }; switch (action) { @@ -147,7 +147,7 @@ async function browse(session, action, args = {}) { * @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>} */ async function account(session, action, args = {}) { - if (!session.logged_in) throw new Error('You are not signed in'); + if (!session.logged_in) throw new Utils.InnertubeError('You are not signed in'); const data = {}; switch (action) { @@ -262,7 +262,7 @@ async function search(session, client, args = {}) { * @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>} */ async function notifications(session, action, args = {}) { - if (!session.logged_in) throw new Error('You are not signed in'); + if (!session.logged_in) throw new Utils.InnertubeError('You are not signed in'); const data = {}; switch (action) { @@ -396,7 +396,7 @@ async function next(session, args = {}) { */ async function getVideoInfo(session, args = {}) { const response = await session.YTRequester.post(`/player`, JSON.stringify(Constants.VIDEO_INFO_REQBODY(args.id, session.sts, session.context))).catch((err) => err); - if (response instanceof Error) throw new Error(`Could not get video info: ${response.message}`); + if (response instanceof Error) throw new Utils.InnertubeError(`Could not get video info: ${response.message}`); return response.data; } @@ -407,21 +407,28 @@ async function getVideoInfo(session, args = {}) { * @param {string} query - Search query * @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>} */ -async function getYTSearchSuggestions(session, query) { - const response = await Axios.get(`${Constants.URLS.YT_SUGGESTIONS}search?client=firefox&ds=yt&q=${encodeURIComponent(query)}`, - Constants.DEFAULT_HEADERS(session)).catch((error) => error); - - if (response instanceof Error) return { - success: false, - status_code: response.status, - message: response.message - }; - - return { - success: true, - status_code: response.status, - data: response.data - }; +async function getSearchSuggestions(session, client, input) { + if (!['YOUTUBE', 'YTMUSIC'].includes(client)) + throw new Utils.InnertubeError('Invalid client', client); + + const response = await ({ + 'YOUTUBE': async () => { + const response = await Axios.get(`${Constants.URLS.YT_SUGGESTIONS}search?client=firefox&ds=yt&q=${encodeURIComponent(input)}`, + Constants.DEFAULT_HEADERS(session)).catch((error) => error); + + return { + success: !(response instanceof Error), + status_code: response.status, + data: response?.data + }; + }, + 'YTMUSIC': async () => { + const response = await music(session, 'get_search_suggestions', { input }); + return response; + } + }[client])(); + + return response; } -module.exports = { engage, browse, account, music, search, notifications, livechat, getVideoInfo, next, getYTSearchSuggestions }; +module.exports = { engage, browse, account, music, search, notifications, livechat, getVideoInfo, next, getSearchSuggestions }; diff --git a/lib/core/OAuth.js b/lib/core/OAuth.js index 836defad..d507a73f 100644 --- a/lib/core/OAuth.js +++ b/lib/core/OAuth.js @@ -2,34 +2,43 @@ const Axios = require('axios'); const Constants = require('../utils/Constants'); -const EventEmitter = require('events'); const Uuid = require('uuid'); -class OAuth extends EventEmitter { - constructor(auth_info) { - super(); - this.auth_info = auth_info; - this.refresh_interval = 5; - - this.oauth_code_url = `${Constants.URLS.YT_BASE}/o/oauth2/device/code`; - this.oauth_token_url = `${Constants.URLS.YT_BASE}/o/oauth2/token`; - - this.model_name = Constants.OAUTH.MODEL_NAME; - this.grant_type = Constants.OAUTH.GRANT_TYPE; - this.scope = Constants.OAUTH.SCOPE; - - this.auth_script_regex = /