Compare commits

...

8 Commits

Author SHA1 Message Date
luan.lrt4@gmail.com
5090c572d5 chore(release): v1.3.8 2022-03-30 23:52:28 -03:00
luan.lrt4@gmail.com
c9c72d0f31 feat: add support for comment replies, like and dislike 2022-03-30 23:31:11 -03:00
luan.lrt4@gmail.com
7635f49191 chore: add comment reply/action prototbuf messages 2022-03-30 14:33:22 -03:00
luan.lrt4@gmail.com
c932e65dad chore: simplify livechat logic and fix yt search suggestions 2022-03-28 14:18:49 -03:00
luan.lrt4@gmail.com
23717aab11 chore: rephrase comment 2022-03-26 05:42:53 -03:00
luan.lrt4@gmail.com
85df28a7fb feat: add support for channels (WIP) 2022-03-26 05:35:16 -03:00
luan.lrt4@gmail.com
9f4970b3ee refactor: separate protobuf stuff from utilities 2022-03-26 05:33:49 -03:00
luan.lrt4@gmail.com
82bbc715ff fix: playlists and home feed should work when logged out 2022-03-23 03:18:40 -03:00
11 changed files with 449 additions and 247 deletions

View File

@@ -274,14 +274,22 @@ const video = await youtube.getDetails('VIDEO_ID');
Get comments:
```js
const comments = await youtube.getComments('VIDEO_ID');
const response = await youtube.getComments('VIDEO_ID');
// Or:
const video = await youtube.getDetails('VIDEO_ID');
const comments = await video.getComments();
const response = await video.getComments();
// Get comments continuation:
const continuation = await comments.getContinuation();
// Get comment replies:
const replies = await response.comments[0].getReplies();
// Like, dislike, reply (same logic for replies):
await response.comments[0].like();
await response.comments[0].dislike();
await response.comments[0].reply('Nice comment!');
// Get comments continuation (same logic for replies):
const continuation = await response.getContinuation();
```
<details>
@@ -307,6 +315,8 @@ const continuation = await comments.getContinuation();
metadata:{
published: string,
is_liked: boolean,
is_disliked: boolean,
is_pinned: boolean,
is_channel_owner: boolean,
like_count: number,
reply_count: number,

View File

@@ -2,7 +2,7 @@
const Uuid = require('uuid');
const Axios = require('axios');
const Utils = require('./Utils');
const Proto = require('./proto');
const Constants = require('./Constants');
/**
@@ -32,12 +32,23 @@ async function engage(session, engagement_type, args = {}) {
break;
case 'comment/create_comment':
data.commentText = args.text;
data.createCommentParams = Utils.encodeCommentParams(args.video_id);
data.createCommentParams = Proto.encodeCommentParams(args.video_id);
break;
case 'comment/create_comment_reply':
data.createReplyParams = Proto.encodeCommentReplyParams(args.comment_id, args.video_id);
data.commentText = args.text;
break;
case 'comment/perform_comment_action':
const action = ({
like: () => Proto.encodeCommentActionParams(5, args.comment_id, args.video_id, args.channel_id),
dislike: () => Proto.encodeCommentActionParams(4, args.comment_id, args.video_id, args.channel_id),
})[args.comment_action]();
data.actions = [ action ];
break;
default:
}
const response = await session.YTRequester.post(`/${engagement_type}`, JSON.stringify(data));
const response = await session.YTRequester.post(`/${engagement_type}`, JSON.stringify(data)).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
return {
@@ -55,8 +66,10 @@ async function engage(session, engagement_type, args = {}) {
* @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>}
*/
async function browse(session, action_type, args = {}) {
if (!session.logged_in && action_type !== 'lyrics' && action_type !== 'music_playlist')
throw new Error('You are not signed-in');
if (!session.logged_in && action_type != 'home_feed'
&& action_type !== 'lyrics' && action_type !== 'music_playlist'
&& action_type !== 'playlist')
throw new Error('You are not signed-in');
const data = { context: session.context };
switch (action_type) {
@@ -86,6 +99,7 @@ async function browse(session, action_type, args = {}) {
data.context = context;
data.browseId = args.browse_id;
break;
case 'channel':
case 'playlist':
data.browseId = args.browse_id;
break;
@@ -93,7 +107,7 @@ async function browse(session, action_type, args = {}) {
}
const requester = args.ytmusic && session.YTMRequester || session.YTRequester;
const response = await requester.post('/browse', JSON.stringify(data));
const response = await requester.post('/browse', JSON.stringify(data)).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
return {
@@ -126,10 +140,9 @@ async function account(session, action_type, args = {}) {
data.settingItemId = arts.setting_item_id;
break;
default:
break;
}
const response = await session.YTRequester.post(`/${action_type}`, JSON.stringify(data));
const response = await session.YTRequester.post(`/${action_type}`, JSON.stringify(data)).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
return {
@@ -166,7 +179,7 @@ async function music(session, action_type, args) {
break;
}
const response = await session.YTMRequester.post(`/music/${action_type}`, JSON.stringify(data));
const response = await session.YTMRequester.post(`/music/${action_type}`, JSON.stringify(data)).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
return {
@@ -190,7 +203,7 @@ async function search(session, client, args = {}) {
const data = { context: session.context };
switch (client) {
case 'YOUTUBE':
data.params = Utils.encodeFilter(args.options.period, args.options.duration, args.options.order);
data.params = Proto.encodeSearchFilter(args.options.period, args.options.duration, args.options.order);
data.query = args.query;
break;
case 'YTMUSIC':
@@ -208,7 +221,7 @@ async function search(session, client, args = {}) {
}
const requester = client == 'YOUTUBE' && session.YTRequester || session.YTMRequester;
const response = await requester.post('/search', JSON.stringify(data));
const response = await requester.post('/search', JSON.stringify(data)).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
return {
@@ -234,7 +247,7 @@ async function notifications(session, action_type, args = {}) {
case 'modify_channel_preference':
const pref_types = { PERSONALIZED: 1, ALL: 2, NONE: 3 };
data.context = session.context;
data.params = Utils.encodeNotificationPref(args.channel_id, pref_types[args.pref.toUpperCase()]);
data.params = Proto.encodeNotificationPref(args.channel_id, pref_types[args.pref.toUpperCase()]);
break;
case 'get_notification_menu':
data.context = session.context;
@@ -274,7 +287,7 @@ async function livechat(session, action_type, args = {}) {
break;
case 'live_chat/send_message':
data.context = session.context;
data.params = Utils.encodeMessageParams(args.channel_id, args.video_id);
data.params = Proto.encodeMessageParams(args.channel_id, args.video_id);
data.clientMessageId = `ytjs-${Uuid.v4()}`;
data.richMessage = {
textSegments: [{ text: args.text }]
@@ -301,19 +314,6 @@ async function livechat(session, action_type, args = {}) {
return { success: true, data: response.data };
}
/**
* Retrieves video data.
*
* @param {Innertube} session - A valid Innertube session.
* @param {object} args - Request arguments.
* @returns {Promise.<object>} - Video data.
*/
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}`);
return response.data;
}
/**
* Requests continuation for previously performed actions.
*
@@ -321,7 +321,7 @@ async function getVideoInfo(session, args = {}) {
* @param {object} args - Continuation arguments.
* @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>}
*/
async function getContinuation(session, args = {}) {
async function next(session, args = {}) {
let data = { context: session.context };
args.continuation_token && (data.continuation = args.continuation_token);
@@ -341,16 +341,13 @@ async function getContinuation(session, args = {}) {
data.racyCheckOk = true;
data.contentCheckOk = false;
data.autonavState = 'STATE_NONE';
data.playbackContext = {
vis: 0,
lactMilliseconds: '-1'
}
data.playbackContext = { vis: 0, lactMilliseconds: '-1' };
data.captionsRequested = false;
}
}
const requester = args.ytmusic && session.YTMRequester || session.YTRequester;
const response = await requester.post('/next', JSON.stringify(data));
const response = await requester.post('/next', JSON.stringify(data)).catch((error) => error);
if (response instanceof Error) return {
success: false,
@@ -365,6 +362,19 @@ async function getContinuation(session, args = {}) {
};
}
/**
* Retrieves video data.
*
* @param {Innertube} session - A valid Innertube session.
* @param {object} args - Request arguments.
* @returns {Promise.<object>} - Video data.
*/
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}`);
return response.data;
}
/**
* Gets search suggestions.
*
@@ -373,12 +383,12 @@ async function getContinuation(session, args = {}) {
* @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=${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.response.status,
status_code: response.status,
message: response.message
};
@@ -389,4 +399,4 @@ async function getYTSearchSuggestions(session, query) {
};
}
module.exports = { engage, browse, account, music, search, notifications, livechat, getVideoInfo, getContinuation, getYTSearchSuggestions };
module.exports = { engage, browse, account, music, search, notifications, livechat, getVideoInfo, next, getYTSearchSuggestions };

View File

@@ -34,8 +34,7 @@ module.exports = {
'Accept': 'text/html',
'Accept-Language': 'en-US,en',
'Accept-Encoding': 'gzip'
},
}
};
},
STREAM_HEADERS: {

View File

@@ -83,7 +83,7 @@ class Innertube {
this.#initMethods();
} else {
throw new Error('Could not retrieve Innertube session due to unknown reasons');
throw new Error('No InnerTubeContext shell provided in ytconfig.');
}
} catch (err) {
this.#retry_count += 1;
@@ -328,7 +328,7 @@ class Innertube {
language: menu.sections[1].multiPageMenuSectionRenderer.items[1].compactLinkRenderer.subtitle.simpleText
}
}
/**
* Searches on YouTube.
*
@@ -344,7 +344,7 @@ class Innertube {
async search(query, options = { client: 'YOUTUBE', period: 'any', order: 'relevance', duration: 'any' }) {
const response = await Actions.search(this, options.client, { query, options });
if (!response.success) throw new Error(`Could not search on YouTube: ${response.message}`);
const data = new Parser(this, response.data, {
client: options.client,
data_type: 'SEARCH',
@@ -358,11 +358,12 @@ class Innertube {
* Gets search suggestions.
*
* @param {string} input - The search query.
* @param {string} [client='YOUTUBE'] - Client used to retrieve search suggestions, can be: `YOUTUBE` or `YTMUSIC`.
* @param {object} [options] - Search options.
* @param {string} [options.client='YOUTUBE'] - Client used to retrieve search suggestions, can be: `YOUTUBE` or `YTMUSIC`.
* @returns {Promise.<[{ text: string; bold_text: string }]>}
*/
async getSearchSuggestions(input, { client = 'YOUTUBE' }) {
if (client == 'YOUTUBE') {
async getSearchSuggestions(input, options = { client: 'YOUTUBE' }) {
if (options.client == 'YOUTUBE') {
const response = await Actions.getYTSearchSuggestions(this, input);
if (!response.success) throw new Error('Could not get search suggestions');
@@ -372,7 +373,7 @@ class Innertube {
bold_text: response.data[0].trim()
};
});
} else if (client == 'YTMUSIC') {
} else if (options.client == 'YTMUSIC') {
const response = await Actions.music(this, 'get_search_suggestions', { input });
if (!response.success) throw new Error('Could not get search suggestions');
if (!response.data.contents) return [];
@@ -405,21 +406,10 @@ class Innertube {
if (!video_id) throw new Error('You must provide a video id');
const data = await Actions.getVideoInfo(this, { id: video_id });
const continuation = await Actions.getContinuation(this, { video_id });
const continuation = await Actions.next(this, { video_id });
data.continuation = continuation.data;
const details = new Parser(this, data, { client: 'YOUTUBE', data_type: 'VIDEO_INFO' }).parse();
if (details.metadata.is_live_content) {
const data_continuation = await Actions.getContinuation(this, { video_id });
if (data_continuation.data.contents.twoColumnWatchNextResults.conversationBar) {
details.getLivechat = () => new Livechat(this, data_continuation.data.contents.twoColumnWatchNextResults.conversationBar.liveChatRenderer.continuations[0].reloadContinuationData.continuation, details.metadata.channel_id, video_id);
} else {
details.getLivechat = () => { };
}
} else {
details.getLivechat = () => { };
}
// Functions
details.like = () => Actions.engage(this, 'like/like', { video_id });
@@ -428,12 +418,101 @@ class Innertube {
details.subscribe = () => Actions.engage(this, 'subscription/subscribe', { channel_id: details.metadata.channel_id });
details.unsubscribe = () => Actions.engage(this, 'subscription/unsubscribe', { channel_id: details.metadata.channel_id });
details.comment = (text) => Actions.engage(this, 'comment/create_comment', { video_id, text });
details.getComments = () => this.getComments(video_id);
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' });
return details;
}
/**
* Gets info about a given channel. (WIP)
*
* @param {string} id - The id of the channel.
* @return {Promise.<{ title: string; description: string; metadata: object; content: object }>}
*/
async getChannel(id) {
const response = await Actions.browse(this, 'channel', { browse_id: id });
if (!response.success) throw new Error('Could not retrieve channel info.');
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; // Will support only videos & playlists for now
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: () => {}
}
}
}
/**
* Retrieves the lyrics for a given song if available.
*
@@ -441,9 +520,9 @@ class Innertube {
* @returns {Promise.<string>} Song lyrics
*/
async getLyrics(video_id) {
const data_continuation = await Actions.getContinuation(this, { video_id: video_id, ytmusic: true });
const continuation = await Actions.next(this, { video_id: video_id, ytmusic: true });
const lyrics_tab = data_continuation.data.contents.singleColumnMusicWatchNextResultsRenderer.tabbedRenderer
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 });
@@ -473,52 +552,81 @@ class Innertube {
* Gets the comments section of a video.
*
* @param {string} video_id - The id of the video.
* @param {string} [token] - Continuation token (optional).
* @param {string} [data] - Video data and continuation token (optional).
* @return {Promise.<[{ comments: []; comment_count: string }]>
*/
async getComments(video_id, token) {
async getComments(video_id, data = {}) {
let comment_section_token;
if (!token) {
const data_continuation = await Actions.getContinuation(this, { video_id });
const item_section_renderer = data_continuation.data.contents.twoColumnWatchNextResults.results.results.contents.find((item) => item.itemSectionRenderer);
if (!data.token) {
const continuation = await Actions.next(this, { video_id });
const item_section_renderer = continuation.data.contents.twoColumnWatchNextResults.results.results.contents.find((item) => item.itemSectionRenderer);
comment_section_token = item_section_renderer.itemSectionRenderer.contents[0].continuationItemRenderer.continuationEndpoint.continuationCommand.token;
const secondary_info_renderer = continuation.data.contents.twoColumnWatchNextResults
.results.results.contents.find((item) => item.videoSecondaryInfoRenderer).videoSecondaryInfoRenderer;
data.channel_id = secondary_info_renderer.owner.videoOwnerRenderer.navigationEndpoint.browseEndpoint.browseId;
}
const response = await Actions.getContinuation(this, { continuation_token: comment_section_token || token });
const response = await Actions.next(this, { continuation_token: comment_section_token || data.token });
if (!response.success) throw new Error('Could not fetch comments section');
const comments_section = { comments: [] };
!token && (comments_section.comment_count = response.data.onResponseReceivedEndpoints[0].reloadContinuationItemsCommand.continuationItems && response.data.onResponseReceivedEndpoints[0].reloadContinuationItemsCommand.continuationItems[0].commentsHeaderRenderer.countText.runs[0].text || 'N/A');
!data.token && (comments_section.comment_count = response.data.onResponseReceivedEndpoints[0].reloadContinuationItemsCommand.continuationItems && response.data.onResponseReceivedEndpoints[0].reloadContinuationItemsCommand.continuationItems[0].commentsHeaderRenderer.countText.runs[0].text || 'N/A');
let continuation_token;
!token && (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);
comments_section.getContinuation = () => this.getComments(video_id, continuation_token);
!data.token &&
(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));
continuation_token && (comments_section.getContinuation = () => this.getComments(video_id, { token: continuation_token, channel_id: data.channel_id }));
let contents;
!token && (contents = response.data.onResponseReceivedEndpoints[1].reloadContinuationItemsCommand.continuationItems) ||
!data.token && (contents = response.data.onResponseReceivedEndpoints[1].reloadContinuationItemsCommand.continuationItems) ||
(contents = response.data.onResponseReceivedEndpoints[0].appendContinuationItemsAction.continuationItems);
contents.forEach((thread) => {
if (!thread.commentThreadRenderer) return;
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;
const like_btn = thread?.actionButtons?.commentActionButtonsRenderer.likeButton;
const dislike_btn = thread?.actionButtons?.commentActionButtonsRenderer.dislikeButton;
const comment = {
text: thread.commentThreadRenderer.comment.commentRenderer.contentText.runs.map((t) => t.text).join(' '),
text: thread.contentText.runs.map((t) => t.text).join(' '),
author: {
name: thread.commentThreadRenderer.comment.commentRenderer.authorText.simpleText,
thumbnail: thread.commentThreadRenderer.comment.commentRenderer.authorThumbnail.thumbnails,
channel_id: thread.commentThreadRenderer.comment.commentRenderer.authorEndpoint.browseEndpoint.browseId
name: thread.authorText.simpleText,
thumbnail: thread.authorThumbnail.thumbnails,
channel_id: thread.authorEndpoint.browseEndpoint.browseId
},
metadata: {
published: thread.commentThreadRenderer.comment.commentRenderer.publishedTimeText.runs[0].text,
is_liked: thread.commentThreadRenderer.comment.commentRenderer.isLiked,
is_channel_owner: thread.commentThreadRenderer.comment.commentRenderer.authorIsChannelOwner,
like_count: thread.commentThreadRenderer.comment.commentRenderer.voteCount && thread.commentThreadRenderer.comment.commentRenderer.voteCount.simpleText || 'N/A',
reply_count: thread.commentThreadRenderer.comment.commentRenderer.replyCount || 0,
id: thread.commentThreadRenderer.comment.commentRenderer.commentId,
}
published: thread.publishedTimeText.runs[0].text,
is_liked: like_btn?.toggleButtonRenderer.isToggled,
is_disliked: dislike_btn?.toggleButtonRenderer.isToggled,
is_pinned: thread.pinnedCommentBadge && true || false,
is_channel_owner: thread.authorIsChannelOwner,
like_count: thread?.voteCount?.simpleText || '0',
reply_count: thread.replyCount || 0,
id: thread.commentId,
},
like: () => Actions.engage(this, 'comment/perform_comment_action', { comment_action: 'like', comment_id: thread.commentId, video_id, channel_id: data.channel_id }),
dislike: () => Actions.engage(this, 'comment/perform_comment_action', { comment_action: 'dislike', comment_id: thread.commentId, video_id, channel_id: data.channel_id }),
reply: (text) => Actions.engage(this, 'comment/create_comment_reply', { text, comment_id: thread.commentId, video_id }),
getReplies: () => this.getComments(video_id, { token: replies_token, channel_id: data.channel_id })
};
!replies_token && (delete comment.getReplies);
comments_section.comments.push(comment);
});
@@ -688,7 +796,7 @@ class Innertube {
read: notification.read,
notification_id: notification.notificationId,
};
}).filter((notification_block) => notification_block);
}).filter((notification) => notification);
}
/**

View File

@@ -6,6 +6,10 @@ const EventEmitter = require('events');
class Livechat extends EventEmitter {
constructor(session, token, channel_id, video_id) {
super(session);
if (!token)
throw new Error('Could not retrieve livechat data');
this.ctoken = token;
this.session = session;
this.video_id = video_id;
@@ -16,7 +20,7 @@ class Livechat extends EventEmitter {
this.poll_intervals_ms = 1000;
this.running = true;
this.#poll();
}

View File

@@ -40,7 +40,8 @@ class Parser {
response.query = contents[0].showingResultsForRenderer && contents[0].showingResultsForRenderer.originalQuery.simpleText || this.args.query;
response.corrected_query = contents[0].showingResultsForRenderer && contents[0].showingResultsForRenderer.correctedQueryEndpoint.searchEndpoint.query || this.args.query;
response.estimated_results = parseInt(this.data.estimatedResults);
response.getContinuation = () => {};
response.videos = contents.map((data) => {
if (!data.videoRenderer) return;
const video = data.videoRenderer;
@@ -124,7 +125,7 @@ class Parser {
title: list_item.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text,
artist: list_item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[2].text,
album: list_item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[4].text,
duration: list_item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[6].text,
duration: list_item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs.find((run) => /^\d+$/.test(run.text.replace(/:/g, ''))).text,
thumbnail: list_item.thumbnail.musicThumbnailRenderer.thumbnail,
getLyrics: () => this.session.getLyrics(list_item.playlistItemData.videoId)
};
@@ -139,7 +140,7 @@ class Parser {
title: list_item.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text,
author: list_item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[2].text,
views: list_item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[4].text,
duration: list_item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[6].text,
duration: list_item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs.find((run) => /^\d+$/.test(run.text.replace(/:/g, ''))).text,
thumbnail: list_item.thumbnail.musicThumbnailRenderer.thumbnail,
getLyrics: () => this.session.getLyrics(list_item.playlistItemData.videoId)
};

View File

@@ -1,7 +1,6 @@
'use strict';
const Fs = require('fs');
const Proto = require('protons');
const Crypto = require('crypto');
const UserAgent = require('user-agents');
@@ -81,99 +80,6 @@ function camelToSnake(string) {
return string[0].toLowerCase() + string.slice(1, string.length).replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
}
/**
* Encodes notification preferences protobuf.
*
* @param {string} channel_id
* @param {string} index
* @returns {string}
*/
function encodeNotificationPref(channel_id, index) {
const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/proto/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'));
}
/**
* Encodes livestream message protobuf.
*
* @param {string} channel_id
* @param {string} video_id
* @returns {string}
*/
function encodeMessageParams(channel_id, video_id) {
const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/proto/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 params protobuf.
*
* @param {string} video_id
* @returns {string}
*/
function encodeCommentParams(video_id) {
const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/proto/youtube.proto`));
const buf = youtube_proto.CreateCommentParams.encode({
video_id,
params: {
index: 0
},
number: 7
});
return encodeURIComponent(Buffer.from(buf).toString('base64'));
}
/**
* Encodes search filter protobuf
*
* @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 encodeFilter(period, duration, order) {
const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/proto/youtube.proto`));
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 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'));
}
/**
* Turns the ntoken transform data into a valid json array
*
@@ -190,4 +96,4 @@ function refineNTokenData(data) {
.replace(/""/g, '').replace(/length]\)}"/g, 'length])}');
}
module.exports = { getRandomUserAgent, generateSidAuth, getStringBetweenStrings, camelToSnake, timeToSeconds, encodeMessageParams, encodeCommentParams, encodeNotificationPref, encodeFilter, refineNTokenData };
module.exports = { getRandomUserAgent, generateSidAuth, getStringBetweenStrings, camelToSnake, timeToSeconds, refineNTokenData };

133
lib/proto/index.js Normal file
View File

@@ -0,0 +1,133 @@
'use strict';
const Fs = require('fs');
const Proto = require('protons');
/**
* 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`));
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 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`));
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,
});
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 };

View File

@@ -1,44 +1,75 @@
syntax = "proto2";
package proto;
message NotificationPreferences {
string channel_id = 1;
message Preference {
int32 index = 1;
}
Preference pref_id = 2;
int32 number_0 = 3;
int32 number_1 = 4;
}
message LiveMessageParams {
message Params {
message Ids {
string channel_id = 1;
string video_id = 2;
}
Ids ids = 5;
}
Params params = 1;
int32 number_0 = 2;
int32 number_1 = 3;
}
message CreateCommentParams {
string video_id = 2;
message Params {
int32 index = 1;
}
Params params = 5;
int32 number = 10;
}
message SearchFilter {
int32 number = 1;
message Filter {
int32 param_0 = 1;
int32 param_1 = 2;
int32 param_2 = 3;
}
Filter filter = 2;
syntax = "proto2";
package proto;
message NotificationPreferences {
string channel_id = 1;
message Preference {
int32 index = 1;
}
Preference pref_id = 2;
int32 number_0 = 3;
int32 number_1 = 4;
}
message LiveMessageParams {
message Params {
message Ids {
string channel_id = 1;
string video_id = 2;
}
Ids ids = 5;
}
Params params = 1;
int32 number_0 = 2;
int32 number_1 = 3;
}
message CreateCommentParams {
string video_id = 2;
message Params {
int32 index = 1;
}
Params params = 5;
int32 number = 10;
}
message CreateCommentReplyParams {
string video_id = 2;
string comment_id = 4;
message UnknownParams {
int32 unk_num = 1;
}
UnknownParams params = 5;
int32 unk_num = 10;
}
message PeformCommentActionParams {
int32 type = 1;
int32 unk_num = 2;
string comment_id = 3;
string video_id = 5;
int32 unk_num_1 = 6;
int32 unk_num_2 = 7;
string unk_num_3 = 9;
int32 unk_num_4 = 10;
int32 unk_num_5 = 21;
string channel_id = 23;
int32 unk_num_6 = 30;
}
message SearchFilter {
int32 number = 1;
message Filter {
int32 param_0 = 1;
int32 param_1 = 2;
int32 param_2 = 3;
}
Filter filter = 2;
}

16
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "youtubei.js",
"version": "1.3.7",
"version": "1.3.8",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "youtubei.js",
"version": "1.3.7",
"version": "1.3.8",
"license": "MIT",
"dependencies": {
"axios": "^0.21.4",
@@ -130,9 +130,9 @@
}
},
"node_modules/user-agents": {
"version": "1.0.963",
"resolved": "https://registry.npmjs.org/user-agents/-/user-agents-1.0.963.tgz",
"integrity": "sha512-TzX6aH2dfEbPsTcQk1ZKYCKXU9VUIZy8Vyaiml2pdraKRB1TC6hcPx38M5JLGZicNXyuVInsbSW+xQ30etZmyw==",
"version": "1.0.971",
"resolved": "https://registry.npmjs.org/user-agents/-/user-agents-1.0.971.tgz",
"integrity": "sha512-5gwiuDE6Rmi4YRf1wDedduBwkv1RwdveW295+qMbdnWhc6CFSeVceQ2rpYxP/m022E0f45Z7ednPjerh9SMuXw==",
"dependencies": {
"dot-json": "^1.2.2",
"lodash.clonedeep": "^4.5.0"
@@ -242,9 +242,9 @@
}
},
"user-agents": {
"version": "1.0.963",
"resolved": "https://registry.npmjs.org/user-agents/-/user-agents-1.0.963.tgz",
"integrity": "sha512-TzX6aH2dfEbPsTcQk1ZKYCKXU9VUIZy8Vyaiml2pdraKRB1TC6hcPx38M5JLGZicNXyuVInsbSW+xQ30etZmyw==",
"version": "1.0.971",
"resolved": "https://registry.npmjs.org/user-agents/-/user-agents-1.0.971.tgz",
"integrity": "sha512-5gwiuDE6Rmi4YRf1wDedduBwkv1RwdveW295+qMbdnWhc6CFSeVceQ2rpYxP/m022E0f45Z7ednPjerh9SMuXw==",
"requires": {
"dot-json": "^1.2.2",
"lodash.clonedeep": "^4.5.0"

View File

@@ -1,6 +1,6 @@
{
"name": "youtubei.js",
"version": "1.3.7",
"version": "1.3.8",
"description": "An object-oriented library that allows you to search, get detailed info about videos, subscribe, unsubscribe, like, dislike, comment, download videos and much more!",
"main": "index.js",
"scripts": {