diff --git a/examples/channel.js b/examples/channel/basic-info.js similarity index 68% rename from examples/channel.js rename to examples/channel/basic-info.js index a93d6abf..b8453b64 100644 --- a/examples/channel.js +++ b/examples/channel/basic-info.js @@ -1,12 +1,12 @@ -const Innertube = require('..'); +const { Innertube } = require('../../dist/index'); (async () => { -const session = await new Innertube(); +const session = await Innertube.create(); const channel = await session.getChannel('UCX6OQ3DkcsbYNE6H8uQQuVA'); -console.log('Viewing channel:', channel.title); +console.log('Viewing channel:', channel.header.author.name); console.log('Family Safe:', channel.metadata.is_family_safe); const about = await channel.getAbout(); console.log('Country:', about.country.toString()); @@ -15,13 +15,13 @@ console.log('Country:', about.country.toString()); console.log('\nLists the following videos:'); const videos = await channel.getVideos(); for (const video of videos.videos) { - console.log('Video:', video.title); + console.log('Video:', video.title.toString()); } console.log('\nLists the following playlists:'); const playlists = await channel.getPlaylists(); for (const playlist of playlists.playlists) { - console.log('Playlist:', playlist.title); + console.log('Playlist:', playlist.title.toString()); } console.log('\nLists the following channels:'); @@ -32,8 +32,8 @@ for (const channel of channels.channels) { console.log('\nLists the following community posts:'); const posts = await channel.getCommunity(); -for (const post of posts.backstage_posts) { - console.log('Backstage post:', post.content.toString().substring(0, 20) + '...'); +for (const post of posts.posts) { + console.log('Post:', post.content.toString().substring(0, 20) + '...'); } })(); \ No newline at end of file diff --git a/src/core/TabbedFeed.ts b/src/core/TabbedFeed.ts index 1fbadd39..2d553c21 100644 --- a/src/core/TabbedFeed.ts +++ b/src/core/TabbedFeed.ts @@ -27,7 +27,10 @@ class TabbedFeed extends Feed { return this; const response = await tab.endpoint.call(this.#actions); - return new TabbedFeed(this.#actions, response, true); + if (!response) + throw new InnertubeError('Failed to call endpoint'); + + return new TabbedFeed(this.#actions, response.data, false); } get title() { diff --git a/src/parser/classes/C4TabbedHeader.js b/src/parser/classes/C4TabbedHeader.ts similarity index 60% rename from src/parser/classes/C4TabbedHeader.js rename to src/parser/classes/C4TabbedHeader.ts index eba8ac4a..fa246ea1 100644 --- a/src/parser/classes/C4TabbedHeader.js +++ b/src/parser/classes/C4TabbedHeader.ts @@ -7,7 +7,16 @@ import { YTNode } from '../helpers'; class C4TabbedHeader extends YTNode { static type = 'C4TabbedHeader'; - constructor(data) { + author; + banner; + tv_banner; + mobile_banner; + subscribers; + sponsor_button; + subscribe_button; + header_links; + + constructor(data: any) { super(); this.author = new Author({ simpleText: data.title, @@ -18,9 +27,9 @@ class C4TabbedHeader extends YTNode { this.tv_banner = data.tvBanner ? Thumbnail.fromResponse(data.tvBanner) : []; this.mobile_banner = data.mobileBanner ? Thumbnail.fromResponse(data.mobileBanner) : []; this.subscribers = new Text(data.subscriberCountText); - this.sponsor_button = data.sponsorButton && Parser.parse(data.sponsorButton); - this.subscribe_button = data.subscribeButton && Parser.parse(data.subscribeButton); - this.header_links = data.headerLinks && Parser.parse(data.headerLinks); + this.sponsor_button = data.sponsorButton ? Parser.parseItem(data.sponsorButton) : undefined; + this.subscribe_button = data.subscribeButton ? Parser.parseItem(data.subscribeButton) : undefined; + this.header_links = data.headerLinks ? Parser.parse(data.headerLinks) : undefined; } } diff --git a/src/parser/index.ts b/src/parser/index.ts index abbc55d2..53fa9556 100644 --- a/src/parser/index.ts +++ b/src/parser/index.ts @@ -182,7 +182,7 @@ export default class Parser { sidebar: Parser.parseItem(data.sidebar), overlay: Parser.parseItem(data.overlay), refinements: data.refinements || null, - estimated_results: data.estimatedResults || null, + estimated_results: data.estimatedResults ? parseInt(data.estimatedResults) : null, player_overlays: Parser.parse(data.playerOverlays), playability_status: data.playabilityStatus ? { status: data.playabilityStatus.status as string, diff --git a/src/parser/youtube/Channel.js b/src/parser/youtube/Channel.js deleted file mode 100644 index 456416b8..00000000 --- a/src/parser/youtube/Channel.js +++ /dev/null @@ -1,63 +0,0 @@ -import TabbedFeed from '../../core/TabbedFeed'; - -class Channel extends TabbedFeed { - #tab; - - constructor(actions, data, already_parsed = false) { - super(actions, data, already_parsed); - this.header = { - author: this.page.header.author, - subscribers: this.page.header.subscribers.toString(), - banner: this.page.header.banner, - tv_banner: this.page.header.tv_banner, - mobile_banner: this.page.header.mobile_banner, - header_links: this.page.header.header_links - }; - - this.metadata = { ...this.page.metadata, ...this.page.microformat }; - this.sponsor_button = this.page.header.sponsor_button || null; - this.subscribe_button = this.page.header.subscribe_button || null; - - const tab = this.page.contents.tabs.get({ selected: true }); - - this.current_tab = tab; - } - - async getVideos() { - const tab = await this.getTab('Videos'); - return new Channel(this.actions, tab.page, true); - } - - async getPlaylists() { - const tab = await this.getTab('Playlists'); - return new Channel(this.actions, tab.page, true); - } - - async getHome() { - const tab = await this.getTab('Home'); - return new Channel(this.actions, tab.page, true); - } - - async getCommunity() { - const tab = await this.getTab('Community'); - return new Channel(this.actions, tab.page, true); - } - - async getChannels() { - const tab = await this.getTab('Channels'); - return new Channel(this.actions, tab.page, true); - } - - /** - * Retrieves the channel about page. - * Note that this does not return a new {@link Channel} object. - * - * @returns {Promise} - */ - async getAbout() { - const tab = await this.getTab('About'); - return tab.memo.get('ChannelAboutFullMetadata')?.[0]; - } -} - -export default Channel; \ No newline at end of file diff --git a/src/parser/youtube/Channel.ts b/src/parser/youtube/Channel.ts new file mode 100644 index 00000000..46293457 --- /dev/null +++ b/src/parser/youtube/Channel.ts @@ -0,0 +1,67 @@ +import Actions from '../../core/Actions'; +import TabbedFeed from '../../core/TabbedFeed'; +import C4TabbedHeader from '../classes/C4TabbedHeader'; +import ChannelAboutFullMetadata from '../classes/ChannelAboutFullMetadata'; +import ChannelMetadata from '../classes/ChannelMetadata'; +import MicroformatData from '../classes/MicroformatData'; +import Tab from '../classes/Tab'; + +class Channel extends TabbedFeed { + header; + metadata; + sponsor_button; + subscribe_button; + current_tab; + + constructor(actions: Actions, data: any, already_parsed = false) { + super(actions, data, already_parsed); + + this.header = this.page.header.item().as(C4TabbedHeader); + const metadata = this.page.metadata.item().as(ChannelMetadata); + const microformat = this.page.microformat?.as(MicroformatData); + + this.metadata = { ...metadata, ...(microformat || {}) }; + this.sponsor_button = this.header.sponsor_button; + this.subscribe_button = this.header.subscribe_button; + + const tab = this.page.contents.item().key('tabs').parsed().array().filterType(Tab).get({ selected: true }); + + this.current_tab = tab; + } + + async getVideos() { + const tab = await this.getTab('Videos'); + return new Channel(this.actions, tab.page, true); + } + + async getPlaylists() { + const tab = await this.getTab('Playlists'); + return new Channel(this.actions, tab.page, true); + } + + async getHome() { + const tab = await this.getTab('Home'); + return new Channel(this.actions, tab.page, true); + } + + async getCommunity() { + const tab = await this.getTab('Community'); + return new Channel(this.actions, tab.page, true); + } + + async getChannels() { + const tab = await this.getTab('Channels'); + return new Channel(this.actions, tab.page, true); + } + + /** + * Retrieves the channel about page. + * Note that this does not return a new {@link Channel} object. + */ + async getAbout() { + const tab = await this.getTab('About'); + return tab.memo.getType(ChannelAboutFullMetadata)?.[0]; + } +} + +export default Channel; \ No newline at end of file diff --git a/src/parser/youtube/Search.js b/src/parser/youtube/Search.js deleted file mode 100644 index f0667c1c..00000000 --- a/src/parser/youtube/Search.js +++ /dev/null @@ -1,72 +0,0 @@ -import Feed from '../../core/Feed'; -import { InnertubeError } from '../../utils/Utils'; -import TwoColumnSearchResults from '../classes/TwoColumnSearchResults'; - -/** @namespace */ -class Search extends Feed { - /** - * @param {import('../../core/Actions').default} actions - * @param {object} data - API response data. - * @param {boolean} [already_parsed] - already parsed response. - */ - constructor(actions, data, already_parsed = false) { - super(actions, data, already_parsed); - const contents = this.page.contents.item().as(TwoColumnSearchResults).primary_contents.item().contents.array() - || this.page.on_response_received_commands[0].contents; - const secondary_contents = this.page.contents?.secondary_contents?.contents; - /** @type {object[]} */ - this.results = contents.get({ type: 'ItemSection' }).contents; - const card_list = this.results.get({ type: 'HorizontalCardList' }, true); - const universal_watch_card = secondary_contents?.get({ type: 'UniversalWatchCard' }); - this.refinements = this.page.refinements || []; - this.estimated_results = this.page.estimated_results; - this.watch_card = { - /** @type {import('../classes/UniversalWatchCard')} */ - header: universal_watch_card?.header || null, - /** @type {import('../classes/WatchCardHeroVideo')} */ - call_to_action: universal_watch_card?.call_to_action || null, - /** @type {import('../classes/WatchCardSectionSequence')[]} */ - sections: universal_watch_card?.sections || [] - }; - this.refinement_cards = { - /** @type {import('../classes/RichListHeader')} */ - header: card_list?.header || null, - /** @type {import('../classes/SearchRefinementCard')} */ - cards: card_list?.cards || [] - }; - } - /** - * Applies given refinement card and returns a new {@link Search} object. - * - * @param {import('../classes/SearchRefinementCard') | string} card - refinement card object or query - * @returns {Promise.} - */ - async selectRefinementCard(card) { - let target_card; - if (typeof card === 'string') { - target_card = this.refinement_cards.cards.get({ query: card }); - if (!target_card) - throw new InnertubeError('Refinement card not found!', { available_cards: this.refinement_card_queries }); - } else if (card.type === 'SearchRefinementCard') { - target_card = card; - } else { - throw new InnertubeError('Invalid refinement card!'); - } - const page = await target_card.endpoint.call(this.actions); - return new Search(this.actions, page, true); - } - /** @type {string[]} */ - get refinement_card_queries() { - return this.refinement_cards.cards.map((card) => card.query); - } - /** - * Retrieves next batch of results. - * - * @returns {Promise.} - */ - async getContinuation() { - const continuation = await this.getContinuationData(); - return new Search(this.actions, continuation, true); - } -} -export default Search; diff --git a/src/parser/youtube/Search.ts b/src/parser/youtube/Search.ts new file mode 100644 index 00000000..1d0b8bc3 --- /dev/null +++ b/src/parser/youtube/Search.ts @@ -0,0 +1,70 @@ +import Actions from '../../core/Actions'; +import Feed from '../../core/Feed'; +import { InnertubeError } from '../../utils/Utils'; +import HorizontalCardList from '../classes/HorizontalCardList'; +import ItemSection from '../classes/ItemSection'; +import RichListHeader from '../classes/RichListHeader'; +import SearchRefinementCard from '../classes/SearchRefinementCard'; +import TwoColumnSearchResults from '../classes/TwoColumnSearchResults'; +import UniversalWatchCard from '../classes/UniversalWatchCard'; +import WatchCardHeroVideo from '../classes/WatchCardHeroVideo'; +import WatchCardSectionSequence from '../classes/WatchCardSectionSequence'; +import { observe, ObservedArray, YTNode } from '../helpers'; + +class Search extends Feed { + results: ObservedArray | null | undefined; + refinements; + estimated_results; + watch_card; + refinement_cards; + + constructor(actions: Actions, data: any, already_parsed = false) { + super(actions, data, already_parsed); + const contents = this.page.contents.item().as(TwoColumnSearchResults).primary_contents.item().key('contents').parsed().array() + || this.page.on_response_received_commands?.[0].contents; + const secondary_contents_maybe = this.page.contents.item().key('secondary_contents'); + const secondary_contents = secondary_contents_maybe.isParsed() ? secondary_contents_maybe.parsed().item().key('contents').parsed().array() : undefined; + this.results = contents.firstOfType(ItemSection)?.contents; + const card_list = this.results?.get({ type: 'HorizontalCardList' }, true)?.as(HorizontalCardList); + const universal_watch_card = secondary_contents?.firstOfType(UniversalWatchCard); + this.refinements = this.page.refinements || []; + this.estimated_results = this.page.estimated_results; + this.watch_card = { + header: universal_watch_card?.header.item() || null, + call_to_action: universal_watch_card?.call_to_action.item().as(WatchCardHeroVideo) || null, + sections: universal_watch_card?.sections.array().filterType(WatchCardSectionSequence) || [] + }; + this.refinement_cards = { + header: card_list?.header.item().as(RichListHeader) || null, + cards: card_list?.cards.array().filterType(SearchRefinementCard) || observe([] as SearchRefinementCard[]) + }; + } + /** + * Applies given refinement card and returns a new {@link Search} object. + */ + async selectRefinementCard(card: SearchRefinementCard | string) { + let target_card: SearchRefinementCard | undefined; + if (typeof card === 'string') { + target_card = this.refinement_cards.cards.get({ query: card }); + if (!target_card) + throw new InnertubeError('Refinement card not found!', { available_cards: this.refinement_card_queries }); + } else if (card.type === 'SearchRefinementCard') { + target_card = card; + } else { + throw new InnertubeError('Invalid refinement card!'); + } + const page = await target_card.endpoint.call(this.actions); + return new Search(this.actions, page, true); + } + get refinement_card_queries() { + return this.refinement_cards.cards.map((card) => card.query); + } + /** + * Retrieves next batch of results. + */ + async getContinuation() { + const continuation = await this.getContinuationData(); + return new Search(this.actions, continuation, true); + } +} +export default Search; diff --git a/test/main.test.js b/test/main.test.js index cc14a48f..b6891908 100644 --- a/test/main.test.js +++ b/test/main.test.js @@ -14,9 +14,12 @@ describe('YouTube.js Tests', () => { it('Should search on YouTube', async () => { const search = await this.session.search(Constants.VIDEOS[0].QUERY); expect(search.results.length).toBeLessThanOrEqual(30); + expect(search.videos.length).toBeLessThanOrEqual(30); + expect(search.playlists.length).toBeLessThanOrEqual(30); + expect(search.channels.length).toBeLessThanOrEqual(30); + expect(search.has_continuation).toBe(true); }); - it('Should search on YouTube Music', async () => { const search = await this.session.music.search(Constants.VIDEOS[1].QUERY); expect(search.songs.contents.length).toBeLessThanOrEqual(3);