mirror of
https://github.com/LuanRT/YouTube.js.git
synced 2026-06-26 08:08:54 +00:00
feat(Channel): Support new about popup (#537)
* feat(Channel): Support new about popup * chore: Minor cleanup * fix(concatMemos): Merge duplicate nodes instead of overwriting * fix(Feed): `has_continuation` and `getContinuation()` avoid header continuations * chore(Channel): Remove unused import --------- Co-authored-by: LuanRT <luan.lrt4@gmail.com>
This commit is contained in:
@@ -177,7 +177,7 @@ export default class Feed<T extends IParsedResponse = IParsedResponse> {
|
||||
* Checks if the feed has continuation.
|
||||
*/
|
||||
get has_continuation(): boolean {
|
||||
return (this.#memo.get('ContinuationItem') || []).length > 0;
|
||||
return this.#getBodyContinuations().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -193,7 +193,7 @@ export default class Feed<T extends IParsedResponse = IParsedResponse> {
|
||||
return response;
|
||||
}
|
||||
|
||||
this.#continuation = this.#memo.getType(ContinuationItem);
|
||||
this.#continuation = this.#getBodyContinuations();
|
||||
|
||||
if (this.#continuation)
|
||||
return this.getContinuationData();
|
||||
@@ -208,4 +208,14 @@ export default class Feed<T extends IParsedResponse = IParsedResponse> {
|
||||
throw new InnertubeError('Could not get continuation data');
|
||||
return new Feed<T>(this.actions, continuation_data, true);
|
||||
}
|
||||
|
||||
#getBodyContinuations(): ObservedArray<ContinuationItem> {
|
||||
if (this.#page.header_memo) {
|
||||
const header_continuations = this.#page.header_memo.getType(ContinuationItem);
|
||||
|
||||
return this.#memo.getType(ContinuationItem).filter((continuation) => !header_continuations.includes(continuation)) as ObservedArray<ContinuationItem>;
|
||||
}
|
||||
|
||||
return this.#memo.getType(ContinuationItem);
|
||||
}
|
||||
}
|
||||
18
src/parser/classes/AboutChannel.ts
Normal file
18
src/parser/classes/AboutChannel.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { YTNode } from '../helpers.js';
|
||||
import { Parser, type RawNode } from '../index.js';
|
||||
import AboutChannelView from './AboutChannelView.js';
|
||||
import Button from './Button.js';
|
||||
|
||||
export default class AboutChannel extends YTNode {
|
||||
static type = 'AboutChannel';
|
||||
|
||||
metadata: AboutChannelView | null;
|
||||
share_channel: Button | null;
|
||||
|
||||
constructor(data: RawNode) {
|
||||
super();
|
||||
|
||||
this.metadata = Parser.parseItem(data.metadata, AboutChannelView);
|
||||
this.share_channel = Parser.parseItem(data.shareChannel, Button);
|
||||
}
|
||||
}
|
||||
87
src/parser/classes/AboutChannelView.ts
Normal file
87
src/parser/classes/AboutChannelView.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { ObservedArray } from '../helpers.js';
|
||||
import { YTNode } from '../helpers.js';
|
||||
import { Parser, type RawNode } from '../index.js';
|
||||
import ChannelExternalLinkView from './ChannelExternalLinkView.js';
|
||||
import NavigationEndpoint from './NavigationEndpoint.js';
|
||||
import Text from './misc/Text.js';
|
||||
|
||||
export default class AboutChannelView extends YTNode {
|
||||
static type = 'AboutChannelView';
|
||||
|
||||
description?: string;
|
||||
description_label?: Text;
|
||||
country?: string;
|
||||
custom_links_label?: Text;
|
||||
subscriber_count?: string;
|
||||
view_count?: string;
|
||||
joined_date?: Text;
|
||||
canonical_channel_url?: string;
|
||||
channel_id?: string;
|
||||
additional_info_label?: Text;
|
||||
custom_url_on_tap?: NavigationEndpoint;
|
||||
video_count?: string;
|
||||
sign_in_for_business_email?: Text;
|
||||
links: ObservedArray<ChannelExternalLinkView>;
|
||||
|
||||
constructor(data: RawNode) {
|
||||
super();
|
||||
|
||||
if (Reflect.has(data, 'description')) {
|
||||
this.description = data.description;
|
||||
}
|
||||
|
||||
if (Reflect.has(data, 'descriptionLabel')) {
|
||||
this.description_label = Text.fromAttributed(data.descriptionLabel);
|
||||
}
|
||||
|
||||
if (Reflect.has(data, 'country')) {
|
||||
this.country = data.country;
|
||||
}
|
||||
|
||||
if (Reflect.has(data, 'customLinksLabel')) {
|
||||
this.custom_links_label = Text.fromAttributed(data.customLinksLabel);
|
||||
}
|
||||
|
||||
if (Reflect.has(data, 'subscriberCountText')) {
|
||||
this.subscriber_count = data.subscriberCountText;
|
||||
}
|
||||
|
||||
if (Reflect.has(data, 'viewCountText')) {
|
||||
this.view_count = data.viewCountText;
|
||||
}
|
||||
|
||||
if (Reflect.has(data, 'joinedDateText')) {
|
||||
this.joined_date = Text.fromAttributed(data.joinedDateText);
|
||||
}
|
||||
|
||||
if (Reflect.has(data, 'canonicalChannelUrl')) {
|
||||
this.canonical_channel_url = data.canonicalChannelUrl;
|
||||
}
|
||||
|
||||
if (Reflect.has(data, 'channelId')) {
|
||||
this.channel_id = data.channelId;
|
||||
}
|
||||
|
||||
if (Reflect.has(data, 'additionalInfoLabel')) {
|
||||
this.additional_info_label = Text.fromAttributed(data.additionalInfoLabel);
|
||||
}
|
||||
|
||||
if (Reflect.has(data, 'customUrlOnTap')) {
|
||||
this.custom_url_on_tap = new NavigationEndpoint(data.customUrlOnTap);
|
||||
}
|
||||
|
||||
if (Reflect.has(data, 'videoCountText')) {
|
||||
this.video_count = data.videoCountText;
|
||||
}
|
||||
|
||||
if (Reflect.has(data, 'signInForBusinessEmail')) {
|
||||
this.sign_in_for_business_email = Text.fromAttributed(data.signInForBusinessEmail);
|
||||
}
|
||||
|
||||
if (Reflect.has(data, 'links')) {
|
||||
this.links = Parser.parseArray(data.links, ChannelExternalLinkView);
|
||||
} else {
|
||||
this.links = [] as unknown as ObservedArray<ChannelExternalLinkView>;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import Parser, { type RawNode } from '../index.js';
|
||||
import Button from './Button.js';
|
||||
import ChannelHeaderLinks from './ChannelHeaderLinks.js';
|
||||
import ChannelHeaderLinksView from './ChannelHeaderLinksView.js';
|
||||
import ChannelTagline from './ChannelTagline.js';
|
||||
import SubscribeButton from './SubscribeButton.js';
|
||||
import Author from './misc/Author.js';
|
||||
import Text from './misc/Text.js';
|
||||
@@ -22,6 +23,7 @@ export default class C4TabbedHeader extends YTNode {
|
||||
header_links?: ChannelHeaderLinks | ChannelHeaderLinksView | null;
|
||||
channel_handle?: Text;
|
||||
channel_id?: string;
|
||||
tagline?: ChannelTagline | null;
|
||||
|
||||
constructor(data: RawNode) {
|
||||
super();
|
||||
@@ -69,5 +71,9 @@ export default class C4TabbedHeader extends YTNode {
|
||||
if (Reflect.has(data, 'channelId')) {
|
||||
this.channel_id = data.channelId;
|
||||
}
|
||||
|
||||
if (Reflect.has(data, 'tagline')) {
|
||||
this.tagline = Parser.parseItem(data.tagline, ChannelTagline);
|
||||
}
|
||||
}
|
||||
}
|
||||
20
src/parser/classes/ChannelExternalLinkView.ts
Normal file
20
src/parser/classes/ChannelExternalLinkView.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { YTNode } from '../helpers.js';
|
||||
import type { RawNode } from '../index.js';
|
||||
import Text from './misc/Text.js';
|
||||
import Thumbnail from './misc/Thumbnail.js';
|
||||
|
||||
export default class ChannelExternalLinkView extends YTNode {
|
||||
static type = 'ChannelExternalLinkView';
|
||||
|
||||
title: Text;
|
||||
link: Text;
|
||||
favicon: Thumbnail[];
|
||||
|
||||
constructor(data: RawNode) {
|
||||
super();
|
||||
|
||||
this.title = Text.fromAttributed(data.title);
|
||||
this.link = Text.fromAttributed(data.link);
|
||||
this.favicon = data.favicon.sources.map((x: any) => new Thumbnail(x)).sort((a: Thumbnail, b: Thumbnail) => b.width - a.width);
|
||||
}
|
||||
}
|
||||
44
src/parser/classes/ChannelTagline.ts
Normal file
44
src/parser/classes/ChannelTagline.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { YTNode } from '../helpers.js';
|
||||
import { Parser, type RawNode } from '../index.js';
|
||||
import NavigationEndpoint from './NavigationEndpoint.js';
|
||||
import EngagementPanelSectionList from './EngagementPanelSectionList.js';
|
||||
|
||||
export default class ChannelTagline extends YTNode {
|
||||
static type = 'ChannelTagline';
|
||||
|
||||
content: string;
|
||||
max_lines: number;
|
||||
more_endpoint: {
|
||||
show_engagement_panel_endpoint: {
|
||||
engagement_panel: EngagementPanelSectionList | null,
|
||||
engagement_panel_popup_type: string;
|
||||
identifier: {
|
||||
surface: string,
|
||||
tag: string
|
||||
}
|
||||
}
|
||||
} | NavigationEndpoint;
|
||||
more_icon_type: string;
|
||||
more_label: string;
|
||||
target_id: string;
|
||||
|
||||
constructor(data: RawNode) {
|
||||
super();
|
||||
|
||||
this.content = data.content;
|
||||
this.max_lines = data.maxLines;
|
||||
this.more_endpoint = data.moreEndpoint.showEngagementPanelEndpoint ? {
|
||||
show_engagement_panel_endpoint: {
|
||||
engagement_panel: Parser.parseItem(data.moreEndpoint.showEngagementPanelEndpoint.engagementPanel, EngagementPanelSectionList),
|
||||
engagement_panel_popup_type: data.moreEndpoint.showEngagementPanelEndpoint.engagementPanelPresentationConfigs.engagementPanelPopupPresentationConfig.popupType,
|
||||
identifier: {
|
||||
surface: data.moreEndpoint.showEngagementPanelEndpoint.identifier.surface,
|
||||
tag: data.moreEndpoint.showEngagementPanelEndpoint.identifier.tag
|
||||
}
|
||||
}
|
||||
} : new NavigationEndpoint(data.moreEndpoint);
|
||||
this.more_icon_type = data.moreIcon.iconType;
|
||||
this.more_label = data.moreLabel;
|
||||
this.target_id = data.targetId;
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,10 @@ export default class EngagementPanelSectionList extends YTNode {
|
||||
content: VideoAttributeView | SectionList | ContinuationItem | ClipSection | StructuredDescriptionContent | MacroMarkersList | ProductList | null;
|
||||
target_id?: string;
|
||||
panel_identifier?: string;
|
||||
identifier?: {
|
||||
surface: string,
|
||||
tag: string
|
||||
};
|
||||
visibility?: string;
|
||||
|
||||
constructor(data: RawNode) {
|
||||
@@ -23,6 +27,10 @@ export default class EngagementPanelSectionList extends YTNode {
|
||||
this.header = Parser.parseItem(data.header, EngagementPanelTitleHeader);
|
||||
this.content = Parser.parseItem(data.content, [ VideoAttributeView, SectionList, ContinuationItem, ClipSection, StructuredDescriptionContent, MacroMarkersList, ProductList ]);
|
||||
this.panel_identifier = data.panelIdentifier;
|
||||
this.identifier = data.identifier ? {
|
||||
surface: data.identifier.surface,
|
||||
tag: data.identifier.tag
|
||||
} : undefined;
|
||||
this.target_id = data.targetId;
|
||||
this.visibility = data.visibility;
|
||||
}
|
||||
|
||||
@@ -56,6 +56,10 @@ export default class Text {
|
||||
const content = data.content;
|
||||
const command_runs = data.commandRuns;
|
||||
|
||||
// Haven't found an actually useful one yet, but they look like this:
|
||||
// [ { startIndex: 0, length: 19 } ] (for a string that is 19 characters long)
|
||||
// Const style_runs = data.styleRuns;
|
||||
|
||||
let last_end_index = 0;
|
||||
|
||||
if (command_runs) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// This file was auto generated, do not edit.
|
||||
// See ./scripts/build-parser-map.js
|
||||
|
||||
export { default as AboutChannel } from './classes/AboutChannel.js';
|
||||
export { default as AboutChannelView } from './classes/AboutChannelView.js';
|
||||
export { default as AccountChannel } from './classes/AccountChannel.js';
|
||||
export { default as AccountItemSection } from './classes/AccountItemSection.js';
|
||||
export { default as AccountItemSectionHeader } from './classes/AccountItemSectionHeader.js';
|
||||
@@ -36,6 +38,7 @@ export { default as CarouselLockup } from './classes/CarouselLockup.js';
|
||||
export { default as Channel } from './classes/Channel.js';
|
||||
export { default as ChannelAboutFullMetadata } from './classes/ChannelAboutFullMetadata.js';
|
||||
export { default as ChannelAgeGate } from './classes/ChannelAgeGate.js';
|
||||
export { default as ChannelExternalLinkView } from './classes/ChannelExternalLinkView.js';
|
||||
export { default as ChannelFeaturedContent } from './classes/ChannelFeaturedContent.js';
|
||||
export { default as ChannelHeaderLinks } from './classes/ChannelHeaderLinks.js';
|
||||
export { default as ChannelHeaderLinksView } from './classes/ChannelHeaderLinksView.js';
|
||||
@@ -43,6 +46,7 @@ export { default as ChannelMetadata } from './classes/ChannelMetadata.js';
|
||||
export { default as ChannelMobileHeader } from './classes/ChannelMobileHeader.js';
|
||||
export { default as ChannelOptions } from './classes/ChannelOptions.js';
|
||||
export { default as ChannelSubMenu } from './classes/ChannelSubMenu.js';
|
||||
export { default as ChannelTagline } from './classes/ChannelTagline.js';
|
||||
export { default as ChannelThumbnailWithLink } from './classes/ChannelThumbnailWithLink.js';
|
||||
export { default as ChannelVideoPlayer } from './classes/ChannelVideoPlayer.js';
|
||||
export { default as Chapter } from './classes/Chapter.js';
|
||||
|
||||
@@ -2,6 +2,7 @@ import TabbedFeed from '../../core/mixins/TabbedFeed.js';
|
||||
import C4TabbedHeader from '../classes/C4TabbedHeader.js';
|
||||
import CarouselHeader from '../classes/CarouselHeader.js';
|
||||
import ChannelAboutFullMetadata from '../classes/ChannelAboutFullMetadata.js';
|
||||
import AboutChannel from '../classes/AboutChannel.js';
|
||||
import ChannelMetadata from '../classes/ChannelMetadata.js';
|
||||
import InteractiveTabbedHeader from '../classes/InteractiveTabbedHeader.js';
|
||||
import MicroformatData from '../classes/MicroformatData.js';
|
||||
@@ -18,6 +19,8 @@ import ChipCloudChip from '../classes/ChipCloudChip.js';
|
||||
import FeedFilterChipBar from '../classes/FeedFilterChipBar.js';
|
||||
import ChannelSubMenu from '../classes/ChannelSubMenu.js';
|
||||
import SortFilterSubMenu from '../classes/SortFilterSubMenu.js';
|
||||
import ContinuationItem from '../classes/ContinuationItem.js';
|
||||
import NavigationEndpoint from '../classes/NavigationEndpoint.js';
|
||||
|
||||
import { ChannelError, InnertubeError } from '../../utils/Utils.js';
|
||||
|
||||
@@ -189,9 +192,35 @@ export default class Channel extends TabbedFeed<IBrowseResponse> {
|
||||
* Retrieves the about page.
|
||||
* Note that this does not return a new {@link Channel} object.
|
||||
*/
|
||||
async getAbout(): Promise<ChannelAboutFullMetadata> {
|
||||
const tab = await this.getTabByURL('about');
|
||||
return tab.memo.getType(ChannelAboutFullMetadata)?.[0];
|
||||
async getAbout(): Promise<ChannelAboutFullMetadata | AboutChannel> {
|
||||
if (this.hasTabWithURL('about')) {
|
||||
const tab = await this.getTabByURL('about');
|
||||
return tab.memo.getType(ChannelAboutFullMetadata)[0];
|
||||
} else if (this.header?.is(C4TabbedHeader) && this.header.tagline) {
|
||||
|
||||
if (this.header.tagline.more_endpoint instanceof NavigationEndpoint) {
|
||||
const response = await this.header.tagline.more_endpoint.call(this.actions);
|
||||
|
||||
const tab = new TabbedFeed<IBrowseResponse>(this.actions, response, false);
|
||||
return tab.memo.getType(ChannelAboutFullMetadata)[0];
|
||||
}
|
||||
|
||||
const endpoint = this.page.header_memo?.getType(ContinuationItem)[0]?.endpoint;
|
||||
|
||||
if (!endpoint) {
|
||||
throw new InnertubeError('Failed to extract continuation to get channel about');
|
||||
}
|
||||
|
||||
const response = await endpoint.call<IBrowseResponse>(this.actions, { parse: true });
|
||||
|
||||
if (!response.on_response_received_endpoints_memo) {
|
||||
throw new InnertubeError('Unexpected response while fetching channel about', { response });
|
||||
}
|
||||
|
||||
return response.on_response_received_endpoints_memo.getType(AboutChannel)[0];
|
||||
}
|
||||
|
||||
throw new InnertubeError('About not found');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -241,7 +270,8 @@ export default class Channel extends TabbedFeed<IBrowseResponse> {
|
||||
}
|
||||
|
||||
get has_about(): boolean {
|
||||
return this.hasTabWithURL('about');
|
||||
// Game topic channels still have an about tab, user channels have switched to the popup
|
||||
return this.hasTabWithURL('about') || !!(this.header?.is(C4TabbedHeader) && this.header.tagline?.more_endpoint);
|
||||
}
|
||||
|
||||
get has_search(): boolean {
|
||||
|
||||
@@ -138,6 +138,13 @@ export function concatMemos(...iterables: Array<Memo | undefined>): Memo {
|
||||
for (const iterable of iterables) {
|
||||
if (!iterable) continue;
|
||||
for (const item of iterable) {
|
||||
// Update existing items.
|
||||
const memo_item = memo.get(item[0]);
|
||||
if (memo_item) {
|
||||
memo.set(item[0], [ ...memo_item, ...item[1] ]);
|
||||
continue;
|
||||
}
|
||||
|
||||
memo.set(...item);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,8 +211,15 @@ describe('YouTube.js Tests', () => {
|
||||
test('Channel#getAbout', async () => {
|
||||
const about = await channel.getAbout();
|
||||
expect(about).toBeDefined();
|
||||
expect(about.id).toBe('UC7_gcs09iThXybpVgjHZ_7g');
|
||||
expect(about.description).toBeDefined();
|
||||
|
||||
if (about.is(YTNodes.ChannelAboutFullMetadata)) {
|
||||
expect(about.id).toBe('UC7_gcs09iThXybpVgjHZ_7g');
|
||||
expect(about.description).toBeDefined();
|
||||
} else {
|
||||
expect(about.metadata).toBeDefined();
|
||||
expect(about.metadata?.channel_id).toBe('UC7_gcs09iThXybpVgjHZ_7g');
|
||||
expect(about.metadata?.description).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
test('Channel#search', async () => {
|
||||
|
||||
Reference in New Issue
Block a user