From dbc8b62ba274805645d37479a5f03d75b09921f5 Mon Sep 17 00:00:00 2001 From: "luan.lrt4@gmail.com" Date: Tue, 19 Apr 2022 05:35:11 -0300 Subject: [PATCH] feat: add option to change geolocation & fix minor bugs, closes #34 --- README.md | 4 +- lib/Innertube.js | 43 ++++++---- lib/core/Actions.js | 2 +- lib/core/OAuth.js | 2 +- lib/parser/index.js | 3 +- lib/proto/index.js | 187 ++++++++++++++--------------------------- lib/utils/Constants.js | 16 ++-- typings/index.d.ts | 7 +- 8 files changed, 109 insertions(+), 155 deletions(-) diff --git a/README.md b/README.md index 2a193bee..65ad9c4c 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ And to make things faster, you should do this only once and reuse the Innertube ```js const Innertube = require('youtubei.js'); -const youtube = await new Innertube(); +const youtube = await new Innertube({ gl: 'US' }); // all parameters are optional. ``` ### Doing a simple search @@ -774,7 +774,7 @@ The library makes it easy to interact with YouTube programmatically. However, do * Change notification preferences: ```js // Options: ALL | NONE | PERSONALIZED - await youtube.interact.changeNotificationPreferences('CHANNEL_ID', 'ALL'); + await youtube.interact.setNotificationPreferences('CHANNEL_ID', 'ALL'); ``` These methods will always return ```{ success: true, status_code: 200 }``` if successful. diff --git a/lib/Innertube.js b/lib/Innertube.js index a29275cc..42f39cca 100644 --- a/lib/Innertube.js +++ b/lib/Innertube.js @@ -21,25 +21,27 @@ class Innertube { #oauth; #player; #retry_count; - + /** * ```js * const Innertube = require('youtubei.js'); * const youtube = await new Innertube(); * ``` - * @param {string} [cookie] + * @param {object} [config] + * @param {string} [config.gl] + * @param {string} [config.cookie] * @returns {Innertube} * @constructor */ - constructor(cookie) { - this.cookie = cookie || ''; + constructor(config) { + this.config = config || {}; this.#retry_count = 0; return this.#init(); } async #init() { - const response = await Axios.get(Constants.URLS.YT_BASE, Constants.DEFAULT_HEADERS(this)).catch((error) => error); - if (response instanceof Error) throw new Utils.InnertubeError('Could not retrieve Innertube session', { status_code: response.status || 0 }); + const response = await Axios.get(Constants.URLS.YT_BASE, Constants.DEFAULT_HEADERS(this.config)).catch((error) => error); + if (response instanceof Error) throw new Utils.InnertubeError('Could not retrieve Innertube session', { message: response.message, status_code: response.status || 0 }); const data = JSON.parse(`{${Utils.getStringBetweenStrings(response.data, 'ytcfg.set({', '});') || ''}}`); if (data.INNERTUBE_CONTEXT) { @@ -52,7 +54,7 @@ class Innertube { this.sts = data.STS; this.context.client.hl = 'en'; - this.context.client.gl = 'US'; + this.context.client.gl = this.config.gl || 'US'; /** * @event Innertube#auth - Fired when signing in to an account. @@ -65,8 +67,8 @@ class Innertube { this.#player = new Player(this); await this.#player.init(); - if (this.logged_in && this.cookie.length) { - this.auth_apisid = Utils.getStringBetweenStrings(this.cookie, 'PAPISID=', ';'); + if (this.logged_in && this.config.cookie) { + this.auth_apisid = Utils.getStringBetweenStrings(this.config.cookie, 'PAPISID=', ';'); this.auth_apisid = Utils.generateSidAuth(this.auth_apisid); } @@ -229,7 +231,7 @@ class Innertube { * @param {string} type PERSONALIZED | ALL | NONE * @returns {Promise<{ success: boolean; status_code: string; }>} */ - changeNotificationPreferences: (channel_id, type) => Actions.notifications(this, 'modify_channel_preference', { channel_id, pref: type || 'NONE' }), + setNotificationPreferences: (channel_id, type) => Actions.notifications(this, 'modify_channel_preference', { channel_id, pref: type || 'NONE' }), }; this.playlist = { @@ -431,7 +433,7 @@ class Innertube { details.comment = (text) => Actions.engage(this, 'comment/create_comment', { video_id, text }); details.getComments = () => this.getComments(video_id, { channel_id: details.metadata.channel_id }); details.getLivechat = () => new Livechat(this, continuation.data.contents?.twoColumnWatchNextResults?.conversationBar?.liveChatRenderer?.continuations?.find((continuation) => continuation.reloadContinuationData).reloadContinuationData.continuation, details.metadata.channel_id, video_id); - details.changeNotificationPreferences = (type) => Actions.notifications(this, 'modify_channel_preference', { channel_id: details.metadata.channel_id, pref: type || 'NONE' }); + details.setNotificationPreferences = (type) => Actions.notifications(this, 'modify_channel_preference', { channel_id: details.metadata.channel_id, pref: type || 'NONE' }); return details; } @@ -464,14 +466,13 @@ class Innertube { const continuation = await Actions.next(this, { video_id: video_id, ytmusic: true }); if (!continuation.success) throw new Utils.InnertubeError('Could not retrieve lyrics', continuation); - const lyrics_tab = continuation.data.contents.singleColumnMusicWatchNextResultsRenderer.tabbedRenderer - .watchNextTabbedResultsRenderer.tabs.find((obj) => obj.tabRenderer.title == 'Lyrics'); - - const response = await Actions.browse(this, 'lyrics', { ytmusic: true, browse_id: lyrics_tab.tabRenderer.endpoint.browseEndpoint.browseId }); + const lyrics_tab = Utils.findNode(continuation, 'contents', 'Lyrics', 8, false); + + const response = await Actions.browse(this, 'lyrics', { ytmusic: true, browse_id: lyrics_tab.endpoint?.browseEndpoint.browseId }); if (!response.success || !response.data?.contents?.sectionListRenderer) throw new Utils.UnavailableContentError('Lyrics not available', { video_id }); - - const lyrics = response.data.contents.sectionListRenderer.contents[0].musicDescriptionShelfRenderer.description.runs[0].text; - return lyrics; + + const lyrics = Utils.findNode(response.data, 'contents', 'runs', 6, false); + return lyrics.runs[0].text; } /** @@ -619,6 +620,12 @@ class Innertube { return homefeed; } + /** + * Retrieves trending content. + * @returns {Promise.<{ now: { content: [{ title: string; videos: []; }] }; + * music: { getVideos: Promise.; }; gaming: { getVideos: Promise.; }; + * gaming: { getVideos: Promise.; }; }>} + */ async getTrending() { const response = await Actions.browse(this, 'trending'); if (!response.success) throw new Utils.InnertubeError('Could not retrieve trending content', response); diff --git a/lib/core/Actions.js b/lib/core/Actions.js index 655eb7d2..e6e66881 100644 --- a/lib/core/Actions.js +++ b/lib/core/Actions.js @@ -417,7 +417,7 @@ async function getSearchSuggestions(session, client, input) { 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); + Constants.DEFAULT_HEADERS(session.config)).catch((error) => error); return { success: !(response instanceof Error), diff --git a/lib/core/OAuth.js b/lib/core/OAuth.js index d507a73f..f42e743e 100644 --- a/lib/core/OAuth.js +++ b/lib/core/OAuth.js @@ -197,7 +197,7 @@ class OAuth { const url_body = Constants.OAUTH.REGEX.AUTH_SCRIPT.exec(yttv_response.data)[1]; const script_url = `${Constants.URLS.YT_BASE}/${url_body}`; - const response = await Axios.get(script_url, Constants.DEFAULT_HEADERS).catch((error) => error); + const response = await Axios.get(script_url, Constants.DEFAULT_HEADERS()).catch((error) => error); if (response instanceof Error) throw new Error(`Could not extract client identity: ${response.message}`); const client_identity = response.data.replace(/\n/g, '').match(Constants.OAUTH.REGEX.CLIENT_IDENTITY); diff --git a/lib/parser/index.js b/lib/parser/index.js index d05b66c2..ceb409b5 100644 --- a/lib/parser/index.js +++ b/lib/parser/index.js @@ -101,7 +101,7 @@ class Parser { const section_title = section.title.runs[0].text; const section_items = ({ - ['Top result']: () => YTMusicDataItems.TopResultItem.parse(section.contents), // console.log(JSON.stringify(section.contents, null, 4)), + ['Top result']: () => YTMusicDataItems.TopResultItem.parse(section.contents), ['Songs']: () => YTMusicDataItems.SongResultItem.parse(section.contents), ['Videos']: () => YTMusicDataItems.VideoResultItem.parse(section.contents), ['Featured playlists']: () => YTMusicDataItems.PlaylistResultItem.parse(section.contents), @@ -342,7 +342,6 @@ class Parser { #processTrending() { const tabs = Utils.findNode(this.data, 'contents', 'tabRenderer', 4, false); - const categories = {}; const trending = tabs.map((tab) => { diff --git a/lib/proto/index.js b/lib/proto/index.js index 5a1580d3..dc988a2f 100644 --- a/lib/proto/index.js +++ b/lib/proto/index.js @@ -1,133 +1,74 @@ 'use strict'; const Fs = require('fs'); -const Proto = require('protons'); +const Protons = require('protons'); +const messages = Protons(Fs.readFileSync(`${__dirname}/youtube.proto`)); -/** - * Encodes advanced search filters. - * - * @param {string} period - Period in which a video is uploaded: any | hour | day | week | month | year - * @param {string} duration - The duration of a video: any | short | long - * @param {string} order - The order of the search results: relevance | rating | age | views - * @returns {string} - */ -function encodeSearchFilter(period, duration, order) { - const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/youtube.proto`)); +class Proto { + static encodeSearchFilter(period, duration, order) { + const periods = { 'any': null, 'hour': 1, 'day': 2, 'week': 3, 'month': 4, 'year': 5 }; + const durations = { 'any': null, 'short': 1, 'long': 2 }; + const orders = { 'relevance': null, 'rating': 1, 'age': 2, 'views': 3 }; - const periods = { 'any': null, 'hour': 1, 'day': 2, 'week': 3, 'month': 4, 'year': 5 }; - const durations = { 'any': null, 'short': 1, 'long': 2 }; - const orders = { 'relevance': null, 'rating': 1, 'age': 2, 'views': 3 }; + const buf = messages.SearchFilter.encode({ + number: orders[order], + filter: { + param_0: periods[period], + param_1: (period == 'hour' && order == 'relevance') ? null : 1, + param_2: durations[duration] + } + }); - const search_filter_buff = youtube_proto.SearchFilter.encode({ - number: orders[order], - filter: { - param_0: periods[period], - param_1: (period == 'hour' && order == 'relevance') ? null : 1, - param_2: durations[duration] - } - }); - - return encodeURIComponent(Buffer.from(search_filter_buff).toString('base64')); -} - -/** - * Encodes livestream message parameters. - * - * @param {string} channel_id - The id of the channel hosting the livestream. - * @param {string} video_id - The id of the livestream. - * @returns {string} - */ -function encodeMessageParams(channel_id, video_id) { - const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/youtube.proto`)); - - const buf = youtube_proto.LiveMessageParams.encode({ - params: { - ids: { channel_id, video_id } - }, - number_0: 1, - number_1: 4 - }); - - return Buffer.from(encodeURIComponent(Buffer.from(buf).toString('base64'))).toString('base64'); -} - -/** - * Encodes comment parameters. - * - * @param {string} video_id - The id of the video you're commenting on. - * @returns {string} - */ -function encodeCommentParams(video_id) { - const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/youtube.proto`)); - - const buf = youtube_proto.CreateCommentParams.encode({ - video_id, - params: { index: 0 }, - number: 7 - }); - - return encodeURIComponent(Buffer.from(buf).toString('base64')); -} - -/** - * Encodes comment reply parameters. - * - * @param {string} comment_id - The id of the comment. - * @param {string} video_id - The id of the video you're commenting on. - * @returns {string} - */ -function encodeCommentReplyParams(comment_id, video_id) { - const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/youtube.proto`)); - - const buf = youtube_proto.CreateCommentReplyParams.encode({ - video_id, comment_id, - params: { unk_num: 0 }, - unk_num: 7 - }); - - return encodeURIComponent(Buffer.from(buf).toString('base64')); -} - -/** - * Encodes comment action parameters (liking, disliking, reporting a comment etc). - * - * @param {string} type - Type of action. - * @param {string} comment_id - The id of the comment. - * @param {string} video_id - The id of the video you're commenting on. - * @param {string} channel_id - The id of the channel. - * @returns {string} - */ -function encodeCommentActionParams(type, comment_id, video_id, channel_id) { - const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/youtube.proto`)); + return encodeURIComponent(Buffer.from(buf).toString('base64')); + } - const buf = youtube_proto.PeformCommentActionParams.encode({ - type, comment_id, channel_id, video_id, - unk_num: 2, unk_num_1: 0, unk_num_2: 0, - unk_num_3: "0", unk_num_4: 0, - unk_num_5: 12, unk_num_6: 0, - }); + static encodeMessageParams(channel_id, video_id) { + const buf = messages.LiveMessageParams.encode({ + params: { ids: { channel_id, video_id } }, + number_0: 1, number_1: 4 + }); - return encodeURIComponent(Buffer.from(buf).toString('base64')); + return Buffer.from(encodeURIComponent(Buffer.from(buf).toString('base64'))).toString('base64'); + } + + static encodeCommentParams(video_id) { + const buf = messages.CreateCommentParams.encode({ + video_id, params: { index: 0 }, + number: 7 + }); + + return encodeURIComponent(Buffer.from(buf).toString('base64')); + } + + static encodeCommentReplyParams(comment_id, video_id) { + const buf = messages.CreateCommentReplyParams.encode({ + video_id, comment_id, + params: { unk_num: 0 }, + unk_num: 7 + }); + + return encodeURIComponent(Buffer.from(buf).toString('base64')); + } + + static encodeCommentActionParams(type, comment_id, video_id, channel_id) { + const buf = messages.PeformCommentActionParams.encode({ + type, comment_id, channel_id, video_id, + unk_num: 2, unk_num_1: 0, unk_num_2: 0, + unk_num_3: "0", unk_num_4: 0, + unk_num_5: 12, unk_num_6: 0, + }); + + return encodeURIComponent(Buffer.from(buf).toString('base64')); + } + + static encodeNotificationPref(channel_id, index) { + const buf = messages.NotificationPreferences.encode({ + channel_id, pref_id: { index }, + number_0: 0, number_1: 4 + }); + + return encodeURIComponent(Buffer.from(buf).toString('base64')); + } } -/** - * Encodes notification preferences. - * - * @param {string} channel_id - The id of the channel. - * @param {string} index - The index of the preference id. - * @returns {string} - */ -function encodeNotificationPref(channel_id, index) { - const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/youtube.proto`)); - - const buf = youtube_proto.NotificationPreferences.encode({ - channel_id, - pref_id: { index }, - number_0: 0, - number_1: 4 - }); - - return encodeURIComponent(Buffer.from(buf).toString('base64')); -} - -module.exports = { encodeMessageParams, encodeCommentParams, encodeCommentReplyParams, encodeCommentActionParams, encodeNotificationPref, encodeSearchFilter }; \ No newline at end of file +module.exports = Proto; \ No newline at end of file diff --git a/lib/utils/Constants.js b/lib/utils/Constants.js index c22dfc65..7fdb6f1b 100644 --- a/lib/utils/Constants.js +++ b/lib/utils/Constants.js @@ -30,14 +30,14 @@ module.exports = { CLIENT_IDENTITY: /.+?={};var .+?={clientId:\"(?.+?)\",.+?:\"(?.+?)\"},/ } }, - DEFAULT_HEADERS: (session) => { + DEFAULT_HEADERS: (config) => { return { headers: { - 'Cookie': session.cookie, + 'Cookie': config?.cookie || '', 'user-agent': Utils.getRandomUserAgent('desktop').userAgent, 'Referer': 'https://www.google.com/', 'Accept': 'text/html', - 'Accept-Language': 'en-US,en', + 'Accept-Language': `en-${config?.gl || 'US'}`, 'Accept-Encoding': 'gzip' } }; @@ -57,7 +57,7 @@ module.exports = { 'accept': '*/*', 'user-agent': Utils.getRandomUserAgent('desktop').userAgent, 'content-type': 'application/json', - 'accept-language': 'en-US,en;q=0.9', + 'accept-language': `en-${info.session.config.gl || 'US'}`, 'x-goog-authuser': 0, 'x-goog-visitor-id': info.session.context.client.visitorData || '', 'x-youtube-client-name': 1, @@ -67,10 +67,12 @@ module.exports = { 'origin': origin }; - const auth_creds = info.session.cookie.length && info.session.auth_apisid || `Bearer ${info.session.access_token}` + const auth_creds = info.session.cookie + && info.session.auth_apisid + || `Bearer ${info.session.access_token}`; if (info.session.logged_in) { - headers.Cookie = info.session.cookie; + headers.cookie = info.session.config.cookie || ''; headers.authorization = auth_creds; } @@ -144,4 +146,4 @@ module.exports = { TRANSLATE_1: 'function(d,e){for(var f', TRANSLATE_2: 'function(d,e,f){var' } -}; +}; \ No newline at end of file diff --git a/typings/index.d.ts b/typings/index.d.ts index 22e0e768..44875f38 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -157,8 +157,13 @@ interface StreamingOptions { format?: string; } +interface Config { + gl?: string; + cookie?: string; +} + export default class Innertube { - constructor(cookie?: string) + constructor(auth_info?: Config) public signIn(auth_info: AuthInfo): Promise; public signOut(): Promise;