Compare commits

..

4 Commits

Author SHA1 Message Date
LuanRT
044ab16a0c fix(CommentThread.ts): Handle subthread being empty in is_prepopulated
Happens when the old format is received.

This doesn't fix the problem that this isn't backward compatible, though.
2026-06-25 03:40:42 -03:00
LuanRT
28de687945 chore: catch up with main branch 2026-06-25 03:16:50 -03:00
LuanRT
524db0dd7b feat(CommentThread.ts): Add support for sub threads 2026-06-25 03:16:05 -03:00
LuanRT
bf6067b8b7 chore(Comments.ts): Improve error messages a bit 2026-06-25 03:09:55 -03:00
5 changed files with 165 additions and 51 deletions

View File

@@ -1,6 +1,9 @@
import { Parser } from '../../index.js';
import Button from '../Button.js';
import Thumbnail from '../misc/Thumbnail.js';
import CommentView from './CommentView.js';
import CommentThread from './CommentThread.js';
import ContinuationItem from '../ContinuationItem.js';
import { YTNode, type ObservedArray } from '../../helpers.js';
import type { RawNode } from '../../index.js';
@@ -8,15 +11,17 @@ import type { RawNode } from '../../index.js';
export default class CommentReplies extends YTNode {
static type = 'CommentReplies';
contents: ObservedArray<YTNode>;
view_replies: Button | null;
hide_replies: Button | null;
view_replies_creator_thumbnail: Thumbnail[];
has_channel_owner_replied: boolean;
public contents: ObservedArray<CommentView | ContinuationItem>;
public sub_threads: ObservedArray<CommentThread | ContinuationItem>;
public view_replies: Button | null;
public hide_replies: Button | null;
public view_replies_creator_thumbnail: Thumbnail[];
public has_channel_owner_replied: boolean;
constructor(data: RawNode) {
super();
this.contents = Parser.parseArray(data.contents);
this.contents = Parser.parseArray(data.contents, [ CommentView, ContinuationItem ]);
this.sub_threads = Parser.parseArray(data.subThreads, [ CommentThread, ContinuationItem ]);
this.view_replies = Parser.parseItem(data.viewReplies, Button);
this.hide_replies = Parser.parseItem(data.hideReplies, Button);
this.view_replies_creator_thumbnail = Thumbnail.fromResponse(data.viewRepliesCreatorThumbnail);

View File

@@ -1,25 +1,34 @@
import { Parser } from '../../index.js';
import Button from '../Button.js';
import ContinuationItem from '../ContinuationItem.js';
import CommentView from './CommentView.js';
import CommentReplies from './CommentReplies.js';
import CommentsContinuation from '../misc/CommentsContinuation.js';
import AppendContinuationItemsAction from '../actions/AppendContinuationItemsAction.js';
import { InnertubeError } from '../../../utils/Utils.js';
import { observe, YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';
import type Actions from '../../../core/Actions.js';
import type { Memo, ObservedArray } from '../../helpers.js';
import type { ObservedArray } from '../../helpers.js';
export default class CommentThread extends YTNode {
static type = 'CommentThread';
public comment: CommentView | null;
public replies?: ObservedArray<CommentView>;
public replies?: ObservedArray<CommentThread>;
public comment_replies_data: CommentReplies | null;
public is_moderated_elq_comment: boolean;
public has_replies: boolean;
public rendering_priority?:
| 'RENDERING_PRIORITY_UNKNOWN'
| 'RENDERING_PRIORITY_PINNED_COMMENT'
| 'RENDERING_PRIORITY_LINKED_COMMENT'
| 'RENDERING_PRIORITY_REALTIME_COMMENT'
| 'RENDERING_PRIORITY_COMMUNITY_GUIDELINES_BELOW_HEADER'
| 'RENDERING_PRIORITY_FAN_COMMUNITY_SETUP_CARD'
| 'RENDERING_PRIORITY_COMMENT_HEADER';
#actions?: Actions;
#continuation?: ContinuationItem;
@@ -28,37 +37,58 @@ export default class CommentThread extends YTNode {
this.comment = Parser.parseItem(data.commentViewModel, CommentView);
this.comment_replies_data = Parser.parseItem(data.replies, CommentReplies);
this.is_moderated_elq_comment = data.isModeratedElqComment;
this.rendering_priority = data.renderingPriority;
this.has_replies = !!this.comment_replies_data;
}
/**
* Indicates whether this comment thread has more replies that can be fetched.
*/
get has_continuation(): boolean {
if (!this.replies)
throw new InnertubeError('Cannot determine if there is a continuation because this thread\'s replies have not been loaded.');
throw new InnertubeError('Cannot determine if there is a continuation because this comment thread\'s replies have not been loaded');
return !!this.#continuation;
}
/**
* Indicates whether this comment thread has prepopulated reply data. If false, you will need to call {@link CommentThread.getReplies} to fetch the initial batch of replies.
*/
get is_prepopulated(): boolean {
return !!this.comment_replies_data && this.comment_replies_data.sub_threads[0]?.is(CommentThread);
}
/**
* Retrieves replies to this comment thread.
*/
async getReplies(): Promise<CommentThread> {
if (!this.#actions)
throw new InnertubeError('Actions instance not set for this thread.');
throw new InnertubeError('Actions instance not set for this comment thread');
if (!this.comment_replies_data)
throw new InnertubeError('This comment has no replies.', this);
throw new InnertubeError('This comment thread has no replies', this);
const continuation = this.comment_replies_data.contents?.firstOfType(ContinuationItem);
if (!this.is_prepopulated) {
const continuation = this.comment_replies_data.sub_threads.firstOfType(ContinuationItem);
if (!continuation)
throw new InnertubeError('Replies continuation not found.');
if (!continuation)
throw new InnertubeError('Replies continuation item not found');
const response = await continuation.endpoint.call(this.#actions, { parse: true });
let endpoint = continuation.endpoint;
if (!response.on_response_received_endpoints_memo)
throw new InnertubeError('Unexpected response.', response);
if (continuation.button)
endpoint = continuation.button.endpoint;
this.replies = this.#getPatchedReplies(response.on_response_received_endpoints_memo);
this.#continuation = response.on_response_received_endpoints_memo.getType(ContinuationItem)[0];
const response = await endpoint.call(this.#actions, { parse: true });
if (!response.on_response_received_endpoints)
throw new InnertubeError('Unexpected response', response);
const appendContinuationItemsNode = response.on_response_received_endpoints.firstOfType(AppendContinuationItemsAction);
if (appendContinuationItemsNode) {
this.#processList(appendContinuationItemsNode.contents.as(CommentThread, ContinuationItem));
}
}
return this;
}
@@ -66,40 +96,56 @@ export default class CommentThread extends YTNode {
/**
* Retrieves next batch of replies.
*/
async getContinuation(): Promise<CommentThread> {
async getContinuation(): Promise<CommentsContinuation> {
if (!this.replies)
throw new InnertubeError('Cannot retrieve continuation because this thread\'s replies have not been loaded.');
throw new InnertubeError('Cannot retrieve continuation because this comment thread\'s replies have not been loaded');
if (!this.#continuation)
throw new InnertubeError('Continuation not found.');
throw new InnertubeError('No continuation item found');
if (!this.#actions)
throw new InnertubeError('Actions instance not set for this thread.');
throw new InnertubeError('Actions instance not set for this comment thread');
const load_more_button = this.#continuation.button?.as(Button);
const loadMoreButton = this.#continuation.button?.as(Button);
if (!load_more_button)
throw new InnertubeError('"Load more" button not found.');
if (!loadMoreButton)
throw new InnertubeError('Cannot retrieve continuation because the "Load more" button is missing');
const response = await load_more_button.endpoint.call(this.#actions, { parse: true });
const response = await loadMoreButton.endpoint.call(this.#actions, { parse: true });
if (!response.on_response_received_endpoints_memo)
throw new InnertubeError('Unexpected response.', response);
this.replies = this.#getPatchedReplies(response.on_response_received_endpoints_memo);
this.#continuation = response.on_response_received_endpoints_memo.getType(ContinuationItem)[0];
return this;
return new CommentsContinuation(this.#actions, response);
}
setActions(actions: Actions) {
/**
* @internal
*/
public setActions(actions: Actions): void {
this.#actions = actions;
}
#getPatchedReplies(data: Memo): ObservedArray<CommentView> {
return observe(data.getType(CommentView).map((comment) => {
comment.setActions(this.#actions);
return comment;
}));
/**
* @internal
*/
public processRepliesData(): void {
if (this.is_prepopulated && !this.replies) {
this.#processList(this.comment_replies_data!.sub_threads);
}
}
#processList(contents: ObservedArray<CommentThread | ContinuationItem>): void {
if (!this.#actions)
throw new InnertubeError('Actions instance not set for this comment thread');
this.replies = observe([]);
for (const item of contents) {
if (item.is(CommentThread)) {
item.setActions(this.#actions);
item.processRepliesData();
this.replies.push(item);
} else if (item.is(ContinuationItem)) {
this.#continuation = item;
}
}
}
}

View File

@@ -0,0 +1,61 @@
import { InnertubeError } from '../../../utils/Utils.js';
import { type ObservedArray, observe } from '../../helpers.js';
import AppendContinuationItemsAction from '../actions/AppendContinuationItemsAction.js';
import CommentThread from '../comments/CommentThread.js';
import ContinuationItem from '../ContinuationItem.js';
import type { Actions } from '../../../core/index.js';
import type { INextResponse } from '../../types/index.js';
export default class CommentsContinuation {
public replies: ObservedArray<CommentThread> = observe([]);
readonly #actions: Actions;
readonly #nextContinuationItem?: ContinuationItem;
constructor(actions: Actions, data: INextResponse) {
this.#actions = actions;
if (!data.on_response_received_endpoints || !data.on_response_received_endpoints_memo) {
throw new InnertubeError('Invalid response received for comments continuation', data);
}
const appendContinuationItemsNode = data.on_response_received_endpoints.firstOfType(AppendContinuationItemsAction);
if (appendContinuationItemsNode) {
for (const item of appendContinuationItemsNode.contents) {
if (item.is(CommentThread)) {
item.setActions(this.#actions);
item.processRepliesData();
this.replies.push(item);
} else if (item.is(ContinuationItem)) {
this.#nextContinuationItem = item;
}
}
}
}
/**
* Indicates whether this comment thread has more replies that can be fetched.
*/
get has_continuation(): boolean {
return !!this.#nextContinuationItem;
}
/**
* Retrieves next batch of replies.
*/
public async getContinuation(): Promise<CommentsContinuation> {
if (!this.#nextContinuationItem)
throw new InnertubeError('No continuation item found');
const loadMoreButton = this.#nextContinuationItem.button;
if (!loadMoreButton)
throw new InnertubeError('"Load more" button not found in continuation item');
const response = await loadMoreButton.endpoint.call(this.#actions, { parse: true });
return new CommentsContinuation(this.#actions, response);
}
}

View File

@@ -6,6 +6,7 @@ export { default as AccessibilityData } from './classes/misc/AccessibilityData.j
export { default as Author } from './classes/misc/Author.js';
export { default as ChildElement } from './classes/misc/ChildElement.js';
export { default as CommandContext } from './classes/misc/CommandContext.js';
export { default as CommentsContinuation } from './classes/misc/CommentsContinuation.js';
export { default as EmojiRun } from './classes/misc/EmojiRun.js';
export { default as Format } from './classes/misc/Format.js';
export { default as RendererContext } from './classes/misc/RendererContext.js';

View File

@@ -27,7 +27,7 @@ export default class Comments {
const contents = this.#page.on_response_received_endpoints;
if (!contents)
throw new InnertubeError('Comments page did not have any content.');
throw new InnertubeError('The comments page did not have any content');
const header_node = contents.at(0)?.as(AppendContinuationItemsAction, ReloadContinuationItemsCommand);
const body_node = contents.at(1)?.as(AppendContinuationItemsAction, ReloadContinuationItemsCommand);
@@ -40,6 +40,7 @@ export default class Comments {
if (thread.comment)
thread.comment.setActions(this.#actions);
thread.setActions(this.#actions);
thread.processRepliesData();
return thread;
}));
@@ -52,7 +53,7 @@ export default class Comments {
*/
async applySort(sort: 'TOP_COMMENTS' | 'NEWEST_FIRST'): Promise<Comments> {
if (!this.header)
throw new InnertubeError('Page header is missing. Cannot apply sort option.');
throw new InnertubeError('Could not apply sort because the comments header is missing');
let button;
@@ -63,7 +64,7 @@ export default class Comments {
}
if (!button)
throw new InnertubeError('Could not find target button.');
throw new InnertubeError('Could not apply sort because the sort button is missing');
if (button.selected)
return this;
@@ -79,15 +80,15 @@ export default class Comments {
*/
async createComment(text: string): Promise<ApiResponse> {
if (!this.header)
throw new InnertubeError('Page header is missing. Cannot create comment.');
throw new InnertubeError('Comment could not be created because the page header is missing');
const button = this.header.create_renderer?.as(CommentSimplebox).submit_button;
if (!button)
throw new InnertubeError('Could not find target button. You are probably not logged in.');
throw new InnertubeError('Comment could not be created because the comment button is missing');
if (!button.endpoint)
throw new InnertubeError('Button does not have an endpoint.');
throw new InnertubeError('Comment could not be created because the comment button does not have an endpoint');
return await button.endpoint.call(this.#actions, { commentText: text });
}
@@ -97,7 +98,7 @@ export default class Comments {
*/
async getContinuation(): Promise<Comments> {
if (!this.#continuation)
throw new InnertubeError('Continuation not found');
throw new InnertubeError('No continuation item found');
const data = await this.#continuation.endpoint.call(this.#actions, { parse: true });
@@ -105,7 +106,7 @@ export default class Comments {
const page = Object.assign({}, this.#page);
if (!page.on_response_received_endpoints || !data.on_response_received_endpoints)
throw new InnertubeError('Invalid reponse format, missing on_response_received_endpoints.');
throw new InnertubeError('Invalid reponse format, missing on_response_received_endpoints');
// Remove previous items and append the continuation.
page.on_response_received_endpoints.pop();