diff --git a/src/parser/types/ParsedResponse.ts b/src/parser/types/ParsedResponse.ts index 3807dc98..25f6547e 100644 --- a/src/parser/types/ParsedResponse.ts +++ b/src/parser/types/ParsedResponse.ts @@ -141,7 +141,7 @@ export interface IStreamingData { export type IPlayerResponse = Pick; export type INextResponse = Pick; export type IBrowseResponse = Pick; -export type ISearchResponse = Pick; +export type ISearchResponse = Pick; export type IResolveURLResponse = Pick; export type IGetTranscriptResponse = Pick; export type IGetNotificationsMenuResponse = Pick; diff --git a/src/parser/youtube/Search.ts b/src/parser/youtube/Search.ts index e6dd4daf..2f0a4fb7 100644 --- a/src/parser/youtube/Search.ts +++ b/src/parser/youtube/Search.ts @@ -1,20 +1,19 @@ import Feed from '../../core/mixins/Feed.js'; import { InnertubeError } from '../../utils/Utils.js'; -import HorizontalCardList from '../classes/HorizontalCardList.js'; import ItemSection from '../classes/ItemSection.js'; import SearchHeader from '../classes/SearchHeader.js'; -import SearchRefinementCard from '../classes/SearchRefinementCard.js'; import SearchSubMenu from '../classes/SearchSubMenu.js'; import SectionList from '../classes/SectionList.js'; import UniversalWatchCard from '../classes/UniversalWatchCard.js'; +import AppendContinuationItemsAction from '../classes/actions/AppendContinuationItemsAction.js'; +import ChipCloudChip from '../classes/ChipCloudChip.js'; +import type NavigationEndpoint from '../classes/NavigationEndpoint.js'; +import { ReloadContinuationItemsCommand } from '../continuations.js'; import { observe } from '../helpers.js'; - import type { ApiResponse, Actions } from '../../core/index.js'; import type { ObservedArray, YTNode } from '../helpers.js'; import type { ISearchResponse } from '../types/index.js'; -import { ReloadContinuationItemsCommand } from '../index.js'; -import AppendContinuationItemsAction from '../classes/actions/AppendContinuationItemsAction.js'; export default class Search extends Feed { public header?: SearchHeader; @@ -23,23 +22,28 @@ export default class Search extends Feed { public estimated_results: number; public sub_menu?: SearchSubMenu; public watch_card?: UniversalWatchCard; - public refinement_cards?: HorizontalCardList | null; constructor(actions: Actions, data: ApiResponse | ISearchResponse, already_parsed = false) { super(actions, data, already_parsed); const contents = this.page.contents_memo?.getType(SectionList)[0].contents || + this.page.on_response_received_commands_memo?.getType(SectionList)[0]?.contents || this.page.on_response_received_commands?.[0].as(AppendContinuationItemsAction, ReloadContinuationItemsCommand).contents; if (!contents) throw new InnertubeError('No contents found in search response'); - if (this.page.header) - this.header = this.page.header.item().as(SearchHeader); + if (this.page.on_response_received_commands && !this.page.header) { + const headerSlot = this.page.on_response_received_commands.as(ReloadContinuationItemsCommand).find( + (command) => command.is(ReloadContinuationItemsCommand) && command.slot === 'RELOAD_CONTINUATION_SLOT_HEADER' + ); + this.header = headerSlot?.contents?.firstOfType(SearchHeader); + } else { + this.header = this.page.header?.item().as(SearchHeader); + } this.results = observe(contents.filterType(ItemSection).flatMap((section) => section.contents)); - this.refinements = this.page.refinements || []; this.estimated_results = this.page.estimated_results || 0; @@ -47,39 +51,54 @@ export default class Search extends Feed { this.sub_menu = this.page.contents_memo.getType(SearchSubMenu)[0]; this.watch_card = this.page.contents_memo.getType(UniversalWatchCard)[0]; } - - this.refinement_cards = this.results?.firstOfType(HorizontalCardList); } /** - * Applies given refinement card and returns a new {@link Search} object. Use {@link refinement_card_queries} to get a list of available refinement cards. + * Applies a refinement filter to the search results. + * + * Use {@link Search.refinement_filters} to get a list of available refinements. + * + * @example + * ```ts + * const results = await yt.search('PilotRedSun'); + * // Narrow down to only YouTube Shorts + * const shortsOnly = await results.applyRefinement('Shorts'); + * ``` + * @param refinementFilter - The text label of the chip or the {@link ChipCloudChip} node itself. */ - async selectRefinementCard(card: SearchRefinementCard | string): Promise { - let target_card: SearchRefinementCard | undefined; + async applyRefinement(refinementFilter: string | ChipCloudChip): Promise { + let endpoint: NavigationEndpoint | undefined; - if (typeof card === 'string') { - if (!this.refinement_cards) throw new InnertubeError('No refinement cards found.'); - target_card = this.refinement_cards?.cards.find((refinement_card): refinement_card is SearchRefinementCard => { - return refinement_card.is(SearchRefinementCard) && refinement_card.query === card; - }); - if (!target_card) - throw new InnertubeError(`Refinement card "${card}" not found`, { available_cards: this.refinement_card_queries }); - } else if (card.type === 'SearchRefinementCard') { - target_card = card; + if (typeof refinementFilter === 'string') { + const chipBar = this.header?.chip_bar; + if (!chipBar) throw new InnertubeError('No chip bar found in search header'); + + const targetChip = chipBar.chips.find((chip) => chip.text === refinementFilter); + if (!targetChip) throw new InnertubeError(`Refinement filter "${refinementFilter}" not found`, { available_filters: this.refinement_filters }); + + endpoint = targetChip.endpoint; + + if (!endpoint && targetChip.is_selected) return this; // The 'All' filter doesn't have an endpoint when it's selected. + } else if (refinementFilter.is(ChipCloudChip)) { + if (!refinementFilter.endpoint && refinementFilter.is_selected) return this; + endpoint = refinementFilter.endpoint; } else { - throw new InnertubeError('Invalid refinement card!'); + throw new InnertubeError('Invalid filter type'); } - const page = await target_card.endpoint.call(this.actions, { parse: true }); + if (!endpoint) + throw new InnertubeError('Could not find endpoint for the specified filter'); + + const page = await endpoint.call(this.actions, { parse: true }); return new Search(this.actions, page, true); } /** - * Returns a list of refinement card queries. + * Returns a list of available refinement filters. Use {@link Search.applyRefinement} to apply a filter. */ - get refinement_card_queries(): string[] { - return this.refinement_cards?.cards.as(SearchRefinementCard).map((card) => card.query) || []; + get refinement_filters(): string[] { + return this.header?.chip_bar?.chips.map((chip) => chip.text) || []; } /**