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
Expand Up @@ -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"
Expand Down
7 changes: 4 additions & 3 deletions src/chat/actions/addChatItemAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
7 changes: 5 additions & 2 deletions src/chat/actions/showLiveChatActionPanelAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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,
Expand Down
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(
Expand All @@ -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,
Expand Down
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)
Expand Down Expand Up @@ -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 =
Expand All @@ -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
Expand Up @@ -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
Expand Up @@ -501,7 +501,7 @@ export interface YTLiveChatPollRenderer {
liveChatPollId: string;
header: {
pollHeaderRenderer: {
pollQuestion?: YTSimpleTextContainer;
pollQuestion?: Partial<YTSimpleTextContainer & YTRunContainer>;
thumbnail: YTThumbnailList;
metadataText: YTRunContainer<YTTextRun>;
liveChatPollType: YTLiveChatPollType;
Expand Down
1 change: 1 addition & 0 deletions src/interfaces/yt/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -748,6 +748,7 @@ export interface OwnerBadgeMetadataBadgeRenderer {

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

export interface MembershipButton {
Expand Down
47 changes: 32 additions & 15 deletions src/masterchat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
});

Expand All @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -465,6 +473,8 @@ export class Masterchat extends EventEmitter {
channelName: this.channelName,
title: this.title,
isLive: this.isLive,
isMembersOnly: this.isMembersOnly,
videoMetadata: this.videoMetadata,
};
}

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