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)
This commit is contained in:
LuanRT
2023-01-10 01:44:51 -03:00
committed by GitHub
parent 5a362a0bd5
commit 8e37efa575
35 changed files with 413 additions and 182 deletions

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');
}
});

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

@@ -12,7 +12,7 @@ class SortFilterSubMenu extends YTNode {
sub_menu_items?: {
title: string;
selected: boolean;
continuation: string | null;
continuation: string;
endpoint: NavigationEndpoint;
subtitle: string | null;
}[];
@@ -40,7 +40,7 @@ class SortFilterSubMenu extends YTNode {
this.sub_menu_items = data.subMenuItems.map((item: any) => ({
title: item.title,
selected: item.selected,
continuation: item.continuation?.reloadContinuationData?.continuation || null,
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,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

@@ -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';
@@ -496,11 +501,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;
@@ -512,18 +522,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

@@ -112,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';
@@ -137,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';
@@ -293,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';
@@ -419,6 +422,7 @@ export const YTNodes = {
AddBannerToLiveChatCommand,
AddChatItemAction,
AddLiveChatTickerItemAction,
DimChatItemAction,
LiveChatAutoModMessage,
LiveChatBanner,
LiveChatBannerHeader,
@@ -444,6 +448,7 @@ export const YTNodes = {
ReplaceChatItemAction,
ReplayChatItemAction,
ShowLiveChatActionPanelAction,
ShowLiveChatDialogAction,
ShowLiveChatTooltipCommand,
UpdateDateTextAction,
UpdateDescriptionAction,
@@ -600,6 +605,7 @@ export const YTNodes = {
TwoColumnSearchResults,
TwoColumnWatchNextResults,
UniversalWatchCard,
UpsellDialog,
VerticalList,
VerticalWatchCardList,
Video,

View File

@@ -43,11 +43,11 @@ 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 {
@@ -69,10 +69,19 @@ class LiveChat extends EventEmitter {
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;
@@ -87,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();
})();
}
@@ -141,39 +162,47 @@ class LiveChat extends EventEmitter {
#pollMetadata() {
(async () => {
const payload: {
videoId: string | undefined;
continuation?: string;
} = { videoId: this.#video_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', {
@@ -189,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

@@ -77,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();