Compare commits

..

8 Commits

Author SHA1 Message Date
LuanRT
2842b1d917 chore(release): v2.9.0 2023-01-11 05:41:02 -03:00
LuanRT
870b2811d9 chore(Comments): reword a few things in the docs 2023-01-10 23:25:31 -03:00
LuanRT
1aedbd3ea6 refactor(ytmusic): minor improvements to Library 2023-01-10 23:24:12 -03:00
LuanRT
e8af2a603d fix(Playlist): trying to parse an already parsed response (#286)
This resulted in a 'InnertubeError: Type not found!' which was then followed by 'InnertubeError: This playlist does not exist' when retrieving the last page of a long playlist.
2023-01-10 17:18:16 -03:00
LuanRT
8e37efa575 refactor: improve livechat parser & add remaining action nodes (#285)
* refactor: improve live chat parsers & add missing nodes

* chore: update example and docs

* docs: rephrasing/formatting

* chore: remove unneeded test (unrelated)
2023-01-10 01:44:51 -03:00
absidue
5a362a0bd5 feat(EmojiRun): Add is_custom to identify custom emojis (#283) 2023-01-10 01:43:18 -03:00
absidue
89ee68b084 refactor(LiveChat): Only store required video info values (#281) 2023-01-09 16:45:02 -03:00
LuanRT
dca61c3a22 feat: finalize comment section nodes (#280)
* fix: comment translation proto missing channel id

* feat: finalize nodes

* docs: update API ref

* chore: update tests
2023-01-09 08:14:31 -03:00
64 changed files with 1001 additions and 353 deletions

View File

@@ -158,8 +158,8 @@ Retrieves library.
- `<library>#applyFilter(filter)`
- Applies given filter to the library.
- `<library>#applySortFilter(filter)`
- Applies given sort filter to the library items.
- `<library>#applySort(sort_by)`
- Applies given sort option to the library items.
- `<library>#getContinuation()`
- Retrieves continuation of the library items.
@@ -170,8 +170,8 @@ Retrieves library.
- `<library>#filters`
- Returns available filters.
- `<library>#sort_filters`
- Returns available sort filters.
- `<library>#sort_options`
- Returns available sort options.
- `<library>#page`
- Returns original InnerTube response (sanitized).

View File

@@ -1,5 +1,5 @@
## Comment
Contains information about a single comment. A [`Comment`](../../lib/parser/contents/classes/Comment.js) can be a top-level comment or a reply to a top-level comment.
Contains information about a single comment. A [`Comment`](../../src/parser/classes/comments/Comment.ts) can be a top-level comment or a reply to a top-level comment.
## API

View File

@@ -9,27 +9,42 @@ A `CommentThread` represents a top-level comment and its replies.
* [.replies](#replies) ⇒ `Comment[]`
* [.getReplies](#getreplies) ⇒ `function`
* [.getContinuation](#getcontinuation) ⇒ `function`
* [.has_continuation](#hascontinuation) ⇒ `boolean`
* [.has_replies](#hasreplies) ⇒ `boolean`
<a name="comment"></a>
### comment
The top-level comment. **Note:** More about `Comment` [here](./Comment.md).
**Type:** [`Comment`](../../lib/parser/contents/classes/Comment.js)
**Type:** [`Comment`](../../src/parser/classes/comments/Comment.ts)
<a name="replies"></a>
### replies
An array of replies to the top-level comment. (not populated until [`getReplies()`](#getreplies) is called).
**Type:** [`Comment[]`](../../lib/parser/contents/classes/Comment.js)
**Type:** [`Comment[]`](../../src/parser/classes/comments/Comment.ts)
<a name="getreplies"></a>
### getReplies()
Retrieves replies to the top-level comment and attaches a [`replies`](#replies) array to the original `CommentThread` object and returns it.
**Returns:** [`Promise.<CommentThread>`](../../lib/parser/contents/classes/CommentThread.js)
**Returns:** [`Promise.<CommentThread>`](../../src/parser/classes/comments/CommentThread.ts)
<a name="getcontinuation"></a>
### getContinuation()
Retrieves next batch of replies and adds them to the [`replies`](#replies) array. **Note:** [`getReplies()`](#getreplies) must be called before using this.
**Returns:** [`Promise.<CommentThread>`](../../lib/parser/contents/classes/CommentThread.js)
**Returns:** [`Promise.<CommentThread>`](../../src/parser/classes/comments/CommentThread.ts)
<a name="hascontinuation"></a>
### has_continuation
Whether there are more replies to be retrieved.
**Type:** `boolean`
<a name="hasreplies"></a>
### has_replies
Whether there are replies to the top-level comment.
**Type:** `boolean`

View File

@@ -1,8 +1,8 @@
## Comments
YouTube.js has full support for comments, including comment actions such as liking, disliking, replying etc.
YouTube.js has full support for comments, including comment actions such as translating, liking, disliking and replying.
## Usage
Get a [`Comments`](../../lib/parser/youtube/Comments.js) instance:
Get a [`Comments`](../../src/parser/youtube/Comments.ts) instance:
```js
const comments = await yt.getComments(VIDEO_ID);
@@ -11,15 +11,27 @@ const comments = await yt.getComments(VIDEO_ID);
## API
* Comments
* [.contents](#commentthread) ⇒ `CommentThread[]`
* [.applySort](#applysort) ⇒ `function`
* [.createComment](#createComment) ⇒ `function`
* [.getContinuation](#getc) ⇒ `function`
* [.has_continuation](#has_continuation) ⇒ `getter`
* [.page](#page) ⇒ `getter`
<a name="commentthread"></a>
### contents
A list of comment threads. **Note:** More about comment threads [**here**](./CommentThread.md).
**Type:** [`CommentThread[]`](../../lib/parser/contents/classes/CommentThread.js)
**Type:** [`CommentThread[]`](../../src/parser/classes/comments/CommentThread.ts)
<a name="applysort"></a>
### applySort(sort)
Applies given sort option to the comments.
| Param | Type | Description |
| --- | --- | --- |
| sort | `string` | Sort option. Can be `TOP_COMMENTS`, `NEWEST_FIRST` |
**Returns:** [`Promise.<Comments>`](../../src/parser/youtube/Comments.ts)
<a name="createComment"></a>
### createComment(text)
@@ -35,7 +47,13 @@ Creates a top-level comment.
### getContinuation()
Retrieves next batch of comment threads.
**Returns:** [`Promise.<Comments>`](../../lib/parser/youtube/Comments.ts)
**Returns:** [`Promise.<Comments>`](../../src/parser/youtube/Comments.ts)
<a name="has_continuation"></a>
### has_continuation
Returns whether there are more comments to be fetched.
**Type:** `boolean`
<a name="page"></a>
### page

View File

@@ -3,37 +3,43 @@ import { Innertube, UniversalCache } from 'youtubei.js';
(async () => {
const yt = await Innertube.create({ cache: new UniversalCache() });
const comments = await yt.getComments('a-rqu-hjobc');
console.info(`This video has ${comments.header?.comments_count.toString() || 'N/A'} comments.\n`);
const comment_section = await yt.getComments('a-rqu-hjobc');
for (const thread of comments.contents) {
console.info(`This video has ${comment_section.header?.comments_count.toString() || 'N/A'} comments.\n`);
for (const thread of comment_section.contents) {
const comment = thread.comment;
if (comment) {
console.info(
`${comment.is_pinned ? '[Pinned]' : ''}`,
`${comment.is_member ? `${comment.sponsor_comment_badge?.tooltip}` : ''}`,
`${comment.author.name}${comment.published}\n`,
`${comment.content.toString()}`, '\n',
`Likes: ${comment.vote_count.short_text}`, '\n'
`Likes: ${comment.vote_count}`, '\n'
);
if (comment.reply_count > 0) {
if (thread.has_replies) {
console.info('Replies:', '\n');
const comment_thread = await thread.getReplies();
if (comment_thread.replies) {
for (const reply of comment_thread.replies) {
let comment_thread = await thread.getReplies();
while (true) {
for (const reply of comment_thread?.replies || []) {
console.info(
`> ${reply.author.name}${reply.published}\n`,
`${reply.content.toString()}`, '\n',
`Likes: ${reply.vote_count.short_text}`, '\n'
`Likes: ${reply.vote_count}`, '\n'
);
}
try {
comment_thread = await comment_thread.getContinuation();
} catch { break; };
}
}
}
console.log('\n');
}
})();

View File

@@ -1,16 +1,16 @@
## Live Chat
The library's Live Chat parser and poller were heavily based on YouTube's original compiled code, this makes it behave in a similar if not identical way to YouTube's Live Chat. Here you can do all sorts of funny things, ex; track messages, donations, polls, and much more.
Represents a livestream chat.
## Usage
Before fetching a Live Chat, you have to retrieve the target livestream's info:
Before fetching a live chat, you have to retrieve the target livestream's info:
```js
const info = await yt.getInfo('video_id');
```
Then you may request a Live Chat instance:
Then you may request a live chat instance:
```js
const livechat = await info.getLiveChat();
```
@@ -21,6 +21,7 @@ const livechat = await info.getLiveChat();
* [.ev](#ev) ⇒ `EventEmitter`
* [.start](#start) ⇒ `function`
* [.stop](#stop) ⇒ `function`
* [.applyFilter](#applyfilter) ⇒ `function`
* [.getItemMenu](#getitemmenu) ⇒ `function`
* [.sendMessage](#sendmessage) ⇒ `function`
@@ -31,6 +32,8 @@ Live Chat's EventEmitter.
**Events:**
- `start`
Fired when the live chat is started.
Arguments:
| Type | Description |
@@ -38,18 +41,35 @@ Live Chat's EventEmitter.
| `LiveChatContinuation` | Initial chat data, actions, info, etc. |
- `chat-update`
Fired when a new chat action is received.
Arguments:
| Type | Description |
| --- | --- |
| `ChatAction` | Chat Action |
| `ChatAction` | Chat action |
- `metadata-update`
Fired when the livestream's metadata is updated.
Arguments:
| Type | Description |
| --- | --- |
| `LiveMetadata` | LiveStream Metadata |
| `LiveMetadata` | Livestream metadata |
- `error`
Fired when an error occurs.
Arguments:
| Type | Description |
| --- | --- |
| `Error` | Details about the error |
- `end`
Fired when the livestream ends.
<a name="start"></a>
### start()
@@ -59,6 +79,15 @@ Starts the Live Chat.
### stop()
Stops the Live Chat.
<a name="applyfilter"></a>
### applyFilter(filter)
Applies given filter to the live chat.
| Param | Type | Description |
| --- | --- | --- |
| filter | `string` | Can be `TOP_CHAT` or `LIVE_CHAT` |
<a name="getitemmenu"></a>
### getItemMenu(item)
Retrieves given chat item's menu.

View File

@@ -1,25 +1,38 @@
import { Innertube, UniversalCache, YTNodes } from 'youtubei.js';
import { LiveChatContinuation } from 'youtubei.js/dist/src/parser';
import { ChatAction, LiveMetadata } from 'youtubei.js/dist/src/parser/youtube/LiveChat';
(async () => {
const yt = await Innertube.create({ cache: new UniversalCache() });
const yt = await Innertube.create({ cache: new UniversalCache(), generate_session_locally: true });
const search = await yt.search('Lofi girl live');
const search = await yt.search('lofi hip hop radio - beats to relax/study to');
const info = await yt.getInfo(search.videos[0].as(YTNodes.Video).id);
const livechat = info.getLiveChat();
livechat.on('start', (initial_data: LiveChatContinuation) => {
/**
* Initial info is what you see when you first open a Live Chat — this is; inital actions (pinned messages, top donations..), account's info and so on.
* Initial info is what you see when you first open a a live chat — this is; initial actions (pinned messages, top donations..), account's info and so forth.
*/
console.info(`Hey ${initial_data.viewer_name || 'Guest'}, welcome to Live Chat!`);
console.info(`Hey ${initial_data.viewer_name || 'N/A'}, welcome to Live Chat!`);
const pinned_action = initial_data.actions.firstOfType(YTNodes.AddBannerToLiveChatCommand);
if (pinned_action) {
if (pinned_action.banner?.contents?.is(YTNodes.LiveChatTextMessage)) {
console.info(
'\n', 'Pinned message:\n',
pinned_action.banner.contents.author?.name.toString(), '-', pinned_action?.banner.contents.message.toString(),
'\n'
);
}
}
});
livechat.on('error', (error: Error) => console.info('Live chat error:', error));
livechat.on('end', () => console.info('This live stream has ended.'));
livechat.on('chat-update', (action: ChatAction) => {
/**
* An action represents what is being added to
@@ -43,24 +56,42 @@ import { ChatAction, LiveMetadata } from 'youtubei.js/dist/src/parser/youtube/Li
switch (item.type) {
case 'LiveChatTextMessage':
console.info(
`${item.as(YTNodes.LiveChatTextMessage).author?.is_moderator ? '[MOD]' : ''}`,
`${hours} - ${item.as(YTNodes.LiveChatTextMessage).author?.name.toString()}:\n` +
`${item.as(YTNodes.LiveChatTextMessage).message.toString()}\n`
);
break;
case 'LiveChatPaidMessage':
console.info(
`${item.as(YTNodes.LiveChatPaidMessage).author?.is_moderator ? '[MOD]' : ''}`,
`${hours} - ${item.as(YTNodes.LiveChatPaidMessage).author.name.toString()}:\n` +
`${item.as(YTNodes.LiveChatPaidMessage).message.toString()}\n`,
`${item.as(YTNodes.LiveChatPaidMessage).purchase_amount}\n`
);
break;
case 'LiveChatPaidSticker':
console.info(
`${item.as(YTNodes.LiveChatPaidSticker).author?.is_moderator ? '[MOD]' : ''}`,
`${hours} - ${item.as(YTNodes.LiveChatPaidSticker).author.name.toString()}:\n` +
`${item.as(YTNodes.LiveChatPaidSticker).purchase_amount}\n`
);
break;
default:
console.debug(action);
break;
}
}
if (action.is(YTNodes.MarkChatItemAsDeletedAction)) {
console.warn(`Message ${action.target_item_id} just got deleted and should be replaced with ${action.deleted_state_message.toString()}!`, '\n');
if (action.is(YTNodes.AddBannerToLiveChatCommand)) {
console.info('Message pinned:', action.banner?.contents);
}
if (action.is(YTNodes.RemoveBannerForLiveChatCommand)) {
console.info(`Message with action id ${action.target_action_id} was unpinned.`);
}
if (action.is(YTNodes.RemoveChatItemAction)) {
console.warn(`Message with action id ${action.target_item_id} just got deleted!`, '\n');
}
});

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "youtubei.js",
"version": "2.8.0",
"version": "2.9.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "youtubei.js",
"version": "2.8.0",
"version": "2.9.0",
"funding": [
"https://github.com/sponsors/LuanRT"
],

View File

@@ -1,6 +1,6 @@
{
"name": "youtubei.js",
"version": "2.8.0",
"version": "2.9.0",
"description": "Full-featured wrapper around YouTube's private API. Supports YouTube, YouTube Music and YouTube Studio (WIP).",
"main": "./dist/index.js",
"browser": "./bundle/browser.js",

View File

@@ -14,7 +14,7 @@ class ContinuationItem extends YTNode {
this.trigger = data.trigger;
if (data.button) {
this.button = Parser.parse(data.button);
this.button = Parser.parseItem(data.button);
}
this.endpoint = new NavigationEndpoint(data.continuationEndpoint);

View File

@@ -0,0 +1,23 @@
import Text from './misc/Text';
import { YTNode } from '../helpers';
class EmojiPickerCategory extends YTNode {
static type = 'EmojiPickerCategory';
category_id: string;
title: Text;
emoji_ids: string[];
image_loading_lazy: boolean;
category_type: string;
constructor(data: any) {
super();
this.category_id = data.categoryId;
this.title = new Text(data.title);
this.emoji_ids = data.emojiIds;
this.image_loading_lazy = !!data.imageLoadingLazy;
this.category_type = data.categoryType;
}
}
export default EmojiPickerCategory;

View File

@@ -0,0 +1,18 @@
import { YTNode } from '../helpers';
class EmojiPickerCategoryButton extends YTNode {
static type = 'EmojiPickerCategoryButton';
category_id: string;
icon_type: string;
tooltip: string;
constructor(data: any) {
super();
this.category_id = data.categoryId;
this.icon_type = data.icon?.iconType;
this.tooltip = data.tooltip;
}
}
export default EmojiPickerCategoryButton;

View File

@@ -0,0 +1,26 @@
import Text from './misc/Text';
import NavigationEndpoint from './NavigationEndpoint';
import { YTNode } from '../helpers';
class EmojiPickerUpsellCategory extends YTNode {
static type = 'EmojiPickerUpsellCategory';
category_id: string;
title: Text;
upsell: Text;
emoji_tooltip: string;
endpoint: NavigationEndpoint;
emoji_ids: string[];
constructor(data: any) {
super();
this.category_id = data.categoryId;
this.title = new Text(data.title);
this.upsell = new Text(data.upsell);
this.emoji_tooltip = data.emojiTooltip;
this.endpoint = new NavigationEndpoint(data.command);
this.emoji_ids = data.emojiIds;
}
}
export default EmojiPickerUpsellCategory;

View File

@@ -1,18 +1,21 @@
import Parser from '../index';
import type Menu from './menus/Menu';
import type Button from './Button';
import type SortFilterSubMenu from './SortFilterSubMenu';
import { YTNode } from '../helpers';
class LiveChatHeader extends YTNode {
static type = 'LiveChatHeader';
overflow_menu;
collapse_button;
view_selector;
overflow_menu: Menu | null;
collapse_button: Button | null;
view_selector: SortFilterSubMenu | null;
constructor(data: any) {
super();
this.overflow_menu = Parser.parse(data.overflowMenu);
this.collapse_button = Parser.parse(data.collapseButton);
this.view_selector = Parser.parse(data.viewSelector);
this.overflow_menu = Parser.parseItem<Menu>(data.overflowMenu);
this.collapse_button = Parser.parseItem<Button>(data.collapseButton);
this.view_selector = Parser.parseItem<SortFilterSubMenu>(data.viewSelector);
}
}

View File

@@ -1,16 +1,17 @@
import Parser from '../index';
import { YTNode } from '../helpers';
import type Button from './Button';
class LiveChatItemList extends YTNode {
static type = 'LiveChatItemList';
max_items_to_display: string;
more_comments_below_button;
more_comments_below_button: Button | null;
constructor(data: any) {
super();
this.max_items_to_display = data.maxItemsToDisplay;
this.more_comments_below_button = Parser.parse(data.moreCommentsBelowButton);
this.more_comments_below_button = Parser.parseItem<Button>(data.moreCommentsBelowButton);
}
}

View File

@@ -1,6 +1,7 @@
import Text from './misc/Text';
import Parser from '../index';
import Thumbnail from './misc/Thumbnail';
import type Button from './Button';
import { YTNode } from '../helpers';
class LiveChatMessageInput extends YTNode {
@@ -8,14 +9,14 @@ class LiveChatMessageInput extends YTNode {
author_name: Text;
author_photo: Thumbnail[];
send_button;
send_button: Button | null;
target_id: string;
constructor(data: any) {
super();
this.author_name = new Text(data.authorName);
this.author_photo = Thumbnail.fromResponse(data.authorPhoto);
this.send_button = Parser.parse(data.sendButton);
this.send_button = Parser.parseItem<Button>(data.sendButton);
this.target_id = data.targetId;
}
}

View File

@@ -1,17 +1,18 @@
import Parser from '../index';
import Text from './misc/Text';
import { YTNode } from '../helpers';
import { ObservedArray, YTNode } from '../helpers';
import type LiveChatParticipant from './LiveChatParticipant';
class LiveChatParticipantsList extends YTNode {
static type = 'LiveChatParticipantsList';
title: Text;
participants;
participants: ObservedArray<LiveChatParticipant>;
constructor(data: any) {
super();
this.title = new Text(data.title);
this.participants = Parser.parse(data.participants);
this.participants = Parser.parseArray<LiveChatParticipant>(data.participants);
}
}

View File

@@ -1,28 +1,50 @@
import { YTNode } from '../helpers';
import NavigationEndpoint from './NavigationEndpoint';
class SortFilterSubMenu extends YTNode {
static type = 'SortFilterSubMenu';
sub_menu_items: {
title?: string;
icon_type?: string;
label?: string;
tooltip?: string;
sub_menu_items?: {
title: string;
selected: boolean;
continuation: string;
subtitle: string;
endpoint: NavigationEndpoint;
subtitle: string | null;
}[];
label: string;
constructor(data: any) {
super();
this.sub_menu_items = data.subMenuItems.map((item: any) => ({
title: item.title,
selected: item.selected,
continuation: item.continuation?.reloadContinuationData.continuation,
subtitle: item.subtitle
}));
if (data.title) {
this.title = data.title;
}
this.label = data.accessibility.accessibilityData.label;
if (data.icon?.iconType) {
this.icon_type = data.icon.iconType;
}
if (data.accessibility?.accessibilityData?.label) {
this.label = data.accessibility.accessibilityData.label;
}
if (data.tooltip) {
this.tooltip = data.tooltip;
}
if (data.subMenuItems) {
this.sub_menu_items = data.subMenuItems.map((item: any) => ({
title: item.title,
selected: item.selected,
continuation: item.continuation?.reloadContinuationData?.continuation,
endpoint: new NavigationEndpoint(item.serviceEndpoint),
subtitle: item.subtitle || null
}));
}
}
}

View File

@@ -0,0 +1,25 @@
import Parser from '..';
import { YTNode } from '../helpers';
import type Button from './Button';
import Text from './misc/Text';
class UpsellDialog extends YTNode {
static type = 'UpsellDialog';
message_title: Text;
message_text: Text;
action_button: Button | null;
dismiss_button: Button | null;
is_visible: boolean;
constructor(data: any) {
super();
this.message_title = new Text(data.dialogMessageTitle);
this.message_text = new Text(data.dialogMessageText);
this.action_button = Parser.parseItem<Button>(data.actionButton);
this.dismiss_button = Parser.parseItem<Button>(data.dismissButton);
this.is_visible = data.isVisible;
}
}
export default UpsellDialog;

View File

@@ -1,16 +1,20 @@
import Parser from '../../index';
import Text from '../misc/Text';
import Thumbnail from '../misc/Thumbnail';
import Author from '../misc/Author';
import ToggleButton from '../ToggleButton';
import CommentReplyDialog from './CommentReplyDialog';
import CommentActionButtons from './CommentActionButtons';
import AuthorCommentBadge from './AuthorCommentBadge';
import Author from '../misc/Author';
import type Menu from '../menus/Menu';
import type CommentActionButtons from './CommentActionButtons';
import type SponsorCommentBadge from './SponsorCommentBadge';
import type PdgCommentChip from './PdgCommentChip';
import type { ApiResponse } from '../../../core/Actions';
import type Actions from '../../../core/Actions';
import Proto from '../../../proto/index';
import Actions from '../../../core/Actions';
import { InnertubeError } from '../../../utils/Utils';
import { YTNode, SuperParsedResult } from '../../helpers';
class Comment extends YTNode {
@@ -22,22 +26,23 @@ class Comment extends YTNode {
published: Text;
author_is_channel_owner: boolean;
current_user_reply_thumbnail: Thumbnail[];
author_badge;
sponsor_comment_badge: SponsorCommentBadge | null;
paid_comment_chip: PdgCommentChip | null;
author_badge: AuthorCommentBadge | null;
author: Author;
action_menu;
action_buttons;
action_menu: Menu | null;
action_buttons: CommentActionButtons | null;
comment_id: string;
vote_status: string;
vote_count: {
text: string;
short_text: string;
};
vote_count: string;
reply_count: number;
is_liked: boolean;
is_disliked: boolean;
is_hearted: boolean;
is_pinned: boolean;
is_member: boolean;
constructor(data: any) {
super();
@@ -45,6 +50,8 @@ class Comment extends YTNode {
this.published = new Text(data.publishedTimeText);
this.author_is_channel_owner = data.authorIsChannelOwner;
this.current_user_reply_thumbnail = Thumbnail.fromResponse(data.currentUserReplyThumbnail);
this.sponsor_comment_badge = Parser.parseItem<SponsorCommentBadge>(data.sponsorCommentBadge);
this.paid_comment_chip = Parser.parseItem<PdgCommentChip>(data.paidCommentChipRenderer);
this.author_badge = Parser.parseItem<AuthorCommentBadge>(data.authorCommentBadge, AuthorCommentBadge);
this.author = new Author({
@@ -54,30 +61,32 @@ class Comment extends YTNode {
metadataBadgeRenderer: this.author_badge?.orig_badge
} ] : null, data.authorThumbnail);
this.action_menu = Parser.parse(data.actionMenu);
this.action_buttons = Parser.parse(data.actionButtons);
this.action_menu = Parser.parseItem<Menu>(data.actionMenu);
this.action_buttons = Parser.parseItem<CommentActionButtons>(data.actionButtons);
this.comment_id = data.commentId;
this.vote_status = data.voteStatus;
this.vote_count = {
text: data.voteCount ? data.voteCount.accessibility.accessibilityData?.label.replace(/\D/g, '') : '0',
short_text: data.voteCount ? new Text(data.voteCount).toString() : '0'
};
this.vote_count = data.voteCount ? new Text(data.voteCount).toString() : '0';
this.reply_count = data.replyCount || 0;
this.is_liked = this.action_buttons.item().as(CommentActionButtons).like_button.item().as(ToggleButton).is_toggled;
this.is_disliked = this.action_buttons.item().as(CommentActionButtons).dislike_button.item().as(ToggleButton).is_toggled;
this.is_liked = !!this.action_buttons?.like_button?.is_toggled;
this.is_disliked = !!this.action_buttons?.dislike_button?.is_toggled;
this.is_hearted = !!this.action_buttons?.creator_heart?.is_hearted;
this.is_pinned = !!data.pinnedCommentBadge;
this.is_member = !!data.sponsorCommentBadge;
}
/**
* Likes the comment.
*/
async like() {
async like(): Promise<ApiResponse> {
if (!this.#actions)
throw new InnertubeError('An active caller must be provide to perform this operation.');
const button = this.action_buttons.item().as(CommentActionButtons).like_button.item().as(ToggleButton);
const button = this.action_buttons?.like_button;
if (!button)
throw new InnertubeError('Like button was not found.', { comment_id: this.comment_id });
if (button.is_toggled)
throw new InnertubeError('This comment is already liked', { comment_id: this.comment_id });
@@ -89,11 +98,14 @@ class Comment extends YTNode {
/**
* Dislikes the comment.
*/
async dislike() {
async dislike(): Promise<ApiResponse> {
if (!this.#actions)
throw new InnertubeError('An active caller must be provide to perform this operation.');
const button = this.action_buttons.item().as(CommentActionButtons).dislike_button.item().as(ToggleButton);
const button = this.action_buttons?.dislike_button;
if (!button)
throw new InnertubeError('Dislike button was not found.', { comment_id: this.comment_id });
if (button.is_toggled)
throw new InnertubeError('This comment is already disliked', { comment_id: this.comment_id });
@@ -106,26 +118,28 @@ class Comment extends YTNode {
/**
* Creates a reply to the comment.
*/
async reply(text: string) {
async reply(text: string): Promise<ApiResponse> {
if (!this.#actions)
throw new InnertubeError('An active caller must be provide to perform this operation.');
if (!this.action_buttons.item().as(CommentActionButtons).reply_button)
if (!this.action_buttons?.reply_button)
throw new InnertubeError('Cannot reply to another reply. Try mentioning the user instead.', { comment_id: this.comment_id });
const button = this.action_buttons.item().as(CommentActionButtons).reply_button.item().as(ToggleButton);
const button = this.action_buttons?.reply_button;
if (!button.endpoint.dialog)
if (!button.endpoint?.dialog)
throw new InnertubeError('Reply button endpoint did not have a dialog.');
const dialog = button.endpoint.dialog as SuperParsedResult<YTNode>;
const dialog_button = dialog.item().as(CommentReplyDialog).reply_button.item().as(ToggleButton);
const dialog_button = dialog.item().as(CommentReplyDialog).reply_button;
const payload = {
commentText: text
};
if (!dialog_button)
throw new InnertubeError('Reply button was not found in the dialog.', { comment_id: this.comment_id });
const response = await dialog_button.endpoint.call(this.#actions, payload);
if (!dialog_button.endpoint)
throw new InnertubeError('Reply button endpoint was not found.', { comment_id: this.comment_id });
const response = await dialog_button.endpoint.call(this.#actions, { commentText: text });
return response;
}
@@ -134,7 +148,12 @@ class Comment extends YTNode {
* Translates the comment to the given language.
* @param target_language - Ex; en, ja
*/
async translate(target_language: string) {
async translate(target_language: string): Promise<{
content: any;
success: boolean;
status_code: number;
data: any;
}> {
if (!this.#actions)
throw new InnertubeError('An active caller must be provide to perform this operation.');
@@ -151,8 +170,8 @@ class Comment extends YTNode {
const response = await this.#actions.execute('comment/perform_comment_action', { action, client: 'ANDROID' });
// TODO: maybe add these to Parser#parseResponse?
const mutations = response.data.frameworkUpdates.entityBatchUpdate.mutations;
const content = mutations[0].payload.commentEntityPayload.translatedContent.content;
const mutations = response.data.frameworkUpdates?.entityBatchUpdate?.mutations;
const content = mutations?.[0]?.payload?.commentEntityPayload?.translatedContent?.content;
return { ...response, content };
}

View File

@@ -1,4 +1,7 @@
import Parser from '../../index';
import type Button from '../Button';
import type ToggleButton from '../ToggleButton';
import type CreatorHeart from './CreatorHeart';
import { YTNode } from '../../helpers';
class CommentActionButtons extends YTNode {
@@ -7,12 +10,14 @@ class CommentActionButtons extends YTNode {
like_button;
dislike_button;
reply_button;
creator_heart;
constructor(data: any) {
super();
this.like_button = Parser.parse(data.likeButton);
this.dislike_button = Parser.parse(data.dislikeButton);
this.reply_button = Parser.parse(data.replyButton);
this.like_button = Parser.parseItem<ToggleButton>(data.likeButton);
this.dislike_button = Parser.parseItem<ToggleButton>(data.dislikeButton);
this.reply_button = Parser.parseItem<Button>(data.replyButton);
this.creator_heart = Parser.parseItem<CreatorHeart>(data.creatorHeart);
}
}

View File

@@ -0,0 +1,31 @@
import Parser from '../..';
import Text from '../misc/Text';
import Thumbnail from '../misc/Thumbnail';
import type Button from '../Button';
import type EmojiPicker from './EmojiPicker';
import { YTNode } from '../../helpers';
class CommentDialog extends YTNode {
static type = 'CommentDialog';
editable_text: Text;
author_thumbnail: Thumbnail[];
submit_button: Button | null;
cancel_button: Button | null;
placeholder: Text;
emoji_button: Button | null;
emoji_picker: any | null;
constructor(data: any) {
super();
this.editable_text = new Text(data.editableText);
this.author_thumbnail = Thumbnail.fromResponse(data.authorThumbnail);
this.submit_button = Parser.parseItem<Button>(data.submitButton);
this.cancel_button = Parser.parseItem<Button>(data.cancelButton);
this.placeholder = new Text(data.placeholderText);
this.emoji_button = Parser.parseItem<Button>(data.emojiButton);
this.emoji_picker = Parser.parseItem<EmojiPicker>(data.emojiPicker);
}
}
export default CommentDialog;

View File

@@ -1,18 +1,24 @@
import Parser from '../../index';
import Thumbnail from '../misc/Thumbnail';
import type Button from '../Button';
import { YTNode } from '../../helpers';
class CommentReplies extends YTNode {
static type = 'CommentReplies';
contents;
view_replies;
hide_replies;
view_replies: Button | null;
hide_replies: Button | null;
view_replies_creator_thumbnail: Thumbnail[];
has_channel_owner_replied: boolean;
constructor(data: any) {
super();
this.contents = Parser.parse(data.contents);
this.view_replies = Parser.parse(data.viewReplies);
this.hide_replies = Parser.parse(data.hideReplies);
this.contents = Parser.parseArray(data.contents);
this.view_replies = Parser.parseItem<Button>(data.viewReplies);
this.hide_replies = Parser.parseItem<Button>(data.hideReplies);
this.view_replies_creator_thumbnail = Thumbnail.fromResponse(data.viewRepliesCreatorThumbnail);
this.has_channel_owner_replied = !!data.viewRepliesCreatorThumbnail;
}
}

View File

@@ -1,21 +1,22 @@
import Parser from '../../index';
import Thumbnail from '../misc/Thumbnail';
import Text from '../misc/Text';
import type Button from '../Button';
import { YTNode } from '../../helpers';
class CommentReplyDialog extends YTNode {
static type = 'CommentReplyDialog';
reply_button;
cancel_button;
author_thumbnail;
placeholder;
error_message;
reply_button: Button | null;
cancel_button: Button | null;
author_thumbnail: Thumbnail[];
placeholder: Text;
error_message: Text;
constructor(data: any) {
super();
this.reply_button = Parser.parse(data.replyButton);
this.cancel_button = Parser.parse(data.cancelButton);
this.reply_button = Parser.parseItem<Button>(data.replyButton);
this.cancel_button = Parser.parseItem<Button>(data.cancelButton);
this.author_thumbnail = Thumbnail.fromResponse(data.authorThumbnail);
this.placeholder = new Text(data.placeholderText);
this.error_message = new Text(data.errorMessage);

View File

@@ -1,21 +1,22 @@
import Parser from '../../index';
import Thumbnail from '../misc/Thumbnail';
import Text from '../misc/Text';
import type Button from '../Button';
import { YTNode } from '../../helpers';
class CommentSimplebox extends YTNode {
static type = 'CommentSimplebox';
submit_button;
cancel_button;
submit_button: Button | null;
cancel_button: Button | null;
author_thumbnails: Thumbnail[];
placeholder: Text;
avatar_size;
constructor(data: any) {
super();
this.submit_button = Parser.parse(data.submitButton);
this.cancel_button = Parser.parse(data.cancelButton);
this.submit_button = Parser.parseItem<Button>(data.submitButton);
this.cancel_button = Parser.parseItem<Button>(data.cancelButton);
this.author_thumbnails = Thumbnail.fromResponse(data.authorThumbnail);
this.placeholder = new Text(data.placeholderText);
this.avatar_size = data.avatarSize;

View File

@@ -1,48 +1,56 @@
import Parser from '../../index';
import Comment from './Comment';
import ContinuationItem from '../ContinuationItem';
import Actions from '../../../core/Actions';
import NavigationEndpoint from '../NavigationEndpoint';
import CommentReplies from './CommentReplies';
import Button from '../Button';
import type Actions from '../../../core/Actions';
import type { ObservedArray } from '../../helpers';
import { InnertubeError } from '../../../utils/Utils';
import { YTNode } from '../../helpers';
import { observe, YTNode } from '../../helpers';
class CommentThread extends YTNode {
static type = 'CommentThread';
#replies;
#actions?: Actions;
#continuation?: ContinuationItem;
comment: Comment | null;
replies?: ObservedArray<Comment>;
comment_replies_data: CommentReplies | null;
is_moderated_elq_comment: boolean;
comment;
replies: Comment[] | undefined;
has_replies: boolean;
constructor(data: any) {
super();
this.comment = Parser.parseItem(data.comment, Comment);
this.#replies = Parser.parseItem(data.replies);
this.comment = Parser.parseItem<Comment>(data.comment, Comment);
this.comment_replies_data = Parser.parseItem<CommentReplies>(data.replies);
this.is_moderated_elq_comment = data.isModeratedElqComment;
this.has_replies = !!this.comment_replies_data;
}
/**
* Retrieves replies to this comment thread.
*/
async getReplies() {
async getReplies(): Promise<CommentThread> {
if (!this.#actions)
throw new InnertubeError('Actions not set for this CommentThread.');
throw new InnertubeError('Actions instance not set for this thread.');
if (!this.#replies)
if (!this.comment_replies_data)
throw new InnertubeError('This comment has no replies.', { comment_id: this.comment?.comment_id });
const continuation = this.#replies.key('contents').parsed().array().get({ type: 'ContinuationItem' })?.as(ContinuationItem);
const response = await continuation?.endpoint.call(this.#actions, { parse: true });
const continuation = this.comment_replies_data.contents?.firstOfType(ContinuationItem);
this.replies = response?.on_response_received_endpoints_memo?.getType(Comment).map((comment) => {
if (!continuation)
throw new InnertubeError('Replies continuation not found.');
const response = await continuation.endpoint.call(this.#actions, { parse: true });
this.replies = observe(response.on_response_received_endpoints_memo?.getType(Comment).map((comment) => {
comment.setActions(this.#actions);
return comment;
});
}));
this.#continuation = response?.on_response_received_endpoints_memo.getType(ContinuationItem)?.[0];
this.#continuation = response?.on_response_received_endpoints_memo.getType(ContinuationItem).first();
return this;
}
@@ -50,28 +58,39 @@ class CommentThread extends YTNode {
/**
* Retrieves next batch of replies.
*/
async getContinuation() {
async getContinuation(): Promise<CommentThread> {
if (!this.replies)
throw new InnertubeError('Continuation not available.');
throw new InnertubeError('Cannot retrieve continuation because this thread\'s replies have not been loaded.');
if (!this.#continuation)
throw new InnertubeError('Continuation not found.');
if (!this.#actions)
throw new InnertubeError('Actions not set for this CommentThread.');
throw new InnertubeError('Actions instance not set for this thread.');
const response = await this.#continuation.button?.item().key('endpoint').nodeOfType(NavigationEndpoint).call(this.#actions, { parse: true });
const load_more_button = this.#continuation.button?.as(Button);
this.replies = response?.on_response_received_endpoints_memo.getType(Comment).map((comment) => {
if (!load_more_button)
throw new InnertubeError('"Load more" button not found.');
const response = await load_more_button.endpoint.call(this.#actions, { parse: true });
this.replies = observe(response?.on_response_received_endpoints_memo.getType(Comment).map((comment) => {
comment.setActions(this.#actions);
return comment;
});
}));
this.#continuation = response?.on_response_received_endpoints_memo.getType(ContinuationItem)?.[0];
return this;
}
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.');
return !!this.#continuation;
}
setActions(actions: Actions) {
this.#actions = actions;
}

View File

@@ -1,6 +1,7 @@
import Parser from '../../index';
import Text from '../misc/Text';
import Thumbnail from '../misc/Thumbnail';
import type SortFilterSubMenu from '../SortFilterSubMenu';
import { YTNode } from '../../helpers';
class CommentsHeader extends YTNode {
@@ -10,7 +11,7 @@ class CommentsHeader extends YTNode {
count: Text;
comments_count: Text;
create_renderer;
sort_menu;
sort_menu: SortFilterSubMenu | null;
custom_emojis: {
emoji_id: string;
@@ -26,7 +27,7 @@ class CommentsHeader extends YTNode {
this.count = new Text(data.countText);
this.comments_count = new Text(data.commentsCount);
this.create_renderer = Parser.parseItem(data.createRenderer);
this.sort_menu = Parser.parse(data.sortMenu);
this.sort_menu = Parser.parseItem<SortFilterSubMenu>(data.sortMenu);
this.custom_emojis = data.customEmojis?.map((emoji: any) => ({
emoji_id: emoji.emojiId,

View File

@@ -0,0 +1,35 @@
import { YTNode } from '../../helpers';
import Thumbnail from '../misc/Thumbnail';
class CreatorHeart extends YTNode {
static type = 'CreatorHeart';
creator_thumbnail: Thumbnail[];
heart_icon_type: string;
heart_color: {
basic_color_palette_data: {
foreground_title_color: string;
}
};
hearted_tooltip: string;
is_hearted: boolean;
is_enabled: boolean;
kennedy_heart_color_string: string;
constructor(data: any) {
super();
this.creator_thumbnail = Thumbnail.fromResponse(data.creatorThumbnail);
this.heart_icon_type = data.heartIcon?.iconType;
this.heart_color = {
basic_color_palette_data: {
foreground_title_color: data.heartColor?.basicColorPaletteData?.foregroundTitleColor
}
};
this.hearted_tooltip = data.heartedTooltip;
this.is_hearted = data.isHearted;
this.is_enabled = data.isEnabled;
this.kennedy_heart_color_string = data.kennedyHeartColorString;
}
}
export default CreatorHeart;

View File

@@ -0,0 +1,40 @@
import Text from '../misc/Text';
import { YTNode } from '../../helpers';
import Parser from '../..';
class EmojiPicker extends YTNode {
static type = 'EmojiPicker';
id: string;
categories: any[];
category_buttons: any[];
search_placeholder: Text;
search_no_results: Text;
pick_skin_tone: Text;
clear_search_label: string;
skin_tone_generic_label: string;
skin_tone_light_label: string;
skin_tone_medium_light_label: string;
skin_tone_medium_label: string;
skin_tone_medium_dark_label: string;
skin_tone_dark_label: string;
constructor(data: any) {
super();
this.id = data.id;
this.categories = Parser.parseArray(data.categories);
this.category_buttons = Parser.parseArray(data.categoryButtons);
this.search_placeholder = new Text(data.searchPlaceholderText);
this.search_no_results = new Text(data.searchNoResultsText);
this.pick_skin_tone = new Text(data.pickSkinToneText);
this.clear_search_label = data.clearSearchLabel;
this.skin_tone_generic_label = data.skinToneGenericLabel;
this.skin_tone_light_label = data.skinToneLightLabel;
this.skin_tone_medium_light_label = data.skinToneMediumLightLabel;
this.skin_tone_medium_label = data.skinToneMediumLabel;
this.skin_tone_medium_dark_label = data.skinToneMediumDarkLabel;
this.skin_tone_dark_label = data.skinToneDarkLabel;
}
}
export default EmojiPicker;

View File

@@ -0,0 +1,25 @@
import Text from '../misc/Text';
import { YTNode } from '../../helpers';
class PdgCommentChip extends YTNode {
static type = 'PdgCommentChip';
text: Text;
color_pallette: {
background_color: string;
foreground_title_color: string;
};
icon_type: string;
constructor(data: any) {
super();
this.text = new Text(data.chipText);
this.color_pallette = {
background_color: data.chipColorPalette?.backgroundColor,
foreground_title_color: data.chipColorPalette?.foregroundTitleColor
};
this.icon_type = data.chipIcon?.iconType;
}
}
export default PdgCommentChip;

View File

@@ -0,0 +1,17 @@
import Thumbnail from '../misc/Thumbnail';
import { YTNode } from '../../helpers';
class SponsorCommentBadge extends YTNode {
static type = 'SponsorCommentBadge';
custom_badge: Thumbnail[];
tooltip: string;
constructor(data: any) {
super();
this.custom_badge = Thumbnail.fromResponse(data.customBadge);
this.tooltip = data.tooltip;
}
}
export default SponsorCommentBadge;

View File

@@ -1,14 +1,15 @@
import Parser from '../../index';
import { YTNode } from '../../helpers';
import type LiveChatBanner from './items/LiveChatBanner';
class AddBannerToLiveChatCommand extends YTNode {
static type = 'AddBannerToLiveChatCommand';
banner;
banner: LiveChatBanner | null;
constructor(data: any) {
super();
this.banner = Parser.parse(data.bannerRenderer);
this.banner = Parser.parseItem<LiveChatBanner>(data.bannerRenderer);
}
}

View File

@@ -5,7 +5,7 @@ class AddLiveChatTickerItemAction extends YTNode {
static type = 'AddLiveChatTickerItemAction';
item;
duration_sec;
duration_sec: string; // TODO: check this assumption
constructor(data: any) {
super();

View File

@@ -0,0 +1,14 @@
import { YTNode } from '../../helpers';
class DimChatItemAction extends YTNode {
static type = 'DimChatItemAction';
client_assigned_id: string;
constructor(data: any) {
super();
this.client_assigned_id = data.clientAssignedId;
}
}
export default DimChatItemAction;

View File

@@ -10,7 +10,7 @@ class ReplaceChatItemAction extends YTNode {
constructor(data: any) {
super();
this.target_item_id = data.targetItemId;
this.replacement_item = Parser.parse(data.replacementItem);
this.replacement_item = Parser.parseItem(data.replacementItem);
}
}

View File

@@ -9,10 +9,10 @@ class ReplayChatItemAction extends YTNode {
constructor(data: any) {
super();
this.actions = Parser.parse(data.actions?.map((action: any) => {
this.actions = Parser.parseArray(data.actions?.map((action: any) => {
delete action.clickTrackingParams;
return action;
})) || [];
}));
this.video_offset_time_msec = data.videoOffsetTimeMsec;
}
}

View File

@@ -1,14 +1,15 @@
import Parser from '../../index';
import { YTNode } from '../../helpers';
import LiveChatActionPanel from './LiveChatActionPanel';
class ShowLiveChatActionPanelAction extends YTNode {
static type = 'ShowLiveChatActionPanelAction';
panel_to_show;
panel_to_show: LiveChatActionPanel | null;
constructor(data: any) {
super();
this.panel_to_show = Parser.parse(data.panelToShow);
this.panel_to_show = Parser.parseItem<LiveChatActionPanel>(data.panelToShow, LiveChatActionPanel);
}
}

View File

@@ -0,0 +1,15 @@
import Parser from '../..';
import { YTNode } from '../../helpers';
class ShowLiveChatDialogAction extends YTNode {
static type = 'ShowLiveChatDialogAction';
dialog;
constructor(data: any) {
super();
this.dialog = Parser.parseItem(data.dialog);
}
}
export default ShowLiveChatDialogAction;

View File

@@ -8,7 +8,7 @@ class ShowLiveChatTooltipCommand extends YTNode {
constructor(data: any) {
super();
this.tooltip = Parser.parse(data.tooltip);
this.tooltip = Parser.parseItem(data.tooltip);
}
}

View File

@@ -8,7 +8,7 @@ class UpdateLiveChatPollAction extends YTNode {
constructor(data: any) {
super();
this.poll_to_update = Parser.parse(data.pollToUpdate);
this.poll_to_update = Parser.parseItem(data.pollToUpdate);
}
}

View File

@@ -1,8 +1,8 @@
import Text from '../../misc/Text';
import Parser from '../../../index';
import { ObservedArray, YTNode } from '../../../helpers';
import NavigationEndpoint from '../../NavigationEndpoint';
import Parser from '../../../index';
import Button from '../../Button';
import Text from '../../misc/Text';
import NavigationEndpoint from '../../NavigationEndpoint';
class LiveChatAutoModMessage extends YTNode {
static type = 'LiveChatAutoModMessage';
@@ -17,10 +17,9 @@ class LiveChatAutoModMessage extends YTNode {
constructor(data: any) {
super();
this.menu_endpoint = new NavigationEndpoint(data.contextMenuEndpoint);
this.moderation_buttons = Parser.parseArray<Button>(data.moderationButtons, [ Button ]);
this.auto_moderated_item = Parser.parse(data.autoModeratedItem);
this.auto_moderated_item = Parser.parseItem(data.autoModeratedItem);
this.header_text = new Text(data.headerText);
this.timestamp = Math.floor(parseInt(data.timestampUsec) / 1000);
this.id = data.id;

View File

@@ -1,10 +1,11 @@
import Parser from '../../../index';
import { YTNode } from '../../../helpers';
import Parser from '../../../index';
import type LiveChatBannerHeader from './LiveChatBannerHeader';
class LiveChatBanner extends YTNode {
static type = 'LiveChatBanner';
header;
header: LiveChatBannerHeader | null;
contents;
action_id: string;
viewer_is_creator: boolean;
@@ -14,9 +15,8 @@ class LiveChatBanner extends YTNode {
constructor(data: any) {
super();
this.header = Parser.parse(data.header);
this.contents = Parser.parse(data.contents);
this.header = Parser.parseItem<LiveChatBannerHeader>(data.header);
this.contents = Parser.parseItem(data.contents);
this.action_id = data.actionId;
this.viewer_is_creator = data.viewerIsCreator;
this.target_id = data.targetId;

View File

@@ -1,19 +1,20 @@
import Parser from '../../../index';
import Text from '../../misc/Text';
import { YTNode } from '../../../helpers';
import Parser from '../../../index';
import type Button from '../../Button';
import Text from '../../misc/Text';
class LiveChatBannerHeader extends YTNode {
static type = 'LiveChatBannerHeader';
text: string;
icon_type: string;
context_menu_button;
context_menu_button: Button | null;
constructor(data: any) {
super();
this.text = new Text(data.text).toString();
this.icon_type = data.icon.iconType;
this.context_menu_button = Parser.parse(data.contextMenuButton);
this.icon_type = data.icon?.iconType;
this.context_menu_button = Parser.parseItem<Button>(data.contextMenuButton);
}
}

View File

@@ -1,7 +1,7 @@
import { YTNode } from '../../../helpers';
import Parser from '../../../index';
import Text from '../../misc/Text';
import Thumbnail from '../../misc/Thumbnail';
import { YTNode } from '../../../helpers';
class LiveChatBannerPoll extends YTNode {
static type = 'LiveChatBannerPoll';
@@ -29,7 +29,7 @@ class LiveChatBannerPoll extends YTNode {
this.collapsed_state_entity_key = data.collapsedStateEntityKey;
this.live_chat_poll_state_entity_key = data.liveChatPollStateEntityKey;
this.context_menu_button = Parser.parse(data.contextMenuButton);
this.context_menu_button = Parser.parseItem(data.contextMenuButton);
}
}

View File

@@ -1,8 +1,10 @@
import { observe, ObservedArray, YTNode } from '../../../helpers';
import Parser from '../../../index';
import LiveChatAuthorBadge from '../../LiveChatAuthorBadge';
import MetadataBadge from '../../MetadataBadge';
import Text from '../../misc/Text';
import Thumbnail from '../../misc/Thumbnail';
import NavigationEndpoint from '../../NavigationEndpoint';
import { YTNode } from '../../../helpers';
class LiveChatMembershipItem extends YTNode {
static type = 'LiveChatMembershipItem';
@@ -15,7 +17,10 @@ class LiveChatMembershipItem extends YTNode {
id: string;
name: Text;
thumbnails: Thumbnail[];
badges: any;
badges: ObservedArray<LiveChatAuthorBadge | MetadataBadge>;
is_moderator: boolean | null;
is_verified: boolean | null;
is_verified_artist: boolean | null;
};
menu_endpoint: NavigationEndpoint;
@@ -30,9 +35,19 @@ class LiveChatMembershipItem extends YTNode {
id: data.authorExternalChannelId,
name: new Text(data?.authorName),
thumbnails: Thumbnail.fromResponse(data.authorPhoto),
badges: Parser.parse(data.authorBadges)
badges: observe([]).as(LiveChatAuthorBadge, MetadataBadge),
is_moderator: null,
is_verified: null,
is_verified_artist: null
};
const badges = Parser.parseArray<LiveChatAuthorBadge | MetadataBadge>(data.authorBadges);
this.author.badges = badges;
this.author.is_moderator = badges ? badges.some((badge) => badge.icon_type == 'MODERATOR') : null;
this.author.is_verified = badges ? badges.some((badge) => badge.style == 'BADGE_STYLE_TYPE_VERIFIED') : null;
this.author.is_verified_artist = badges ? badges.some((badge) => badge.style == 'BADGE_STYLE_TYPE_VERIFIED_ARTIST') : null;
this.menu_endpoint = new NavigationEndpoint(data.contextMenuEndpoint);
}
}

View File

@@ -1,10 +1,10 @@
import { observe, ObservedArray, YTNode } from '../../../helpers';
import Parser from '../../../index';
import LiveChatAuthorBadge from '../../LiveChatAuthorBadge';
import MetadataBadge from '../../MetadataBadge';
import Text from '../../misc/Text';
import Thumbnail from '../../misc/Thumbnail';
import NavigationEndpoint from '../../NavigationEndpoint';
import MetadataBadge from '../../MetadataBadge';
import LiveChatAuthorBadge from '../../LiveChatAuthorBadge';
import Parser from '../../../index';
import { YTNode } from '../../../helpers';
class LiveChatPaidMessage extends YTNode {
static type = 'LiveChatPaidMessage';
@@ -15,7 +15,7 @@ class LiveChatPaidMessage extends YTNode {
id: string;
name: Text;
thumbnails: Thumbnail[];
badges: LiveChatAuthorBadge[] | MetadataBadge[];
badges: ObservedArray<LiveChatAuthorBadge | MetadataBadge>;
is_moderator: boolean | null;
is_verified: boolean | null;
is_verified_artist: boolean | null;
@@ -39,7 +39,7 @@ class LiveChatPaidMessage extends YTNode {
id: data.authorExternalChannelId,
name: new Text(data.authorName),
thumbnails: Thumbnail.fromResponse(data.authorPhoto),
badges: Parser.parseArray<LiveChatAuthorBadge | MetadataBadge>(data.authorBadges, [ MetadataBadge, LiveChatAuthorBadge ]),
badges: observe([]).as(LiveChatAuthorBadge, MetadataBadge),
is_moderator: null,
is_verified: null,
is_verified_artist: null
@@ -48,9 +48,9 @@ class LiveChatPaidMessage extends YTNode {
const badges = Parser.parseArray<LiveChatAuthorBadge | MetadataBadge>(data.authorBadges, [ MetadataBadge, LiveChatAuthorBadge ]);
this.author.badges = badges;
this.author.is_moderator = badges?.some((badge: any) => badge.icon_type == 'MODERATOR') || null;
this.author.is_verified = badges?.some((badge: any) => badge.style == 'BADGE_STYLE_TYPE_VERIFIED') || null;
this.author.is_verified_artist = badges?.some((badge: any) => badge.style == 'BADGE_STYLE_TYPE_VERIFIED_ARTIST') || null;
this.author.is_moderator = badges ? badges.some((badge) => badge.icon_type == 'MODERATOR') : null;
this.author.is_verified = badges ? badges.some((badge) => badge.style == 'BADGE_STYLE_TYPE_VERIFIED') : null;
this.author.is_verified_artist = badges ? badges.some((badge) => badge.style == 'BADGE_STYLE_TYPE_VERIFIED_ARTIST') : null;
this.header_background_color = data.headerBackgroundColor;
this.header_text_color = data.headerTextColor;

View File

@@ -1,8 +1,10 @@
import { observe, ObservedArray, YTNode } from '../../../helpers';
import Parser from '../../../index';
import NavigationEndpoint from '../../NavigationEndpoint';
import Thumbnail from '../../misc/Thumbnail';
import LiveChatAuthorBadge from '../../LiveChatAuthorBadge';
import MetadataBadge from '../../MetadataBadge';
import Text from '../../misc/Text';
import { YTNode } from '../../../helpers';
import Thumbnail from '../../misc/Thumbnail';
import NavigationEndpoint from '../../NavigationEndpoint';
class LiveChatPaidSticker extends YTNode {
static type = 'LiveChatPaidSticker';
@@ -13,7 +15,10 @@ class LiveChatPaidSticker extends YTNode {
id: string;
name: Text;
thumbnails: Thumbnail[];
badges: any;
badges: ObservedArray<LiveChatAuthorBadge | MetadataBadge>;
is_moderator: boolean | null;
is_verified: boolean | null;
is_verified_artist: boolean | null;
};
money_chip_background_color: number;
@@ -34,9 +39,19 @@ class LiveChatPaidSticker extends YTNode {
id: data.authorExternalChannelId,
name: new Text(data.authorName),
thumbnails: Thumbnail.fromResponse(data.authorPhoto),
badges: Parser.parse(data.authorBadges)
badges: observe([]).as(LiveChatAuthorBadge, MetadataBadge),
is_moderator: null,
is_verified: null,
is_verified_artist: null
};
const badges = Parser.parseArray<LiveChatAuthorBadge | MetadataBadge>(data.authorBadges, [ MetadataBadge, LiveChatAuthorBadge ]);
this.author.badges = badges;
this.author.is_moderator = badges ? badges.some((badge) => badge.icon_type == 'MODERATOR') : null;
this.author.is_verified = badges ? badges.some((badge) => badge.style == 'BADGE_STYLE_TYPE_VERIFIED') : null;
this.author.is_verified_artist = badges ? badges.some((badge) => badge.style == 'BADGE_STYLE_TYPE_VERIFIED_ARTIST') : null;
this.money_chip_background_color = data.moneyChipBackgroundColor;
this.money_chip_text_color = data.moneyChipTextColor;
this.background_color = data.backgroundColor;

View File

@@ -32,12 +32,12 @@ class LiveChatProductItem extends YTNode {
this.price = data.price;
this.vendor_name = data.vendorName;
this.from_vendor_text = data.fromVendorText;
this.information_button = Parser.parse(data.informationButton);
this.information_button = Parser.parseItem(data.informationButton);
this.endpoint = new NavigationEndpoint(data.onClickCommand);
this.creator_message = data.creatorMessage;
this.creator_name = data.creatorName;
this.author_photo = Thumbnail.fromResponse(data.authorPhoto);
this.information_dialog = Parser.parse(data.informationDialog);
this.information_dialog = Parser.parseItem(data.informationDialog);
this.is_verified = data.isVerified;
this.creator_custom_message = new Text(data.creatorCustomMessage);
}

View File

@@ -1,12 +1,11 @@
import { observe, ObservedArray, YTNode } from '../../../helpers';
import Parser from '../../../index';
import Button from '../../Button';
import LiveChatAuthorBadge from '../../LiveChatAuthorBadge';
import MetadataBadge from '../../MetadataBadge';
import Text from '../../misc/Text';
import Thumbnail from '../../misc/Thumbnail';
import NavigationEndpoint from '../../NavigationEndpoint';
import MetadataBadge from '../../MetadataBadge';
import LiveChatAuthorBadge from '../../LiveChatAuthorBadge';
import Parser from '../../../index';
import { ObservedArray, YTNode } from '../../../helpers';
import Button from '../../Button';
class LiveChatTextMessage extends YTNode {
static type = 'LiveChatTextMessage';
@@ -16,7 +15,7 @@ class LiveChatTextMessage extends YTNode {
id: string;
name: Text;
thumbnails: Thumbnail[];
badges: LiveChatAuthorBadge[] | MetadataBadge[];
badges: ObservedArray<LiveChatAuthorBadge | MetadataBadge>;
is_moderator: boolean | null;
is_verified: boolean | null;
is_verified_artist: boolean | null;
@@ -35,7 +34,7 @@ class LiveChatTextMessage extends YTNode {
id: data.authorExternalChannelId,
name: new Text(data.authorName),
thumbnails: Thumbnail.fromResponse(data.authorPhoto),
badges: [] as LiveChatAuthorBadge[] | [] as MetadataBadge[],
badges: observe([]).as(LiveChatAuthorBadge, MetadataBadge),
is_moderator: null,
is_verified: null,
is_verified_artist: null

View File

@@ -1,19 +1,20 @@
import Parser from '../../../index';
import LiveChatAuthorBadge from '../../LiveChatAuthorBadge';
import MetadataBadge from '../../MetadataBadge';
import Text from '../../misc/Text';
import Thumbnail from '../../misc/Thumbnail';
import NavigationEndpoint from '../../NavigationEndpoint';
import MetadataBadge from '../../MetadataBadge';
import LiveChatAuthorBadge from '../../LiveChatAuthorBadge';
import Parser from '../../../index';
import { YTNode } from '../../../helpers';
import { observe, ObservedArray, YTNode } from '../../../helpers';
class LiveChatTickerPaidMessageItem extends YTNode {
static type = 'LiveChatTickerPaidMessageItem';
author: {
id: string;
name: Text;
thumbnails: Thumbnail[];
badges: LiveChatAuthorBadge[] | MetadataBadge[];
badges: ObservedArray<LiveChatAuthorBadge | MetadataBadge>;
is_moderator: boolean | null;
is_verified: boolean | null;
is_verified_artist: boolean | null;
@@ -31,8 +32,9 @@ class LiveChatTickerPaidMessageItem extends YTNode {
this.author = {
id: data.authorExternalChannelId,
name: new Text(data?.authorName),
thumbnails: Thumbnail.fromResponse(data.authorPhoto),
badges: Parser.parseArray<LiveChatAuthorBadge | MetadataBadge>(data.authorBadges, [ MetadataBadge, LiveChatAuthorBadge ]),
badges: observe([]).as(LiveChatAuthorBadge, MetadataBadge),
is_moderator: null,
is_verified: null,
is_verified_artist: null
@@ -41,13 +43,14 @@ class LiveChatTickerPaidMessageItem extends YTNode {
const badges = Parser.parseArray<LiveChatAuthorBadge | MetadataBadge>(data.authorBadges, [ MetadataBadge, LiveChatAuthorBadge ]);
this.author.badges = badges;
this.author.is_moderator = badges?.some((badge) => badge.icon_type == 'MODERATOR') || null;
this.author.is_verified = badges?.some((badge) => badge.style == 'BADGE_STYLE_TYPE_VERIFIED') || null;
this.author.is_verified_artist = badges?.some((badge) => badge.style == 'BADGE_STYLE_TYPE_VERIFIED_ARTIST') || null;
this.author.is_moderator = badges ? badges.some((badge) => badge.icon_type == 'MODERATOR') : null;
this.author.is_verified = badges ? badges.some((badge) => badge.style == 'BADGE_STYLE_TYPE_VERIFIED') : null;
this.author.is_verified_artist = badges ? badges.some((badge) => badge.style == 'BADGE_STYLE_TYPE_VERIFIED_ARTIST') : null;
this.amount = new Text(data.amount);
this.duration_sec = data.durationSec;
this.full_duration_sec = data.fullDurationSec;
this.show_item = Parser.parse(data.showItemEndpoint.showLiveChatItemEndpoint.renderer);
this.show_item = Parser.parse(data.showItemEndpoint?.showLiveChatItemEndpoint?.renderer);
this.show_item_endpoint = new NavigationEndpoint(data.showItemEndpoint);
this.id = data.id;
}

View File

@@ -1,19 +1,20 @@
import Parser from '../../../index';
import LiveChatAuthorBadge from '../../LiveChatAuthorBadge';
import MetadataBadge from '../../MetadataBadge';
import Text from '../../misc/Text';
import Thumbnail from '../../misc/Thumbnail';
import NavigationEndpoint from '../../NavigationEndpoint';
import MetadataBadge from '../../MetadataBadge';
import LiveChatAuthorBadge from '../../LiveChatAuthorBadge';
import Parser from '../../../index';
import { YTNode } from '../../../helpers';
import { observe, ObservedArray, YTNode } from '../../../helpers';
class LiveChatTickerPaidStickerItem extends YTNode {
static type = 'LiveChatTickerPaidStickerItem';
author: {
id: string;
name: Text;
thumbnails: Thumbnail[];
badges: LiveChatAuthorBadge[] | MetadataBadge[];
badges: ObservedArray<LiveChatAuthorBadge | MetadataBadge>;
is_moderator: boolean | null;
is_verified: boolean | null;
is_verified_artist: boolean | null;
@@ -31,8 +32,9 @@ class LiveChatTickerPaidStickerItem extends YTNode {
this.author = {
id: data.authorExternalChannelId,
name: new Text(data?.authorName),
thumbnails: Thumbnail.fromResponse(data.authorPhoto),
badges: Parser.parseArray<LiveChatAuthorBadge | MetadataBadge>(data.authorBadges, [ MetadataBadge, LiveChatAuthorBadge ]),
badges: observe([]).as(LiveChatAuthorBadge, MetadataBadge),
is_moderator: null,
is_verified: null,
is_verified_artist: null
@@ -41,13 +43,14 @@ class LiveChatTickerPaidStickerItem extends YTNode {
const badges = Parser.parseArray<LiveChatAuthorBadge | MetadataBadge>(data.authorBadges, [ MetadataBadge, LiveChatAuthorBadge ]);
this.author.badges = badges;
this.author.is_moderator = badges?.some((badge) => badge.icon_type == 'MODERATOR') || null;
this.author.is_verified = badges?.some((badge) => badge.style == 'BADGE_STYLE_TYPE_VERIFIED') || null;
this.author.is_verified_artist = badges?.some((badge) => badge.style == 'BADGE_STYLE_TYPE_VERIFIED_ARTIST') || null;
this.author.is_moderator = badges ? badges.some((badge) => badge.icon_type == 'MODERATOR') : null;
this.author.is_verified = badges ? badges.some((badge) => badge.style == 'BADGE_STYLE_TYPE_VERIFIED') : null;
this.author.is_verified_artist = badges ? badges.some((badge) => badge.style == 'BADGE_STYLE_TYPE_VERIFIED_ARTIST') : null;
this.amount = new Text(data.amount);
this.duration_sec = data.durationSec;
this.full_duration_sec = data.fullDurationSec;
this.show_item = Parser.parse(data.showItemEndpoint.showLiveChatItemEndpoint.renderer);
this.show_item = Parser.parseItem(data.showItemEndpoint?.showLiveChatItemEndpoint?.renderer);
this.show_item_endpoint = new NavigationEndpoint(data.showItemEndpoint);
this.id = data.id;
}

View File

@@ -1,16 +1,23 @@
import Parser from '../../..';
import { observe, ObservedArray, YTNode } from '../../../helpers';
import LiveChatAuthorBadge from '../../LiveChatAuthorBadge';
import MetadataBadge from '../../MetadataBadge';
import Text from '../../misc/Text';
import Thumbnail from '../../misc/Thumbnail';
import { YTNode } from '../../../helpers';
class LiveChatTickerSponsorItem extends YTNode {
static type = 'LiveChatTickerSponsorItem';
id: string;
detail_text: string;
detail: Text;
author: {
id: string;
name: Text;
thumbnails: Thumbnail[];
badges: ObservedArray<LiveChatAuthorBadge | MetadataBadge>;
is_moderator: boolean | null;
is_verified: boolean | null;
is_verified_artist: boolean | null;
};
duration_sec: string;
@@ -18,14 +25,25 @@ class LiveChatTickerSponsorItem extends YTNode {
constructor(data: any) {
super();
this.id = data.id;
this.detail_text = new Text(data.detailText).toString();
this.detail = new Text(data.detailText);
this.author = {
id: data.authorExternalChannelId,
name: new Text(data?.authorName),
thumbnails: Thumbnail.fromResponse(data.sponsorPhoto)
thumbnails: Thumbnail.fromResponse(data.sponsorPhoto),
badges: observe([]).as(LiveChatAuthorBadge, MetadataBadge),
is_moderator: null,
is_verified: null,
is_verified_artist: null
};
const badges = Parser.parseArray<LiveChatAuthorBadge | MetadataBadge>(data.authorBadges, [ MetadataBadge, LiveChatAuthorBadge ]);
this.author.badges = badges;
this.author.is_moderator = badges ? badges.some((badge) => badge.icon_type == 'MODERATOR') : null;
this.author.is_verified = badges ? badges.some((badge) => badge.style == 'BADGE_STYLE_TYPE_VERIFIED') : null;
this.author.is_verified_artist = badges ? badges.some((badge) => badge.style == 'BADGE_STYLE_TYPE_VERIFIED_ARTIST') : null;
this.duration_sec = data.durationSec;
// TODO: finish this
}

View File

@@ -1,5 +1,5 @@
import LiveChatTextMessage from './LiveChatTextMessage';
import Parser from '../../../index';
import LiveChatTextMessage from './LiveChatTextMessage';
class LiveChatViewerEngagementMessage extends LiveChatTextMessage {
static type = 'LiveChatViewerEngagementMessage';
@@ -12,7 +12,7 @@ class LiveChatViewerEngagementMessage extends LiveChatTextMessage {
delete this.author;
delete this.menu_endpoint;
this.icon_type = data.icon.iconType;
this.action_button = Parser.parse(data.actionButton);
this.action_button = Parser.parseItem(data.actionButton);
}
}

View File

@@ -18,7 +18,7 @@ class PollHeader extends YTNode {
this.thumbnails = Thumbnail.fromResponse(data.thumbnail);
this.metadata = new Text(data.metadataText);
this.live_chat_poll_type = data.liveChatPollType;
this.context_menu_button = Parser.parse(data.contextMenuButton);
this.context_menu_button = Parser.parseItem(data.contextMenuButton);
}
}

View File

@@ -7,6 +7,7 @@ class EmojiRun {
shortcuts: string[];
search_terms: string[];
image: Thumbnail[];
is_custom: boolean;
};
constructor(data: any) {
@@ -17,9 +18,10 @@ class EmojiRun {
this.emoji = {
emoji_id: data.emoji.emojiId,
shortcuts: data.emoji.shortcuts,
search_terms: data.emoji.searchTerms,
image: Thumbnail.fromResponse(data.emoji.image)
shortcuts: data.emoji?.shortcuts || [],
search_terms: data.emoji?.searchTerms || [],
image: Thumbnail.fromResponse(data.emoji.image),
is_custom: !!data.emoji?.isCustomEmoji
};
}
}

View File

@@ -5,11 +5,16 @@ import type PlayerAnnotationsExpanded from './classes/PlayerAnnotationsExpanded'
import type PlayerCaptionsTracklist from './classes/PlayerCaptionsTracklist';
import type PlayerLiveStoryboardSpec from './classes/PlayerLiveStoryboardSpec';
import type PlayerStoryboardSpec from './classes/PlayerStoryboardSpec';
import type Message from './classes/Message';
import type LiveChatParticipantsList from './classes/LiveChatParticipantsList';
import type LiveChatHeader from './classes/LiveChatHeader';
import type LiveChatItemList from './classes/LiveChatItemList';
import MusicMultiSelectMenuItem from './classes/menus/MusicMultiSelectMenuItem';
import Format from './classes/misc/Format';
import VideoDetails from './classes/misc/VideoDetails';
import NavigationEndpoint from './classes/NavigationEndpoint';
import Thumbnail from './classes/misc/Thumbnail';
import { InnertubeError, ParsingError } from '../utils/Utils';
import { Memo, observe, ObservedArray, SuperParsedResult, YTNode, YTNodeConstructor } from './helpers';
@@ -80,6 +85,11 @@ export default class Parser {
const on_response_received_commands_memo = this.#getMemo();
this.#clearMemo();
this.#createMemo();
const continuation_contents = data.continuationContents ? Parser.parseLC(data.continuationContents) : null;
const continuation_contents_memo = this.#getMemo();
this.#clearMemo();
this.#createMemo();
const actions = data.actions ? Parser.parseActions(data.actions) : null;
const actions_memo = this.#getMemo();
@@ -122,7 +132,8 @@ export default class Parser {
on_response_received_commands,
on_response_received_commands_memo,
continuation: data.continuation ? Parser.parseC(data.continuation) : null,
continuation_contents: data.continuationContents ? Parser.parseLC(data.continuationContents) : null,
continuation_contents,
continuation_contents_memo,
metadata: Parser.parse(data.metadata),
microformat: data.microformat ? Parser.parseItem(data.microformat) : null,
overlay: Parser.parseItem(data.overlay),
@@ -394,11 +405,13 @@ export class ReloadContinuationItemsCommand extends YTNode {
target_id: string;
contents: ObservedArray<YTNode> | null;
slot?: string;
constructor(data: any) {
super();
this.target_id = data.targetId;
this.contents = Parser.parse(data.continuationItems, true);
this.slot = data?.slot;
}
}
@@ -494,11 +507,16 @@ export class LiveChatContinuation extends YTNode {
actions: ObservedArray<YTNode>;
action_panel: YTNode | null;
item_list: YTNode | null;
header: YTNode | null;
participants_list: YTNode | null;
popout_message: YTNode | null;
emojis: any[] | null; // TODO: give this an actual type
item_list: LiveChatItemList | null;
header: LiveChatHeader | null;
participants_list: LiveChatParticipantsList | null;
popout_message: Message | null;
emojis: {
emoji_id: string;
shortcuts: string[];
search_terms: string[];
image: Thumbnail[];
}[];
continuation: TimedContinuation;
viewer_name: string;
@@ -510,18 +528,18 @@ export class LiveChatContinuation extends YTNode {
}), true) || observe<YTNode>([]);
this.action_panel = Parser.parseItem(data.actionPanel);
this.item_list = Parser.parseItem(data.itemList);
this.header = Parser.parseItem(data.header);
this.participants_list = Parser.parseItem(data.participantsList);
this.popout_message = Parser.parseItem(data.popoutMessage);
this.item_list = Parser.parseItem<LiveChatItemList>(data.itemList);
this.header = Parser.parseItem<LiveChatHeader>(data.header);
this.participants_list = Parser.parseItem<LiveChatParticipantsList>(data.participantsList);
this.popout_message = Parser.parseItem<Message>(data.popoutMessage);
this.emojis = data.emojis?.map((emoji: any) => ({
emoji_id: emoji.emojiId,
shortcuts: emoji.shortcuts,
search_terms: emoji.searchTerms,
image: emoji.image,
image: Thumbnail.fromResponse(emoji.image),
is_custom_emoji: emoji.isCustomEmoji
})) || null;
})) || [];
this.continuation = new TimedContinuation(
data.continuations?.[0].timedContinuationData ||

View File

@@ -48,12 +48,17 @@ import { default as CollageHeroImage } from './classes/CollageHeroImage';
import { default as AuthorCommentBadge } from './classes/comments/AuthorCommentBadge';
import { default as Comment } from './classes/comments/Comment';
import { default as CommentActionButtons } from './classes/comments/CommentActionButtons';
import { default as CommentDialog } from './classes/comments/CommentDialog';
import { default as CommentReplies } from './classes/comments/CommentReplies';
import { default as CommentReplyDialog } from './classes/comments/CommentReplyDialog';
import { default as CommentsEntryPointHeader } from './classes/comments/CommentsEntryPointHeader';
import { default as CommentsHeader } from './classes/comments/CommentsHeader';
import { default as CommentSimplebox } from './classes/comments/CommentSimplebox';
import { default as CommentThread } from './classes/comments/CommentThread';
import { default as CreatorHeart } from './classes/comments/CreatorHeart';
import { default as EmojiPicker } from './classes/comments/EmojiPicker';
import { default as PdgCommentChip } from './classes/comments/PdgCommentChip';
import { default as SponsorCommentBadge } from './classes/comments/SponsorCommentBadge';
import { default as CompactLink } from './classes/CompactLink';
import { default as CompactMix } from './classes/CompactMix';
import { default as CompactPlaylist } from './classes/CompactPlaylist';
@@ -71,6 +76,9 @@ import { default as Dropdown } from './classes/Dropdown';
import { default as DropdownItem } from './classes/DropdownItem';
import { default as Element } from './classes/Element';
import { default as EmergencyOnebox } from './classes/EmergencyOnebox';
import { default as EmojiPickerCategory } from './classes/EmojiPickerCategory';
import { default as EmojiPickerCategoryButton } from './classes/EmojiPickerCategoryButton';
import { default as EmojiPickerUpsellCategory } from './classes/EmojiPickerUpsellCategory';
import { default as Endscreen } from './classes/Endscreen';
import { default as EndscreenElement } from './classes/EndscreenElement';
import { default as EndScreenPlaylist } from './classes/EndScreenPlaylist';
@@ -104,6 +112,7 @@ import { default as LiveChat } from './classes/LiveChat';
import { default as AddBannerToLiveChatCommand } from './classes/livechat/AddBannerToLiveChatCommand';
import { default as AddChatItemAction } from './classes/livechat/AddChatItemAction';
import { default as AddLiveChatTickerItemAction } from './classes/livechat/AddLiveChatTickerItemAction';
import { default as DimChatItemAction } from './classes/livechat/DimChatItemAction';
import { default as LiveChatAutoModMessage } from './classes/livechat/items/LiveChatAutoModMessage';
import { default as LiveChatBanner } from './classes/livechat/items/LiveChatBanner';
import { default as LiveChatBannerHeader } from './classes/livechat/items/LiveChatBannerHeader';
@@ -129,6 +138,7 @@ import { default as RemoveChatItemByAuthorAction } from './classes/livechat/Remo
import { default as ReplaceChatItemAction } from './classes/livechat/ReplaceChatItemAction';
import { default as ReplayChatItemAction } from './classes/livechat/ReplayChatItemAction';
import { default as ShowLiveChatActionPanelAction } from './classes/livechat/ShowLiveChatActionPanelAction';
import { default as ShowLiveChatDialogAction } from './classes/livechat/ShowLiveChatDialogAction';
import { default as ShowLiveChatTooltipCommand } from './classes/livechat/ShowLiveChatTooltipCommand';
import { default as UpdateDateTextAction } from './classes/livechat/UpdateDateTextAction';
import { default as UpdateDescriptionAction } from './classes/livechat/UpdateDescriptionAction';
@@ -285,6 +295,7 @@ import { default as TwoColumnBrowseResults } from './classes/TwoColumnBrowseResu
import { default as TwoColumnSearchResults } from './classes/TwoColumnSearchResults';
import { default as TwoColumnWatchNextResults } from './classes/TwoColumnWatchNextResults';
import { default as UniversalWatchCard } from './classes/UniversalWatchCard';
import { default as UpsellDialog } from './classes/UpsellDialog';
import { default as VerticalList } from './classes/VerticalList';
import { default as VerticalWatchCardList } from './classes/VerticalWatchCardList';
import { default as Video } from './classes/Video';
@@ -347,12 +358,17 @@ export const YTNodes = {
AuthorCommentBadge,
Comment,
CommentActionButtons,
CommentDialog,
CommentReplies,
CommentReplyDialog,
CommentsEntryPointHeader,
CommentsHeader,
CommentSimplebox,
CommentThread,
CreatorHeart,
EmojiPicker,
PdgCommentChip,
SponsorCommentBadge,
CompactLink,
CompactMix,
CompactPlaylist,
@@ -370,6 +386,9 @@ export const YTNodes = {
DropdownItem,
Element,
EmergencyOnebox,
EmojiPickerCategory,
EmojiPickerCategoryButton,
EmojiPickerUpsellCategory,
Endscreen,
EndscreenElement,
EndScreenPlaylist,
@@ -403,6 +422,7 @@ export const YTNodes = {
AddBannerToLiveChatCommand,
AddChatItemAction,
AddLiveChatTickerItemAction,
DimChatItemAction,
LiveChatAutoModMessage,
LiveChatBanner,
LiveChatBannerHeader,
@@ -428,6 +448,7 @@ export const YTNodes = {
ReplaceChatItemAction,
ReplayChatItemAction,
ShowLiveChatActionPanelAction,
ShowLiveChatDialogAction,
ShowLiveChatTooltipCommand,
UpdateDateTextAction,
UpdateDescriptionAction,
@@ -584,6 +605,7 @@ export const YTNodes = {
TwoColumnSearchResults,
TwoColumnWatchNextResults,
UniversalWatchCard,
UpsellDialog,
VerticalList,
VerticalWatchCardList,
Video,

View File

@@ -2,8 +2,8 @@ import Parser, { ParsedResponse } from '..';
import type Actions from '../../core/Actions';
import type { ApiResponse } from '../../core/Actions';
import { InnertubeError } from '../../utils/Utils';
import { observe, ObservedArray } from '../helpers';
import Button from '../classes/Button';
import CommentsHeader from '../classes/comments/CommentsHeader';
import CommentSimplebox from '../classes/comments/CommentSimplebox';
import CommentThread from '../classes/comments/CommentThread';
@@ -15,7 +15,7 @@ class Comments {
#continuation?: ContinuationItem;
header?: CommentsHeader;
contents: CommentThread[];
contents: ObservedArray<CommentThread>;
constructor(actions: Actions, data: any, already_parsed = false) {
this.#page = already_parsed ? data : Parser.parseResponse(data);
@@ -26,17 +26,47 @@ class Comments {
if (!contents)
throw new InnertubeError('Comments page did not have any content.');
this.header = contents[0].contents?.firstOfType(CommentsHeader);
const header_node = contents.at(0);
const body_node = contents.at(1);
const threads: CommentThread[] = contents[1].contents?.filterType(CommentThread) || [];
this.header = header_node?.contents?.firstOfType(CommentsHeader);
this.contents = threads.map((thread) => {
const threads = body_node?.contents?.filterType(CommentThread) || [];
this.contents = observe(threads.map((thread) => {
thread.comment?.setActions(this.#actions);
thread.setActions(this.#actions);
return thread;
}) as CommentThread[];
}));
this.#continuation = contents[1].contents?.firstOfType(ContinuationItem);
this.#continuation = body_node?.contents?.firstOfType(ContinuationItem);
}
/**
* Applies given sort option to the comments.
* @param sort - Sort type.
*/
async applySort(sort: 'TOP_COMMENTS' | 'NEWEST_FIRST'): Promise<Comments> {
if (!this.header)
throw new InnertubeError('Page header is missing. Cannot apply sort option.');
let button;
if (sort === 'TOP_COMMENTS') {
button = this.header.sort_menu?.sub_menu_items?.at(0);
} else if (sort === 'NEWEST_FIRST') {
button = this.header.sort_menu?.sub_menu_items?.at(1);
}
if (!button)
throw new InnertubeError('Could not find target button.');
if (button.selected)
return this;
const response = await button.endpoint.call(this.#actions, { parse: true });
return new Comments(this.#actions, response, true);
}
/**
@@ -47,11 +77,14 @@ class Comments {
if (!this.header)
throw new InnertubeError('Page header is missing. Cannot create comment.');
const button = this.header.create_renderer?.as(CommentSimplebox).submit_button?.item().as(Button);
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.');
if (!button.endpoint)
throw new InnertubeError('Button does not have an endpoint.');
const response = await button.endpoint.call(this.#actions, { commentText: text });
return response;
@@ -79,6 +112,10 @@ class Comments {
return new Comments(this.#actions, page, true);
}
get has_continuation(): boolean {
return !!this.#continuation;
}
get page(): ParsedResponse {
return this.#page;
}

View File

@@ -28,9 +28,12 @@ class ItemMenu {
async selectItem(icon_type: string): Promise<ParsedResponse>
async selectItem(button: Button): Promise<ParsedResponse>
async selectItem(item: string | Button): Promise<ParsedResponse> {
let endpoint: NavigationEndpoint;
let endpoint: NavigationEndpoint | undefined;
if (item instanceof Button) {
if (!item.endpoint)
throw new InnertubeError('Item does not have an endpoint.');
endpoint = item.endpoint;
} else {
const button = this.#items.find((button) => {
@@ -41,12 +44,15 @@ class ItemMenu {
return menuServiceItem.icon_type === item;
});
if (!button)
if (!button || !button.is(MenuServiceItem))
throw new InnertubeError(`Button "${item}" not found.`);
endpoint = button.as(MenuServiceItem).endpoint;
endpoint = button.endpoint;
}
if (!endpoint)
throw new InnertubeError('Target button does not have an endpoint.');
const response = await endpoint.call(this.#actions, { parse: true });
return response;

View File

@@ -43,16 +43,17 @@ export type ChatAction =
export type ChatItemWithMenu = LiveChatAutoModMessage | LiveChatMembershipItem | LiveChatPaidMessage | LiveChatPaidSticker | LiveChatTextMessage | LiveChatViewerEngagementMessage;
export interface LiveMetadata {
title: UpdateTitleAction | undefined;
description: UpdateDescriptionAction | undefined;
views: UpdateViewershipAction | undefined;
likes: UpdateToggleButtonTextAction | undefined;
date: UpdateDateTextAction | undefined;
title?: UpdateTitleAction;
description?: UpdateDescriptionAction;
views?: UpdateViewershipAction;
likes?: UpdateToggleButtonTextAction;
date?: UpdateDateTextAction;
}
class LiveChat extends EventEmitter {
#actions: Actions;
#video_info: VideoInfo;
#video_id: string;
#channel_id: string;
#continuation?: string;
#mcontinuation?: string;
@@ -65,12 +66,22 @@ class LiveChat extends EventEmitter {
constructor(video_info: VideoInfo) {
super();
this.#video_info = video_info;
this.#video_id = video_info.basic_info.id as string;
this.#channel_id = video_info.basic_info.channel_id as string;
this.#actions = video_info.actions;
this.#continuation = video_info.livechat?.continuation || undefined;
this.#continuation = video_info.livechat?.continuation;
this.is_replay = video_info.livechat?.is_replay || false;
}
on(type: 'start', listener: (initial_data: LiveChatContinuation) => void): void;
on(type: 'chat-update', listener: (action: ChatAction) => void): void;
on(type: 'metadata-update', listener: (metadata: LiveMetadata) => void): void;
on(type: 'error', listener: (err: Error) => void): void;
on(type: 'end', listener: () => void): void;
on(type: string, listener: (...args: any[]) => void): void {
super.on(type, listener);
}
start() {
if (!this.running) {
this.running = true;
@@ -85,32 +96,44 @@ class LiveChat extends EventEmitter {
#pollLivechat() {
(async () => {
const endpoint = this.is_replay ? 'live_chat/get_live_chat_replay' : 'live_chat/get_live_chat';
const response = await this.#actions.execute(endpoint, { continuation: this.#continuation });
try {
const endpoint = this.is_replay ? 'live_chat/get_live_chat_replay' : 'live_chat/get_live_chat';
const response = await this.#actions.execute(endpoint, { continuation: this.#continuation });
const data = Parser.parseResponse(response.data);
const contents = data.continuation_contents;
const data = Parser.parseResponse(response.data);
const contents = data.continuation_contents;
if (!(contents instanceof LiveChatContinuation))
throw new InnertubeError('Continuation is not a LiveChatContinuation');
if (!(contents instanceof LiveChatContinuation)) {
this.stop();
this.emit('end');
return;
}
this.#continuation = contents.continuation.token;
this.#continuation = contents.continuation.token;
// Header only exists in the first request
if (contents.header) {
this.initial_info = contents;
this.emit('start', contents);
} else {
await this.#emitSmoothedActions(contents.actions);
// Header only exists in the first request
if (contents.header) {
this.initial_info = contents;
this.emit('start', contents);
} else {
await this.#emitSmoothedActions(contents.actions);
}
/**
* If there are no actions then we wait 1000 milliseconds, otherwise
* the amount of items on the action queue will determine the polling interval.
*/
if (!contents.actions.length && !contents.header)
await this.#wait(1000);
if (this.running)
this.#pollLivechat();
} catch (err) {
this.emit('error', new InnertubeError('Failed to poll livechat, retrying...', err));
await this.#wait(2000);
if (this.running)
this.#pollLivechat();
}
// If there are no actions then we wait 1000 milliseconds, otherwise
// The amount of items on the action queue will determine the polling interval.
if (!contents.actions.length && !contents.header)
await this.#wait(1000);
if (this.running)
this.#pollLivechat();
})();
}
@@ -139,43 +162,51 @@ class LiveChat extends EventEmitter {
#pollMetadata() {
(async () => {
const payload: {
videoId: string | undefined;
continuation?: string;
} = { videoId: this.#video_info.basic_info.id };
try {
const payload: {
videoId?: string;
continuation?: string;
} = { videoId: this.#video_id };
if (this.#mcontinuation) {
payload.continuation = this.#mcontinuation;
if (this.#mcontinuation) {
payload.continuation = this.#mcontinuation;
}
const response = await this.#actions.execute('/updated_metadata', payload);
const data = Parser.parseResponse(response.data);
this.#mcontinuation = data.continuation?.token;
this.metadata = {
title: data.actions?.array().firstOfType(UpdateTitleAction) || this.metadata?.title,
description: data.actions?.array().firstOfType(UpdateDescriptionAction) || this.metadata?.description,
views: data.actions?.array().firstOfType(UpdateViewershipAction) || this.metadata?.views,
likes: data.actions?.array().firstOfType(UpdateToggleButtonTextAction) || this.metadata?.likes,
date: data.actions?.array().firstOfType(UpdateDateTextAction) || this.metadata?.date
};
this.emit('metadata-update', this.metadata);
await this.#wait(5000);
if (this.running)
this.#pollMetadata();
} catch (err) {
this.emit('error', new InnertubeError('Failed to poll live metadata, retrying...', err));
await this.#wait(2000);
if (this.running)
this.#pollMetadata();
}
const response = await this.#actions.execute('/updated_metadata', payload);
const data = Parser.parseResponse(response.data);
this.#mcontinuation = data.continuation?.token;
this.metadata = {
title: data.actions?.array().firstOfType(UpdateTitleAction) || this.metadata?.title,
description: data.actions?.array().firstOfType(UpdateDescriptionAction) || this.metadata?.description,
views: data.actions?.array().firstOfType(UpdateViewershipAction) || this.metadata?.views,
likes: data.actions?.array().firstOfType(UpdateToggleButtonTextAction) || this.metadata?.likes,
date: data.actions?.array().firstOfType(UpdateDateTextAction) || this.metadata?.date
};
this.emit('metadata-update', this.metadata);
await this.#wait(5000);
if (this.running)
this.#pollMetadata();
})();
}
/**
* Sends a message.
* @param text - Text to send.
*/
async sendMessage(text: string): Promise<ObservedArray<AddChatItemAction>> {
const response = await this.#actions.execute('/live_chat/send_message', {
params: Proto.encodeMessageParams(this.#video_info.basic_info.channel_id as string, this.#video_info.basic_info.id as string),
params: Proto.encodeMessageParams(this.#channel_id, this.#video_id),
richMessage: { textSegments: [ { text } ] },
clientMessageId: uuidv4(),
parse: true
@@ -187,6 +218,25 @@ class LiveChat extends EventEmitter {
return response.actions.array().as(AddChatItemAction);
}
/**
* Applies given filter to the live chat.
* @param filter - Filter to apply.
*/
applyFilter(filter: 'TOP_CHAT' | 'LIVE_CHAT'): void {
if (!this.initial_info)
throw new InnertubeError('Cannot apply filter before initial info is retrieved.');
const menu_items = this.initial_info?.header?.view_selector?.sub_menu_items;
if (filter === 'TOP_CHAT') {
if (menu_items?.at(0)?.selected) return;
this.#continuation = menu_items?.at(0)?.continuation;
} else {
if (menu_items?.at(1)?.selected) return;
this.#continuation = menu_items?.at(1)?.continuation;
}
}
/**
* Retrieves given chat item's menu.
*/

View File

@@ -9,22 +9,26 @@ import PlaylistSidebarSecondaryInfo from '../classes/PlaylistSidebarSecondaryInf
import PlaylistCustomThumbnail from '../classes/PlaylistCustomThumbnail';
import PlaylistVideoThumbnail from '../classes/PlaylistVideoThumbnail';
import PlaylistHeader from '../classes/PlaylistHeader';
import Message from '../classes/Message';
import { InnertubeError } from '../../utils/Utils';
import type Actions from '../../core/Actions';
import { ObservedArray } from '../helpers';
import NavigationEndpoint from '../classes/NavigationEndpoint';
class Playlist extends Feed {
info;
menu;
endpoint;
endpoint?: NavigationEndpoint;
messages: ObservedArray<Message>;
constructor(actions: Actions, data: any, already_parsed = false) {
super(actions, data, already_parsed);
const header = this.memo.getType(PlaylistHeader)?.[0];
const primary_info = this.memo.getType(PlaylistSidebarPrimaryInfo)?.[0];
const secondary_info = this.memo.getType(PlaylistSidebarSecondaryInfo)?.[0];
const header = this.memo.getType(PlaylistHeader).first();
const primary_info = this.memo.getType(PlaylistSidebarPrimaryInfo).first();
const secondary_info = this.memo.getType(PlaylistSidebarSecondaryInfo).first();
if (!primary_info && !secondary_info)
throw new InnertubeError('This playlist does not exist');
@@ -46,6 +50,7 @@ class Playlist extends Feed {
this.menu = primary_info?.menu;
this.endpoint = primary_info?.endpoint;
this.messages = this.memo.getType(Message);
}
#getStat(index: number, primary_info?: PlaylistSidebarPrimaryInfo): string {
@@ -59,7 +64,7 @@ class Playlist extends Feed {
async getContinuation(): Promise<Playlist> {
const response = await this.getContinuationData();
return new Playlist(this.actions, response);
return new Playlist(this.actions, response, true);
}
}

View File

@@ -1,4 +1,4 @@
import Parser, { ParsedResponse } from '..';
import Parser, { ParsedResponse, SectionListContinuation } from '..';
import type Actions from '../../core/Actions';
import type { ApiResponse } from '../../core/Actions';
@@ -29,7 +29,7 @@ class Library {
this.#page = Parser.parseResponse(response.data);
this.#actions = actions;
const section_list = this.#page.contents_memo.getType(SectionList)?.[0];
const section_list = this.#page.contents_memo.getType(SectionList).first();
this.header = section_list?.header?.item().as(MusicSideAlignedItem);
this.contents = section_list?.contents?.as(Grid, MusicShelf);
@@ -38,9 +38,9 @@ class Library {
}
/**
* Applies given sort filter to the library items.
* Applies given sort option to the library items.
*/
async applySortFilter(sort_by: string | MusicMultiSelectMenuItem): Promise<Library> {
async applySort(sort_by: string | MusicMultiSelectMenuItem): Promise<Library> {
let target_item: MusicMultiSelectMenuItem | undefined;
if (typeof sort_by === 'string') {
@@ -54,13 +54,13 @@ class Library {
target_item = options?.find((item) => item.title === sort_by);
if (!target_item)
throw new InnertubeError(`Sort filter "${sort_by}" not found`, { available_filters: options.map((item) => item.title) });
throw new InnertubeError(`Sort option "${sort_by}" not found`, { available_filters: options.map((item) => item.title) });
} else if (sort_by instanceof MusicMultiSelectMenuItem) {
target_item = sort_by;
}
if (!target_item)
throw new InnertubeError('Invalid sort filter');
throw new InnertubeError('Invalid sort option');
if (target_item.selected)
return this;
@@ -68,14 +68,23 @@ class Library {
const cmd = target_item.endpoint?.payload?.commands?.find((cmd: any) => cmd.browseSectionListReloadEndpoint)?.browseSectionListReloadEndpoint;
if (!cmd)
throw new InnertubeError('Failed to find sort filter command');
throw new InnertubeError('Failed to find sort option command');
const response = await this.#actions.execute('/browse', {
client: 'YTMUSIC',
continuation: cmd.continuation.reloadContinuationData.continuation
continuation: cmd.continuation.reloadContinuationData.continuation,
parse: true
});
return new Library(response, this.#actions);
const previously_selected_item = this.#page.contents_memo.getType(MusicMultiSelectMenuItem)?.find((item) => item.selected);
if (previously_selected_item)
previously_selected_item.selected = false;
target_item.selected = true;
this.contents = response.continuation_contents?.as(SectionListContinuation).contents?.as(Grid, MusicShelf);
return this;
}
/**
@@ -123,14 +132,14 @@ class Library {
return !!this.#continuation;
}
get sort_filters(): string[] {
get sort_options(): string[] {
const button = this.#page.contents_memo.getType(MusicSortFilterButton)?.[0];
const options = button.menu?.options.filter((item: MusicMultiSelectMenuItem | MusicMenuItemDivider) => item instanceof MusicMultiSelectMenuItem) as MusicMultiSelectMenuItem[];
return options.map((item) => item.title);
}
get filters(): string[] {
return this.#page.contents_memo.getType(ChipCloud)?.[0].chips.map((chip: ChipCloudChip) => chip.text);
return this.#page.contents_memo.getType(ChipCloud)?.first()?.chips.map((chip: ChipCloudChip) => chip.text) || [];
}
get page(): ParsedResponse {

View File

@@ -184,6 +184,7 @@ class Proto {
type,
commentId: args.comment_id || ' ',
videoId: args.video_id || ' ',
channelId: ' ',
unkNum: 2
};

View File

@@ -3,6 +3,7 @@ import Innertube from '..';
import { CHANNELS, VIDEOS } from './constants';
import { streamToIterable } from '../src/utils/Utils';
import TextRun from '../src/parser/classes/misc/TextRun';
import Comments from '../dist/src/parser/youtube/Comments';
describe('YouTube.js Tests', () => {
let yt: Innertube;
@@ -76,14 +77,6 @@ describe('YouTube.js Tests', () => {
expect(search.channels).toBeDefined();
expect(search.has_continuation).toBe(true);
});
it('should search with WatchCardHeroVideo parse', async () => {
search = await yt.search(VIDEOS[2].QUERY);
expect(search.results.length).toBeGreaterThanOrEqual(5);
expect(search.playlists).toBeDefined();
expect(search.channels).toBeDefined();
expect(search.has_continuation).toBe(true);
});
it('should retrieve search continuation', async () => {
const next = await search.getContinuation();
@@ -100,39 +93,41 @@ describe('YouTube.js Tests', () => {
});
describe('Comments', () => {
let threads: any;
let comment_section: Comments;
it('should retrieve comments', async () => {
threads = await yt.getComments(VIDEOS[1].ID);
expect(threads.contents.length).toBeGreaterThan(0);
comment_section = await yt.getComments(VIDEOS[1].ID);
expect(comment_section.contents.length).toBeGreaterThan(0);
});
it('should parse formatted comments', async () => {
const threads = await yt.getComments(VIDEOS[3].ID);
const authorComment = threads.contents.find(t => t.comment?.author_is_channel_owner)
expect(authorComment).not.toBeUndefined();
const comment_section = await yt.getComments(VIDEOS[3].ID);
const channel_owner_thread = comment_section.contents.find(t => t.comment?.author_is_channel_owner);
expect(channel_owner_thread).not.toBeUndefined();
expect(authorComment!.comment?.content.runs?.length).toBeGreaterThan(0)
const runs = authorComment!.comment!.content.runs! as TextRun[]
expect(channel_owner_thread!.comment?.content.runs?.length).toBeGreaterThan(0);
const runs = channel_owner_thread!.comment!.content.runs! as TextRun[];
expect(runs[0].bold).toBeTruthy()
expect(runs[2].italics).toBeTruthy()
expect(runs[4].strikethrough).toBeTruthy()
expect(runs[0].bold).toBeTruthy();
expect(runs[2].italics).toBeTruthy();
expect(runs[4].strikethrough).toBeTruthy();
})
it('should retrieve next batch of comments', async () => {
const next = await threads.getContinuation();
const next = await comment_section.getContinuation();
expect(next.contents.length).toBeGreaterThan(0);
});
it('should retrieve comment replies', async () => {
const comment = threads.contents[0];
const thread = await comment.getReplies();
const thread = comment_section.contents.first();
expect(thread?.has_replies).toBe(true);
const full_thread = await thread?.getReplies();
expect(thread.comment_id).toBe(comment.comment_id);
expect(thread.replies.length).toBeLessThanOrEqual(10);
expect(full_thread?.comment?.comment_id).toBe(thread?.comment?.comment_id);
expect(full_thread?.replies?.length).toBeLessThanOrEqual(10);
});
});
describe('General', () => {