Skip to content

Commit

Permalink
feat(Channel): Support new about popup (#537)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
absidue and LuanRT authored Dec 1, 2023
1 parent 6a5a579 commit c66eb1f
Show file tree
Hide file tree
Showing 12 changed files with 253 additions and 8 deletions.
14 changes: 12 additions & 2 deletions src/core/mixins/Feed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand All @@ -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();
Expand All @@ -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 changes: 18 additions & 0 deletions src/parser/classes/AboutChannel.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 87 additions & 0 deletions src/parser/classes/AboutChannelView.ts
Original file line number Diff line number Diff line change
@@ -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>;
}
}
}
6 changes: 6 additions & 0 deletions src/parser/classes/C4TabbedHeader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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();
Expand Down Expand Up @@ -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 changes: 20 additions & 0 deletions src/parser/classes/ChannelExternalLinkView.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 44 additions & 0 deletions src/parser/classes/ChannelTagline.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
8 changes: 8 additions & 0 deletions src/parser/classes/EngagementPanelSectionList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,21 @@ 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) {
super();
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;
}
Expand Down
4 changes: 4 additions & 0 deletions src/parser/classes/misc/Text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
4 changes: 4 additions & 0 deletions src/parser/nodes.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -36,13 +38,15 @@ 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';
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';
Expand Down
38 changes: 34 additions & 4 deletions src/parser/youtube/Channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';

Expand Down Expand Up @@ -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');
}

/**
Expand Down Expand Up @@ -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 {
Expand Down
7 changes: 7 additions & 0 deletions src/utils/Utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand Down
11 changes: 9 additions & 2 deletions test/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down

0 comments on commit c66eb1f

Please sign in to comment.