refactor!: move everything that needs parsing to parser and improve oauth system

This commit is contained in:
luan.lrt4@gmail.com
2022-04-16 22:08:01 -03:00
parent 440d80063d
commit 4bbc2d50f4
16 changed files with 682 additions and 516 deletions

View File

@@ -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;
}
/**

View File

@@ -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 };

View File

@@ -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 = /<script id=\"base-js\" src=\"(.*?)\" nonce=".*?"><\/script>/;
this.identity_regex = /.+?={};var .+?={clientId:\"(?<id>.+?)\",.+?:\"(?<secret>.+?)\"},/;
if (auth_info.access_token) return;
this.#requestAuthCode();
class OAuth {
#scope = Constants.OAUTH.SCOPE;
#model_name = Constants.OAUTH.MODEL_NAME;
#grant_type = Constants.OAUTH.GRANT_TYPE;
#oauth_code_url = `${Constants.URLS.YT_BASE}/o/oauth2/device/code`;
#oauth_token_url = `${Constants.URLS.YT_BASE}/o/oauth2/token`;
#oauth_revoke_url = `${Constants.URLS.YT_BASE}/o/oauth2/revoke`;
#auth_info = {};
#refresh_interval = 5;
#ev = null;
constructor(ev) {
this.#ev = ev;
}
/**
* Starts the auth flow in case no valid credentials are available.
* @returns {Promise.<void>}
*/
async init(auth_info) {
this.#auth_info = auth_info;
if (!auth_info.access_token) {
this.#requestUserCode();
}
}
/**
* Asks the OAuth server for an auth code.
* Asks the OAuth server for a user code
* and verification URL.
*
* @returns {Promise.<void>}
*/
async #requestAuthCode() {
async #requestUserCode() {
const identity = await this.#getClientIdentity();
this.client_id = identity.id;
@@ -37,20 +46,15 @@ class OAuth extends EventEmitter {
const data = {
client_id: this.client_id,
scope: this.scope,
scope: this.#scope,
device_id: Uuid.v4(),
model_name: this.model_name
model_name: this.#model_name
};
const response = await Axios.post(this.oauth_code_url, JSON.stringify(data), Constants.OAUTH.HEADERS).catch((error) => error);
const response = await Axios.post(this.#oauth_code_url, JSON.stringify(data), Constants.OAUTH.HEADERS).catch((error) => error);
if (response instanceof Error) return this.#ev.emit('auth', { error: 'Could not obtain user code.', status: 'FAILED' });
if (response instanceof Error)
return this.emit('auth', {
error: 'Could not get auth code.',
status: 'FAILED'
});
this.emit('auth', {
this.#ev.emit('auth', {
code: response.data.user_code,
status: 'AUTHORIZATION_PENDING',
expires_in: response.data.expires_in,
@@ -65,7 +69,7 @@ class OAuth extends EventEmitter {
/**
* Waits for sign-in authorization.
*
* @param {string} device_code Client's device code.
* @param {string} device_code - Client's device code.
* @returns
*/
#waitForAuth(device_code) {
@@ -73,16 +77,12 @@ class OAuth extends EventEmitter {
client_id: this.client_id,
client_secret: this.client_secret,
code: device_code,
grant_type: this.grant_type
grant_type: this.#grant_type
};
setTimeout(async () => {
const response = await Axios.post(this.oauth_token_url, JSON.stringify(data), Constants.OAUTH.HEADERS).catch((error) => error);
if (response instanceof Error)
return this.emit('auth', {
error: 'Could not get authentication token.',
status: 'FAILED'
});
const response = await Axios.post(this.#oauth_token_url, JSON.stringify(data), Constants.OAUTH.HEADERS).catch((error) => error);
if (response instanceof Error) return this.#ev.emit('auth', { error: 'Could not get authentication token.', status: 'FAILED' });
if (response.data.error) {
switch (response.data.error) {
@@ -91,78 +91,97 @@ class OAuth extends EventEmitter {
this.#waitForAuth(device_code);
break;
case 'access_denied':
this.emit('auth', {
this.#ev.emit('auth', {
error: 'Access was denied.',
status: 'ACCESS_DENIED'
});
break;
case 'expired_token':
this.emit('auth', {
error: 'The device code has expired, requesting a new one.',
this.#ev.emit('auth', {
error: 'The user code has expired, requesting a new one.',
status: 'DEVICE_CODE_EXPIRED'
});
this.#requestAuthCode();
this.#requestUserCode();
break;
default:
}
} else {
const expiration_date = new Date(new Date().getTime() + response.data.expires_in * 1000);
this.emit('auth', {
credentials: {
access_token: response.data.access_token,
refresh_token: response.data.refresh_token,
expires: expiration_date,
},
token_type: response.data.token_type,
const credentials = {
access_token: response.data.access_token,
refresh_token: response.data.refresh_token,
expires: expiration_date,
};
this.#auth_info = credentials;
this.#ev.emit('auth', {
credentials,
status: 'SUCCESS'
});
}
}, 1000 * this.refresh_interval);
}, 1000 * this.#refresh_interval);
}
/**
* Refreshes the access token if necessary.
* @returns {Promise.<void>}
*/
async checkTokenValidity() {
if (this.shouldRefreshToken()) {
await this.#refreshAccessToken();
}
}
/**
* Gets a new access token using a refresh token.
* @returns {Promise.<{ credentials: { access_token: string; refresh_token: string; expires: Date }; status: string }>}
* @returns {Promise.<void>}
*/
async refreshAccessToken() {
async #refreshAccessToken() {
const identity = await this.#getClientIdentity();
const data = {
client_id: identity.id,
client_secret: identity.secret,
refresh_token: this.auth_info.refresh_token,
refresh_token: this.#auth_info.refresh_token,
grant_type: 'refresh_token',
};
const response = await Axios.post(this.oauth_token_url, JSON.stringify(data), Constants.OAUTH.HEADERS).catch((error) => error);
if (response instanceof Error) {
this.emit('auth', {
const response = await Axios.post(this.#oauth_token_url, JSON.stringify(data), Constants.OAUTH.HEADERS).catch((error) => error);
if (response instanceof Error)
return this.#ev.emit('update-credentials', {
error: 'Could not refresh access token.',
status: 'FAILED'
});
return {
credentials: {
access_token: this.auth_info.access_token,
refresh_token: this.auth_info.refresh_token,
expires: this.auth_info.expires
},
status: 'FAILED'
};
}
const expiration_date = new Date(new Date().getTime() + response.data.expires_in * 1000);
return {
credentials: {
refresh_token: this.auth_info.refresh_token,
access_token: response.data.access_token,
expires: expiration_date
},
token_type: response.data.token_type,
status: 'SUCCESS'
const credentials = {
access_token: response.data.access_token,
refresh_token: response.data.refresh_token || this.#auth_info.refresh_token,
expires: expiration_date,
};
this.#auth_info = credentials;
this.#ev.emit('update-credentials', {
credentials,
status: 'SUCCESS'
});
}
/**
* Revokes access token (note that the refresh token will also be revoked).
* @returns {Promise.<void>}
*/
async revokeAccessToken() {
const response = await Axios.post(`${this.#oauth_revoke_url}?token=${this.getAccessToken()}`, Constants.OAUTH.HEADERS).catch((error) => error);
return {
success: !(response instanceof Error),
status_code: response.status || 0
}
}
/**
@@ -175,24 +194,41 @@ class OAuth extends EventEmitter {
if (yttv_response instanceof Error) throw new Error(`Could not extract client identity: ${yttv_response.message}`);
// Here we download the script and extract the necessary data to proceed with the auth flow.
const url_body = this.auth_script_regex.exec(yttv_response.data)[1];
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);
if (response instanceof Error) throw new Error(`Could not extract client identity: ${response.message}`);
const client_identity = response.data.replace(/\n/g, '').match(this.identity_regex);
const client_identity = response.data.replace(/\n/g, '').match(Constants.OAUTH.REGEX.CLIENT_IDENTITY);
return client_identity.groups;
}
getAccessToken() {
return this.#auth_info.access_token;
}
getRefreshToken() {
return this.#auth_info.refresh_token;
}
/**
* Checks if the auth info is valid.
* @returns {boolean} true | false
*/
isValidAuthInfo() {
return this.#auth_info.hasOwnProperty('access_token')
&& this.#auth_info.hasOwnProperty('refresh_token')
&& this.#auth_info.hasOwnProperty('expires');
}
/**
* Checks access token validity.
* @returns {boolean} true | false
*/
isTokenValid() {
const timestamp = new Date(this.auth_info.expires).getTime();
const is_valid = new Date().getTime() < timestamp;
return is_valid;
shouldRefreshToken() {
const timestamp = new Date(this.#auth_info.expires).getTime();
return new Date().getTime() > timestamp;
}
}

View File

@@ -8,8 +8,8 @@ const YTMusicDataItems = require('./ytmusic');
class Parser {
constructor(session, data, args = {}) {
this.session = session;
this.data = data;
this.session = session;
this.args = args;
}
@@ -23,14 +23,21 @@ class Parser {
case 'YOUTUBE':
processed_data = ({
SEARCH: () => this.#processSearch(),
CHANNEL: () => this.#processChannel(),
PLAYLIST: () => this.#processPlaylist(),
VIDEO_INFO: () => this.#processVideoInfo()
VIDEO_INFO: () => this.#processVideoInfo(),
NOTIFICATIONS: () => this.#processNotifications(),
SEARCH_SUGGESTIONS: () => this.#processSearchSuggestions(),
SUBSFEED: () => this.#processSubscriptionFeed(),
HOMEFEED: () => this.#processHomeFeed(),
HISTORY: () => this.#processHistory()
})[data_type]()
break;
case 'YTMUSIC':
processed_data = ({
SEARCH: () => this.#processMusicSearch(),
PLAYLIST: () => this.#processMusicPlaylist()
PLAYLIST: () => this.#processMusicPlaylist(),
SEARCH_SUGGESTIONS: () => this.#processMusicSearchSuggestions(),
})[data_type]();
break;
default:
@@ -47,13 +54,13 @@ class Parser {
const parseItems = (contents) => {
const content = contents[0].itemSectionRenderer.contents;
processed_data.query = content[0]?.showingResultsForRenderer?.originalQuery?.simpleText || this.args.query;
processed_data.corrected_query = content[0]?.showingResultsForRenderer?.correctedQueryEndpoint?.searchEndpoint?.query || 'N/A';
processed_data.estimated_results = parseInt(this.data.estimatedResults);
processed_data.videos = YTDataItems.VideoResultItem.parse(content);
processed_data.getContinuation = async () => {
const citem = contents.find((item) => item.continuationItemRenderer);
const ctoken = citem.continuationItemRenderer.continuationEndpoint.continuationCommand.token;
@@ -70,7 +77,7 @@ class Parser {
return parseItems(contents);
}
#processMusicSearch() {
const tabs = Utils.findNode(this.data, 'contents', 'tabs').tabs;
const contents = Utils.findNode(tabs, '0', 'contents', 5);
@@ -108,7 +115,16 @@ class Parser {
return processed_data;
}
#processSearchSuggestions() {
return YTDataItems.SearchSuggestionItem.parse(this.data[1], this.data[0]);
}
#processMusicSearchSuggestions() {
const contents = this.data.contents[0].searchSuggestionsSectionRenderer.contents;
return YTMusicDataItems.MusicSearchSuggestionItem.parse(contents);
}
#processPlaylist() {
const details = this.data.sidebar.playlistSidebarRenderer.items[0];
@@ -250,6 +266,179 @@ class Parser {
return processed_data;
}
#processChannel() {
const tabs = this.data.contents.twoColumnBrowseResultsRenderer.tabs;
const metadata = this.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) {
const contents = content.itemSectionRenderer.contents[0];
const list = contents?.shelfRenderer?.content.horizontalListRenderer;
if (!list) return; // Ignores featured channels (for now only videos & playlists are supported)
const shelf = {
title: contents.shelfRenderer.title.runs[0].text,
content: []
};
shelf.content = list.items.map((item) => {
if (item.gridVideoRenderer) {
return YTDataItems.GridVideoItem.parseItem(item);
} else if (item.gridPlaylistRenderer) {
return YTDataItems.GridPlaylistItem.parseItem(item);
}
});
home_shelves.push(shelf);
}
});
const ch_info = YTDataItems.ChannelMetadata.parse(metadata);
return {
...ch_info,
content: {
// Home page of the channel, always available in the first request.
home_page: home_shelves,
// TODO: Implement these (note: they require additional requests)
getVideos: () => {},
getPlaylists: () => {},
getCommunity: () => {},
getChannels: () => {},
getAbout: () => {}
}
}
}
#processNotifications() {
const contents = this.data.actions[0].openPopupAction.popup.multiPageMenuRenderer.sections[0];
if (!contents.multiPageMenuNotificationSectionRenderer) throw new Utils.InnertubeError('No notifications', response);
const parseItems = (items) => {
const parsed_items = YTDataItems.NotificationItem.parse(items);
const getContinuation = async () => {
const citem = items.find((item) => item.continuationItemRenderer);
const ctoken = citem?.continuationItemRenderer?.continuationEndpoint?.getNotificationMenuEndpoint?.ctoken;
const response = await Actions.notifications(this.session, '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);
}
#processHistory() {
const contents = Utils.findNode(this.data, '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 = YTDataItems.VideoItem.parse(contents);
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.session, '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);
}
#processHomeFeed() {
const contents = Utils.findNode(this.data, 'contents', 'videoRenderer', 9, false)
const parseItems = (contents) => {
const videos = YTDataItems.VideoItem.parse(contents);
const getContinuation = async () => {
const citem = contents.find((item) => item.continuationItemRenderer);
const ctoken = citem.continuationItemRenderer.continuationEndpoint.continuationCommand.token;
const response = await Actions.browse(this.session, '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);
}
#processSubscriptionFeed() {
const contents = Utils.findNode(this.data, 'contents', 'contents', 9, false);
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 = YTDataItems.GridVideoItem.parse(section_items);
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.session, '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);
}
}
module.exports = Parser;
module.exports = Parser;

View File

@@ -1,6 +1,12 @@
'use strict';
const VideoResultItem = require('./search/VideoResultItem');
const SearchSuggestionItem = require('./search/SearchSuggestionItem');
const PlaylistItem = require('./others/PlaylistItem');
const NotificationItem = require('./others/NotificationItem');
const VideoItem = require('./others/VideoItem');
const GridVideoItem = require('./others/GridVideoItem');
const GridPlaylistItem = require('./others/GridPlaylistItem');
const ChannelMetadata = require('./others/ChannelMetadata');
module.exports = { VideoResultItem, PlaylistItem };
module.exports = { VideoResultItem, SearchSuggestionItem, PlaylistItem, NotificationItem, VideoItem, GridVideoItem, GridPlaylistItem, ChannelMetadata };

View File

@@ -0,0 +1,20 @@
'use strict';
class ChannelMetadata {
static parse(data) {
return {
title: data.channelMetadataRenderer.title,
description: data.channelMetadataRenderer.description,
metadata: {
url: data.channelMetadataRenderer?.channelUrl,
rss_urls: data.channelMetadataRenderer?.rssUrl,
vanity_channel_url: data.channelMetadataRenderer?.vanityChannelUrl,
external_id: data.channelMetadataRenderer?.externalId,
is_family_safe: data.channelMetadataRenderer?.isFamilySafe,
keywords: data.channelMetadataRenderer?.keywords
}
}
}
}
module.exports = ChannelMetadata;

View File

@@ -0,0 +1,20 @@
'use strict';
class GridPlaylistItem {
static parse(data) {
return data.map((item) => this.parseItem(item)).filter((item) => item);
}
static parseItem(item) {
return {
id: item?.gridPlaylistRenderer.playlistId,
title: item?.gridPlaylistRenderer.title?.runs?.map((run) => run.text).join(''),
metadata: {
thumbnail: item?.gridPlaylistRenderer.thumbnail?.thumbnails?.slice(-1)[0] || {},
video_count: item?.gridPlaylistRenderer.videoCountShortText?.simpleText || 'N/A',
}
};
}
}
module.exports = GridPlaylistItem;

View File

@@ -0,0 +1,35 @@
'use strict';
const Constants = require('../../../utils/Constants');
class GridVideoItem {
static parse(data) {
return data.map((item) => this.parseItem(item)).filter((item) => item);
}
static parseItem(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) || []
}
};
}
}
module.exports = GridVideoItem;

View File

@@ -0,0 +1,25 @@
'use strict';
class NotificationItem {
static parse(data) {
return data.map((item) => this.parseItem(item)).filter((item) => item);
}
static parseItem(item) {
if (item.notificationRenderer) {
const notification = item.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,
};
}
}
}
module.exports = NotificationItem;

View File

@@ -0,0 +1,46 @@
'use strict';
const Utils = require('../../../utils/Utils');
const Constants = require('../../../utils/Constants');
class VideoItem {
static parse(data) {
return data.map((item) => this.parseItem(item)).filter((item) => item);
}
static parseItem(item) {
item = (item.richItemRenderer && item.richItemRenderer.content.videoRenderer)
&& item.richItemRenderer.content
|| item;
if (item.videoRenderer) 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 || 'N/A',
},
thumbnail: item?.videoRenderer?.thumbnail?.thumbnails.slice(-1)[0] || {},
moving_thumbnail: item?.videoRenderer?.richThumbnail?.movingThumbnailRenderer?.movingThumbnailDetails?.thumbnails[0] || {},
published: item?.videoRenderer?.publishedTimeText?.simpleText || 'N/A',
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) || []
}
}
}
}
module.exports = VideoItem;

View File

@@ -0,0 +1,12 @@
'use strict';
class SearchSuggestionItem {
static parse(data, bold_text) {
return data.map((item) => ({
text: item.trim(),
bold_text: bold_text.trim().toLowerCase()
}));
}
}
module.exports = SearchSuggestionItem;

View File

@@ -5,7 +5,8 @@ const VideoResultItem = require('./search/VideoResultItem');
const AlbumResultItem = require('./search/AlbumResultItem');
const ArtistResultItem = require('./search/ArtistResultItem');
const PlaylistResultItem = require('./search/PlaylistResultItem');
const MusicSearchSuggestionItem = require('./search/MusicSearchSuggestionItem');
const TopResultItem = require('./search/TopResultItem');
const PlaylistItem = require('./others/PlaylistItem');
module.exports = { SongResultItem, VideoResultItem, AlbumResultItem, ArtistResultItem, PlaylistResultItem, TopResultItem, PlaylistItem };
module.exports = { SongResultItem, VideoResultItem, AlbumResultItem, ArtistResultItem, PlaylistResultItem, MusicSearchSuggestionItem, TopResultItem, PlaylistItem };

View File

@@ -0,0 +1,22 @@
'use strict';
class MusicSearchSuggestionItem {
static parse(data) {
return data.map((item) => this.parseItem(item));
}
static parseItem(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()
};
}
}
module.exports = MusicSearchSuggestionItem;

View File

@@ -23,7 +23,7 @@ class TopResultItem {
single: () => AlbumResultItem.parseItem(item)
}[type])();
parsed_item.type = type;
parsed_item && (parsed_item.type = type);
return parsed_item;
}).filter((item) => item);

View File

@@ -24,6 +24,10 @@ module.exports = {
'referer': `https://www.youtube.com/tv`,
'accept-language': 'en-US'
}
},
REGEX: {
AUTH_SCRIPT: /<script id=\"base-js\" src=\"(.*?)\" nonce=".*?"><\/script>/,
CLIENT_IDENTITY: /.+?={};var .+?={clientId:\"(?<id>.+?)\",.+?:\"(?<secret>.+?)\"},/
}
},
DEFAULT_HEADERS: (session) => {
@@ -63,13 +67,14 @@ module.exports = {
'origin': origin
};
if (info.session.logged_in) {
const auth_creds = info.session.cookie.length && info.session.auth_apisid || `Bearer ${info.session.access_token}`
if (info.session.logged_in) {
headers.Cookie = info.session.cookie;
headers.authorization = info.session.cookie.length && info.session.auth_apisid || `Bearer ${info.session.access_token}`;
headers.authorization = auth_creds;
}
return headers
return headers;
},
VIDEO_INFO_REQBODY: (id, sts, context) => {
return {

View File

@@ -6,7 +6,7 @@ const UserAgent = require('user-agents');
const Flatten = require('flat');
function InnertubeError(message, info) {
this.info = info;
this.info = info || {};
this.stack = Error(message).stack;
}