Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix members only stream error & update video metadata #15

Open
wants to merge 12 commits into
base: master
Choose a base branch
from
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -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"
7 changes: 4 additions & 3 deletions src/chat/actions/addChatItemAction.ts
Original file line number Diff line number Diff line change
@@ -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);
7 changes: 5 additions & 2 deletions src/chat/actions/showLiveChatActionPanelAction.ts
Original file line number Diff line number Diff line change
@@ -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,
6 changes: 5 additions & 1 deletion src/chat/actions/updateLiveChatPollAction.ts
Original file line number Diff line number Diff line change
@@ -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,
71 changes: 68 additions & 3 deletions src/context/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import * as cheerio from "cheerio";
import {
MembersOnlyError,
NoPermissionError,
NoStreamRecordingError,
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)
@@ -107,7 +112,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 =
@@ -120,12 +125,72 @@ 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);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you parsed the metadata but I don't think you're exposing it anywhere at all?

Copy link
Author

@HitomaruKonpaku HitomaruKonpaku Nov 30, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently we just return simple data (title/channelId/channelName)

But I just think we can return full metadata in case anyone need 🤔

const isLive = !metadata?.publication?.endDate || false;
const isMembersOnly =
primaryInfo.badges?.some?.(
(v) =>
v.metadataBadgeRenderer.style === PurpleStyle.BadgeStyleTypeMembersOnly
) ?? false;

return {
title,
channelId,
channelName,
isLive,
isMembersOnly,
metadata,
};
}

/**
* @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<string, any> = {}
) {
root?.children?.forEach((child: cheerio.Element) => {
const attributes = child?.attribs;
const key = attributes?.itemprop;
if (!key) {
return;
}

if (child.children.length) {
meta[key] = parseVideoMetadataFromElement(child);
return;
}

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;
}
5 changes: 5 additions & 0 deletions src/interfaces/index.ts
Original file line number Diff line number Diff line change
@@ -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;
}
2 changes: 1 addition & 1 deletion src/interfaces/yt/chat.ts
Original file line number Diff line number Diff line change
@@ -501,7 +501,7 @@ export interface YTLiveChatPollRenderer {
liveChatPollId: string;
header: {
pollHeaderRenderer: {
pollQuestion?: YTSimpleTextContainer;
pollQuestion?: Partial<YTSimpleTextContainer & YTRunContainer>;
thumbnail: YTThumbnailList;
metadataText: YTRunContainer<YTTextRun>;
liveChatPollType: YTLiveChatPollType;
1 change: 1 addition & 0 deletions src/interfaces/yt/context.ts
Original file line number Diff line number Diff line change
@@ -748,6 +748,7 @@ export interface OwnerBadgeMetadataBadgeRenderer {

export enum PurpleStyle {
BadgeStyleTypeVerified = "BADGE_STYLE_TYPE_VERIFIED",
BadgeStyleTypeMembersOnly = "BADGE_STYLE_TYPE_MEMBERS_ONLY",
}

export interface MembershipButton {
47 changes: 32 additions & 15 deletions src/masterchat.ts
Original file line number Diff line number Diff line change
@@ -143,14 +143,16 @@ export class Masterchat extends EventEmitter {
public channelId!: string;

public isLive?: boolean;
public isMembersOnly?: boolean;
public channelName?: string;
public title?: string;
public videoMetadata?: Record<string, any>;

private axiosInstance: AxiosInstance;
private listener: ChatListener | null = null;
private listenerAbortion: AbortController = new AbortController();

private credentials?: Credentials;
protected credentials?: Credentials;

/*
* Private API
@@ -201,17 +203,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<T>({
...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,
});

@@ -226,15 +230,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<T>({
...config,
url: input,
signal: this.listenerAbortion.signal,
headers: {
...config.headers,
...(this.credentials && buildAuthHeaders(this.credentials)),
...Constants.DH,
},
headers,
});

return res.data;
@@ -432,7 +438,9 @@ export class Masterchat extends EventEmitter {
this.title = metadata.title;
this.channelId = metadata.channelId;
this.channelName = metadata.channelName;
this.isLive ??= metadata.isLive;
this.isLive = metadata.isLive;
this.isMembersOnly = metadata.isMembersOnly;
this.videoMetadata = metadata.metadata;
}

public async fetchMetadataFromWatch(id: string) {
@@ -465,6 +473,8 @@ export class Masterchat extends EventEmitter {
channelName: this.channelName,
title: this.title,
isLive: this.isLive,
isMembersOnly: this.isMembersOnly,
videoMetadata: this.videoMetadata,
};
}

@@ -852,8 +862,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,
2 changes: 1 addition & 1 deletion src/pool.ts
Original file line number Diff line number Diff line change
@@ -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))
)
);
16 changes: 12 additions & 4 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -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}`;
}