From bbc8c9e9be76065e991787bd272bc71acd7a87af Mon Sep 17 00:00:00 2001 From: HitomaruKonpaku Date: Sun, 4 Sep 2022 03:56:40 +0700 Subject: [PATCH 01/34] Disable assertPlayability to avoid error on members only stream --- src/context/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/context/index.ts b/src/context/index.ts index abea17df..8c8e5ab8 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -107,7 +107,7 @@ export function parseMetadataFromWatch(html: string) { const initialData = findInitialData(html)!; const playabilityStatus = findPlayabilityStatus(html); - assertPlayability(playabilityStatus); + // assertPlayability(playabilityStatus); // TODO: initialData.contents.twoColumnWatchNextResults.conversationBar.conversationBarRenderer.availabilityMessage.messageRenderer.text.runs[0].text === 'Chat is disabled for this live stream.' const results = From 2de2a0a99c9c7cedb8902351f2c2ff8efa9b8925 Mon Sep 17 00:00:00 2001 From: HitomaruKonpaku Date: Sun, 4 Sep 2022 04:03:23 +0700 Subject: [PATCH 02/34] Parse more video metadata from html --- package.json | 1 + src/context/index.ts | 54 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 551fed5b..7920a6cf 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ ], "dependencies": { "axios": "^0.27.2", + "cheerio": "^1.0.0-rc.12", "debug": "^4.3.2", "iterator-helpers-polyfill": "^2.2.8", "sha1": "^1.1.1" diff --git a/src/context/index.ts b/src/context/index.ts index 8c8e5ab8..a813a76c 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -1,3 +1,4 @@ +import * as cheerio from "cheerio"; import { MembersOnlyError, NoPermissionError, @@ -120,7 +121,8 @@ export function parseMetadataFromWatch(html: string) { const title = runsToString(primaryInfo.title.runs); const channelId = videoOwner.navigationEndpoint.browseEndpoint.browseId; const channelName = runsToString(videoOwner.title.runs); - const isLive = primaryInfo.viewCount!.videoViewCountRenderer.isLive ?? false; + const metadata = parseVideoMetadataFromHtml(html); + const isLive = !metadata?.publication?.endDate ?? false; return { title, @@ -129,3 +131,53 @@ export function parseMetadataFromWatch(html: string) { isLive, }; } + +/** + * @see http://schema.org/VideoObject + */ +function parseVideoMetadataFromHtml(html: string) { + const $ = cheerio.load(html); + const meta = parseVideoMetadataFromElement( + $("[itemtype=http://schema.org/VideoObject]")?.[0] + ); + return meta; +} + +function parseVideoMetadataFromElement( + root: any, + meta: Record = {} +) { + root?.children?.forEach((child: any) => { + const { attributes } = child; + const key = attributes.find((v: any) => v.name === "itemprop")?.value; + if (!key) { + return; + } + + if (child.children.length) { + meta[key] = parseVideoMetadataFromElement(child); + return; + } + + const value = attributes.filter((v: any) => + ["href", "content"].includes(v.name) + )[0].value; + switch (key) { + case "paid": + case "unlisted": + case "isFamilyFriendly": + case "interactionCount": + case "isLiveBroadcast": + meta[key] = /true/i.test(value); + break; + case "width": + case "height": + meta[key] = Number(value); + break; + default: + meta[key] = value; + } + }); + + return meta; +} From e5c8b6b9bb10e1d42874d910ea7481e894fa422b Mon Sep 17 00:00:00 2001 From: HitomaruKonpaku Date: Fri, 9 Sep 2022 02:22:08 +0700 Subject: [PATCH 03/34] Format & update typing --- src/context/index.ts | 44 +++++++++++++++++++++++--------------------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/src/context/index.ts b/src/context/index.ts index a813a76c..3c3b8dfa 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -147,9 +147,9 @@ function parseVideoMetadataFromElement( root: any, meta: Record = {} ) { - root?.children?.forEach((child: any) => { - const { attributes } = child; - const key = attributes.find((v: any) => v.name === "itemprop")?.value; + root?.children?.forEach((child: cheerio.Element) => { + const attributes = child?.attribs; + const key = attributes?.itemprop; if (!key) { return; } @@ -159,25 +159,27 @@ function parseVideoMetadataFromElement( return; } - const value = attributes.filter((v: any) => - ["href", "content"].includes(v.name) - )[0].value; - switch (key) { - case "paid": - case "unlisted": - case "isFamilyFriendly": - case "interactionCount": - case "isLiveBroadcast": - meta[key] = /true/i.test(value); - break; - case "width": - case "height": - meta[key] = Number(value); - break; - default: - meta[key] = value; - } + const value = parseVideoMetaValueByKey( + key, + attributes?.href || attributes?.content + ); + meta[key] = value; }); return meta; } + +function parseVideoMetaValueByKey(key: string, value: string) { + switch (key) { + case "paid": + case "unlisted": + case "isFamilyFriendly": + case "interactionCount": + case "isLiveBroadcast": + return /true/i.test(value); + case "width": + case "height": + return Number(value); + } + return value; +} From e2e969c8a9dfb27c4e780c375c9a53cbf528933d Mon Sep 17 00:00:00 2001 From: HitomaruKonpaku Date: Sun, 11 Dec 2022 03:06:13 +0700 Subject: [PATCH 04/34] Fix milestone message `durationText` parser See sigvt/masterchat#16 --- src/chat/actions/addChatItemAction.ts | 7 ++++--- src/utils.ts | 16 ++++++++++++---- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/chat/actions/addChatItemAction.ts b/src/chat/actions/addChatItemAction.ts index 728ae468..80a103d4 100644 --- a/src/chat/actions/addChatItemAction.ts +++ b/src/chat/actions/addChatItemAction.ts @@ -267,9 +267,10 @@ export function parseLiveChatMembershipItemRenderer( if (isMilestoneMessage) { const message = renderer.message ? renderer.message.runs : null; const durationText = renderer - .headerPrimaryText!.runs.slice(1) - .map((r) => r.text) - .join(""); + .headerPrimaryText!.runs.map((r) => r.text) + .join("") + .replace("Member for", "") + .trim(); // duration > membership.since // e.g. 12 months > 6 months const duration = durationToSeconds(durationText); diff --git a/src/utils.ts b/src/utils.ts index b3eb4887..dcc6d12f 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -287,7 +287,9 @@ export function durationToSeconds(durationText: string): number { const match = /^(a|\d+)\s(year|month|week|day|hour|minute|second)s?$/.exec( durationText ); - if (!match) throw new Error(`Invalid duration: ${durationText}`); + if (!match) { + throw new Error(`Invalid duration: ${durationText}`); + } const [_, duration, unit] = match; const durationInt = parseInt(duration) || 1; @@ -300,7 +302,9 @@ export function durationToSeconds(durationText: string): number { minute: 60, second: 1, }[unit]; - if (!multiplier) throw new Error(`Invalid duration unit: ${unit}`); + if (!multiplier) { + throw new Error(`Invalid duration unit: ${unit}`); + } return durationInt * multiplier; } @@ -309,7 +313,9 @@ export function durationToISO8601(durationText: string): string { const match = /^(a|\d+)\s(year|month|week|day|hour|minute|second)s?$/.exec( durationText ); - if (!match) throw new Error(`Invalid duration: ${durationText}`); + if (!match) { + throw new Error(`Invalid duration: ${durationText}`); + } const [_, duration, unit] = match; const durationInt = parseInt(duration) || 1; @@ -322,7 +328,9 @@ export function durationToISO8601(durationText: string): string { minute: "TM", second: "TS", }[unit]; - if (!durationUnit) throw new Error(`Invalid duration unit: ${unit}`); + if (!durationUnit) { + throw new Error(`Invalid duration unit: ${unit}`); + } return `P${durationInt}${durationUnit}`; } From f663e179748fccea744b4f9e134712b86b41c013 Mon Sep 17 00:00:00 2001 From: HitomaruKonpaku Date: Sun, 11 Dec 2022 03:18:06 +0700 Subject: [PATCH 05/34] Skip error actions See sigvt/masterchat#16 --- src/masterchat.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/masterchat.ts b/src/masterchat.ts index dabe02f5..0f53fdad 100644 --- a/src/masterchat.ts +++ b/src/masterchat.ts @@ -852,8 +852,15 @@ export class Masterchat extends EventEmitter { } const actions = rawActions - .map(parseAction) - .filter((a): a is Action => a !== undefined); + .map((action) => { + try { + return parseAction(action); + } catch (error: any) { + this.log("parseAction", error.message, { action }); + return null; + } + }) + .filter((a): a is Action => !!a); const chat: ChatResponse = { actions, From 0849ac9e05c6030a522eefd58c901af4f0aa8dfc Mon Sep 17 00:00:00 2001 From: HitomaruKonpaku Date: Mon, 10 Jul 2023 05:11:05 +0700 Subject: [PATCH 06/34] Add chat metadata isMembersOnly --- src/context/index.ts | 12 +++++++++++- src/interfaces/yt/context.ts | 1 + src/masterchat.ts | 2 ++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/context/index.ts b/src/context/index.ts index 3c3b8dfa..c32ea355 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -6,7 +6,11 @@ import { UnavailableError, } from "../errors"; import { runsToString } from "../utils"; -import { YTInitialData, YTPlayabilityStatus } from "../interfaces/yt/context"; +import { + PurpleStyle, + YTInitialData, + YTPlayabilityStatus, +} from "../interfaces/yt/context"; // OK duration=">0" => Archived (replay chat may be available) // OK duration="0" => Live (chat may be available) @@ -123,12 +127,18 @@ export function parseMetadataFromWatch(html: string) { const channelName = runsToString(videoOwner.title.runs); const metadata = parseVideoMetadataFromHtml(html); const isLive = !metadata?.publication?.endDate ?? false; + const isMembersOnly = + primaryInfo.badges?.some?.( + (v) => + v.metadataBadgeRenderer.style === PurpleStyle.BadgeStyleTypeMembersOnly + ) ?? false; return { title, channelId, channelName, isLive, + isMembersOnly, }; } diff --git a/src/interfaces/yt/context.ts b/src/interfaces/yt/context.ts index 4dcd24f6..b2965868 100644 --- a/src/interfaces/yt/context.ts +++ b/src/interfaces/yt/context.ts @@ -748,6 +748,7 @@ export interface OwnerBadgeMetadataBadgeRenderer { export enum PurpleStyle { BadgeStyleTypeVerified = "BADGE_STYLE_TYPE_VERIFIED", + BadgeStyleTypeMembersOnly = "BADGE_STYLE_TYPE_MEMBERS_ONLY", } export interface MembershipButton { diff --git a/src/masterchat.ts b/src/masterchat.ts index 0f53fdad..68e55cf5 100644 --- a/src/masterchat.ts +++ b/src/masterchat.ts @@ -143,6 +143,7 @@ export class Masterchat extends EventEmitter { public channelId!: string; public isLive?: boolean; + public isMembersOnly?: boolean; public channelName?: string; public title?: string; @@ -433,6 +434,7 @@ export class Masterchat extends EventEmitter { this.channelId = metadata.channelId; this.channelName = metadata.channelName; this.isLive ??= metadata.isLive; + this.isMembersOnly ??= metadata.isMembersOnly; } public async fetchMetadataFromWatch(id: string) { From e8d4f5eb40eadd9ae727f16f4b90b43ed6fcb3e6 Mon Sep 17 00:00:00 2001 From: HitomaruKonpaku Date: Tue, 23 Apr 2024 03:45:22 +0700 Subject: [PATCH 07/34] Try to fix build issue --- src/pool.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pool.ts b/src/pool.ts index f998f86e..937170bb 100644 --- a/src/pool.ts +++ b/src/pool.ts @@ -57,7 +57,7 @@ export class StreamPool extends EventEmitter { fn: (agent: Masterchat, videoId: string, index: number) => void ) { return Promise.allSettled( - this.entries.map(([videoId, instance], i) => + this.entries.map(([videoId, instance]: any, i) => Promise.resolve(fn(instance, videoId, i)) ) ); From c4182c3646ea01d45fa92ec26805a028e7808049 Mon Sep 17 00:00:00 2001 From: HitomaruKonpaku Date: Thu, 9 May 2024 05:46:49 +0700 Subject: [PATCH 08/34] Fix for membersOnly stream --- src/interfaces/index.ts | 5 +++++ src/masterchat.ts | 26 +++++++++++++++----------- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/interfaces/index.ts b/src/interfaces/index.ts index b7f3f3b8..3ee9176e 100644 --- a/src/interfaces/index.ts +++ b/src/interfaces/index.ts @@ -38,4 +38,9 @@ export interface Credentials { * Delegated session id for brand account */ DELEGATED_SESSION_ID?: string; + + "__Secure-1PAPISID"?: string; + "__Secure-1PSID"?: string; + "__Secure-1PSIDTS"?: string; + "__Secure-1PSIDCC"?: string; } diff --git a/src/masterchat.ts b/src/masterchat.ts index 68e55cf5..1aee46d2 100644 --- a/src/masterchat.ts +++ b/src/masterchat.ts @@ -202,17 +202,19 @@ export class Masterchat extends EventEmitter { input = Constants.DO + input; } + const headers = { + "Content-Type": "application/json", + ...Constants.DH, + ...(this.credentials && buildAuthHeaders(this.credentials)), + ...config.headers, + }; + const res = await this.axiosInstance.request({ ...config, url: input, signal: this.listenerAbortion.signal, method: "POST", - headers: { - ...config.headers, - "Content-Type": "application/json", - ...(this.credentials && buildAuthHeaders(this.credentials)), - ...Constants.DH, - }, + headers, data: body, }); @@ -227,15 +229,17 @@ export class Masterchat extends EventEmitter { input = Constants.DO + input; } + const headers = { + ...Constants.DH, + ...(this.credentials && buildAuthHeaders(this.credentials)), + ...config.headers, + }; + const res = await this.axiosInstance.request({ ...config, url: input, signal: this.listenerAbortion.signal, - headers: { - ...config.headers, - ...(this.credentials && buildAuthHeaders(this.credentials)), - ...Constants.DH, - }, + headers, }); return res.data; From b902acdd8b5c865617cefcef40feef5f71325d43 Mon Sep 17 00:00:00 2001 From: HitomaruKonpaku Date: Sun, 12 May 2024 05:12:30 +0700 Subject: [PATCH 09/34] Store video extra metadata --- src/context/index.ts | 1 + src/masterchat.ts | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/src/context/index.ts b/src/context/index.ts index c32ea355..018a1ce1 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -139,6 +139,7 @@ export function parseMetadataFromWatch(html: string) { channelName, isLive, isMembersOnly, + metadata, }; } diff --git a/src/masterchat.ts b/src/masterchat.ts index 1aee46d2..ed30cef9 100644 --- a/src/masterchat.ts +++ b/src/masterchat.ts @@ -146,6 +146,7 @@ export class Masterchat extends EventEmitter { public isMembersOnly?: boolean; public channelName?: string; public title?: string; + public videoMetadata?: Record; private axiosInstance: AxiosInstance; private listener: ChatListener | null = null; @@ -439,6 +440,7 @@ export class Masterchat extends EventEmitter { this.channelName = metadata.channelName; this.isLive ??= metadata.isLive; this.isMembersOnly ??= metadata.isMembersOnly; + this.videoMetadata ??= metadata.metadata; } public async fetchMetadataFromWatch(id: string) { @@ -471,6 +473,8 @@ export class Masterchat extends EventEmitter { channelName: this.channelName, title: this.title, isLive: this.isLive, + isMembersOnly: this.isMembersOnly, + videoMetadata: this.videoMetadata, }; } From 66eaa300c35cd54321f522e299d840c2ac1390f2 Mon Sep 17 00:00:00 2001 From: HitomaruKonpaku Date: Mon, 13 May 2024 06:10:27 +0700 Subject: [PATCH 10/34] Allow to update metadata & extend masterchat --- src/masterchat.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/masterchat.ts b/src/masterchat.ts index ed30cef9..04986a78 100644 --- a/src/masterchat.ts +++ b/src/masterchat.ts @@ -152,7 +152,7 @@ export class Masterchat extends EventEmitter { private listener: ChatListener | null = null; private listenerAbortion: AbortController = new AbortController(); - private credentials?: Credentials; + protected credentials?: Credentials; /* * Private API @@ -438,9 +438,9 @@ export class Masterchat extends EventEmitter { this.title = metadata.title; this.channelId = metadata.channelId; this.channelName = metadata.channelName; - this.isLive ??= metadata.isLive; - this.isMembersOnly ??= metadata.isMembersOnly; - this.videoMetadata ??= metadata.metadata; + this.isLive = metadata.isLive; + this.isMembersOnly = metadata.isMembersOnly; + this.videoMetadata = metadata.metadata; } public async fetchMetadataFromWatch(id: string) { From 0132e7414cebc4a2b8637763c49a0a344db67977 Mon Sep 17 00:00:00 2001 From: HitomaruKonpaku Date: Tue, 24 Sep 2024 17:15:56 +0700 Subject: [PATCH 11/34] Fix unreachable code --- src/context/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/context/index.ts b/src/context/index.ts index 018a1ce1..60ea3e40 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -126,7 +126,7 @@ export function parseMetadataFromWatch(html: string) { const channelId = videoOwner.navigationEndpoint.browseEndpoint.browseId; const channelName = runsToString(videoOwner.title.runs); const metadata = parseVideoMetadataFromHtml(html); - const isLive = !metadata?.publication?.endDate ?? false; + const isLive = !metadata?.publication?.endDate || false; const isMembersOnly = primaryInfo.badges?.some?.( (v) => From 820cf71e5e5bb2730f4046c57c641d0dc98033a1 Mon Sep 17 00:00:00 2001 From: HitomaruKonpaku Date: Wed, 25 Sep 2024 01:49:27 +0700 Subject: [PATCH 12/34] Update YTLiveChatPollRenderer payload --- src/chat/actions/showLiveChatActionPanelAction.ts | 7 +++++-- src/chat/actions/updateLiveChatPollAction.ts | 6 +++++- src/interfaces/yt/chat.ts | 2 +- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/chat/actions/showLiveChatActionPanelAction.ts b/src/chat/actions/showLiveChatActionPanelAction.ts index df9b0943..41fa8cb5 100644 --- a/src/chat/actions/showLiveChatActionPanelAction.ts +++ b/src/chat/actions/showLiveChatActionPanelAction.ts @@ -3,7 +3,7 @@ import { YTLiveChatPollRenderer, YTShowLiveChatActionPanelAction, } from "../../interfaces/yt/chat"; -import { debugLog } from "../../utils"; +import { debugLog, stringify } from "../../utils"; import { pickThumbUrl } from "../utils"; export function parseShowLiveChatActionPanelAction( @@ -16,13 +16,16 @@ export function parseShowLiveChatActionPanelAction( const rdr = panelRdr.contents.pollRenderer as YTLiveChatPollRenderer; const authorName = rdr.header.pollHeaderRenderer.metadataText.runs[0].text; + const question = + rdr.header.pollHeaderRenderer.pollQuestion?.simpleText || + stringify(rdr.header.pollHeaderRenderer.pollQuestion?.runs || ""); const parsed: ShowPollPanelAction = { type: "showPollPanelAction", targetId: panelRdr.targetId, id: panelRdr.id, choices: rdr.choices, - question: rdr.header.pollHeaderRenderer.pollQuestion?.simpleText, + question, authorName, authorPhoto: pickThumbUrl(rdr.header.pollHeaderRenderer.thumbnail), pollType: rdr.header.pollHeaderRenderer.liveChatPollType, diff --git a/src/chat/actions/updateLiveChatPollAction.ts b/src/chat/actions/updateLiveChatPollAction.ts index 12542bfc..9a42c5ba 100644 --- a/src/chat/actions/updateLiveChatPollAction.ts +++ b/src/chat/actions/updateLiveChatPollAction.ts @@ -1,5 +1,6 @@ import { UpdatePollAction } from "../../interfaces/actions"; import { YTUpdateLiveChatPollAction } from "../../interfaces/yt/chat"; +import { stringify } from "../../utils"; import { pickThumbUrl } from "../utils"; export function parseUpdateLiveChatPollAction( @@ -19,13 +20,16 @@ export function parseUpdateLiveChatPollAction( const authorName = meta[0].text; const elapsedText = meta[2].text; const voteCount = parseInt(meta[4].text, 10); + const question = + header.pollQuestion?.simpleText || + stringify(header.pollQuestion?.runs || ""); const parsed: UpdatePollAction = { type: "updatePollAction", id: rdr.liveChatPollId, authorName, authorPhoto: pickThumbUrl(header.thumbnail), - question: header.pollQuestion?.simpleText, + question, choices: rdr.choices, elapsedText, voteCount, diff --git a/src/interfaces/yt/chat.ts b/src/interfaces/yt/chat.ts index 16007d4d..cb247891 100644 --- a/src/interfaces/yt/chat.ts +++ b/src/interfaces/yt/chat.ts @@ -501,7 +501,7 @@ export interface YTLiveChatPollRenderer { liveChatPollId: string; header: { pollHeaderRenderer: { - pollQuestion?: YTSimpleTextContainer; + pollQuestion?: Partial; thumbnail: YTThumbnailList; metadataText: YTRunContainer; liveChatPollType: YTLiveChatPollType; From 297cb6b3edde31ff62e4e660e649a11099dd0b31 Mon Sep 17 00:00:00 2001 From: HitomaruKonpaku Date: Wed, 4 Jun 2025 11:23:12 +0700 Subject: [PATCH 13/34] Update function access modifiers --- src/masterchat.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/masterchat.ts b/src/masterchat.ts index 04986a78..abfd8f6e 100644 --- a/src/masterchat.ts +++ b/src/masterchat.ts @@ -158,7 +158,7 @@ export class Masterchat extends EventEmitter { * Private API */ - private async postWithRetry( + protected async postWithRetry( input: string, body: any, options?: RetryOptions @@ -194,7 +194,7 @@ export class Masterchat extends EventEmitter { } } - private async post( + protected async post( input: string, body: any, config: AxiosRequestConfig = {} @@ -222,7 +222,7 @@ export class Masterchat extends EventEmitter { return res.data; } - private async get( + protected async get( input: string, config: AxiosRequestConfig = {} ): Promise { From 29dcb0affcbd6bd0295c513cc43bb995d69a5671 Mon Sep 17 00:00:00 2001 From: HitomaruKonpaku Date: Tue, 17 Jun 2025 14:49:05 +0700 Subject: [PATCH 14/34] Update metadata parser --- src/context/index.ts | 31 +++++++++++++++++++---------- src/interfaces/yt/index.ts | 1 + src/interfaces/yt/metadata.ts | 37 +++++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 10 deletions(-) create mode 100644 src/interfaces/yt/metadata.ts diff --git a/src/context/index.ts b/src/context/index.ts index 60ea3e40..e19049ce 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -11,6 +11,7 @@ import { YTInitialData, YTPlayabilityStatus, } from "../interfaces/yt/context"; +import { VideoObject } from "../interfaces/yt/metadata"; // OK duration=">0" => Archived (replay chat may be available) // OK duration="0" => Live (chat may be available) @@ -109,6 +110,7 @@ export async function parseMetadataFromEmbed(html: string) { } export function parseMetadataFromWatch(html: string) { + const metadata = parseVideoMetadataFromHtml(html); const initialData = findInitialData(html)!; const playabilityStatus = findPlayabilityStatus(html); @@ -118,17 +120,26 @@ export function parseMetadataFromWatch(html: string) { const results = initialData.contents?.twoColumnWatchNextResults?.results.results!; - const primaryInfo = results.contents[0].videoPrimaryInfoRenderer; - const videoOwner = - results.contents[1].videoSecondaryInfoRenderer.owner.videoOwnerRenderer; + const primaryInfo = results.contents.find( + (v) => v.videoPrimaryInfoRenderer + )?.videoPrimaryInfoRenderer; + const secondaryInfo = results.contents.find( + (v) => v.videoSecondaryInfoRenderer + )?.videoSecondaryInfoRenderer; + const videoOwner = secondaryInfo?.owner?.videoOwnerRenderer; + const badges = primaryInfo?.badges || []; + + const channelId = videoOwner?.navigationEndpoint?.browseEndpoint?.browseId; + if (!channelId) { + throw new Error("CHANNEL_ID_NOT_FOUND"); + } - const title = runsToString(primaryInfo.title.runs); - const channelId = videoOwner.navigationEndpoint.browseEndpoint.browseId; - const channelName = runsToString(videoOwner.title.runs); - const metadata = parseVideoMetadataFromHtml(html); + const channelName = + runsToString(videoOwner?.title?.runs || []) || metadata.author.name; + const title = runsToString(primaryInfo?.title?.runs || []) || metadata.name; const isLive = !metadata?.publication?.endDate || false; const isMembersOnly = - primaryInfo.badges?.some?.( + badges.some?.( (v) => v.metadataBadgeRenderer.style === PurpleStyle.BadgeStyleTypeMembersOnly ) ?? false; @@ -146,11 +157,11 @@ export function parseMetadataFromWatch(html: string) { /** * @see http://schema.org/VideoObject */ -function parseVideoMetadataFromHtml(html: string) { +function parseVideoMetadataFromHtml(html: string): VideoObject { const $ = cheerio.load(html); const meta = parseVideoMetadataFromElement( $("[itemtype=http://schema.org/VideoObject]")?.[0] - ); + ) as VideoObject; return meta; } diff --git a/src/interfaces/yt/index.ts b/src/interfaces/yt/index.ts index 511ea8b3..2348ba30 100644 --- a/src/interfaces/yt/index.ts +++ b/src/interfaces/yt/index.ts @@ -16,3 +16,4 @@ export { YTAccessibilityData, YTReloadContinuation } from "./context"; // export * from "./context"; export * from "./comments"; // export * from "./transcript"; +export * from "./metadata"; diff --git a/src/interfaces/yt/metadata.ts b/src/interfaces/yt/metadata.ts new file mode 100644 index 00000000..b5ddcd9e --- /dev/null +++ b/src/interfaces/yt/metadata.ts @@ -0,0 +1,37 @@ +export interface Person { + url: string; + name: string; +} + +export interface Thumbnail { + url: string; + width: number; + height: number; +} + +export interface PublicationEvent { + isLiveBroadcast: boolean; + startDate: string; + endDate?: string; +} + +export interface VideoObject { + url: string; + name: string; + description: string; + identifier: string; + duration: string; + author: Person; + thumbnailUrl: string; + thumbnail: Thumbnail; + embedUrl: string; + playerType: string; + width: number; + height: number; + isFamilyFriendly: boolean; + regionsAllowed: string; + datePublished: string; + uploadDate: string; + genre?: string; + publication?: PublicationEvent; +} From df729d4e02b73d5c123d104e550f43c004c46097 Mon Sep 17 00:00:00 2001 From: HitomaruKonpaku Date: Tue, 17 Jun 2025 14:56:05 +0700 Subject: [PATCH 15/34] Update metadata interface --- src/masterchat.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/masterchat.ts b/src/masterchat.ts index abfd8f6e..da7614e0 100644 --- a/src/masterchat.ts +++ b/src/masterchat.ts @@ -23,6 +23,7 @@ import { ChatResponse, Credentials, RenderingPriority, + VideoObject, YTCommentThreadRenderer, YTContinuationItem, } from "./interfaces"; @@ -146,7 +147,7 @@ export class Masterchat extends EventEmitter { public isMembersOnly?: boolean; public channelName?: string; public title?: string; - public videoMetadata?: Record; + public videoMetadata?: VideoObject; private axiosInstance: AxiosInstance; private listener: ChatListener | null = null; From ad4e10c3a5b45096b1d3ebdbbfe138aed0c71224 Mon Sep 17 00:00:00 2001 From: HitomaruKonpaku Date: Tue, 17 Jun 2025 15:24:28 +0700 Subject: [PATCH 16/34] Update metadata parser --- src/context/index.ts | 10 ++++++++-- src/interfaces/yt/metadata.ts | 10 +++++++++- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/context/index.ts b/src/context/index.ts index e19049ce..48e6888b 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -177,7 +177,12 @@ function parseVideoMetadataFromElement( } if (child.children.length) { - meta[key] = parseVideoMetadataFromElement(child); + const value = parseVideoMetadataFromElement(child); + if (meta[key]) { + meta[key] = [meta[key], value]; + } else { + meta[key] = value; + } return; } @@ -195,12 +200,13 @@ function parseVideoMetaValueByKey(key: string, value: string) { switch (key) { case "paid": case "unlisted": + case "requiresSubscription": case "isFamilyFriendly": - case "interactionCount": case "isLiveBroadcast": return /true/i.test(value); case "width": case "height": + case "userInteractionCount": return Number(value); } return value; diff --git a/src/interfaces/yt/metadata.ts b/src/interfaces/yt/metadata.ts index b5ddcd9e..afd9d16f 100644 --- a/src/interfaces/yt/metadata.ts +++ b/src/interfaces/yt/metadata.ts @@ -9,9 +9,14 @@ export interface Thumbnail { height: number; } +export interface InteractionCounter { + interactionType: string; + userInteractionCount: number; +} + export interface PublicationEvent { isLiveBroadcast: boolean; - startDate: string; + startDate?: string; endDate?: string; } @@ -19,6 +24,7 @@ export interface VideoObject { url: string; name: string; description: string; + requiresSubscription: boolean; identifier: string; duration: string; author: Person; @@ -30,6 +36,8 @@ export interface VideoObject { height: number; isFamilyFriendly: boolean; regionsAllowed: string; + interactionStatistic?: InteractionCounter | InteractionCounter[]; + keywords?: string; datePublished: string; uploadDate: string; genre?: string; From 712a012116d53e237e8557e73c6078801731e80c Mon Sep 17 00:00:00 2001 From: HitomaruKonpaku Date: Wed, 18 Jun 2025 13:25:45 +0700 Subject: [PATCH 17/34] Add isUpcoming --- src/context/index.ts | 3 +++ src/masterchat.ts | 3 +++ 2 files changed, 6 insertions(+) diff --git a/src/context/index.ts b/src/context/index.ts index 48e6888b..f9eaa1e4 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -138,6 +138,8 @@ export function parseMetadataFromWatch(html: string) { runsToString(videoOwner?.title?.runs || []) || metadata.author.name; const title = runsToString(primaryInfo?.title?.runs || []) || metadata.name; const isLive = !metadata?.publication?.endDate || false; + const isUpcoming = + primaryInfo?.dateText?.simpleText?.includes("Scheduled for") || false; const isMembersOnly = badges.some?.( (v) => @@ -149,6 +151,7 @@ export function parseMetadataFromWatch(html: string) { channelId, channelName, isLive, + isUpcoming, isMembersOnly, metadata, }; diff --git a/src/masterchat.ts b/src/masterchat.ts index da7614e0..e514d149 100644 --- a/src/masterchat.ts +++ b/src/masterchat.ts @@ -144,6 +144,7 @@ export class Masterchat extends EventEmitter { public channelId!: string; public isLive?: boolean; + public isUpcoming?: boolean; public isMembersOnly?: boolean; public channelName?: string; public title?: string; @@ -440,6 +441,7 @@ export class Masterchat extends EventEmitter { this.channelId = metadata.channelId; this.channelName = metadata.channelName; this.isLive = metadata.isLive; + this.isUpcoming = metadata.isUpcoming; this.isMembersOnly = metadata.isMembersOnly; this.videoMetadata = metadata.metadata; } @@ -474,6 +476,7 @@ export class Masterchat extends EventEmitter { channelName: this.channelName, title: this.title, isLive: this.isLive, + isUpcoming: this.isUpcoming, isMembersOnly: this.isMembersOnly, videoMetadata: this.videoMetadata, }; From 9727fdf406f4c45cb0f7c6fea349ed6a8f8934dc Mon Sep 17 00:00:00 2001 From: HitomaruKonpaku Date: Wed, 18 Jun 2025 18:00:53 +0700 Subject: [PATCH 18/34] release: v1.2.0 --- CHANGELOG.md | 25 +++++++++++++++++++++++++ package.json | 10 +++++----- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3b44983..4dec1ca5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # Changelog +## v1.2.0 + +### New + +- Add `isUpcoming` +- Add `isMembersOnly` +- Add `videoMetadata` following format [VideoObject](./src/interfaces/yt/metadata.ts) + +### Improvements + +- Add more field on `Credentials` for members-only live stream + + - `__Secure-1PAPISID` + - `__Secure-1PSID` + - `__Secure-1PSIDTS` + - `__Secure-1PSIDCC` + +- Error parsing action `parseAction` should not crash +- Change access modifier of `get`, `post`, `postWithRetry` to `protected` + +### Fixes + +- Milestone message duration parsing error +- Question text from `parseShowLiveChatActionPanelAction` realted to poll + ## v1.1.0 ### New diff --git a/package.json b/package.json index 7920a6cf..7d8e6d0d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "masterchat", + "name": "@hitomaru/masterchat", "description": "JavaScript library for YouTube Live Chat", - "version": "1.1.0", + "version": "1.2.0", "author": "Yasuaki Uechi (https://uechi.io/)", "scripts": { "build": "cross-env NODE_ENV=production rollup -c && shx rm -rf lib/lib lib/*.map && patch lib/masterchat.d.ts patches/fix-dts.patch", @@ -65,13 +65,13 @@ "typedoc": "^0.22.16", "typescript": "^4.7.2" }, - "homepage": "https://github.com/holodata/masterchat", + "homepage": "https://github.com/HitomaruKonpaku/masterchat", "repository": { "type": "git", - "url": "https://github.com/holodata/masterchat.git" + "url": "git+https://github.com/HitomaruKonpaku/masterchat.git" }, "bugs": { - "url": "https://github.com/holodata/masterchat/issues" + "url": "https://github.com/HitomaruKonpaku/masterchat/issues" }, "license": "Apache-2.0", "keywords": [ From 5eb75b9e4dda976feb25b9f4cce72c78cccb7eda Mon Sep 17 00:00:00 2001 From: HitomaruKonpaku Date: Wed, 18 Jun 2025 18:21:46 +0700 Subject: [PATCH 19/34] Ignore cheerio type --- src/context/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/context/index.ts b/src/context/index.ts index f9eaa1e4..40138335 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -172,7 +172,7 @@ function parseVideoMetadataFromElement( root: any, meta: Record = {} ) { - root?.children?.forEach((child: cheerio.Element) => { + root?.children?.forEach((child: any) => { const attributes = child?.attribs; const key = attributes?.itemprop; if (!key) { From 999b6a2de4224922078c631eb51ed7386563f46a Mon Sep 17 00:00:00 2001 From: HitomaruKonpaku Date: Wed, 18 Jun 2025 18:39:47 +0700 Subject: [PATCH 20/34] Update README --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 6fa656fe..b73641be 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Masterchat -[![npm](https://badgen.net/npm/v/masterchat)](https://npmjs.org/package/masterchat) -[![npm: total downloads](https://badgen.net/npm/dt/masterchat)](https://npmjs.org/package/masterchat) -[![npm: publish size](https://badgen.net/packagephobia/publish/masterchat)](https://npmjs.org/package/masterchat) +[![npm](https://badgen.net/npm/v/@hitomaru/masterchat)](https://npmjs.org/package/@hitomaru/masterchat) +[![npm: total downloads](https://badgen.net/npm/dt/@hitomaru/masterchat)](https://npmjs.org/package/@hitomaru/masterchat) +[![npm: publish size](https://badgen.net/packagephobia/publish/@hitomaru/masterchat)](https://npmjs.org/package/@hitomaru/masterchat) [![typedoc](https://badgen.net/badge/docs/typedoc/purple)](https://holodata.github.io/masterchat/) Masterchat is the most powerful library for YouTube Live Chat, supporting parsing 20+ actions, video comments and transcripts, as well as sending messages and moderating chats. @@ -10,11 +10,11 @@ Masterchat is the most powerful library for YouTube Live Chat, supporting parsin ## Install ``` -npm install masterchat +npm install @hitomaru/masterchat ``` ```js -import { Masterchat, stringify } from "masterchat"; +import { Masterchat, stringify } from "@hitomaru/masterchat"; const mc = await Masterchat.init("oyxvhJW1Cf8"); From e873c9d85c695d917153ab1a152739385b2cfd8e Mon Sep 17 00:00:00 2001 From: HitomaruKonpaku Date: Fri, 20 Jun 2025 13:23:47 +0700 Subject: [PATCH 21/34] Add membership to addMembershipTickerAction --- src/interfaces/actions.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/interfaces/actions.ts b/src/interfaces/actions.ts index 868cc78d..1f10290e 100644 --- a/src/interfaces/actions.ts +++ b/src/interfaces/actions.ts @@ -231,6 +231,7 @@ export interface AddMembershipTickerAction { id: string; authorChannelId: string; authorPhoto: string; + membership?: Membership; durationSec: number; fullDurationSec: number; detailText: YTText; From 5e03fc68e56b8f2acf74235fc2757d770993297f Mon Sep 17 00:00:00 2001 From: HitomaruKonpaku Date: Fri, 20 Jun 2025 15:32:04 +0700 Subject: [PATCH 22/34] Update addBannerToLiveChatCommand from stu43005/masterchat - Ver: 1.5.0 - SHA: 5e30cf71ee0c9b7739e59cdcf92402f7794b7ee5 --- .../actions/addBannerToLiveChatCommand.ts | 159 ++++++++++--- src/chat/actions/addChatItemAction.ts | 223 +++++++++--------- src/chat/badge.ts | 12 +- src/chat/index.ts | 110 +++++---- src/chat/superchat.ts | 44 ++-- src/chat/utils.ts | 35 ++- src/interfaces/actions.ts | 162 +++++++++---- src/interfaces/misc.ts | 29 ++- src/interfaces/yt/chat.ts | 147 +++++++++++- 9 files changed, 635 insertions(+), 286 deletions(-) diff --git a/src/chat/actions/addBannerToLiveChatCommand.ts b/src/chat/actions/addBannerToLiveChatCommand.ts index a6c7b418..86d4a537 100644 --- a/src/chat/actions/addBannerToLiveChatCommand.ts +++ b/src/chat/actions/addBannerToLiveChatCommand.ts @@ -1,9 +1,14 @@ +import { toUnknownAction } from ".."; import { AddBannerAction, - AddRedirectBannerAction, + AddIncomingRaidBannerAction, + AddOutgoingRaidBannerAction, + AddProductBannerAction, + AddChatSummaryBannerAction, + AddCallForQuestionsBannerAction, } from "../../interfaces/actions"; import { YTAddBannerToLiveChatCommand } from "../../interfaces/yt/chat"; -import { debugLog, stringify, tsToDate } from "../../utils"; +import { debugLog, endpointToUrl, stringify, tsToDate } from "../../utils"; import { parseBadges } from "../badge"; import { pickThumbUrl } from "../utils"; @@ -13,20 +18,12 @@ export function parseAddBannerToLiveChatCommand( // add pinned item const bannerRdr = payload["bannerRenderer"]["liveChatBannerRenderer"]; - if ( - bannerRdr.header && - bannerRdr.header.liveChatBannerHeaderRenderer.icon.iconType !== "KEEP" - ) { - debugLog( - "[action required] Unknown icon type (addBannerToLiveChatCommand)", - JSON.stringify(bannerRdr.header) - ); - } - // banner const actionId = bannerRdr.actionId; const targetId = bannerRdr.targetId; const viewerIsCreator = bannerRdr.viewerIsCreator; + const isStackable = bannerRdr.isStackable; + const bannerType = bannerRdr.bannerType; // contents const contents = bannerRdr.contents; @@ -40,7 +37,7 @@ export function parseAddBannerToLiveChatCommand( const authorName = stringify(rdr.authorName); const authorPhoto = pickThumbUrl(rdr.authorPhoto); const authorChannelId = rdr.authorExternalChannelId; - const { isVerified, isOwner, isModerator, membership } = parseBadges(rdr); + const badges = parseBadges(rdr); // header const header = bannerRdr.header!.liveChatBannerHeaderRenderer; @@ -65,33 +62,137 @@ export function parseAddBannerToLiveChatCommand( authorName, authorPhoto, authorChannelId, - isVerified, - isOwner, - isModerator, - membership, + ...badges, viewerIsCreator, contextMenuEndpointParams: rdr.contextMenuEndpoint?.liveChatItemContextMenuEndpoint.params, }; return parsed; - } else if ("liveChatBannerRedirectRenderer" in contents) { - // TODO: + } + + if ("liveChatBannerRedirectRenderer" in contents) { const rdr = contents.liveChatBannerRedirectRenderer; - const authorName = rdr.bannerMessage.runs[0].text; + const targetVideoId = + "watchEndpoint" in rdr.inlineActionButton.buttonRenderer.command + ? rdr.inlineActionButton.buttonRenderer.command.watchEndpoint.videoId + : undefined; + + const photo = pickThumbUrl(rdr.authorPhoto); + + if (targetVideoId) { + // Outgoing + const targetName = rdr.bannerMessage.runs[1].text; + const payload: AddOutgoingRaidBannerAction = { + type: "addOutgoingRaidBannerAction", + actionId, + targetId, + targetName, + targetPhoto: photo, + targetVideoId, + }; + return payload; + } else { + // Incoming + const sourceName = rdr.bannerMessage.runs[0].text; + const payload: AddIncomingRaidBannerAction = { + type: "addIncomingRaidBannerAction", + actionId, + targetId, + sourceName, + sourcePhoto: photo, + }; + return payload; + } + } + + if ("liveChatProductItemRenderer" in contents) { + const rdr = contents.liveChatProductItemRenderer; + const title = rdr.title; + const description = rdr.accessibilityTitle; + const thumbnail = rdr.thumbnail.thumbnails[0].url; + const price = rdr.price; + const vendorName = rdr.vendorName; + const creatorMessage = rdr.creatorMessage; + const creatorName = rdr.creatorName; const authorPhoto = pickThumbUrl(rdr.authorPhoto); - const payload: AddRedirectBannerAction = { - type: "addRedirectBannerAction", + const url = endpointToUrl(rdr.onClickCommand)!; + + if (!url) { + debugLog( + `Empty url at liveChatProductItemRenderer: ${JSON.stringify(rdr)}` + ); + } + + const dialogMessage = + rdr.informationDialog.liveChatDialogRenderer.dialogMessages; + const isVerified = rdr.isVerified; + + const payload: AddProductBannerAction = { + type: "addProductBannerAction", actionId, targetId, - authorName, + viewerIsCreator, + isStackable, + title, + description, + thumbnail, + price, + vendorName, + creatorMessage, + creatorName, authorPhoto, + url, + dialogMessage, + isVerified, }; return payload; - } else { - throw new Error( - `[action required] Unrecognized content type found in parseAddBannerToLiveChatCommand: ${JSON.stringify( - payload - )}` - ); } + + if ("liveChatCallForQuestionsRenderer" in contents) { + const rdr = contents.liveChatCallForQuestionsRenderer; + const creatorAvatar = rdr.creatorAvatar.thumbnails[0].url; + const creatorAuthorName = stringify(rdr.creatorAuthorName); + const questionMessage = rdr.questionMessage.runs; + + const parsed: AddCallForQuestionsBannerAction = { + type: "addCallForQuestionsBannerAction", + actionId, + targetId, + isStackable, + bannerType, + creatorAvatar, + creatorAuthorName, + questionMessage, + }; + return parsed; + } + + if ("liveChatBannerChatSummaryRenderer" in contents) { + const rdr = contents.liveChatBannerChatSummaryRenderer; + const id = rdr.liveChatSummaryId; + const timestampUsec = id.split("_").at(-1)!; + const timestamp = tsToDate(timestampUsec); + const chatSummary = rdr.chatSummary.runs; + + const parsed: AddChatSummaryBannerAction = { + type: "addChatSummaryBannerAction", + id, + actionId, + targetId, + isStackable, + bannerType, + timestampUsec, + timestamp, + chatSummary, + }; + return parsed; + } + + debugLog( + `[action required] Unrecognized content type found in parseAddBannerToLiveChatCommand: ${JSON.stringify( + payload + )}` + ); + + return toUnknownAction(payload); } diff --git a/src/chat/actions/addChatItemAction.ts b/src/chat/actions/addChatItemAction.ts index 80a103d4..790c1ed6 100644 --- a/src/chat/actions/addChatItemAction.ts +++ b/src/chat/actions/addChatItemAction.ts @@ -1,19 +1,24 @@ +import { toUnknownAction } from ".."; import { AddChatItemAction, AddMembershipItemAction, AddMembershipMilestoneItemAction, AddPlaceholderItemAction, + AddPollResultAction, AddSuperChatItemAction, AddSuperStickerItemAction, AddViewerEngagementMessageAction, LiveChatMode, - ModeChangeAction, - AddPollResultAction, MembershipGiftPurchaseAction, - MembershipGiftRedemptionAction, MembershipGiftPurchaseTickerContent, + MembershipGiftRedemptionAction, + ModeChangeAction, ModerationMessageAction, -} from "../../interfaces/actions"; + YTLiveChatTextMessageRenderer, + YTRun, + YTRunContainer, + YTTextRun, +} from "../../interfaces"; import { YTAddChatItemAction, YTLiveChatMembershipItemRenderer, @@ -24,11 +29,7 @@ import { YTLiveChatPlaceholderItemRenderer, YTLiveChatSponsorshipsGiftPurchaseAnnouncementRenderer, YTLiveChatSponsorshipsGiftRedemptionAnnouncementRenderer, - YTLiveChatTextMessageRenderer, YTLiveChatViewerEngagementMessageRenderer, - YTRun, - YTRunContainer, - YTTextRun, } from "../../interfaces/yt/chat"; import { debugLog, @@ -37,64 +38,73 @@ import { stringify, tsToDate, } from "../../utils"; -import { parseBadges, parseMembership } from "../badge"; -import { parseAmountText, parseSuperChat } from "../superchat"; -import { parseColorCode, pickThumbUrl } from "../utils"; +import { parseBadges } from "../badge"; +import { parseSuperChat } from "../superchat"; +import { pickThumbUrl, unitsToNumber } from "../utils"; export function parseAddChatItemAction(payload: YTAddChatItemAction) { const { item } = payload; - if ("liveChatTextMessageRenderer" in item) { - // Chat - const renderer = item["liveChatTextMessageRenderer"]!; - return parseLiveChatTextMessageRenderer(renderer); - } else if ("liveChatPaidMessageRenderer" in item) { - // Super Chat - const renderer = item["liveChatPaidMessageRenderer"]!; - return parseLiveChatPaidMessageRenderer(renderer); - } else if ("liveChatPaidStickerRenderer" in item) { - // Super Sticker - const renderer = item["liveChatPaidStickerRenderer"]!; - return parseLiveChatPaidStickerRenderer(renderer); - } else if ("liveChatMembershipItemRenderer" in item) { - // Membership updates - const renderer = item["liveChatMembershipItemRenderer"]!; - return parseLiveChatMembershipItemRenderer(renderer); - } else if ("liveChatViewerEngagementMessageRenderer" in item) { - // Engagement message - const renderer = item["liveChatViewerEngagementMessageRenderer"]!; - return parseLiveChatViewerEngagementMessageRenderer(renderer); - } else if ("liveChatPlaceholderItemRenderer" in item) { - // Placeholder chat - const renderer = item["liveChatPlaceholderItemRenderer"]!; - return parseLiveChatPlaceholderItemRenderer(renderer); - } else if ("liveChatModeChangeMessageRenderer" in item) { - // Mode change message (e.g. toggle members-only) - const renderer = item["liveChatModeChangeMessageRenderer"]!; - return parseLiveChatModeChangeMessageRenderer(renderer); - } else if ("liveChatSponsorshipsGiftPurchaseAnnouncementRenderer" in item) { - // Sponsorships gift purchase announcement - const renderer = - item["liveChatSponsorshipsGiftPurchaseAnnouncementRenderer"]; - return parseLiveChatSponsorshipsGiftPurchaseAnnouncementRenderer( - renderer - ) as MembershipGiftPurchaseAction; - } else if ("liveChatSponsorshipsGiftRedemptionAnnouncementRenderer" in item) { - // Sponsorships gift purchase announcement - const renderer = - item["liveChatSponsorshipsGiftRedemptionAnnouncementRenderer"]; - return parseLiveChatSponsorshipsGiftRedemptionAnnouncementRenderer( - renderer + const parsedAction = (() => { + if ("liveChatTextMessageRenderer" in item) { + // Chat + const renderer = item["liveChatTextMessageRenderer"]!; + return parseLiveChatTextMessageRenderer(renderer); + } else if ("liveChatPaidMessageRenderer" in item) { + // Super Chat + const renderer = item["liveChatPaidMessageRenderer"]!; + return parseLiveChatPaidMessageRenderer(renderer); + } else if ("liveChatPaidStickerRenderer" in item) { + // Super Sticker + const renderer = item["liveChatPaidStickerRenderer"]!; + return parseLiveChatPaidStickerRenderer(renderer); + } else if ("liveChatMembershipItemRenderer" in item) { + // Membership updates + const renderer = item["liveChatMembershipItemRenderer"]!; + return parseLiveChatMembershipItemRenderer(renderer); + } else if ("liveChatViewerEngagementMessageRenderer" in item) { + // Engagement message + const renderer = item["liveChatViewerEngagementMessageRenderer"]!; + return parseLiveChatViewerEngagementMessageRenderer(renderer); + } else if ("liveChatPlaceholderItemRenderer" in item) { + // Placeholder chat + const renderer = item["liveChatPlaceholderItemRenderer"]!; + return parseLiveChatPlaceholderItemRenderer(renderer); + } else if ("liveChatModeChangeMessageRenderer" in item) { + // Mode change message (e.g. toggle members-only) + const renderer = item["liveChatModeChangeMessageRenderer"]!; + return parseLiveChatModeChangeMessageRenderer(renderer); + } else if ("liveChatSponsorshipsGiftPurchaseAnnouncementRenderer" in item) { + // Sponsorships gift purchase announcement + const renderer = + item["liveChatSponsorshipsGiftPurchaseAnnouncementRenderer"]; + return parseLiveChatSponsorshipsGiftPurchaseAnnouncementRenderer( + renderer + ) as MembershipGiftPurchaseAction; + } else if ( + "liveChatSponsorshipsGiftRedemptionAnnouncementRenderer" in item + ) { + // Sponsorships gift purchase announcement + const renderer = + item["liveChatSponsorshipsGiftRedemptionAnnouncementRenderer"]; + return parseLiveChatSponsorshipsGiftRedemptionAnnouncementRenderer( + renderer + ); + } else if ("liveChatModerationMessageRenderer" in item) { + const renderer = item["liveChatModerationMessageRenderer"]; + return parseLiveChatModerationMessageRenderer(renderer); + } + })(); + + if (!parsedAction) { + debugLog( + "[action required] Unrecognized chat item renderer type:", + JSON.stringify(item) ); - } else if ("liveChatModerationMessageRenderer" in item) { - const renderer = item["liveChatModerationMessageRenderer"]; - return parseLiveChatModerationMessageRenderer(renderer); + return toUnknownAction(payload); } - debugLog( - "[action required] Unrecognized chat item renderer type:", - JSON.stringify(item) - ); + return parsedAction; } // Chat @@ -116,8 +126,7 @@ export function parseLiveChatTextMessageRenderer( renderer.authorPhoto.thumbnails[renderer.authorPhoto.thumbnails.length - 1] .url; - const { isVerified, isOwner, isModerator, membership } = - parseBadges(renderer); + const badges = parseBadges(renderer); const contextMenuEndpointParams = renderer.contextMenuEndpoint!.liveChatItemContextMenuEndpoint.params; @@ -141,10 +150,7 @@ export function parseLiveChatTextMessageRenderer( authorChannelId, authorPhoto, message, - membership, - isVerified, - isOwner, - isModerator, + ...badges, contextMenuEndpointParams, rawMessage: message, // deprecated }; @@ -172,6 +178,7 @@ export function parseLiveChatPaidMessageRenderer( const message = renderer.message?.runs ?? null; const superchat = parseSuperChat(renderer); + const badges = parseBadges(renderer); const parsed: AddSuperChatItemAction = { type: "addSuperChatItemAction", @@ -183,6 +190,7 @@ export function parseLiveChatPaidMessageRenderer( authorPhoto, message, ...superchat, + ...badges, superchat, // deprecated rawMessage: renderer.message?.runs, // deprecated }; @@ -191,31 +199,30 @@ export function parseLiveChatPaidMessageRenderer( // Super Sticker export function parseLiveChatPaidStickerRenderer( - rdr: YTLiveChatPaidStickerRenderer + renderer: YTLiveChatPaidStickerRenderer ): AddSuperStickerItemAction { - const { timestampUsec, authorExternalChannelId: authorChannelId } = rdr; + const { timestampUsec, authorExternalChannelId: authorChannelId } = renderer; const timestamp = tsToDate(timestampUsec); - const authorName = stringify(rdr.authorName); - const authorPhoto = pickThumbUrl(rdr.authorPhoto); + const authorName = stringify(renderer.authorName); + const authorPhoto = pickThumbUrl(renderer.authorPhoto); if (!authorName) { debugLog( "[action required] empty authorName (super sticker)", - JSON.stringify(rdr) + JSON.stringify(renderer) ); } - const stickerUrl = "https:" + pickThumbUrl(rdr.sticker); - const stickerText = rdr.sticker.accessibility!.accessibilityData.label; - const { amount, currency } = parseAmountText( - rdr.purchaseAmountText.simpleText - ); + const stickerUrl = "https:" + pickThumbUrl(renderer.sticker); + const stickerText = renderer.sticker.accessibility!.accessibilityData.label; + const superchat = parseSuperChat(renderer); + const badges = parseBadges(renderer); const parsed: AddSuperStickerItemAction = { type: "addSuperStickerItemAction", - id: rdr.id, + id: renderer.id, timestamp, timestampUsec, authorName, @@ -223,14 +230,10 @@ export function parseLiveChatPaidStickerRenderer( authorPhoto, stickerUrl, stickerText, - amount, - currency, - stickerDisplayWidth: rdr.stickerDisplayWidth, - stickerDisplayHeight: rdr.stickerDisplayHeight, - moneyChipBackgroundColor: parseColorCode(rdr.moneyChipBackgroundColor), - moneyChipTextColor: parseColorCode(rdr.moneyChipTextColor), - backgroundColor: parseColorCode(rdr.backgroundColor), - authorNameTextColor: parseColorCode(rdr.authorNameTextColor), + stickerDisplayWidth: renderer.stickerDisplayWidth, + stickerDisplayHeight: renderer.stickerDisplayHeight, + ...superchat, + ...badges, }; return parsed; @@ -249,18 +252,7 @@ export function parseLiveChatMembershipItemRenderer( const authorChannelId = renderer.authorExternalChannelId; const authorPhoto = pickThumbUrl(renderer.authorPhoto); - // observed, MODERATOR - // observed, undefined renderer.authorBadges - const membership = renderer.authorBadges - ? parseMembership(renderer.authorBadges[renderer.authorBadges.length - 1]) - : undefined; - if (!membership) { - debugLog( - `missing membership information while parsing neww membership action: ${JSON.stringify( - renderer - )}` - ); - } + const badges = parseBadges(renderer); const isMilestoneMessage = "empty" in renderer || "message" in renderer; @@ -287,7 +279,7 @@ export function parseLiveChatMembershipItemRenderer( authorName, authorChannelId, authorPhoto, - membership, + ...badges, level, message, duration, @@ -310,7 +302,7 @@ export function parseLiveChatMembershipItemRenderer( authorName, authorChannelId, authorPhoto, - membership, + ...badges, level, }; return parsed; @@ -326,11 +318,8 @@ export function parseLiveChatViewerEngagementMessageRenderer( * POLL: poll result message */ - const { - id, - timestampUsec, - icon: { iconType }, - } = renderer; + const { id, timestampUsec, icon } = renderer; + const { iconType } = icon ?? {}; if ("simpleText" in renderer.message) { debugLog( "[action required] message is simpleText (engagement):", @@ -383,14 +372,19 @@ export function parseLiveChatViewerEngagementMessageRenderer( } else { text.pop(); } - return { text, votePercentage }; + return { + text, + voteRatio: parseFloat(votePercentage) / 100, + votePercentage, + }; }); const parsed: AddPollResultAction = { type: "addPollResultAction", id, question, - total, + total, // deprecated + voteCount: unitsToNumber(total), choices, }; return parsed; @@ -468,6 +462,7 @@ export function parseLiveChatSponsorshipsGiftPurchaseAnnouncementRenderer( const channelName = header.primaryText.runs[3].text; const amount = parseInt(header.primaryText.runs[1].text, 10); const image = header.image.thumbnails[0].url; + const badges = parseBadges(header); if (!authorName) { debugLog( @@ -476,23 +471,13 @@ export function parseLiveChatSponsorshipsGiftPurchaseAnnouncementRenderer( ); } - const membership = parseMembership( - header.authorBadges[header.authorBadges.length - 1] - )!; - - if (!membership) { - debugLog( - "[action required] empty membership (gift purchase)", - JSON.stringify(renderer) - ); - } - if (!timestampUsec || !timestamp) { const tickerContent: MembershipGiftPurchaseTickerContent = { + type: "membershipGiftPurchaseAction", id, channelName, amount, - membership, + ...badges, authorName, authorChannelId, authorPhoto, @@ -508,7 +493,7 @@ export function parseLiveChatSponsorshipsGiftPurchaseAnnouncementRenderer( timestampUsec, channelName, amount, - membership, + ...badges, authorName, authorChannelId, authorPhoto, @@ -529,6 +514,7 @@ export function parseLiveChatSponsorshipsGiftRedemptionAnnouncementRenderer( const authorName = stringify(renderer.authorName); const authorPhoto = pickThumbUrl(renderer.authorPhoto); const senderName = renderer.message.runs[1].text; + const badges = parseBadges(renderer); if (!authorName) { debugLog( @@ -546,6 +532,7 @@ export function parseLiveChatSponsorshipsGiftRedemptionAnnouncementRenderer( authorName, authorChannelId, authorPhoto, + ...badges, }; return parsed; } diff --git a/src/chat/badge.ts b/src/chat/badge.ts index 2fad3453..05d73c79 100644 --- a/src/chat/badge.ts +++ b/src/chat/badge.ts @@ -1,9 +1,6 @@ +import { Badges, Membership } from "../interfaces/misc"; +import { YTAuthorBadge } from "../interfaces/yt/chat"; import { debugLog } from "../utils"; -import { - YTAuthorBadge, - YTLiveChatTextMessageRenderer, -} from "../interfaces/yt/chat"; -import { Membership } from "../interfaces/misc"; export function parseMembership(badge: YTAuthorBadge): Membership | undefined { const renderer = badge.liveChatAuthorBadgeRenderer; @@ -24,7 +21,9 @@ export function parseMembership(badge: YTAuthorBadge): Membership | undefined { } } -export function parseBadges(renderer: YTLiveChatTextMessageRenderer) { +export function parseBadges(renderer: { + authorBadges?: YTAuthorBadge[]; +}): Badges { let isVerified = false, isOwner = false, isModerator = false, @@ -54,7 +53,6 @@ export function parseBadges(renderer: YTLiveChatTextMessageRenderer) { iconType, JSON.stringify(renderer) ); - throw new Error("Unrecognized iconType: " + iconType); } } } diff --git a/src/chat/index.ts b/src/chat/index.ts index 6152bf29..f0cfe4de 100644 --- a/src/chat/index.ts +++ b/src/chat/index.ts @@ -1,4 +1,4 @@ -import { Action, UnknownAction } from "../interfaces/actions"; +import { Action, ErrorAction, UnknownAction } from "../interfaces/actions"; import { YTAction } from "../interfaces/yt/chat"; import { debugLog, omitTrackingParams } from "../utils"; import { parseAddBannerToLiveChatCommand } from "./actions/addBannerToLiveChatCommand"; @@ -16,62 +16,86 @@ import { parseUpdateLiveChatPollAction } from "./actions/updateLiveChatPollActio /** * Parse raw action object and returns Action */ -export function parseAction(action: YTAction): Action | UnknownAction { - const filteredActions = omitTrackingParams(action); - const type = Object.keys(filteredActions)[0] as keyof typeof filteredActions; - - switch (type) { - case "addChatItemAction": { - const parsed = parseAddChatItemAction(action[type]!); - if (parsed) return parsed; - break; - } +export function parseAction(action: YTAction): Action { + try { + const filteredActions = omitTrackingParams(action); + const type = Object.keys( + filteredActions + )[0] as keyof typeof filteredActions; - case "markChatItemsByAuthorAsDeletedAction": - return parseMarkChatItemsByAuthorAsDeletedAction(action[type]!); + switch (type) { + case "addChatItemAction": { + const parsed = parseAddChatItemAction(action[type]!); + if (parsed) return parsed; + break; + } - case "markChatItemAsDeletedAction": - return parseMarkChatItemAsDeletedAction(action[type]!); + case "markChatItemsByAuthorAsDeletedAction": + return parseMarkChatItemsByAuthorAsDeletedAction(action[type]!); - case "addLiveChatTickerItemAction": { - const parsed = parseAddLiveChatTickerItemAction(action[type]!); - if (parsed) return parsed; - break; - } + case "markChatItemAsDeletedAction": + return parseMarkChatItemAsDeletedAction(action[type]!); + + case "addLiveChatTickerItemAction": { + const parsed = parseAddLiveChatTickerItemAction(action[type]!); + if (parsed) return parsed; + break; + } - case "replaceChatItemAction": - return parseReplaceChatItemAction(action[type]!); + case "replaceChatItemAction": + return parseReplaceChatItemAction(action[type]!); - case "addBannerToLiveChatCommand": - return parseAddBannerToLiveChatCommand(action[type]!); + case "addBannerToLiveChatCommand": + return parseAddBannerToLiveChatCommand(action[type]!); - case "removeBannerForLiveChatCommand": - return parseRemoveBannerForLiveChatCommand(action[type]!); + case "removeBannerForLiveChatCommand": + return parseRemoveBannerForLiveChatCommand(action[type]!); - case "showLiveChatTooltipCommand": - return parseShowLiveChatTooltipCommand(action[type]!); + case "showLiveChatTooltipCommand": + return parseShowLiveChatTooltipCommand(action[type]!); - case "showLiveChatActionPanelAction": - const parsed = parseShowLiveChatActionPanelAction(action[type]!); - return parsed; + case "showLiveChatActionPanelAction": + return parseShowLiveChatActionPanelAction(action[type]!); - case "updateLiveChatPollAction": - return parseUpdateLiveChatPollAction(action[type]!); + case "updateLiveChatPollAction": + return parseUpdateLiveChatPollAction(action[type]!); - case "closeLiveChatActionPanelAction": - return parseCloseLiveChatActionPanelAction(action[type]!); + case "closeLiveChatActionPanelAction": + return parseCloseLiveChatActionPanelAction(action[type]!); - default: { - const _: never = type; - debugLog( - "[action required] Unrecognized action type:", - JSON.stringify(action) - ); + default: { + debugLog( + "[action required] Unrecognized action type:", + JSON.stringify(action) + ); + } } + + return toUnknownAction(action); + } catch (error: any) { + debugLog( + "[action required] Error occurred while parsing action:", + error.message || error, + JSON.stringify(action) + ); + return toErrorAction(action, error); } +} +/** + * Unknown action used for unexpected payloads. You should implement an appropriate action parser as soon as you discover this action in the production. + */ +export function toUnknownAction(payload: unknown): UnknownAction { return { type: "unknown", - payload: action, - } as UnknownAction; + payload, + }; +} + +export function toErrorAction(payload: unknown, error: unknown): ErrorAction { + return { + type: "error", + error, + payload, + }; } diff --git a/src/chat/superchat.ts b/src/chat/superchat.ts index 9eef9937..b40bb25d 100644 --- a/src/chat/superchat.ts +++ b/src/chat/superchat.ts @@ -1,10 +1,14 @@ -import { stringify } from "../utils"; -import { YTLiveChatPaidMessageRenderer, YTText } from "../interfaces/yt/chat"; import { SuperChat, SUPERCHAT_COLOR_MAP, SUPERCHAT_SIGNIFICANCE_MAP, + SuperChatColorFields, } from "../interfaces/misc"; +import { + YTLiveChatPaidMessageRenderer, + YTLiveChatPaidStickerRenderer, +} from "../interfaces/yt/chat"; +import { debugLog, stringify } from "../utils"; import { parseColorCode } from "./utils"; const AMOUNT_REGEXP = /[\d.,]+/; @@ -41,29 +45,39 @@ export function parseAmountText(purchaseAmountText: string) { return { amount, currency }; } -export function parseSuperChat( - renderer: YTLiveChatPaidMessageRenderer -): SuperChat { +export function parseSuperChat< + T extends YTLiveChatPaidMessageRenderer | YTLiveChatPaidStickerRenderer +>(renderer: T): SuperChat { const { amount, currency } = parseAmountText( - renderer.purchaseAmountText.simpleText + stringify(renderer.purchaseAmountText) ); + const originalColor = + "headerBackgroundColor" in renderer + ? renderer.headerBackgroundColor.toString() + : renderer.backgroundColor.toString(); const color = - SUPERCHAT_COLOR_MAP[ - renderer.headerBackgroundColor.toString() as keyof typeof SUPERCHAT_COLOR_MAP - ]; + SUPERCHAT_COLOR_MAP[originalColor as keyof typeof SUPERCHAT_COLOR_MAP]; const significance = SUPERCHAT_SIGNIFICANCE_MAP[color]; + if (!color) { + debugLog( + "[action required] Can't find the color:", + JSON.stringify(renderer) + ); + } + + const colorFields = Object.fromEntries( + Object.entries(renderer) + .filter(([key]) => key.endsWith("Color")) + .map(([key, value]) => [key, parseColorCode(value)]) + ) as SuperChatColorFields; + return { amount, currency, color, significance, - authorNameTextColor: parseColorCode(renderer.authorNameTextColor), - timestampColor: parseColorCode(renderer.timestampColor), - headerBackgroundColor: parseColorCode(renderer.headerBackgroundColor), - headerTextColor: parseColorCode(renderer.headerTextColor), - bodyBackgroundColor: parseColorCode(renderer.bodyBackgroundColor), - bodyTextColor: parseColorCode(renderer.bodyTextColor), + ...colorFields, }; } diff --git a/src/chat/utils.ts b/src/chat/utils.ts index c8bf2ab5..9e3f1ac5 100644 --- a/src/chat/utils.ts +++ b/src/chat/utils.ts @@ -1,5 +1,5 @@ -import { YTThumbnailList } from "../interfaces/yt/chat"; import { Color } from "../interfaces/misc"; +import { YTThumbnailList } from "../interfaces/yt/chat"; export function pickThumbUrl(thumbList: YTThumbnailList): string { return thumbList.thumbnails[thumbList.thumbnails.length - 1].url; @@ -17,3 +17,36 @@ export function parseColorCode(code: number): Color { return { r, g, b, opacity }; } + +const magnitudes = new Map([ + ["K", 1000 ** 1], + ["M", 1000 ** 2], + ["G", 1000 ** 3], + ["T", 1000 ** 4], + ["P", 1000 ** 5], + ["E", 1000 ** 6], +] as const); + +const unitRegex = /(?[0-9]+(\.[0-9]*)?)(?([KMGTPE]))?/; + +export function unitsToNumber(text: string) { + const unitsMatch = text.match(unitRegex); + + if (!unitsMatch?.groups) { + return NaN; + } + + const parsedValue = parseFloat(unitsMatch.groups.value); + + if (!unitsMatch.groups?.suffix) { + return parsedValue; + } + + const magnitude = magnitudes.get(unitsMatch.groups.suffix as never); + + if (!magnitude) { + throw new Error("UnitRegex is wrong some how"); + } + + return parseInt((parsedValue * magnitude).toFixed(1)); +} diff --git a/src/interfaces/actions.ts b/src/interfaces/actions.ts index 1f10290e..8ef12765 100644 --- a/src/interfaces/actions.ts +++ b/src/interfaces/actions.ts @@ -1,15 +1,12 @@ +import { Badges, SuperChat, Color } from "./misc"; import { - Color, - Membership, - SuperChat, - SuperChatColor, - SuperChatSignificance, -} from "./misc"; -import { - YTLiveChatPollChoice, + YTLiveChatPaidMessageRenderer, + YTLiveChatPaidStickerRenderer, + YTText, + YTSimpleTextContainer, YTLiveChatPollType, + YTLiveChatPollChoice, YTRun, - YTText, } from "./yt/chat"; /** @@ -32,6 +29,11 @@ export type Action = | AddBannerAction | RemoveBannerAction | AddRedirectBannerAction + | AddIncomingRaidBannerAction + | AddOutgoingRaidBannerAction + | AddProductBannerAction + | AddCallForQuestionsBannerAction + | AddChatSummaryBannerAction | AddViewerEngagementMessageAction | ShowPanelAction | ShowPollPanelAction @@ -42,9 +44,13 @@ export type Action = | ModeChangeAction | MembershipGiftPurchaseAction | MembershipGiftRedemptionAction - | ModerationMessageAction; + | ModerationMessageAction + | RemoveChatItemAction + | RemoveChatItemByAuthorAction + | UnknownAction + | ErrorAction; -export interface AddChatItemAction { +export interface AddChatItemAction extends Badges { type: "addChatItemAction"; id: string; timestamp: Date; @@ -59,17 +65,15 @@ export interface AddChatItemAction { authorName?: string; authorChannelId: string; authorPhoto: string; - membership?: Membership; - isOwner: boolean; - isModerator: boolean; - isVerified: boolean; contextMenuEndpointParams: string; /** @deprecated use `message` */ rawMessage?: YTRun[]; } -export interface AddSuperChatItemAction { +export interface AddSuperChatItemAction + extends SuperChat, + Badges { type: "addSuperChatItemAction"; id: string; timestamp: Date; @@ -79,25 +83,17 @@ export interface AddSuperChatItemAction { authorChannelId: string; authorPhoto: string; message: YTRun[] | null; - amount: number; - currency: string; - color: SuperChatColor; - significance: SuperChatSignificance; - authorNameTextColor: Color; - timestampColor: Color; - headerBackgroundColor: Color; - headerTextColor: Color; - bodyBackgroundColor: Color; - bodyTextColor: Color; /** @deprecated use `message` */ rawMessage: YTRun[] | undefined; /** @deprecated flattened */ - superchat: SuperChat; + superchat: SuperChat; } -export interface AddSuperStickerItemAction { +export interface AddSuperStickerItemAction + extends SuperChat, + Badges { type: "addSuperStickerItemAction"; id: string; timestamp: Date; @@ -107,17 +103,11 @@ export interface AddSuperStickerItemAction { authorPhoto: string; stickerUrl: string; stickerText: string; - amount: number; - currency: string; stickerDisplayWidth: number; stickerDisplayHeight: number; - moneyChipBackgroundColor: Color; - moneyChipTextColor: Color; - backgroundColor: Color; - authorNameTextColor: Color; } -export interface AddMembershipItemAction { +export interface AddMembershipItemAction extends Badges { type: "addMembershipItemAction"; id: string; timestamp: Date; @@ -126,16 +116,13 @@ export interface AddMembershipItemAction { // `level` is only shown when there's multiple levels available level?: string; - /** Sometimes customThumbnail is not available */ - membership?: Membership; - /** rare but can be undefined */ authorName?: string; authorChannelId: string; authorPhoto: string; } -export interface AddMembershipMilestoneItemAction { +export interface AddMembershipMilestoneItemAction extends Badges { type: "addMembershipMilestoneItemAction"; id: string; timestamp: Date; @@ -144,9 +131,6 @@ export interface AddMembershipMilestoneItemAction { /** `level` is only shown when there's multiple levels available */ level?: string; - /** Sometimes customThumbnail is not available */ - membership?: Membership; - authorName?: string; authorChannelId: string; authorPhoto: string; @@ -231,7 +215,6 @@ export interface AddMembershipTickerAction { id: string; authorChannelId: string; authorPhoto: string; - membership?: Membership; durationSec: number; fullDurationSec: number; detailText: YTText; @@ -246,7 +229,7 @@ export interface AddMembershipTickerAction { endBackgroundColor: Color; } -export interface AddBannerAction { +export interface AddBannerAction extends Badges { type: "addBannerAction"; actionId: string; targetId: string; @@ -258,10 +241,6 @@ export interface AddBannerAction { authorName: string; authorChannelId: string; authorPhoto: string; - membership?: Membership; - isOwner: boolean; - isModerator: boolean; - isVerified: boolean; viewerIsCreator: boolean; contextMenuEndpointParams?: string; } @@ -279,6 +258,65 @@ export interface AddRedirectBannerAction { authorPhoto: string; } +export interface AddIncomingRaidBannerAction { + type: "addIncomingRaidBannerAction"; + actionId: string; + targetId: string; + sourceName: string; + sourcePhoto: string; +} + +export interface AddOutgoingRaidBannerAction { + type: "addOutgoingRaidBannerAction"; + actionId: string; + targetId: string; + targetName: string; + targetPhoto: string; + targetVideoId: string; +} + +export interface AddProductBannerAction { + type: "addProductBannerAction"; + actionId: string; + targetId: string; + viewerIsCreator: boolean; + isStackable?: boolean; + title: string; + description: string; + thumbnail: string; + price: string; + vendorName: string; + creatorMessage: string; + creatorName: string; + authorPhoto: string; + url: string; + dialogMessage: YTSimpleTextContainer[]; + isVerified: boolean; +} + +export interface AddCallForQuestionsBannerAction { + type: "addCallForQuestionsBannerAction"; + actionId: string; + targetId: string; + isStackable?: boolean; + bannerType?: string; + creatorAvatar: string; + creatorAuthorName: string; + questionMessage: YTRun[]; +} + +export interface AddChatSummaryBannerAction { + type: "addChatSummaryBannerAction"; + id: string; + actionId: string; + targetId: string; + isStackable?: boolean; + bannerType?: string; + timestamp: Date; + timestampUsec: string; + chatSummary: YTRun[]; +} + export interface ShowTooltipAction { type: "showTooltipAction"; targetId: string; @@ -337,12 +375,15 @@ export interface AddPollResultAction { type: "addPollResultAction"; id: string; question?: YTRun[]; + /** @deprecated use `voteCount` */ total: string; + voteCount: number; choices: PollChoice[]; } export interface PollChoice { text: YTRun[]; + voteRatio: number; votePercentage: string; } @@ -360,14 +401,13 @@ export interface ModeChangeAction { description: string; } -export interface MembershipGiftPurchaseAction { +export interface MembershipGiftPurchaseAction extends Badges { type: "membershipGiftPurchaseAction"; id: string; timestamp: Date; timestampUsec: string; channelName: string; // MEMO: is it limited for ¥500 membership? amount: number; // 5, 10, 20 - membership: Membership; authorName: string; authorChannelId: string; authorPhoto: string; @@ -376,10 +416,10 @@ export interface MembershipGiftPurchaseAction { export type MembershipGiftPurchaseTickerContent = Omit< MembershipGiftPurchaseAction, - "timestamp" | "timestampUsec" | "type" + "timestamp" | "timestampUsec" >; -export interface MembershipGiftRedemptionAction { +export interface MembershipGiftRedemptionAction extends Badges { type: "membershipGiftRedemptionAction"; id: string; timestamp: Date; @@ -398,7 +438,25 @@ export interface ModerationMessageAction { message: YTRun[]; } +export interface RemoveChatItemAction { + type: "removeChatItemAction"; + targetId: string; + timestamp: Date; +} + +export interface RemoveChatItemByAuthorAction { + type: "removeChatItemByAuthorAction"; + channelId: string; + timestamp: Date; +} + export interface UnknownAction { type: "unknown"; payload: unknown; } + +export interface ErrorAction { + type: "error"; + error: unknown; + payload: unknown; +} diff --git a/src/interfaces/misc.ts b/src/interfaces/misc.ts index 28f8f5ed..6b113236 100644 --- a/src/interfaces/misc.ts +++ b/src/interfaces/misc.ts @@ -13,12 +13,19 @@ export const SUPERCHAT_SIGNIFICANCE_MAP = { export const SUPERCHAT_COLOR_MAP = { "4279592384": "blue", + "4280191205": "blue", "4278237396": "lightblue", + "4278248959": "lightblue", "4278239141": "green", + "4280150454": "green", "4294947584": "yellow", + "4294953512": "yellow", "4293284096": "orange", + "4294278144": "orange", "4290910299": "magenta", + "4293467747": "magenta", "4291821568": "red", + "4293271831": "red", } as const; /** * Components @@ -30,24 +37,30 @@ export type SuperChatSignificance = export type SuperChatColor = typeof SUPERCHAT_COLOR_MAP[keyof typeof SUPERCHAT_COLOR_MAP]; -export interface SuperChat { +export type SuperChatColorFields = { + [K in keyof T as K extends `${string}Color` ? K : never]: Color; +}; + +export type SuperChat = { amount: number; currency: string; color: SuperChatColor; significance: SuperChatSignificance; - authorNameTextColor: Color; - timestampColor: Color; - headerBackgroundColor: Color; - headerTextColor: Color; - bodyBackgroundColor: Color; - bodyTextColor: Color; -} +} & SuperChatColorFields; export interface Membership { status: string; since?: string; thumbnail: string; } + +export interface Badges { + isOwner: boolean; + isModerator: boolean; + isVerified: boolean; + membership?: Membership; +} + /** * 0 - 255 */ diff --git a/src/interfaces/yt/chat.ts b/src/interfaces/yt/chat.ts index cb247891..e60fe74f 100644 --- a/src/interfaces/yt/chat.ts +++ b/src/interfaces/yt/chat.ts @@ -1,9 +1,10 @@ import { - YTAccessibilityLabel, + FrameworkUpdates, YTAccessibilityData, + YTAccessibilityLabel, + YTBrowseEndpointContainer, YTReloadContinuation, YTResponseContext, - YTBrowseEndpointContainer, } from "./context"; // -------------------- @@ -27,12 +28,18 @@ export interface YTTextRun { text: string; bold?: boolean; italics?: boolean; + fontFace?: YTFontFace | string; navigationEndpoint?: | YTUrlEndpointContainer | YTBrowseEndpointContainer | YTWatchEndpointContainer; } +export enum YTFontFace { + RobotoRegular = "FONT_FACE_ROBOTO_REGULAR", + RobotoMedium = "FONT_FACE_ROBOTO_MEDIUM", +} + export interface YTEmojiRun { emoji: YTEmoji; } @@ -92,6 +99,7 @@ export interface YTChatResponse { continuationContents?: YTContinuationContents; error?: YTChatError; trackingParams: string; + frameworkUpdates?: FrameworkUpdates; } export interface YTGetItemContextMenuResponse { @@ -232,6 +240,8 @@ export interface YTAction { addChatItemAction?: YTAddChatItemAction; markChatItemsByAuthorAsDeletedAction?: YTMarkChatItemsByAuthorAsDeletedAction; markChatItemAsDeletedAction?: YTMarkChatItemAsDeletedAction; + removeChatItemAction?: YTRemoveChatItemAction; + removeChatItemByAuthorAction?: YTRemoveChatItemByAuthorAction; // Ticker addLiveChatTickerItemAction?: YTAddLiveChatTickerItemAction; @@ -291,6 +301,14 @@ export interface YTMarkChatItemsByAuthorAsDeletedAction { externalChannelId: string; } +export interface YTRemoveChatItemAction { + targetItemId: string; +} + +export interface YTRemoveChatItemByAuthorAction { + externalChannelId: string; +} + export interface YTAddBannerToLiveChatCommand { bannerRenderer: YTLiveChatBannerRendererContainer; } @@ -375,6 +393,18 @@ export interface YTLiveChatBannerRedirectRendererContainer { liveChatBannerRedirectRenderer: YTLiveChatBannerRedirectRenderer; } +export interface YTLiveChatProductItemRendererContainer { + liveChatProductItemRenderer: YTLiveChatProductItemRenderer; +} + +export interface YTLiveChatCallForQuestionsRendererContainer { + liveChatCallForQuestionsRenderer: YTLiveChatCallForQuestionsRenderer; +} + +export interface YTLiveChatBannerChatSummaryRendererContainer { + liveChatBannerChatSummaryRenderer: YTLiveChatBannerChatSummaryRenderer; +} + // LiveChat Renderers export interface YTLiveChatTextMessageRenderer { @@ -401,6 +431,7 @@ export interface YTLiveChatPaidMessageRenderer { authorExternalChannelId: string; contextMenuEndpoint: YTLiveChatItemContextMenuEndpointContainer; contextMenuAccessibility: YTAccessibilityData; + authorBadges?: YTAuthorBadge[]; purchaseAmountText: YTSimpleTextContainer; timestampColor: number; @@ -409,6 +440,7 @@ export interface YTLiveChatPaidMessageRenderer { headerTextColor: number; bodyBackgroundColor: number; bodyTextColor: number; + textInputBackgroundColor: number; trackingParams: string; } @@ -421,6 +453,7 @@ export interface YTLiveChatPaidStickerRenderer { authorName: YTText; authorExternalChannelId: string; sticker: YTThumbnailList; // with accessibility + authorBadges?: YTAuthorBadge[]; moneyChipBackgroundColor: number; moneyChipTextColor: number; purchaseAmountText: YTSimpleTextContainer; @@ -464,21 +497,47 @@ export interface YTLiveChatPlaceholderItemRenderer { export interface YTLiveChatBannerRenderer { actionId: string; - targetId: string; // live-chat-banner + targetId: "live-chat-banner" | string; contents: | YTLiveChatTextMessageRendererContainer - | YTLiveChatBannerRedirectRendererContainer; - header?: YTLiveChatBannerRendererHeader; + | YTLiveChatBannerRedirectRendererContainer + | YTLiveChatProductItemRendererContainer + | YTLiveChatCallForQuestionsRendererContainer + | YTLiveChatBannerChatSummaryRendererContainer; viewerIsCreator: boolean; + header?: YTLiveChatBannerRendererHeader; + isStackable?: boolean; + backgroundType?: "LIVE_CHAT_BANNER_BACKGROUND_TYPE_STATIC" | string; + bannerType?: YTLiveChatBannerType | string; + onCollapseCommand?: YTElementsCommandContainer; + onExpandCommand?: YTElementsCommandContainer; +} + +export enum YTLiveChatBannerType { + ChatSummary = "LIVE_CHAT_BANNER_TYPE_CHAT_SUMMARY", + CallForQuestions = "LIVE_CHAT_BANNER_TYPE_QNA_START", +} + +export interface YTElementsCommandContainer { + clickTrackingParams: string; + elementsCommand: YTSetEntityCommandContainer; +} + +export interface YTSetEntityCommandContainer { + setEntityCommand: { + identifier: string; + entity: string; + }; } export interface YTLiveChatViewerEngagementMessageRenderer { id: string; timestampUsec?: string; - icon: YTIcon; + icon?: YTIcon; message: YTText; actionButton?: YTActionButtonRendererContainer; contextMenuEndpoint?: YTLiveChatItemContextMenuEndpointContainer; + trackingParams?: string; } export interface YTTooltipRenderer { @@ -534,10 +593,65 @@ export interface YTLiveChatBannerRedirectRenderer { */ bannerMessage: YTRunContainer; authorPhoto: YTThumbnailList; - inlineActionButton: YTActionButtonRendererContainer; + inlineActionButton: YTContextMenuButtonRendererContainer< + YTUrlEndpointContainer | YTWatchEndpointContainer + >; contextMenuButton: YTContextMenuButtonRendererContainer; } +export interface YTLiveChatProductItemRenderer { + title: string; + accessibilityTitle: string; + thumbnail: YTThumbnailList; + price: string; + vendorName: string; + fromVendorText: string; + informationButton: InformationButton; + onClickCommand: YTUrlEndpointContainer; + trackingParams: string; + creatorMessage: string; + creatorName: string; + authorPhoto: YTThumbnailList; + informationDialog: InformationDialog; + isVerified: boolean; +} + +export interface InformationButton { + buttonRenderer: InformationButtonButtonRenderer; +} + +export interface InformationButtonButtonRenderer { + icon: YTIcon; + accessibility: YTAccessibilityLabel; + trackingParams: string; +} + +export interface InformationDialog { + liveChatDialogRenderer: LiveChatDialogRenderer; +} + +export interface LiveChatDialogRenderer { + trackingParams: string; + dialogMessages: YTSimpleTextContainer[]; + confirmButton: CollapseButton; +} + +export interface YTLiveChatCallForQuestionsRenderer { + creatorAvatar: YTThumbnailList; + featureLabel: YTSimpleTextContainer; + contentSeparator: YTSimpleTextContainer; + overflowMenuButton: YTContextMenuButtonRendererContainer; + creatorAuthorName: YTSimpleTextContainer; + questionMessage: YTRunContainer; +} + +export interface YTLiveChatBannerChatSummaryRenderer { + liveChatSummaryId: string; + chatSummary: YTRunContainer; + icon: YTIcon; + trackingParams: string; +} + export interface YTLiveChatPollChoice { text: YTText; selected: boolean; @@ -612,7 +726,7 @@ export interface YTLiveChatSponsorshipsHeaderRenderer { { text: " memberships"; bold: true } ]; }; - authorBadges: YTLiveChatAuthorBadgeRendererContainer[]; + authorBadges?: YTLiveChatAuthorBadgeRendererContainer[]; contextMenuEndpoint: YTLiveChatItemContextMenuEndpointContainer; contextMenuAccessibility: YTAccessibilityData; image: YTThumbnailList; // https://www.gstatic.com/youtube/img/sponsorships/sponsorships_gift_purchase_announcement_artwork.png @@ -631,6 +745,7 @@ export interface YTLiveChatSponsorshipsGiftRedemptionAnnouncementRenderer { { text: string; bold: true; italics: true } // text: "User" ]; }; + authorBadges?: YTLiveChatAuthorBadgeRendererContainer[]; contextMenuEndpoint: YTLiveChatItemContextMenuEndpointContainer; contextMenuAccessibility: YTAccessibilityData; trackingParams: string; @@ -854,6 +969,7 @@ export enum YTIconType { TabSubscriptions = "TAB_SUBSCRIPTIONS", BlockUser = "BLOCK_USER", ErrorOutline = "ERROR_OUTLINE", + Spark = "SPARK", } export interface YTPicker { @@ -919,7 +1035,8 @@ export interface YTContextMenuButtonRendererContainer< buttonRenderer: { icon: YTIcon; style?: string; - command?: Command; + command: Command; + accessibility?: YTAccessibilityLabel; accessibilityData: YTAccessibilityData; trackingParams: string; }; @@ -936,12 +1053,16 @@ export interface YTServiceButtonRenderer { trackingParams: string; } +// Generic type +export interface YTButton {} + export interface YTButtonRenderer { - size: string; - style: string; - isDisabled: boolean; + icon?: YTIcon; + text?: YTText; + size?: string; + style?: string; + isDisabled?: boolean; accessibility: YTAccessibilityLabel; - trackingParams: string; } export interface YTIconButtonRenderer { From b15aa48345554de89117c70695f79d76f9b4da31 Mon Sep 17 00:00:00 2001 From: HitomaruKonpaku Date: Mon, 30 Jun 2025 03:13:04 +0700 Subject: [PATCH 23/34] Update assertPlayability --- src/context/index.ts | 21 +++++++++++++++------ src/interfaces/yt/context.ts | 5 ++++- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/context/index.ts b/src/context/index.ts index 40138335..2c22825f 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -20,18 +20,20 @@ function assertPlayability(playabilityStatus: YTPlayabilityStatus | undefined) { if (!playabilityStatus) { throw new Error("playabilityStatus missing"); } + + const msg = playabilityStatus.reason || playabilityStatus.messages?.join(" "); switch (playabilityStatus.status) { case "ERROR": - throw new UnavailableError(playabilityStatus.reason!); + throw new UnavailableError(msg!); case "LOGIN_REQUIRED": - throw new NoPermissionError(playabilityStatus.reason!); + throw new NoPermissionError(msg!); case "UNPLAYABLE": { if ( "playerLegacyDesktopYpcOfferRenderer" in playabilityStatus.errorScreen! ) { - throw new MembersOnlyError(playabilityStatus.reason!); + throw new MembersOnlyError(msg!); } - throw new NoStreamRecordingError(playabilityStatus.reason!); + throw new NoStreamRecordingError(msg!); } case "LIVE_STREAM_OFFLINE": case "OK": @@ -113,8 +115,15 @@ export function parseMetadataFromWatch(html: string) { const metadata = parseVideoMetadataFromHtml(html); const initialData = findInitialData(html)!; - const playabilityStatus = findPlayabilityStatus(html); - // assertPlayability(playabilityStatus); + try { + const playabilityStatus = findPlayabilityStatus(html); + assertPlayability(playabilityStatus); + } catch (error) { + // If members-only video is ended it should be able to get chat normally + if (!(error instanceof MembersOnlyError && metadata.publication?.endDate)) { + throw error; + } + } // TODO: initialData.contents.twoColumnWatchNextResults.conversationBar.conversationBarRenderer.availabilityMessage.messageRenderer.text.runs[0].text === 'Chat is disabled for this live stream.' const results = diff --git a/src/interfaces/yt/context.ts b/src/interfaces/yt/context.ts index b2965868..967502fc 100644 --- a/src/interfaces/yt/context.ts +++ b/src/interfaces/yt/context.ts @@ -23,9 +23,12 @@ export interface YTPlayabilityStatus { contextParams: string; // if not OK reason?: string; + messages?: string[]; errorScreen?: { playerErrorMessageRenderer?: { reason: YTSimpleTextContainer; + subreason?: YTSimpleTextContainer; + proceedButton?: YTDismissButtonClass; thumbnail: YTThumbnailList; icon: YTIcon; }; @@ -219,7 +222,7 @@ export interface YTCollapseButtonButtonRenderer { isDisabled: boolean; accessibility?: YTAccessibilityLabel; trackingParams: string; - text?: YTRunContainer; + text?: Partial; } export interface YTAccessibilityData { From 8666af400e00f2b1b5b6b0ba5a9274dfa6fbcb7a Mon Sep 17 00:00:00 2001 From: HitomaruKonpaku Date: Mon, 30 Jun 2025 03:25:23 +0700 Subject: [PATCH 24/34] Update assertPlayability --- src/context/index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/context/index.ts b/src/context/index.ts index 2c22825f..11ea1eef 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -117,7 +117,10 @@ export function parseMetadataFromWatch(html: string) { try { const playabilityStatus = findPlayabilityStatus(html); - assertPlayability(playabilityStatus); + // even if playabilityStatus missing you can still have chat + if (playabilityStatus) { + assertPlayability(playabilityStatus); + } } catch (error) { // If members-only video is ended it should be able to get chat normally if (!(error instanceof MembersOnlyError && metadata.publication?.endDate)) { From 5477ae1fc9f14b71d9a914389a3ea8ba891ac01c Mon Sep 17 00:00:00 2001 From: HitomaruKonpaku Date: Thu, 3 Jul 2025 01:29:39 +0700 Subject: [PATCH 25/34] Throw some data on MembersOnlyError --- src/context/index.ts | 34 +++++++++++++++++++--------------- src/errors.ts | 19 ++++++++++++++----- 2 files changed, 33 insertions(+), 20 deletions(-) diff --git a/src/context/index.ts b/src/context/index.ts index 11ea1eef..0ade9045 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -16,7 +16,10 @@ import { VideoObject } from "../interfaces/yt/metadata"; // OK duration=">0" => Archived (replay chat may be available) // OK duration="0" => Live (chat may be available) // LIVE_STREAM_OFFLINE => Offline (chat may be available) -function assertPlayability(playabilityStatus: YTPlayabilityStatus | undefined) { +function assertPlayability( + playabilityStatus: YTPlayabilityStatus | undefined, + data?: any +) { if (!playabilityStatus) { throw new Error("playabilityStatus missing"); } @@ -31,7 +34,7 @@ function assertPlayability(playabilityStatus: YTPlayabilityStatus | undefined) { if ( "playerLegacyDesktopYpcOfferRenderer" in playabilityStatus.errorScreen! ) { - throw new MembersOnlyError(msg!); + throw new MembersOnlyError(msg!, data); } throw new NoStreamRecordingError(msg!); } @@ -115,19 +118,6 @@ export function parseMetadataFromWatch(html: string) { const metadata = parseVideoMetadataFromHtml(html); const initialData = findInitialData(html)!; - try { - const playabilityStatus = findPlayabilityStatus(html); - // even if playabilityStatus missing you can still have chat - if (playabilityStatus) { - assertPlayability(playabilityStatus); - } - } catch (error) { - // If members-only video is ended it should be able to get chat normally - if (!(error instanceof MembersOnlyError && metadata.publication?.endDate)) { - throw error; - } - } - // TODO: initialData.contents.twoColumnWatchNextResults.conversationBar.conversationBarRenderer.availabilityMessage.messageRenderer.text.runs[0].text === 'Chat is disabled for this live stream.' const results = initialData.contents?.twoColumnWatchNextResults?.results.results!; @@ -142,6 +132,20 @@ export function parseMetadataFromWatch(html: string) { const badges = primaryInfo?.badges || []; const channelId = videoOwner?.navigationEndpoint?.browseEndpoint?.browseId; + + try { + const playabilityStatus = findPlayabilityStatus(html); + // even if playabilityStatus missing you can still have chat + if (playabilityStatus) { + assertPlayability(playabilityStatus, { channelId, meta: metadata }); + } + } catch (error) { + // If members-only video is ended it should be able to get chat normally + if (!(error instanceof MembersOnlyError && metadata.publication?.endDate)) { + throw error; + } + } + if (!channelId) { throw new Error("CHANNEL_ID_NOT_FOUND"); } diff --git a/src/errors.ts b/src/errors.ts index 55be872c..dc5dd8a9 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,3 +1,5 @@ +import { VideoObject } from "./interfaces"; + export type EndReason = | "privated" // Privated by streamer | "deleted" // Deleted by streamer @@ -14,12 +16,19 @@ export type ErrorCode = | "denied" // Access denied (429) | "invalid"; // Invalid request -export class MasterchatError extends Error { +export interface MembersOnlyErrorData { + channelId?: string; + meta?: VideoObject; +} + +export class MasterchatError extends Error { public code: ErrorCode; + public data?: T; - constructor(code: ErrorCode, msg: string) { + constructor(code: ErrorCode, msg: string, data?: T) { super(msg); this.code = code; + this.data = data; Object.setPrototypeOf(this, MasterchatError.prototype); } @@ -46,9 +55,9 @@ export class NoPermissionError extends MasterchatError { } } -export class MembersOnlyError extends MasterchatError { - constructor(msg: string) { - super("membersOnly", msg); +export class MembersOnlyError extends MasterchatError { + constructor(msg: string, data?: MembersOnlyErrorData) { + super("membersOnly", msg, data); Object.setPrototypeOf(this, MembersOnlyError.prototype); } } From 1ef1a39e91b6003fc78beb7e9f004b5228c6d6fe Mon Sep 17 00:00:00 2001 From: HitomaruKonpaku Date: Thu, 3 Jul 2025 17:36:29 +0700 Subject: [PATCH 26/34] Fix error --- src/context/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/context/index.ts b/src/context/index.ts index 0ade9045..d37cc512 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -122,10 +122,10 @@ export function parseMetadataFromWatch(html: string) { const results = initialData.contents?.twoColumnWatchNextResults?.results.results!; - const primaryInfo = results.contents.find( + const primaryInfo = results.contents?.find( (v) => v.videoPrimaryInfoRenderer )?.videoPrimaryInfoRenderer; - const secondaryInfo = results.contents.find( + const secondaryInfo = results.contents?.find( (v) => v.videoSecondaryInfoRenderer )?.videoSecondaryInfoRenderer; const videoOwner = secondaryInfo?.owner?.videoOwnerRenderer; From d02f025cc8dedefdcb57ef4fcd9aa83b34285fa4 Mon Sep 17 00:00:00 2001 From: FlashlightXi Date: Sat, 5 Jul 2025 00:38:34 +0900 Subject: [PATCH 27/34] Ability to set timeoutLength in timeoutParams --- src/masterchat.ts | 4 ++-- src/protobuf/assembler.ts | 14 +++++++++++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/masterchat.ts b/src/masterchat.ts index e514d149..934e2278 100644 --- a/src/masterchat.ts +++ b/src/masterchat.ts @@ -998,8 +998,8 @@ export class Masterchat extends EventEmitter { /** * Put user in timeout for 300 seconds */ - public async timeout(channelId: string): Promise { - const params = timeoutParams(channelId, this.cvPair()); + public async timeout(channelId: string, timeoutLength: number = 300): Promise { + const params = timeoutParams(channelId, this.cvPair(), timeoutLength); const res = await this.post( Constants.EP_MOD, diff --git a/src/protobuf/assembler.ts b/src/protobuf/assembler.ts index 9e6e376a..ea288d21 100644 --- a/src/protobuf/assembler.ts +++ b/src/protobuf/assembler.ts @@ -129,13 +129,21 @@ export function removeMessageParams( ); } -export function timeoutParams(channelId: string, origin: CVPair): string { +export function timeoutParams( + channelId: string, + origin: CVPair, + timeoutLength: number +): string { return b64e( cc([ ld(1, cvToken(origin)), - ld(6, ld(1, truc(channelId))), - vt(10, 2), + ld(6, [ + ld(1, truc(channelId)), + ld(2, [vt(1, encv(BigInt(timeoutLength)))]), + ]), + vt(10, 1), vt(11, 1), + vt(14, 4), ]), B64Type.B2 ); From 8650ff7eef6fdf213822b8b206282d75bb1f1386 Mon Sep 17 00:00:00 2001 From: FlashlightXi Date: Sat, 5 Jul 2025 00:55:44 +0900 Subject: [PATCH 28/34] Fix flatMap Type Error --- src/masterchat.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/masterchat.ts b/src/masterchat.ts index 934e2278..a399900a 100644 --- a/src/masterchat.ts +++ b/src/masterchat.ts @@ -996,10 +996,17 @@ export class Masterchat extends EventEmitter { } /** - * Put user in timeout for 300 seconds + * Put user in timeout for *timeoutLength* seconds (default set to 300 seconds) */ - public async timeout(channelId: string, timeoutLength: number = 300): Promise { - const params = timeoutParams(channelId, this.cvPair(), timeoutLength); + public async timeout( + channelId: string, + timeoutLength: number = 300 + ): Promise { + const params = timeoutParams( + channelId, + this.cvPair(), + Math.min(Math.max(timeoutLength, 10), 86400) + ); const res = await this.post( Constants.EP_MOD, From d2998bdaaf5bc1dbf8fc7e4b86af4a1588a38854 Mon Sep 17 00:00:00 2001 From: FlashlightXi Date: Sat, 5 Jul 2025 01:03:21 +0900 Subject: [PATCH 29/34] Delete iterator-helpers-polyfill --- src/masterchat.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/masterchat.ts b/src/masterchat.ts index a399900a..a7a088ba 100644 --- a/src/masterchat.ts +++ b/src/masterchat.ts @@ -1,6 +1,5 @@ import axios, { AxiosError, AxiosInstance, AxiosRequestConfig } from "axios"; import { EventEmitter } from "events"; -import { AsyncIterator } from "iterator-helpers-polyfill"; import { buildMeta } from "./api"; import { buildAuthHeaders } from "./auth"; import { parseAction } from "./chat"; @@ -600,10 +599,10 @@ export class Masterchat extends EventEmitter { /** * AsyncIterator API */ - public iter(options?: IterateChatOptions): AsyncIterator { - return AsyncIterator.from( - this.iterate(options) - ).flatMap((action) => action.actions); + public async *iter(options?: IterateChatOptions): AsyncIterator { + for await (const response of this.iterate(options)) { + yield* response.actions; + } } /** From d796dd58a3fc20510cd5f6998175937c13c46d89 Mon Sep 17 00:00:00 2001 From: HitomaruKonpaku Date: Sat, 5 Jul 2025 21:25:23 +0700 Subject: [PATCH 30/34] Add bot error --- src/context/index.ts | 8 ++++++++ src/errors.ts | 8 ++++++++ src/interfaces/yt/chat.ts | 9 ++++++++- src/interfaces/yt/context.ts | 5 +++-- 4 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/context/index.ts b/src/context/index.ts index d37cc512..9b8c3de8 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -1,5 +1,6 @@ import * as cheerio from "cheerio"; import { + BotError, MembersOnlyError, NoPermissionError, NoStreamRecordingError, @@ -29,6 +30,13 @@ function assertPlayability( case "ERROR": throw new UnavailableError(msg!); case "LOGIN_REQUIRED": + if ( + playabilityStatus.reason === "Sign in to confirm you’re not a bot" || + playabilityStatus.skip?.playabilityErrorSkipConfig + ?.skipOnPlayabilityError === false + ) { + throw new BotError(msg!); + } throw new NoPermissionError(msg!); case "UNPLAYABLE": { if ( diff --git a/src/errors.ts b/src/errors.ts index dc5dd8a9..61736d29 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -11,6 +11,7 @@ export type ErrorCode = | "unavailable" // Deleted video OR wrong video id | "disabled" // Live chat is disabled | "private" // No permission (private) + | "bot" // Bot detected | "membersOnly" // No permission (members-only) | "unarchived" // Live stream recording is not available | "denied" // Access denied (429) @@ -55,6 +56,13 @@ export class NoPermissionError extends MasterchatError { } } +export class BotError extends MasterchatError { + constructor(msg: string) { + super("bot", msg); + Object.setPrototypeOf(this, BotError.prototype); + } +} + export class MembersOnlyError extends MasterchatError { constructor(msg: string, data?: MembersOnlyErrorData) { super("membersOnly", msg, data); diff --git a/src/interfaces/yt/chat.ts b/src/interfaces/yt/chat.ts index e60fe74f..87b95f51 100644 --- a/src/interfaces/yt/chat.ts +++ b/src/interfaces/yt/chat.ts @@ -13,6 +13,13 @@ import { export type YTText = YTSimpleTextContainer | YTRunContainer; +/** + * Actually same {@link YTText} but better for IntelliSense (?) + * + * It can have `simpleText` or `runs` not both + */ +export type YTAnyText = Partial; + export interface YTSimpleTextContainer { simpleText: string; accessibility?: YTAccessibilityData; @@ -560,7 +567,7 @@ export interface YTLiveChatPollRenderer { liveChatPollId: string; header: { pollHeaderRenderer: { - pollQuestion?: Partial; + pollQuestion?: YTAnyText; thumbnail: YTThumbnailList; metadataText: YTRunContainer; liveChatPollType: YTLiveChatPollType; diff --git a/src/interfaces/yt/context.ts b/src/interfaces/yt/context.ts index 967502fc..06bf4a2a 100644 --- a/src/interfaces/yt/context.ts +++ b/src/interfaces/yt/context.ts @@ -1,5 +1,6 @@ import { UIActions, + YTAnyText, YTApiEndpointMetadataContainer, YTClientMessages, YTIcon, @@ -27,7 +28,7 @@ export interface YTPlayabilityStatus { errorScreen?: { playerErrorMessageRenderer?: { reason: YTSimpleTextContainer; - subreason?: YTSimpleTextContainer; + subreason?: YTAnyText; proceedButton?: YTDismissButtonClass; thumbnail: YTThumbnailList; icon: YTIcon; @@ -222,7 +223,7 @@ export interface YTCollapseButtonButtonRenderer { isDisabled: boolean; accessibility?: YTAccessibilityLabel; trackingParams: string; - text?: Partial; + text?: YTAnyText; } export interface YTAccessibilityData { From 1ab38d7d85127567f960cd0741897fe2854f0189 Mon Sep 17 00:00:00 2001 From: HitomaruKonpaku Date: Sun, 6 Jul 2025 16:24:36 +0700 Subject: [PATCH 31/34] Refactor --- src/context/index.ts | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/src/context/index.ts b/src/context/index.ts index 9b8c3de8..398eb9e3 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -140,6 +140,17 @@ export function parseMetadataFromWatch(html: string) { const badges = primaryInfo?.badges || []; const channelId = videoOwner?.navigationEndpoint?.browseEndpoint?.browseId; + const channelName = + runsToString(videoOwner?.title?.runs || []) || metadata.author?.name; + const title = runsToString(primaryInfo?.title?.runs || []) || metadata.name; + const isLive = !metadata.publication?.endDate || false; + const isUpcoming = + primaryInfo?.dateText?.simpleText?.includes("Scheduled for") || false; + const isMembersOnly = + badges.some( + (v) => + v.metadataBadgeRenderer.style === PurpleStyle.BadgeStyleTypeMembersOnly + ) || false; try { const playabilityStatus = findPlayabilityStatus(html); @@ -158,18 +169,6 @@ export function parseMetadataFromWatch(html: string) { throw new Error("CHANNEL_ID_NOT_FOUND"); } - const channelName = - runsToString(videoOwner?.title?.runs || []) || metadata.author.name; - const title = runsToString(primaryInfo?.title?.runs || []) || metadata.name; - const isLive = !metadata?.publication?.endDate || false; - const isUpcoming = - primaryInfo?.dateText?.simpleText?.includes("Scheduled for") || false; - const isMembersOnly = - badges.some?.( - (v) => - v.metadataBadgeRenderer.style === PurpleStyle.BadgeStyleTypeMembersOnly - ) ?? false; - return { title, channelId, From baa1066f95324f32be8b830cfbb0503414708ad2 Mon Sep 17 00:00:00 2001 From: HitomaruKonpaku Date: Sun, 6 Jul 2025 16:40:50 +0700 Subject: [PATCH 32/34] Update docs url --- MANUAL.md | 4 ++-- README.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/MANUAL.md b/MANUAL.md index 07bc759f..62b8b5f0 100644 --- a/MANUAL.md +++ b/MANUAL.md @@ -113,7 +113,7 @@ await mc .iter() .filter((action) => action.type === "addChatItemAction") // only chat events .map((chat) => JSON.stringify(chat) + "\n") // convert to JSONL - .forEach((jsonl) => appendFile("./chats.jsonl", jsonl)) // append to the file + .forEach((jsonl) => appendFile("./chats.jsonl", jsonl)); // append to the file ``` ### Chat moderation bot @@ -248,7 +248,7 @@ const mc = await Masterchat.init("", { axiosInstance }); ## Reference -[API Documentation](https://holodata.github.io/masterchat) +[API Documentation](https://sigvt.github.io/masterchat) ### Action type diff --git a/README.md b/README.md index b73641be..42794b23 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![npm](https://badgen.net/npm/v/@hitomaru/masterchat)](https://npmjs.org/package/@hitomaru/masterchat) [![npm: total downloads](https://badgen.net/npm/dt/@hitomaru/masterchat)](https://npmjs.org/package/@hitomaru/masterchat) [![npm: publish size](https://badgen.net/packagephobia/publish/@hitomaru/masterchat)](https://npmjs.org/package/@hitomaru/masterchat) -[![typedoc](https://badgen.net/badge/docs/typedoc/purple)](https://holodata.github.io/masterchat/) +[![typedoc](https://badgen.net/badge/docs/typedoc/purple)](https://sigvt.github.io/masterchat/) Masterchat is the most powerful library for YouTube Live Chat, supporting parsing 20+ actions, video comments and transcripts, as well as sending messages and moderating chats. From 036f78948e6ba675be8098b285f301bcd8263579 Mon Sep 17 00:00:00 2001 From: HitomaruKonpaku Date: Sun, 6 Jul 2025 17:02:31 +0700 Subject: [PATCH 33/34] Update CHANGELOG --- CHANGELOG.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4dec1ca5..d61b0df4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## v1.3.0 + +### New + +- Add `ErrorAction`, `UnknownAction` +- Add `BotError` + +### Improvements + +- Update from [stu43005/masterchat](https://github.com/stu43005/masterchat) + - Commit: [5e30cf71ee0c9b7739e59cdcf92402f7794b7ee5](https://github.com/stu43005/masterchat/commit/5e30cf71ee0c9b7739e59cdcf92402f7794b7ee5) + - `addBannerToLiveChatCommand` + - `addChatItemAction` +- #1 Updated version of timeoutParams() (@FlashlightXi) +- Update interfaces + - `YTAnyText` + - `YTPlayabilityStatus` +- `MembersOnlyError` should have additional data +- `parseMetadataFromWatch` should handle more edge cases + - Chat can be available even when `playabilityStatus` not exist + - `MembersOnly` chat should be available when stream ended + ## v1.2.0 ### New From ae0d899fdd883e07ea622e5cdb189d3a20187b9e Mon Sep 17 00:00:00 2001 From: HitomaruKonpaku Date: Sun, 6 Jul 2025 17:03:33 +0700 Subject: [PATCH 34/34] release: v1.3.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7d8e6d0d..0f91caf2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@hitomaru/masterchat", "description": "JavaScript library for YouTube Live Chat", - "version": "1.2.0", + "version": "1.3.0", "author": "Yasuaki Uechi (https://uechi.io/)", "scripts": { "build": "cross-env NODE_ENV=production rollup -c && shx rm -rf lib/lib lib/*.map && patch lib/masterchat.d.ts patches/fix-dts.patch",