diff --git a/src/core/mixins/Feed.ts b/src/core/mixins/Feed.ts index 26daa3c7c..889b19a5d 100644 --- a/src/core/mixins/Feed.ts +++ b/src/core/mixins/Feed.ts @@ -177,7 +177,7 @@ export default class Feed { * 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 { 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 { throw new InnertubeError('Could not get continuation data'); return new Feed(this.actions, continuation_data, true); } + + #getBodyContinuations(): ObservedArray { + 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; + } + + return this.#memo.getType(ContinuationItem); + } } \ No newline at end of file diff --git a/src/parser/classes/AboutChannel.ts b/src/parser/classes/AboutChannel.ts new file mode 100644 index 000000000..43c5f7733 --- /dev/null +++ b/src/parser/classes/AboutChannel.ts @@ -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); + } +} \ No newline at end of file diff --git a/src/parser/classes/AboutChannelView.ts b/src/parser/classes/AboutChannelView.ts new file mode 100644 index 000000000..8598994b9 --- /dev/null +++ b/src/parser/classes/AboutChannelView.ts @@ -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; + + 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; + } + } +} \ No newline at end of file diff --git a/src/parser/classes/C4TabbedHeader.ts b/src/parser/classes/C4TabbedHeader.ts index e6417d11c..2aa6f79d8 100644 --- a/src/parser/classes/C4TabbedHeader.ts +++ b/src/parser/classes/C4TabbedHeader.ts @@ -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); + } } } \ No newline at end of file diff --git a/src/parser/classes/ChannelExternalLinkView.ts b/src/parser/classes/ChannelExternalLinkView.ts new file mode 100644 index 000000000..4f2a01fb0 --- /dev/null +++ b/src/parser/classes/ChannelExternalLinkView.ts @@ -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); + } +} \ No newline at end of file diff --git a/src/parser/classes/ChannelTagline.ts b/src/parser/classes/ChannelTagline.ts new file mode 100644 index 000000000..585f03d92 --- /dev/null +++ b/src/parser/classes/ChannelTagline.ts @@ -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; + } +} \ No newline at end of file diff --git a/src/parser/classes/EngagementPanelSectionList.ts b/src/parser/classes/EngagementPanelSectionList.ts index c69d194f9..99f80b7ba 100644 --- a/src/parser/classes/EngagementPanelSectionList.ts +++ b/src/parser/classes/EngagementPanelSectionList.ts @@ -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; } diff --git a/src/parser/classes/misc/Text.ts b/src/parser/classes/misc/Text.ts index d90140c8a..ed93f4da6 100644 --- a/src/parser/classes/misc/Text.ts +++ b/src/parser/classes/misc/Text.ts @@ -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) { diff --git a/src/parser/nodes.ts b/src/parser/nodes.ts index 0cfb0d8fa..da8202cb9 100644 --- a/src/parser/nodes.ts +++ b/src/parser/nodes.ts @@ -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'; diff --git a/src/parser/youtube/Channel.ts b/src/parser/youtube/Channel.ts index 4300bd303..5b13f170d 100644 --- a/src/parser/youtube/Channel.ts +++ b/src/parser/youtube/Channel.ts @@ -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 { * Retrieves the about page. * Note that this does not return a new {@link Channel} object. */ - async getAbout(): Promise { - const tab = await this.getTabByURL('about'); - return tab.memo.getType(ChannelAboutFullMetadata)?.[0]; + async getAbout(): Promise { + 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(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(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 { } 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 { diff --git a/src/utils/Utils.ts b/src/utils/Utils.ts index 96413520e..7242c105c 100644 --- a/src/utils/Utils.ts +++ b/src/utils/Utils.ts @@ -138,6 +138,13 @@ export function concatMemos(...iterables: Array): 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); } } diff --git a/test/main.test.ts b/test/main.test.ts index 59e1dc59b..800dec430 100644 --- a/test/main.test.ts +++ b/test/main.test.ts @@ -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 () => {