mirror of
https://github.com/LuanRT/YouTube.js.git
synced 2026-06-18 20:12:12 +00:00
fix: TabbedFeed#getTab to parse response. (#120)
* fix: TabbedFeed#getTab to parse response. * fix: Channel parser and example * refactor: migrate youtube Search to TS * chore: lint
This commit is contained in:
@@ -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) + '...');
|
||||
}
|
||||
|
||||
})();
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<import('../classes/ChannelAboutFullMetadata')>}
|
||||
*/
|
||||
async getAbout() {
|
||||
const tab = await this.getTab('About');
|
||||
return tab.memo.get('ChannelAboutFullMetadata')?.[0];
|
||||
}
|
||||
}
|
||||
|
||||
export default Channel;
|
||||
67
src/parser/youtube/Channel.ts
Normal file
67
src/parser/youtube/Channel.ts
Normal file
@@ -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;
|
||||
@@ -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.<Feed>}
|
||||
*/
|
||||
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.<Search>}
|
||||
*/
|
||||
async getContinuation() {
|
||||
const continuation = await this.getContinuationData();
|
||||
return new Search(this.actions, continuation, true);
|
||||
}
|
||||
}
|
||||
export default Search;
|
||||
70
src/parser/youtube/Search.ts
Normal file
70
src/parser/youtube/Search.ts
Normal file
@@ -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<YTNode> | 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;
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user