From bbc8c9e9be76065e991787bd272bc71acd7a87af Mon Sep 17 00:00:00 2001 From: HitomaruKonpaku Date: Sun, 4 Sep 2022 03:56:40 +0700 Subject: [PATCH 01/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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/50] 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", From 97a8c22b25348b38afda2aaf14d91565980db5ce Mon Sep 17 00:00:00 2001 From: HitomaruKonpaku Date: Sun, 28 Dec 2025 23:14:25 +0700 Subject: [PATCH 35/50] Fix MembersOnly chat --- src/context/index.ts | 6 ++++++ src/interfaces/index.ts | 3 +++ src/masterchat.ts | 27 +++++++++++++++++++++------ 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/src/context/index.ts b/src/context/index.ts index 398eb9e3..8d1dc1fe 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -152,6 +152,11 @@ export function parseMetadataFromWatch(html: string) { v.metadataBadgeRenderer.style === PurpleStyle.BadgeStyleTypeMembersOnly ) || false; + // TODO: 2025-12-28: Force return continuations for init usage since YT change something I have no idea how to fix + const continuations = + initialData?.contents?.twoColumnWatchNextResults?.conversationBar + ?.liveChatRenderer?.continuations || []; + try { const playabilityStatus = findPlayabilityStatus(html); // even if playabilityStatus missing you can still have chat @@ -177,6 +182,7 @@ export function parseMetadataFromWatch(html: string) { isUpcoming, isMembersOnly, metadata, + continuations, }; } diff --git a/src/interfaces/index.ts b/src/interfaces/index.ts index 3ee9176e..df3c12d2 100644 --- a/src/interfaces/index.ts +++ b/src/interfaces/index.ts @@ -39,6 +39,9 @@ export interface Credentials { */ DELEGATED_SESSION_ID?: string; + /** + * Value of `SAPISID` + */ "__Secure-1PAPISID"?: string; "__Secure-1PSID"?: string; "__Secure-1PSIDTS"?: string; diff --git a/src/masterchat.ts b/src/masterchat.ts index a7a088ba..e4bae2f6 100644 --- a/src/masterchat.ts +++ b/src/masterchat.ts @@ -25,6 +25,7 @@ import { VideoObject, YTCommentThreadRenderer, YTContinuationItem, + YTReloadContinuation, } from "./interfaces"; import { Action, @@ -149,6 +150,8 @@ export class Masterchat extends EventEmitter { public title?: string; public videoMetadata?: VideoObject; + private continuations?: YTReloadContinuation[]; + private axiosInstance: AxiosInstance; private listener: ChatListener | null = null; private listenerAbortion: AbortController = new AbortController(); @@ -443,6 +446,7 @@ export class Masterchat extends EventEmitter { this.isUpcoming = metadata.isUpcoming; this.isMembersOnly = metadata.isMembersOnly; this.videoMetadata = metadata.metadata; + this.continuations = metadata.continuations; } public async fetchMetadataFromWatch(id: string) { @@ -677,16 +681,27 @@ export class Masterchat extends EventEmitter { let requestUrl: string = ""; let requestBody; let payload: YTChatResponse; + let self = this; function applyNewLiveStatus(isLive: boolean) { requestUrl = isLive ? Constants.EP_GLC : Constants.EP_GLCR; - const continuation = - typeof tokenOrOptions === "string" - ? tokenOrOptions - : isLive - ? liveReloadContinuation(target, { top: topChat }) - : replayTimedContinuation(target, { top: topChat }); + let continuation: string; + if (typeof tokenOrOptions === "string") { + continuation = tokenOrOptions; + } else if (isLive) { + continuation = liveReloadContinuation(target, { top: topChat }); + } else if (self.isMembersOnly) { + // TODO: 2025-12-28: Temp fix for MembersOnly as I have no idea how to update `replayTimedContinuation` for new continuation as YT changed something + continuation = self.continuations?.[0]?.reloadContinuationData + ?.continuation as string; + } else { + continuation = replayTimedContinuation(target, { top: topChat }); + } + + if (!continuation) { + throw new DisabledChatError("continuation not found"); + } requestBody = withContext({ continuation, From 1fc8b05e50633081af5e5ad4ce0860ddab540d9b Mon Sep 17 00:00:00 2001 From: HitomaruKonpaku Date: Mon, 29 Dec 2025 01:36:03 +0700 Subject: [PATCH 36/50] Update error info --- src/masterchat.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/masterchat.ts b/src/masterchat.ts index e4bae2f6..318e0f88 100644 --- a/src/masterchat.ts +++ b/src/masterchat.ts @@ -149,8 +149,7 @@ export class Masterchat extends EventEmitter { public channelName?: string; public title?: string; public videoMetadata?: VideoObject; - - private continuations?: YTReloadContinuation[]; + public continuations?: YTReloadContinuation[]; private axiosInstance: AxiosInstance; private listener: ChatListener | null = null; @@ -693,16 +692,20 @@ export class Masterchat extends EventEmitter { continuation = liveReloadContinuation(target, { top: topChat }); } else if (self.isMembersOnly) { // TODO: 2025-12-28: Temp fix for MembersOnly as I have no idea how to update `replayTimedContinuation` for new continuation as YT changed something - continuation = self.continuations?.[0]?.reloadContinuationData - ?.continuation as string; + const item = self.continuations?.find( + (v) => v?.reloadContinuationData?.continuation + ); + if (!item) { + throw new MembersOnlyError( + "Continuation not found. Please retry with credentials", + { channelId: self.channelId, meta: self.videoMetadata } + ); + } + continuation = item.reloadContinuationData.continuation; } else { continuation = replayTimedContinuation(target, { top: topChat }); } - if (!continuation) { - throw new DisabledChatError("continuation not found"); - } - requestBody = withContext({ continuation, }); From af71d08a42eedf37df583d959619ffe8d3f266aa Mon Sep 17 00:00:00 2001 From: HitomaruKonpaku Date: Tue, 30 Dec 2025 00:46:30 +0700 Subject: [PATCH 37/50] Rollback & Refactor as YT not allow membersOnly without credentials anymore --- src/context/index.ts | 21 ++++----------------- src/masterchat.ts | 38 +++++++++++++++----------------------- 2 files changed, 19 insertions(+), 40 deletions(-) diff --git a/src/context/index.ts b/src/context/index.ts index 8d1dc1fe..f51cdb60 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -152,22 +152,10 @@ export function parseMetadataFromWatch(html: string) { v.metadataBadgeRenderer.style === PurpleStyle.BadgeStyleTypeMembersOnly ) || false; - // TODO: 2025-12-28: Force return continuations for init usage since YT change something I have no idea how to fix - const continuations = - initialData?.contents?.twoColumnWatchNextResults?.conversationBar - ?.liveChatRenderer?.continuations || []; - - 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; - } + const playabilityStatus = findPlayabilityStatus(html); + // even if playabilityStatus missing you can still have chat + if (playabilityStatus) { + assertPlayability(playabilityStatus, { channelId, meta: metadata }); } if (!channelId) { @@ -182,7 +170,6 @@ export function parseMetadataFromWatch(html: string) { isUpcoming, isMembersOnly, metadata, - continuations, }; } diff --git a/src/masterchat.ts b/src/masterchat.ts index 318e0f88..0afaaeb6 100644 --- a/src/masterchat.ts +++ b/src/masterchat.ts @@ -25,7 +25,6 @@ import { VideoObject, YTCommentThreadRenderer, YTContinuationItem, - YTReloadContinuation, } from "./interfaces"; import { Action, @@ -53,6 +52,7 @@ import { liveReloadContinuation, pinParams, removeMessageParams, + replayReloadContinuation, replayTimedContinuation, sendMessageParams, timeoutParams, @@ -149,7 +149,6 @@ export class Masterchat extends EventEmitter { public channelName?: string; public title?: string; public videoMetadata?: VideoObject; - public continuations?: YTReloadContinuation[]; private axiosInstance: AxiosInstance; private listener: ChatListener | null = null; @@ -445,7 +444,6 @@ export class Masterchat extends EventEmitter { this.isUpcoming = metadata.isUpcoming; this.isMembersOnly = metadata.isMembersOnly; this.videoMetadata = metadata.metadata; - this.continuations = metadata.continuations; } public async fetchMetadataFromWatch(id: string) { @@ -682,29 +680,23 @@ export class Masterchat extends EventEmitter { let payload: YTChatResponse; let self = this; + function getContinuationValue(isLive: boolean): string { + if (typeof tokenOrOptions === "string") { + return tokenOrOptions; + } + if (isLive) { + return liveReloadContinuation(target, { top: topChat }); + } + if (self.isMembersOnly) { + return replayReloadContinuation(target, { top: topChat }); + } + return replayTimedContinuation(target, { top: topChat }); + } + function applyNewLiveStatus(isLive: boolean) { requestUrl = isLive ? Constants.EP_GLC : Constants.EP_GLCR; - let continuation: string; - if (typeof tokenOrOptions === "string") { - continuation = tokenOrOptions; - } else if (isLive) { - continuation = liveReloadContinuation(target, { top: topChat }); - } else if (self.isMembersOnly) { - // TODO: 2025-12-28: Temp fix for MembersOnly as I have no idea how to update `replayTimedContinuation` for new continuation as YT changed something - const item = self.continuations?.find( - (v) => v?.reloadContinuationData?.continuation - ); - if (!item) { - throw new MembersOnlyError( - "Continuation not found. Please retry with credentials", - { channelId: self.channelId, meta: self.videoMetadata } - ); - } - continuation = item.reloadContinuationData.continuation; - } else { - continuation = replayTimedContinuation(target, { top: topChat }); - } + const continuation = getContinuationValue(isLive); requestBody = withContext({ continuation, From 1ef481160e5fe3993a1844453c70ff949271e8ed Mon Sep 17 00:00:00 2001 From: HitomaruKonpaku Date: Fri, 16 Jan 2026 21:12:17 +0700 Subject: [PATCH 38/50] Add more detail to addOutgoingRaidBannerAction & addIncomingRaidBannerAction --- src/chat/actions/addBannerToLiveChatCommand.ts | 4 ++++ src/interfaces/actions.ts | 6 ++++++ src/interfaces/yt/chat.ts | 12 ++++++++---- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/chat/actions/addBannerToLiveChatCommand.ts b/src/chat/actions/addBannerToLiveChatCommand.ts index 86d4a537..43a0e7e8 100644 --- a/src/chat/actions/addBannerToLiveChatCommand.ts +++ b/src/chat/actions/addBannerToLiveChatCommand.ts @@ -86,9 +86,11 @@ export function parseAddBannerToLiveChatCommand( type: "addOutgoingRaidBannerAction", actionId, targetId, + bannerType, targetName, targetPhoto: photo, targetVideoId, + bannerMessage: rdr.bannerMessage, }; return payload; } else { @@ -98,8 +100,10 @@ export function parseAddBannerToLiveChatCommand( type: "addIncomingRaidBannerAction", actionId, targetId, + bannerType, sourceName, sourcePhoto: photo, + bannerMessage: rdr.bannerMessage, }; return payload; } diff --git a/src/interfaces/actions.ts b/src/interfaces/actions.ts index 8ef12765..48d5185f 100644 --- a/src/interfaces/actions.ts +++ b/src/interfaces/actions.ts @@ -7,6 +7,8 @@ import { YTLiveChatPollType, YTLiveChatPollChoice, YTRun, + YTTextRun, + YTRunContainer, } from "./yt/chat"; /** @@ -262,17 +264,21 @@ export interface AddIncomingRaidBannerAction { type: "addIncomingRaidBannerAction"; actionId: string; targetId: string; + bannerType: string; sourceName: string; sourcePhoto: string; + bannerMessage: YTRunContainer; } export interface AddOutgoingRaidBannerAction { type: "addOutgoingRaidBannerAction"; actionId: string; targetId: string; + bannerType: string; targetName: string; targetPhoto: string; targetVideoId: string; + bannerMessage: YTRunContainer; } export interface AddProductBannerAction { diff --git a/src/interfaces/yt/chat.ts b/src/interfaces/yt/chat.ts index 87b95f51..6da9a351 100644 --- a/src/interfaces/yt/chat.ts +++ b/src/interfaces/yt/chat.ts @@ -515,7 +515,7 @@ export interface YTLiveChatBannerRenderer { header?: YTLiveChatBannerRendererHeader; isStackable?: boolean; backgroundType?: "LIVE_CHAT_BANNER_BACKGROUND_TYPE_STATIC" | string; - bannerType?: YTLiveChatBannerType | string; + bannerType: YTLiveChatBannerType | string; onCollapseCommand?: YTElementsCommandContainer; onExpandCommand?: YTElementsCommandContainer; } @@ -523,6 +523,7 @@ export interface YTLiveChatBannerRenderer { export enum YTLiveChatBannerType { ChatSummary = "LIVE_CHAT_BANNER_TYPE_CHAT_SUMMARY", CallForQuestions = "LIVE_CHAT_BANNER_TYPE_QNA_START", + CrossChannelRedirect = "LIVE_CHAT_BANNER_TYPE_CROSS_CHANNEL_REDIRECT", } export interface YTElementsCommandContainer { @@ -1040,12 +1041,15 @@ export interface YTContextMenuButtonRendererContainer< Command = YTLiveChatItemContextMenuEndpointContainer > { buttonRenderer: { - icon: YTIcon; + icon?: YTIcon; style?: string; + size?: string; + isDisabled?: boolean; + text?: YTRunContainer; + trackingParams: string; command: Command; accessibility?: YTAccessibilityLabel; - accessibilityData: YTAccessibilityData; - trackingParams: string; + accessibilityData?: YTAccessibilityData; }; } From 6054388a8d2997a60dd0e49934a59d46dd04c52d Mon Sep 17 00:00:00 2001 From: HitomaruKonpaku Date: Fri, 30 Jan 2026 05:05:07 +0700 Subject: [PATCH 39/50] Ignore BotError --- src/context/index.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/context/index.ts b/src/context/index.ts index f51cdb60..0d437756 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -155,7 +155,14 @@ export function parseMetadataFromWatch(html: string) { const playabilityStatus = findPlayabilityStatus(html); // even if playabilityStatus missing you can still have chat if (playabilityStatus) { - assertPlayability(playabilityStatus, { channelId, meta: metadata }); + try { + assertPlayability(playabilityStatus, { channelId, meta: metadata }); + } catch (error) { + // Even when BotError chat still able to load + if (!(error instanceof BotError)) { + throw error; + } + } } if (!channelId) { From 44fcc04405ab9af0cf07cdc638e8c07f2643a4f3 Mon Sep 17 00:00:00 2001 From: HitomaruKonpaku Date: Thu, 16 Apr 2026 04:12:10 +0700 Subject: [PATCH 40/50] Add emojis --- src/interfaces/yt/chat.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/interfaces/yt/chat.ts b/src/interfaces/yt/chat.ts index 6da9a351..0a69a0fa 100644 --- a/src/interfaces/yt/chat.ts +++ b/src/interfaces/yt/chat.ts @@ -150,6 +150,7 @@ export interface YTLiveChatContinuation { trackingParams?: string; participantsList?: YTParticipantsList; popoutMessage?: YTPopoutMessage; + emojis?: YTEmoji[]; clientMessages?: YTClientMessages; } From 794f4b2fe33c88f9354980fd500bfd1f9d1dfafc Mon Sep 17 00:00:00 2001 From: HitomaruKonpaku Date: Thu, 16 Apr 2026 04:12:30 +0700 Subject: [PATCH 41/50] Emit payload for more control --- src/masterchat.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/masterchat.ts b/src/masterchat.ts index 0afaaeb6..e2f334b4 100644 --- a/src/masterchat.ts +++ b/src/masterchat.ts @@ -94,6 +94,7 @@ export interface FetchChatOptions { export type ChatListener = Promise; export interface Events { + payload: (data: YTChatResponse, mc: Masterchat) => void; data: (data: ChatResponse, mc: Masterchat) => void; actions: (actions: Action[], mc: Masterchat) => void; chats: (chats: AddChatItemAction[], mc: Masterchat) => void; @@ -708,6 +709,7 @@ export class Masterchat extends EventEmitter { loop: while (true) { try { payload = await this.post(requestUrl, requestBody); + this.emit("payload", payload, this); } catch (err) { // handle user cancallation if ((err as any)?.message === "canceled") { From 44fd4ce10944e09340f676cbb250043aa2551c11 Mon Sep 17 00:00:00 2001 From: HitomaruKonpaku Date: Sun, 26 Apr 2026 04:52:48 +0700 Subject: [PATCH 42/50] Allow custom cookies --- src/auth.ts | 25 +++++++++++++++++++------ src/masterchat.ts | 35 +++++++++++++++++++++++++++++------ 2 files changed, 48 insertions(+), 12 deletions(-) diff --git a/src/auth.ts b/src/auth.ts index 899b158e..a84f923d 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -2,10 +2,21 @@ import sha1 from "sha1"; import { DO, SASH, XGAU, XGPID, XO } from "./constants"; import { Credentials } from "./interfaces"; -export function buildAuthHeaders(creds: Credentials): Record { +export function buildBaseHeaders( + cookies?: Record +): Record { + return { + Cookie: genCookieString(cookies), + }; +} + +export function buildAuthHeaders( + creds: Credentials, + cookies?: Record +): Record { const dsid = creds.DELEGATED_SESSION_ID ?? creds.SESSION_ID; return { - Cookie: genCookieString(creds), + Cookie: genCookieString({ ...creds, ...cookies }), Authorization: genAuthToken(creds.SAPISID, DO), [XO]: DO, [XGAU]: "0", @@ -13,10 +24,12 @@ export function buildAuthHeaders(creds: Credentials): Record { }; } -function genCookieString(creds: Credentials) { - return Object.entries(creds) - .map(([key, value]) => `${key}=${value};`) - .join(" "); +function genCookieString(cookies?: Record) { + return ( + Object.entries(cookies || {}) + .map(([key, value]) => `${key}=${value};`) + .join(" ") || "" + ); } function genAuthToken(sid: string, origin: string): string { diff --git a/src/masterchat.ts b/src/masterchat.ts index e2f334b4..ce90dca7 100644 --- a/src/masterchat.ts +++ b/src/masterchat.ts @@ -1,7 +1,7 @@ import axios, { AxiosError, AxiosInstance, AxiosRequestConfig } from "axios"; import { EventEmitter } from "events"; import { buildMeta } from "./api"; -import { buildAuthHeaders } from "./auth"; +import { buildAuthHeaders, buildBaseHeaders } from "./auth"; import { parseAction } from "./chat"; import { parseMarkChatItemAsDeletedAction } from "./chat/actions/markChatItemAsDeletedAction"; import { pickThumbUrl } from "./chat/utils"; @@ -119,6 +119,8 @@ export interface MasterchatOptions { /** you can grab Credentials using `extra/credential-fetcher` */ credentials?: Credentials | string; + cookies?: Record | string; + /** set live chat mode * * ``` @@ -156,6 +158,7 @@ export class Masterchat extends EventEmitter { private listenerAbortion: AbortController = new AbortController(); protected credentials?: Credentials; + protected cookies?: Record; /* * Private API @@ -209,7 +212,9 @@ export class Masterchat extends EventEmitter { const headers = { "Content-Type": "application/json", ...Constants.DH, - ...(this.credentials && buildAuthHeaders(this.credentials)), + ...(this.credentials + ? buildAuthHeaders(this.credentials, this.cookies) + : buildBaseHeaders(this.cookies)), ...config.headers, }; @@ -235,7 +240,9 @@ export class Masterchat extends EventEmitter { const headers = { ...Constants.DH, - ...(this.credentials && buildAuthHeaders(this.credentials)), + ...(this.credentials + ? buildAuthHeaders(this.credentials, this.cookies) + : buildBaseHeaders(this.cookies)), ...config.headers, }; @@ -415,7 +422,7 @@ export class Masterchat extends EventEmitter { constructor( videoId: string, channelId: string, - { mode, credentials, axiosInstance }: MasterchatOptions = {} + { mode, credentials, cookies, axiosInstance }: MasterchatOptions = {} ) { super(); this.videoId = videoId; @@ -430,6 +437,7 @@ export class Masterchat extends EventEmitter { }); this.setCredentials(credentials); + this.setCookies(cookies); } /** @@ -453,8 +461,10 @@ export class Masterchat extends EventEmitter { return parseMetadataFromWatch(html); } catch (err) { // Check ban status - if ((err as AxiosError).code === "429") { - throw new AccessDeniedError("Rate limit exceeded: " + this.videoId); + if (err instanceof AxiosError) { + if (err.response?.status === 429 || err.code === "429") { + throw new AccessDeniedError("Rate limit exceeded: " + this.videoId); + } } throw err; } @@ -496,6 +506,19 @@ export class Masterchat extends EventEmitter { this.credentials = credentials; } + public setCookies(cookies?: Record | string): void { + if (typeof cookies === "string") { + this.cookies = cookies.split(/;\s/g).reduce((obj, str) => { + const [key, value] = str.trim().split(/=/); + Object.assign(obj, { [key]: value }); + return obj; + }, {}); + return; + } + + this.cookies = cookies; + } + /** * (EventEmitter API) * start listening live stream From 49ef0fb3a5771635e08922adeba2839658d7f720 Mon Sep 17 00:00:00 2001 From: HitomaruKonpaku Date: Sun, 26 Apr 2026 04:55:34 +0700 Subject: [PATCH 43/50] Update doc --- src/masterchat.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/masterchat.ts b/src/masterchat.ts index ce90dca7..f389771d 100644 --- a/src/masterchat.ts +++ b/src/masterchat.ts @@ -116,9 +116,14 @@ export interface Masterchat { } export interface MasterchatOptions { - /** you can grab Credentials using `extra/credential-fetcher` */ + /** + * you can grab Credentials using `extra/credential-fetcher` + */ credentials?: Credentials | string; + /** + * some common cookies like `VISITOR_INFO1_LIVE` + */ cookies?: Record | string; /** set live chat mode From 23d2638ccf5a44ce0977857e2a313e269d14fde2 Mon Sep 17 00:00:00 2001 From: HitomaruKonpaku Date: Tue, 28 Apr 2026 04:57:19 +0700 Subject: [PATCH 44/50] Fix channel parser for `Collaborators` case --- src/context/index.ts | 60 ++++++++++++++++++++++++------ src/interfaces/yt/context.ts | 71 +++++++++++++++++++++++++++++++++++- 2 files changed, 119 insertions(+), 12 deletions(-) diff --git a/src/context/index.ts b/src/context/index.ts index 0d437756..d2d208af 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -9,6 +9,9 @@ import { import { runsToString } from "../utils"; import { PurpleStyle, + VideoOwnerRenderer, + VideoPrimaryInfoRenderer, + VideoSecondaryInfoRenderer, YTInitialData, YTPlayabilityStatus, } from "../interfaces/yt/context"; @@ -76,12 +79,49 @@ export function findEPR(data: string) { return findCfg(data)?.PLAYER_VARS?.embedded_player_response; } +export function findChannelId(data?: VideoOwnerRenderer) { + const navigationEndpoint = data?.navigationEndpoint; + const browseEndpoint = + navigationEndpoint?.showDialogCommand?.panelLoadingStrategy?.inlineContent + ?.dialogViewModel?.customContent?.listViewModel?.listItems?.[0] + ?.listItemViewModel?.title?.commandRuns?.[0]?.onTap?.innertubeCommand + ?.browseEndpoint || navigationEndpoint?.browseEndpoint; + const value = browseEndpoint?.browseId; + return value; +} + +export function findChannelName(data?: VideoOwnerRenderer) { + const navigationEndpoint = data?.navigationEndpoint; + const value = + navigationEndpoint?.showDialogCommand?.panelLoadingStrategy?.inlineContent + ?.dialogViewModel?.customContent?.listViewModel?.listItems?.[0] + ?.listItemViewModel?.title?.content || + runsToString(data?.title?.runs || []); + return value; +} + export function findPlayabilityStatus( data: string ): YTPlayabilityStatus | undefined { const ipr = findIPR(data); return (ipr as any)?.playabilityStatus; } + +export function parseIsUpcoming(data?: VideoPrimaryInfoRenderer): boolean { + const text = data?.dateText?.simpleText || ""; + const value = text.includes("Scheduled for") || text.includes("Premieres"); + return value; +} + +export function parseIsMembersOnly(data?: VideoPrimaryInfoRenderer): boolean { + const badges = data?.badges || []; + const value = badges.some( + (v) => + v.metadataBadgeRenderer.style === PurpleStyle.BadgeStyleTypeMembersOnly + ); + return value; +} + // embed disabled https://www.youtube.com/embed/JfJYHfrOGgQ // unavailable video https://www.youtube.com/embed/YEAINgb2xfo // private video https://www.youtube.com/embed/UUjdYGda4N4 @@ -136,21 +176,15 @@ export function parseMetadataFromWatch(html: string) { const secondaryInfo = results.contents?.find( (v) => v.videoSecondaryInfoRenderer )?.videoSecondaryInfoRenderer; + const videoOwner = secondaryInfo?.owner?.videoOwnerRenderer; - const badges = primaryInfo?.badges || []; - const channelId = videoOwner?.navigationEndpoint?.browseEndpoint?.browseId; - const channelName = - runsToString(videoOwner?.title?.runs || []) || metadata.author?.name; + const channelId = findChannelId(videoOwner); + const channelName = findChannelName(videoOwner) || 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; + const isUpcoming = parseIsUpcoming(primaryInfo); + const isMembersOnly = parseIsMembersOnly(primaryInfo); const playabilityStatus = findPlayabilityStatus(html); // even if playabilityStatus missing you can still have chat @@ -180,6 +214,8 @@ export function parseMetadataFromWatch(html: string) { }; } +//#region metadata + /** * @see http://schema.org/VideoObject */ @@ -237,3 +273,5 @@ function parseVideoMetaValueByKey(key: string, value: string) { } return value; } + +//#endregion diff --git a/src/interfaces/yt/context.ts b/src/interfaces/yt/context.ts index 06bf4a2a..7a4bf296 100644 --- a/src/interfaces/yt/context.ts +++ b/src/interfaces/yt/context.ts @@ -163,6 +163,7 @@ export interface YTPurpleWebCommandMetadata { url: string; webPageType: YTWebPageType; rootVe: number; + apiUrl: string; } export interface YTAutoplayVideoWatchEndpoint { @@ -811,7 +812,8 @@ export interface IndecentNavigationEndpoint { export interface VideoOwnerRendererNavigationEndpoint { clickTrackingParams: string; commandMetadata: YTAutoplayVideoCommandMetadata; - browseEndpoint: FluffyBrowseEndpoint; + browseEndpoint?: FluffyBrowseEndpoint; + showDialogCommand?: ShowDialogCommand; } export interface FluffyBrowseEndpoint { @@ -819,6 +821,73 @@ export interface FluffyBrowseEndpoint { canonicalBaseUrl: string; } +export interface ShowDialogCommand { + panelLoadingStrategy: PanelLoadingStrategy; +} + +export interface PanelLoadingStrategy { + inlineContent: InlineContent; + screenVe: number; +} + +export interface InlineContent { + dialogViewModel: DialogViewModel; +} + +export interface DialogViewModel { + header: Header; + customContent: CustomContent; +} + +export interface Header { + dialogHeaderViewModel: DialogHeaderViewModel; +} + +export interface DialogHeaderViewModel { + headline: Headline; +} + +export interface Headline { + content: string; +} + +export interface CustomContent { + listViewModel: ListViewModel; +} + +export interface ListViewModel { + listItems: ListItem[]; +} + +export interface ListItem { + listItemViewModel: ListItemViewModel; +} + +export interface ListItemViewModel { + title: ListItemViewModelTitle; +} + +export interface ListItemViewModelTitle { + content: string; + commandRuns: CommandRun[]; +} + +export interface CommandRun { + startIndex: number; + length: number; + onTap: OnTap; +} + +export interface OnTap { + innertubeCommand?: InnertubeCommand; +} + +export interface InnertubeCommand { + clickTrackingParams: string; + webCommandMetadata: YTPurpleWebCommandMetadata; + browseEndpoint: FluffyBrowseEndpoint; +} + export interface Byline { runs: BylineRun[]; } From 873d50b495bbe0b8d9c95c08b105079eeff5e568 Mon Sep 17 00:00:00 2001 From: HitomaruKonpaku Date: Tue, 28 Apr 2026 22:21:27 +0700 Subject: [PATCH 45/50] Update by pass YT check --- src/context/index.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/context/index.ts b/src/context/index.ts index d2d208af..c9776b98 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -192,8 +192,11 @@ export function parseMetadataFromWatch(html: string) { try { assertPlayability(playabilityStatus, { channelId, meta: metadata }); } catch (error) { - // Even when BotError chat still able to load - if (!(error instanceof BotError)) { + const byPass = + false || + error instanceof BotError || + (error instanceof NoStreamRecordingError && (isLive || isUpcoming)); + if (!byPass) { throw error; } } From 89cfd1806863954a4a313ad350119eb0b7402e74 Mon Sep 17 00:00:00 2001 From: HitomaruKonpaku Date: Tue, 28 Apr 2026 22:55:43 +0700 Subject: [PATCH 46/50] Update bypass condition --- src/context/index.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/context/index.ts b/src/context/index.ts index c9776b98..0eba10a2 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -193,9 +193,8 @@ export function parseMetadataFromWatch(html: string) { assertPlayability(playabilityStatus, { channelId, meta: metadata }); } catch (error) { const byPass = - false || error instanceof BotError || - (error instanceof NoStreamRecordingError && (isLive || isUpcoming)); + (error instanceof NoStreamRecordingError && channelId); if (!byPass) { throw error; } From 617ca5c257cd33baedfcca07cbb5f28d3f640fdf Mon Sep 17 00:00:00 2001 From: HitomaruKonpaku Date: Thu, 30 Apr 2026 17:18:28 +0700 Subject: [PATCH 47/50] Send more data on error --- src/context/index.ts | 30 ++++++++++++++++-------------- src/masterchat.ts | 4 +++- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/src/context/index.ts b/src/context/index.ts index 0eba10a2..e69811ba 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -186,11 +186,25 @@ export function parseMetadataFromWatch(html: string) { const isUpcoming = parseIsUpcoming(primaryInfo); const isMembersOnly = parseIsMembersOnly(primaryInfo); + if (!channelId) { + throw new Error("CHANNEL_ID_NOT_FOUND"); + } + + const data = { + title, + channelId, + channelName, + isLive, + isUpcoming, + isMembersOnly, + metadata, + }; + const playabilityStatus = findPlayabilityStatus(html); // even if playabilityStatus missing you can still have chat if (playabilityStatus) { try { - assertPlayability(playabilityStatus, { channelId, meta: metadata }); + assertPlayability(playabilityStatus, { channelId, data }); } catch (error) { const byPass = error instanceof BotError || @@ -201,19 +215,7 @@ export function parseMetadataFromWatch(html: string) { } } - if (!channelId) { - throw new Error("CHANNEL_ID_NOT_FOUND"); - } - - return { - title, - channelId, - channelName, - isLive, - isUpcoming, - isMembersOnly, - metadata, - }; + return data; } //#region metadata diff --git a/src/masterchat.ts b/src/masterchat.ts index f389771d..92805fb0 100644 --- a/src/masterchat.ts +++ b/src/masterchat.ts @@ -569,7 +569,9 @@ export class Masterchat extends EventEmitter { this.emit("end", null); }) .catch((err) => { - if (err instanceof AbortError) return; + if (err instanceof AbortError) { + return; + } // special treatment for unrecoverable unavailable/private errors // emit 'end' only if -> From 40bcf401cc8396e9ce460280a54a62dc416a1380 Mon Sep 17 00:00:00 2001 From: HitomaruKonpaku Date: Thu, 30 Apr 2026 17:27:35 +0700 Subject: [PATCH 48/50] Update error data --- src/errors.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/errors.ts b/src/errors.ts index 61736d29..9b5f2bf4 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -19,7 +19,15 @@ export type ErrorCode = export interface MembersOnlyErrorData { channelId?: string; - meta?: VideoObject; + data?: { + title?: string; + channelId?: string; + channelName?: string; + isLive?: boolean; + isUpcoming?: boolean; + isMembersOnly?: boolean; + metadata?: VideoObject; + }; } export class MasterchatError extends Error { From 8bdd7cd7f621fbfac41adedac423c17e2fade99b Mon Sep 17 00:00:00 2001 From: HitomaruKonpaku Date: Mon, 18 May 2026 11:32:00 +0700 Subject: [PATCH 49/50] Allow to provide custom clientVersion --- src/constants.ts | 6 ++- src/masterchat.ts | 97 +++++++++++++++++++++++++---------------------- src/utils.ts | 9 ++++- 3 files changed, 64 insertions(+), 48 deletions(-) diff --git a/src/constants.ts b/src/constants.ts index d53175d1..6edd7f36 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -9,7 +9,11 @@ export const DH = { "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.93 Safari/537.36", }; -export const DC = { clientName: "WEB", clientVersion: "2.20211014.05.00" }; + +export const DC = { + clientName: "WEB", + clientVersion: "2.20211014.05.00", +}; export const DO = "https://www.youtube.com"; export const DAK = h`41497a615379414f5f464a32536c7155385134535445484c4743696c775f59395f313171635738`; diff --git a/src/masterchat.ts b/src/masterchat.ts index 92805fb0..438f142b 100644 --- a/src/masterchat.ts +++ b/src/masterchat.ts @@ -116,6 +116,8 @@ export interface Masterchat { } export interface MasterchatOptions { + clientVersion?: string; + /** * you can grab Credentials using `extra/credential-fetcher` */ @@ -158,6 +160,7 @@ export class Masterchat extends EventEmitter { public title?: string; public videoMetadata?: VideoObject; + private clientVersion?: string; private axiosInstance: AxiosInstance; private listener: ChatListener | null = null; private listenerAbortion: AbortController = new AbortController(); @@ -284,7 +287,7 @@ export class Masterchat extends EventEmitter { const endpoint = Constants.EP_GICM + "&" + query.toString(); const response = await this.postWithRetry( endpoint, - withContext(), + withContext({}, { clientVersion: this.clientVersion }), { retry: 2, } @@ -385,9 +388,10 @@ export class Masterchat extends EventEmitter { let json: YTActionResponse; if (actionInfo.isPost) { json = await this.post(url, { - body: withContext({ - params: actionInfo.params, - }), + body: withContext( + { params: actionInfo.params }, + { clientVersion: this.clientVersion } + ), }); } else { json = await this.get(url); @@ -403,7 +407,14 @@ export class Masterchat extends EventEmitter { */ /** - * Useful when you don't know channelId or isLive status + * Useful when you don't know `channelId` or `isLive` status + * + * If you already have `channelId` + * try to reduce this call + * as it will throw `AccessDeniedError` + * as you increase amount of `Masterchat` instance + * + * Directly use `new Masterchat(videoId, channelId)` instead */ public static async init( videoIdOrUrl: string, @@ -427,7 +438,13 @@ export class Masterchat extends EventEmitter { constructor( videoId: string, channelId: string, - { mode, credentials, cookies, axiosInstance }: MasterchatOptions = {} + { + mode, + credentials, + cookies, + clientVersion, + axiosInstance, + }: MasterchatOptions = {} ) { super(); this.videoId = videoId; @@ -435,6 +452,7 @@ export class Masterchat extends EventEmitter { this.isLive = mode === "live" ? true : mode === "replay" ? false : undefined; + this.clientVersion = clientVersion; this.axiosInstance = axiosInstance ?? axios.create({ @@ -729,9 +747,10 @@ export class Masterchat extends EventEmitter { const continuation = getContinuationValue(isLive); - requestBody = withContext({ - continuation, - }); + requestBody = withContext( + { continuation }, + { clientVersion: self.clientVersion } + ); } applyNewLiveStatus(this.isLive ?? true); @@ -940,16 +959,19 @@ export class Masterchat extends EventEmitter { ): Promise { const params = sendMessageParams(this.cvPair()); - const body = withContext({ - richMessage: { - textSegments: [ - { - text: message, - }, - ], + const body = withContext( + { + richMessage: { + textSegments: [ + { + text: message, + }, + ], + }, + params, }, - params, - }); + { clientVersion: this.clientVersion } + ); const res = await this.postWithRetry( Constants.EP_SM, @@ -979,9 +1001,7 @@ export class Masterchat extends EventEmitter { const params = pinParams(chatId, this.cvPair()); const res = await this.post( Constants.EP_LCA, - withContext({ - params, - }) + withContext({ params }, { clientVersion: this.clientVersion }) ); if (!res.success) { throw new Error(`Failed to pin chat: ` + JSON.stringify(res)); @@ -994,9 +1014,7 @@ export class Masterchat extends EventEmitter { const res = await this.post( Constants.EP_LCA, - withContext({ - params, - }) + withContext({ params }, { clientVersion: this.clientVersion }) ); if (!res.success) { @@ -1015,9 +1033,7 @@ export class Masterchat extends EventEmitter { const res = await this.post( Constants.EP_MOD, - withContext({ - params, - }) + withContext({ params }, { clientVersion: this.clientVersion }) ); if (!res.success) { @@ -1051,9 +1067,7 @@ export class Masterchat extends EventEmitter { const res = await this.post( Constants.EP_MOD, - withContext({ - params, - }) + withContext({ params }, { clientVersion: this.clientVersion }) ); if (!res.success) { @@ -1069,9 +1083,7 @@ export class Masterchat extends EventEmitter { const res = await this.post( Constants.EP_MOD, - withContext({ - params, - }) + withContext({ params }, { clientVersion: this.clientVersion }) ); if (!res.success) { @@ -1086,9 +1098,7 @@ export class Masterchat extends EventEmitter { const res = await this.post( Constants.EP_MOD, - withContext({ - params, - }) + withContext({ params }, { clientVersion: this.clientVersion }) ); if (!res.success) { @@ -1120,9 +1130,7 @@ export class Masterchat extends EventEmitter { const params = addModeratorParams(channelId, this.cvPair()); const res = await this.post( Constants.EP_MU, - withContext({ - params, - }) + withContext({ params }, { clientVersion: this.clientVersion }) ); if (!res.success) { throw new Error(`Failed to perform action: ` + JSON.stringify(res)); @@ -1134,9 +1142,7 @@ export class Masterchat extends EventEmitter { const params = addModeratorParams(channelId, this.cvPair(), true); const res = await this.post( Constants.EP_MU, - withContext({ - params, - }) + withContext({ params }, { clientVersion: this.clientVersion }) ); if (!res.success) { throw new Error(`Failed to perform action: ` + JSON.stringify(res)); @@ -1164,9 +1170,10 @@ export class Masterchat extends EventEmitter { continuation = csc(this.videoId, continuation); } - const body = withContext({ - continuation, - }); + const body = withContext( + { continuation }, + { clientVersion: this.clientVersion } + ); const payload = await this.post(Constants.EP_NXT, body); diff --git a/src/utils.ts b/src/utils.ts index dcc6d12f..9f51e26e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -273,12 +273,17 @@ export function groupBy>( }, {} as { [k in S]: (T extends { [s in K]: k } ? T : never)[] }); } -export function withContext(input: any = {}) { +export function withContext( + input: Record = {}, + config?: { clientVersion?: string } +) { + const client = DC; + client.clientVersion = config?.clientVersion || client.clientVersion; return { ...input, context: { ...input?.context, - client: DC, + client, }, }; } From d10f430be7dbbbdf35127436cd974aa7840e446c Mon Sep 17 00:00:00 2001 From: HitomaruKonpaku Date: Mon, 18 May 2026 12:11:23 +0700 Subject: [PATCH 50/50] Change access --- src/masterchat.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/masterchat.ts b/src/masterchat.ts index 438f142b..38805b34 100644 --- a/src/masterchat.ts +++ b/src/masterchat.ts @@ -160,11 +160,11 @@ export class Masterchat extends EventEmitter { public title?: string; public videoMetadata?: VideoObject; - private clientVersion?: string; private axiosInstance: AxiosInstance; private listener: ChatListener | null = null; private listenerAbortion: AbortController = new AbortController(); + protected clientVersion?: string; protected credentials?: Credentials; protected cookies?: Record;