diff --git a/lib/Actions.js b/lib/Actions.js index 47edad1f..f4fd8f8f 100644 --- a/lib/Actions.js +++ b/lib/Actions.js @@ -6,7 +6,7 @@ const Constants = require('./Constants'); const Uuid = require('uuid'); async function engage(session, engagement_type, args = {}) { - if (!session.logged_in) throw new Error('You must be signed-in to interact with a video/channel'); + if (!session.logged_in) throw new Error('You are not logged in'); let data = {}; switch (engagement_type) { case 'like/like': @@ -44,13 +44,34 @@ async function engage(session, engagement_type, args = {}) { }; } +async function browse(session, action_type) { + if (!session.logged_in) throw new Error('You are not logged in'); + let data; + switch (action_type) { + case 'subscriptions_feed': + data = { + context: session.context, + browseId: 'FEsubscriptions' + }; + break; + default: + } + + const response = await Axios.post(`${Constants.urls.YT_BASE_URL}/youtubei/v1/browse${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`, JSON.stringify(data), Constants.innertube_request_opts({ session })).catch((error) => error); + if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message }; + return { + success: true, + status_code: response.status, + data: response.data + }; +} + async function notifications(session, action_type, args = {}) { - if (!session.logged_in) throw new Error('You must be logged in to fetch notifications'); + if (!session.logged_in) throw new Error('You are not logged in'); let data; switch (action_type) { case 'modify_channel_preference': let pref_types = { PERSONALIZED: 1, ALL: 2, NONE: 3 }; - data = { context: session.context, params: Utils.encodeNotificationPref(args.channel_id, pref_types[args.pref.toUpperCase()]) @@ -70,7 +91,7 @@ async function notifications(session, action_type, args = {}) { default: } - const response = await Axios.post(`${Constants.urls.YT_BASE_URL}/youtubei/v1/notification/${action_type}${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`, JSON.stringify(data), Constants.innertube_request_opts({ session, data, desktop: true })).catch((error) => error); + const response = await Axios.post(`${Constants.urls.YT_BASE_URL}/youtubei/v1/notification/${action_type}${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`, JSON.stringify(data), Constants.innertube_request_opts({ session })).catch((error) => error); if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message }; if (action_type === 'modify_channel_preference') return { success: true, status_code: response.status }; return { @@ -83,7 +104,7 @@ async function notifications(session, action_type, args = {}) { async function livechat(session, action_type, args = {}) { let data; switch (action_type) { - case 'live_chat/send_message': + case 'live_chat/send_message': data = { context: session.context, params: Utils.generateMessageParams(args.channel_id, args.video_id), @@ -106,8 +127,8 @@ async function livechat(session, action_type, args = {}) { break; default: } - - const response = await Axios.post(`${Constants.urls.YT_BASE_URL}/youtubei/v1/${action_type}${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`, JSON.stringify(data), Constants.innertube_request_opts({ session, data, params: args.params, desktop: true })).catch((error) => error); + + const response = await Axios.post(`${Constants.urls.YT_BASE_URL}/youtubei/v1/${action_type}${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`, JSON.stringify(data), Constants.innertube_request_opts({ session, params: args.params })).catch((error) => error); if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message }; return { success: true, @@ -135,7 +156,7 @@ async function getContinuation(session, info = {}) { data.captionsRequested = false; } - const response = await Axios.post(`${Constants.urls.YT_BASE_URL}/youtubei/v1/next${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`, JSON.stringify(data), Constants.innertube_request_opts({ session, data, desktop: true })).catch((error) => error); + const response = await Axios.post(`${Constants.urls.YT_BASE_URL}/youtubei/v1/next${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`, JSON.stringify(data), Constants.innertube_request_opts({ session })).catch((error) => error); if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message }; return { success: true, @@ -144,4 +165,4 @@ async function getContinuation(session, info = {}) { }; } -module.exports = { engage, notifications, livechat, getContinuation }; \ No newline at end of file +module.exports = { engage, browse, notifications, livechat, getContinuation }; \ No newline at end of file diff --git a/lib/Constants.js b/lib/Constants.js index 79fabad1..62767895 100644 --- a/lib/Constants.js +++ b/lib/Constants.js @@ -64,10 +64,6 @@ const innertube_request_opts = (info) => { req_opts.headers.authorization = info.session.cookie.length < 1 ? `Bearer ${info.session.access_token}` : info.session.auth_apisid; } - if (info.data) { - req_opts.headers['content-length'] = Buffer.byteLength(JSON.stringify(info.data), 'utf8'); - } - if (info.id) { req_opts.headers.referer = (info.desktop ? urls.YT_BASE_URL : urls.YT_MOBILE_URL) + '/watch?v=' + info.id; } @@ -169,6 +165,7 @@ const formatVideoData = (data, context, desktop) => { video_details.subscribe = () => {}; video_details.unsubscribe = () => {}; video_details.comment = () => {}; + video_details.getComments = () => {}; video_details.setNotificationPref = () => {}; video_details.getLivechat = () => {}; @@ -178,8 +175,7 @@ const formatVideoData = (data, context, desktop) => { return video_details; }; -const filters = (order) => { - // TODO: Do this more efficiently +const filters = (order) => { // TODO: Refactor this crazy thing switch (order) { case 'any,any,relevance': return 'EgIQAQ%3D%3D'; diff --git a/lib/Innertube.js b/lib/Innertube.js index b508fd7a..54a79575 100644 --- a/lib/Innertube.js +++ b/lib/Innertube.js @@ -148,20 +148,109 @@ class Innertube extends EventEmitter { } else { video_data.getLivechat = () => {}; } - + video_data.like = () => Actions.engage(this, 'like/like', { video_id: id }); video_data.dislike = () => Actions.engage(this, 'like/dislike', { video_id: id }); video_data.removeLike = () => Actions.engage(this, 'like/removelike', { video_id: id }); video_data.subscribe = () => Actions.engage(this, 'subscription/subscribe', { video_id: id, channel_id: video_data.metadata.channel_id }); video_data.unsubscribe = () => Actions.engage(this, 'subscription/unsubscribe', { video_id: id, channel_id: video_data.metadata.channel_id }); video_data.comment = text => Actions.engage(this, 'comment/create_comment', { video_id: id, text }); + video_data.getComments = () => this.getComments(id); video_data.setNotificationPref = pref => Actions.notifications(this, 'modify_channel_preference', { channel_id: video_data.metadata.channel_id, pref: pref || 'NONE' }); return video_data; } + + async getComments(video_id, token) { + 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); + comment_section_token = item_section_renderer.itemSectionRenderer.contents[0].continuationItemRenderer.continuationEndpoint.continuationCommand.token; + } + + const response = await Actions.getContinuation(this, { continuation_token: comment_section_token || token }); + if (!response.success) throw new Error('Could not fetch comment 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'); + + 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); + + let contents; + !token && (contents = response.data.onResponseReceivedEndpoints[1].reloadContinuationItemsCommand.continuationItems) + || (contents = response.data.onResponseReceivedEndpoints[0].appendContinuationItemsAction.continuationItems); + + contents.forEach((thread) => { + if (!thread.commentThreadRenderer) return; + const comment = { + text: thread.commentThreadRenderer.comment.commentRenderer.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 + }, + 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.simpleText, + reply_count: thread.commentThreadRenderer.comment.commentRenderer.replyCount || 0, + id: thread.commentThreadRenderer.comment.commentRenderer.commentId, + } + }; + comments_section.comments.push(comment); + }); + + return comments_section; + } + + async getSubscriptionsFeed() { + const response = await Actions.browse(this, 'subscriptions_feed'); + if (!response.success) throw new Error('Could not fetch subscriptions feed'); + + const contents = response.data.contents.twoColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents; + const subscriptions_feed = {}; + + contents.forEach((section) => { + if (!section.itemSectionRenderer) return; + + const section_contents = section.itemSectionRenderer.contents[0]; + const section_items = section_contents.shelfRenderer.content.gridRenderer.items; + + const key = section_contents.shelfRenderer.title.runs[0].text; + subscriptions_feed[key.toLowerCase().replace(/ +/g, '_')] = []; + + section_items.forEach((item) => { + const content = { + title: item.gridVideoRenderer.title.runs.map((run) => run.text).join(' '), + id: item.gridVideoRenderer.videoId, + channel: item.gridVideoRenderer.shortBylineText && item.gridVideoRenderer.shortBylineText.runs[0].text || 'N/A', + metadata: { + view_count: item.gridVideoRenderer.viewCountText && item.gridVideoRenderer.viewCountText.simpleText || 'N/A', + thumbnail: item.gridVideoRenderer.thumbnail && item.gridVideoRenderer.thumbnail.thumbnails || [], + published: item.gridVideoRenderer.publishedTimeText && item.gridVideoRenderer.publishedTimeText.simpleText || 'N/A', + badges: item.gridVideoRenderer.badges && item.gridVideoRenderer.badges.map((badge) => badge.metadataBadgeRenderer.label) || 'N/A', + owner_badges: item.gridVideoRenderer.ownerBadges && item.gridVideoRenderer.ownerBadges.map((badge) => badge.metadataBadgeRenderer.tooltip) || 'N/A' + } + }; + + subscriptions_feed[key.toLowerCase().replace(/ +/g, '_')].push(content); + }); + }); + + return subscriptions_feed; + } async getNotifications() { const response = await Actions.notifications(this, 'get_notification_menu'); + if (!response.success) throw new Error('Could not fetch notifications'); + const contents = response.data.actions[0].openPopupAction.popup.multiPageMenuRenderer.sections[0]; if (!contents.multiPageMenuNotificationSectionRenderer) return { error: 'You don\'t have any notification.' }; return contents.multiPageMenuNotificationSectionRenderer.items.map((notification) => { @@ -182,6 +271,7 @@ class Innertube extends EventEmitter { async getUnseenNotificationsCount() { const response = await Actions.notifications(this, 'get_unseen_count'); + if (!response.success) throw new Error('Could not fetch unseen notifications count'); return response.data.unseenCount; } @@ -326,10 +416,10 @@ class Innertube extends EventEmitter { const downloadChunk = async () => { if (chunk_end >= selected_format.contentLength) end = true; - const response = await Axios.get(selected_format.url, { + const response = await Axios.get(`${selected_format.url}&range=${chunk_start}-${chunk_end || ''}`, { responseType: 'stream', cancelToken: new CancelToken(function executor(c) { cancel = c; }), - headers: Constants.stream_headers(`bytes=${chunk_start}-${chunk_end || ''}`) + headers: Constants.stream_headers() }).catch((error) => error); if (response instanceof Error) { diff --git a/lib/Livechat.js b/lib/Livechat.js index 8c8f4e86..12e7bcb6 100644 --- a/lib/Livechat.js +++ b/lib/Livechat.js @@ -21,7 +21,7 @@ class Livechat extends EventEmitter { this.poll(); } - + enqueueActionGroup(group) { group.forEach((action) => { if (!action.addChatItemAction) return; //TODO: handle different action types */ @@ -38,7 +38,7 @@ class Livechat extends EventEmitter { timestamp: message_content.timestampUsec, id: message_content.id }; - + this.message_queue.push(message); }); } @@ -62,9 +62,9 @@ class Livechat extends EventEmitter { setTimeout(() => this.emit('chat-update', message), message.timestamp / 1000 - new Date().getTime()); this.id_cache.push(message.id); }); - + this.message_queue = []; - + data = { context: this.session.context, videoId: this.video_id }; if (this.metadata_ctoken) data.continuation = this.metadata_ctoken; @@ -81,23 +81,23 @@ class Livechat extends EventEmitter { short_view_count: metadata[0].updateViewershipAction.viewCount.videoViewCountRenderer.extraShortViewCount.simpleText } }); - + this.livechat_poller = setTimeout(async () => await this.poll(), this.poll_intervals_ms); } - + async sendMessage(text) { const message = await Actions.livechat(this.session, 'live_chat/send_message', { text, channel_id: this.channel_id, video_id: this.video_id }); if (!message.success) return message; - + const deleteMessage = async () => { const menu = await Actions.livechat(this.session, 'live_chat/get_item_context_menu', { params: { params: message.data.actions[0].addChatItemAction.item.liveChatTextMessageRenderer.contextMenuEndpoint.liveChatItemContextMenuEndpoint.params, pbj: 1 } }); if (!menu.success) return menu; - + const chat_item_menu = menu.data.liveChatItemContextMenuSupportedRenderers.menuRenderer.items[0]; - + const cmd = await Actions.livechat(this.session, 'live_chat/moderate', { cmd_params: chat_item_menu.menuServiceItemRenderer.serviceEndpoint.moderateLiveChatEndpoint.params }); if (!cmd.success) return cmd; - + return { success: true, status_code: cmd.status_code }; }; @@ -117,9 +117,9 @@ class Livechat extends EventEmitter { } }; } - + async blockUser(msg_params) { - /* TODO: Implement this */ + /* TODO: Implement this */ throw new Error('Not implemented'); } diff --git a/lib/Utils.js b/lib/Utils.js index 35fd20e3..88e12029 100644 --- a/lib/Utils.js +++ b/lib/Utils.js @@ -43,7 +43,7 @@ function createFunction(input, raw_code) { // I hate this 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: { @@ -52,24 +52,24 @@ function encodeNotificationPref(channel_id, index) { number_0: 0, number_1: 4 }); - + return encodeURIComponent(Buffer.from(buf).toString('base64')); } function generateMessageParams(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 + channel_id, + video_id } }, number_0: 1, number_1: 4 }); - + return Buffer.from(encodeURIComponent(Buffer.from(buf).toString('base64'))).toString('base64'); }