diff --git a/deno/package.json b/deno/package.json index cecf3cb1..fb6fe4ae 100644 --- a/deno/package.json +++ b/deno/package.json @@ -1,6 +1,6 @@ { "name": "youtubei.js", - "version": "9.2.1", + "version": "9.3.0", "description": "A wrapper around YouTube's private API. Supports YouTube, YouTube Music, YouTube Kids and YouTube Studio (WIP).", "type": "module", "types": "./dist/src/platform/lib.d.ts", diff --git a/deno/src/parser/classes/comments/CommentThread.ts b/deno/src/parser/classes/comments/CommentThread.ts index 8dd43a69..3f8db9ff 100644 --- a/deno/src/parser/classes/comments/CommentThread.ts +++ b/deno/src/parser/classes/comments/CommentThread.ts @@ -90,7 +90,7 @@ export default class CommentThread extends YTNode { if (!response.on_response_received_endpoints_memo) throw new InnertubeError('Unexpected response.', response); - this.replies = observe(response.on_response_received_endpoints_memo.getType(Comment).map((comment) => { + this.replies = observe(response.on_response_received_endpoints_memo.getType(Comment, CommentView).map((comment) => { comment.setActions(this.#actions); return comment; })); diff --git a/deno/src/parser/classes/comments/CommentView.ts b/deno/src/parser/classes/comments/CommentView.ts index 962acbf7..82f3216e 100644 --- a/deno/src/parser/classes/comments/CommentView.ts +++ b/deno/src/parser/classes/comments/CommentView.ts @@ -1,15 +1,26 @@ import { YTNode } from '../../helpers.ts'; -import type { RawNode } from '../../index.ts'; - -import type Actions from '../../../core/Actions.ts'; +import NavigationEndpoint from '../NavigationEndpoint.ts'; import Author from '../misc/Author.ts'; import Text from '../misc/Text.ts'; +import CommentReplyDialog from './CommentReplyDialog.ts'; +import { InnertubeError } from '../../../utils/Utils.ts'; +import * as Proto from '../../../proto/index.ts'; + +import type Actions from '../../../core/Actions.ts'; +import type { ApiResponse } from '../../../core/Actions.ts'; +import type { RawNode } from '../../index.ts'; export default class CommentView extends YTNode { static type = 'CommentView'; #actions?: Actions; + like_command?: NavigationEndpoint; + dislike_command?: NavigationEndpoint; + unlike_command?: NavigationEndpoint; + undislike_command?: NavigationEndpoint; + reply_command?: NavigationEndpoint; + comment_id: string; is_pinned: boolean; keys: { @@ -32,6 +43,8 @@ export default class CommentView extends YTNode { }; author?: Author; + test: any; + is_liked?: boolean; is_disliked?: boolean; is_hearted?: boolean; @@ -51,7 +64,7 @@ export default class CommentView extends YTNode { }; } - applyMutations(comment?: RawNode, toolbar_state?: RawNode) { + applyMutations(comment?: RawNode, toolbar_state?: RawNode, toolbar_surface?: RawNode) { if (comment) { this.content = Text.fromAttributed(comment.properties.content); this.published_time = comment.properties.publishedTime; @@ -78,8 +91,148 @@ export default class CommentView extends YTNode { if (toolbar_state) { this.is_hearted = toolbar_state.heartState === 'TOOLBAR_HEART_STATE_HEARTED'; this.is_liked = toolbar_state.likeState === 'TOOLBAR_LIKE_STATE_LIKED'; - this.is_disliked = toolbar_state.likeState === 'TOOLBAR_HEART_STATE_DISLIKED'; + this.is_disliked = toolbar_state.likeState === 'TOOLBAR_LIKE_STATE_DISLIKED'; } + + if (toolbar_surface && !Reflect.has(toolbar_surface, 'prepareAccountCommand')) { + this.like_command = new NavigationEndpoint(toolbar_surface.likeCommand); + this.dislike_command = new NavigationEndpoint(toolbar_surface.dislikeCommand); + this.unlike_command = new NavigationEndpoint(toolbar_surface.unlikeCommand); + this.undislike_command = new NavigationEndpoint(toolbar_surface.undislikeCommand); + this.reply_command = new NavigationEndpoint(toolbar_surface.replyCommand); + } + } + + /** + * Likes the comment. + * @returns A promise that resolves to the API response. + * @throws If the Actions instance is not set for this comment or if the like command is not found. + */ + async like(): Promise { + if (!this.#actions) + throw new InnertubeError('Actions instance not set for this comment.'); + + if (!this.like_command) + throw new InnertubeError('Like command not found.'); + + if (this.is_liked) + throw new InnertubeError('This comment is already liked.', { comment_id: this.comment_id }); + + return this.like_command.call(this.#actions); + } + + /** + * Dislikes the comment. + * @returns A promise that resolves to the API response. + * @throws If the Actions instance is not set for this comment or if the dislike command is not found. + */ + async dislike(): Promise { + if (!this.#actions) + throw new InnertubeError('Actions instance not set for this comment.'); + + if (!this.dislike_command) + throw new InnertubeError('Dislike command not found.'); + + if (this.is_disliked) + throw new InnertubeError('This comment is already disliked.', { comment_id: this.comment_id }); + + return this.dislike_command.call(this.#actions); + } + + /** + * Unlikes the comment. + * @returns A promise that resolves to the API response. + * @throws If the Actions instance is not set for this comment or if the unlike command is not found. + */ + async unlike(): Promise { + if (!this.#actions) + throw new InnertubeError('Actions instance not set for this comment.'); + + if (!this.unlike_command) + throw new InnertubeError('Unlike command not found.'); + + if (!this.is_liked) + throw new InnertubeError('This comment is not liked.', { comment_id: this.comment_id }); + + return this.unlike_command.call(this.#actions); + } + + /** + * Undislikes the comment. + * @returns A promise that resolves to the API response. + * @throws If the Actions instance is not set for this comment or if the undislike command is not found. + */ + async undislike(): Promise { + if (!this.#actions) + throw new InnertubeError('Actions instance not set for this comment.'); + + if (!this.undislike_command) + throw new InnertubeError('Undislike command not found.'); + + if (!this.is_disliked) + throw new InnertubeError('This comment is not disliked.', { comment_id: this.comment_id }); + + return this.undislike_command.call(this.#actions); + } + + /** + * Replies to the comment. + * @param comment_text - The text of the reply. + * @returns A promise that resolves to the API response. + * @throws If the Actions instance is not set for this comment or if the reply command is not found. + */ + async reply(comment_text: string): Promise { + if (!this.#actions) + throw new InnertubeError('Actions instance not set for this comment.'); + + if (!this.reply_command) + throw new InnertubeError('Reply command not found.'); + + const dialog = this.reply_command.dialog?.as(CommentReplyDialog); + + if (!dialog) + throw new InnertubeError('Reply dialog not found.'); + + const reply_button = dialog.reply_button; + + if (!reply_button) + throw new InnertubeError('Reply button not found in the dialog.'); + + if (!reply_button.endpoint) + throw new InnertubeError('Reply button endpoint not found.'); + + return reply_button.endpoint.call(this.#actions, { commentText: comment_text }); + } + + /** + * Translates the comment to the specified target language. + * @param target_language - The target language to translate the comment to, e.g. 'en', 'ja'. + * @returns A Promise that resolves to an ApiResponse object with the translated content, if available. + * @throws if the Actions instance is not set for this comment or if the comment content is not found. + */ + async translate(target_language: string): Promise { + if (!this.#actions) + throw new InnertubeError('Actions instance not set for this comment.'); + + if (!this.content) + throw new InnertubeError('Comment content not found.', { comment_id: this.comment_id }); + + // Emojis must be removed otherwise InnerTube throws a 400 status code at us. + const text = this.content.toString().replace(/[^\p{L}\p{N}\p{P}\p{Z}]/gu, ''); + + const payload = { + text, + target_language + }; + + const action = Proto.encodeCommentActionParams(22, payload); + const response = await this.#actions.execute('comment/perform_comment_action', { action, client: 'ANDROID' }); + + // XXX: Should move this to Parser#parseResponse + const mutations = response.data.frameworkUpdates?.entityBatchUpdate?.mutations; + const content = mutations?.[0]?.payload?.commentEntityPayload?.translatedContent?.content; + + return { ...response, content }; } setActions(actions: Actions | undefined) { diff --git a/deno/src/parser/parser.ts b/deno/src/parser/parser.ts index d8675629..86c8161b 100644 --- a/deno/src/parser/parser.ts +++ b/deno/src/parser/parser.ts @@ -710,7 +710,10 @@ export function applyCommentsMutations(memo: Memo, mutations: RawNode[]) { .find((mutation) => mutation.payload?.engagementToolbarStateEntityPayload?.key === comment_view.keys.toolbar_state) ?.payload?.engagementToolbarStateEntityPayload; - comment_view.applyMutations(comment_mutation, toolbar_state_mutation); + const engagement_toolbar = mutations.find((mutation) => mutation.entityKey === comment_view.keys.toolbar_surface) + ?.payload?.engagementToolbarSurfaceEntityPayload; + + comment_view.applyMutations(comment_mutation, toolbar_state_mutation, engagement_toolbar); } } }