Compare commits

...

2 Commits

Author SHA1 Message Date
absidue
650945535f chore: v17.1.0 release 2026-06-22 20:59:24 +00:00
LuanRT
f21db117e8 chore: v17.0.1 release 2026-03-16 19:03:11 +00:00
25 changed files with 625 additions and 199 deletions

View File

@@ -12,7 +12,7 @@
<img src="https://luanrt.github.io/assets/img/ytjs.svg" alt="YouTube.js Logo" width="200" />
</a>
</p>
<p>A JavaScript client for YouTube's private API</p>
<p>A JavaScript client for YouTube's internal API.<br/>Works on Node.js, Deno, modern browsers, and more.</p>
[![Discord](https://img.shields.io/badge/discord-online-brightgreen.svg)][discord]
[![CI](https://github.com/LuanRT/YouTube.js/actions/workflows/test.yml/badge.svg)][actions]
@@ -22,8 +22,6 @@
</div>
YouTube.js is a JavaScript client for YouTube's private API, known as "InnerTube". It allows you to interact with YouTube programmatically, providing access to videos, comments, live chats, streaming data and more. It works seamlessly across Node.js, Deno, and modern browsers.
## Installation
Before installing, make sure your environment meets the [prerequisites](https://ytjs.dev/guide/getting-started.html#prerequisites).
@@ -54,10 +52,10 @@ import { Innertube } from 'youtubei.js';
const innertube = await Innertube.create(/* options */);
```
For detailed usage, check out the [YouTube.js Guide and API Documentation](https://ytjs.dev).
For detailed usage, read the [YouTube.js Guide and API Documentation](https://ytjs.dev).
## Contributing
We welcome all contributions, issues and feature requests, whether small or large. If you want to contribute, feel free to check out our [issues page](https://github.com/LuanRT/YouTube.js/issues) and our [guidelines](https://github.com/LuanRT/YouTube.js/blob/main/CONTRIBUTING.md).
All contributions are welcome, big or small. If you want to contribute, take a look at the [issues page](https://github.com/LuanRT/YouTube.js/issues) and our [guidelines](https://github.com/LuanRT/YouTube.js/blob/main/CONTRIBUTING.md).
## Contributors
<a href="https://github.com/LuanRT/YouTube.js/graphs/contributors">

View File

@@ -1,6 +1,6 @@
{
"name": "youtubei.js",
"version": "17.0.0",
"version": "17.1.0",
"description": "A JavaScript client for YouTube's private API, known as InnerTube.",
"type": "module",
"types": "./dist/src/platform/lib.d.ts",
@@ -103,6 +103,7 @@
"license": "MIT",
"dependencies": {
"@bufbuild/protobuf": "^2.0.0",
"fflate": "^0.8.2",
"meriyah": "^6.1.4"
},
"devDependencies": {
@@ -112,7 +113,7 @@
"@typescript-eslint/eslint-plugin": "^8.46.0",
"@typescript-eslint/parser": "^8.46.0",
"cpy-cli": "^6.0.0",
"esbuild": "^0.25.6",
"esbuild": "^0.28.0",
"eslint": "^9.37.0",
"globals": "^17.0.0",
"replace": "^1.2.2",

View File

@@ -528,7 +528,7 @@ export default class Session extends EventEmitter {
const buffer = BinarySerializer.serialize({
...session_data,
library_version: parseInt(packageInfo.version)
library_version: parseInt(packageInfo.version.split('.', 1)[0])
});
await cache.set('innertube_session_data', buffer);

View File

@@ -29,7 +29,7 @@ import TwoColumnSearchResults from '../../parser/classes/TwoColumnSearchResults.
import WatchCardCompactVideo from '../../parser/classes/WatchCardCompactVideo.ts';
import type { Actions, ApiResponse } from '../index.ts';
import type { Memo, ObservedArray } from '../../parser/helpers.ts';
import { observe, type Memo, type ObservedArray } from '../../parser/helpers.ts';
import type MusicQueue from '../../parser/classes/MusicQueue.ts';
import type RichGrid from '../../parser/classes/RichGrid.ts';
import type SectionList from '../../parser/classes/SectionList.ts';
@@ -76,16 +76,17 @@ export default class Feed<T extends IParsedResponse = IParsedResponse> {
* Get all videos on a given page via memo
*/
static getVideosFromMemo(memo: Memo) {
return memo.getType(
return observe(memo.getType(
Video,
GridVideo,
ReelItem,
ShortsLockupView,
CompactVideo,
LockupView,
PlaylistVideo,
PlaylistPanelVideo,
WatchCardCompactVideo
);
).filter((item) => !item.is(LockupView) || (item.content_type === 'VIDEO' || item.content_type === 'MOVIE' || item.content_type === 'SHORT')));
}
/**
@@ -145,7 +146,6 @@ export default class Feed<T extends IParsedResponse = IParsedResponse> {
const tab_content = this.#memo.getType(Tab)?.[0].content;
const reload_continuation_items = this.#memo.getType(ReloadContinuationItemsCommand)[0];
const append_continuation_items = this.#memo.getType(AppendContinuationItemsAction)[0];
return tab_content || reload_continuation_items || append_continuation_items;
}
@@ -202,7 +202,7 @@ export default class Feed<T extends IParsedResponse = IParsedResponse> {
async getContinuationData(): Promise<T | undefined> {
if (this.#continuation) {
if (this.#continuation.length === 0)
throw new InnertubeError('There are no continuations.');
throw new InnertubeError('There are no continuations');
return await this.#continuation[0].endpoint.call<T>(this.#actions, { parse: true });
}

View File

@@ -1,70 +1,167 @@
import Feed from './Feed.ts';
import ChipCloudChip from '../../parser/classes/ChipCloudChip.ts';
import FeedFilterChipBar from '../../parser/classes/FeedFilterChipBar.ts';
import { InnertubeError } from '../../utils/Utils.ts';
import ChipView from '../../parser/classes/ChipView.ts';
import ShowSheetCommand from '../../parser/classes/commands/ShowSheetCommand.ts';
import SheetView from '../../parser/classes/SheetView.ts';
import ListView from '../../parser/classes/ListView.ts';
import ListItemView from '../../parser/classes/ListItemView.ts';
import type NavigationEndpoint from '../../parser/classes/NavigationEndpoint.ts';
import type { ObservedArray } from '../../parser/helpers.ts';
import { observe, type ObservedArray } from '../../parser/helpers.ts';
import type { IParsedResponse } from '../../parser/index.ts';
import type { ApiResponse, Actions } from '../index.ts';
import { InnertubeError } from '../../utils/Utils.ts';
export interface FilterNodes {
primary_filters?: ObservedArray<ChipCloudChip | ListItemView | ChipView>;
secondary_filters?: ObservedArray<ChipView>;
}
export default class FilterableFeed<T extends IParsedResponse> extends Feed<T> {
#chips?: ObservedArray<ChipCloudChip>;
#filter_nodes?: FilterNodes;
constructor(actions: Actions, data: ApiResponse | T, already_parsed = false) {
super(actions, data, already_parsed);
}
/**
* Returns the filter chips.
* Returns the InnerTube renderer nodes representing filters.
*/
get filter_chips(): ObservedArray<ChipCloudChip> {
if (this.#chips)
return this.#chips || [];
get filter_nodes(): FilterNodes {
if (this.#filter_nodes)
return this.#filter_nodes;
let primary_filters: ObservedArray<ChipCloudChip | ListItemView | ChipView> | undefined;
let secondary_filters: ObservedArray<ChipView> | undefined;
if (this.memo.getType(FeedFilterChipBar)?.length > 1)
throw new InnertubeError('There are too many feed filter chipbars, you\'ll need to find the correct one yourself in this.page');
if (this.memo.getType(FeedFilterChipBar)?.length === 0)
throw new InnertubeError('There are no feed filter chipbars');
if (this.memo.has('FeedFilterChipBar')) {
primary_filters = this.memo.getType(ChipCloudChip);;
} else if (this.memo.has('ChipView')) {
const chips = this.memo.getType(ChipView);
const firstChip = chips[0];
this.#chips = this.memo.getType(ChipCloudChip);
if (firstChip.is(ChipView)) {
const hasDropdown =
firstChip.display_type === 'CHIP_VIEW_MODEL_DISPLAY_TYPE_DROP_DOWN' ||
firstChip.display_type === 'CHIP_VIEW_MODEL_DISPLAY_TYPE_DROP_DOWN_WITH_CLEAR';
return this.#chips || [];
if (hasDropdown) {
const tapCommand = firstChip.tap_command?.command;
if (
tapCommand?.is(ShowSheetCommand) &&
tapCommand.inline_content?.is(SheetView) &&
tapCommand.inline_content.content?.is(ListView)
) {
primary_filters = tapCommand.inline_content.content.items.as(ListItemView);
}
secondary_filters = observe(chips.slice(1));
} else primary_filters = chips;
}
}
this.#filter_nodes = {
primary_filters,
secondary_filters
};
return this.#filter_nodes;
}
/**
* Returns available filters.
* Returns the available primary filters as strings.
*/
get filters(): string[] {
return this.filter_chips.map((chip) => chip.text.toString()) || [];
return this.filter_nodes?.primary_filters?.map((chip) => {
if (chip.is(ChipView) || chip.is(ChipCloudChip)) {
return chip.text?.toString() || '';
} else if (chip.is(ListItemView)) {
return chip.title?.toString() || '';
}
return '';
}) || [];
}
/**
* Returns the available secondary filters as strings.
*/
get secondary_filters(): string[] {
return this.filter_nodes?.secondary_filters?.map((chip) => chip.text?.toString() || '') || [];
}
/**
* Applies given filter and returns a new {@link Feed} object.
*/
async getFilteredFeed(filter: string | ChipCloudChip): Promise<Feed<T>> {
let target_filter: ChipCloudChip | undefined;
async getFilteredFeed(filter: string | ChipCloudChip | ChipView | ListItemView, secondaryFilter?: string | ChipView): Promise<Feed<T>> {
let filterEndpoint: NavigationEndpoint | undefined;
let isSelected: boolean = false;
if (typeof filter === 'string') {
if (!this.filters.includes(filter))
throw new InnertubeError('Filter not found', { available_filters: this.filters });
target_filter = this.filter_chips.find((chip) => chip.text.toString() === filter);
} else if (filter.type === 'ChipCloudChip') {
target_filter = filter;
throw new InnertubeError(`Filter '${filter}' not found`, { available_filters: this.filters });
for (const primaryFilterNode of this.filter_nodes.primary_filters || []) {
if ((primaryFilterNode.is(ChipView) || primaryFilterNode.is(ChipCloudChip)) && primaryFilterNode.text?.toString() === filter) {
filterEndpoint = primaryFilterNode.is(ChipView) ? primaryFilterNode.tap_command : primaryFilterNode.endpoint;
isSelected = primaryFilterNode.is(ChipView) ? primaryFilterNode.selected : primaryFilterNode.is_selected;
} else if (primaryFilterNode.is(ListItemView) && primaryFilterNode.title?.toString() === filter) {
filterEndpoint = primaryFilterNode?.renderer_context?.command_context?.on_tap;
isSelected = primaryFilterNode.is_selected;
}
}
} else if ([ 'ChipCloudChip', 'ChipView', 'ListItemView' ].includes(filter.type)) {
if (filter.is(ChipView)) {
filterEndpoint = filter.tap_command;
} else if (filter.is(ChipCloudChip)) {
filterEndpoint = filter.endpoint;
} else if (filter.is(ListItemView)) {
filterEndpoint = filter?.renderer_context?.command_context?.on_tap;
}
isSelected = filter.is(ChipView) ? filter.selected : filter.is_selected;
} else {
throw new InnertubeError('Invalid filter');
throw new InnertubeError('Invalid primary filter type');
}
if (!target_filter)
throw new InnertubeError('Filter not found');
if (!filterEndpoint)
throw new InnertubeError('Could not find endpoint for the specified filter');
if (target_filter.is_selected)
if (isSelected && !secondaryFilter)
return this;
const response = await target_filter.endpoint?.call(this.actions, { parse: true });
// No need to make a request if the filter is already selected...
let response = isSelected ? this.page : await filterEndpoint.call(this.actions, { parse: true });
if (secondaryFilter) {
const feed = new FilterableFeed(this.actions, response, true);
let secondaryFilterNode: ChipView | undefined;
if (typeof secondaryFilter === 'string') {
if (!feed.secondary_filters.includes(secondaryFilter))
throw new InnertubeError(`Secondary filter '${secondaryFilter}' not found`, { available_filters: feed.secondary_filters });
secondaryFilterNode = feed.filter_nodes.secondary_filters?.find((chip) => chip.text?.toString() === secondaryFilter);
} else if (secondaryFilter.is(ChipView)) {
secondaryFilterNode = secondaryFilter;
} else {
throw new InnertubeError('Invalid secondary filter type');
}
if (secondaryFilterNode && !secondaryFilterNode.selected) {
const secondaryFilterEndpoint = secondaryFilterNode?.tap_command || secondaryFilterNode?.endpoint;
if (!secondaryFilterEndpoint)
throw new InnertubeError('Could not find an endpoint for the specified secondary filter');
response = await secondaryFilterEndpoint.call(this.actions, { parse: true });
}
}
if (!response)
throw new InnertubeError('Failed to get filtered feed');
throw new InnertubeError('Failed to fetch data for the specified filter');
return new Feed(this.actions, response, true);
}

View File

@@ -1,14 +1,22 @@
import { YTNode, type ObservedArray } from '../helpers.ts';
import { Parser, type RawNode } from '../index.ts';
import RendererContext from './misc/RendererContext.ts';
import ChipView from './ChipView.ts';
export default class ChipBarView extends YTNode {
static type = 'ChipBarView';
chips: ObservedArray<ChipView>;
public chips: ObservedArray<ChipView>;
public chip_bar_state_entity_key?: string;
public renderer_context?: RendererContext;
constructor(data: RawNode) {
super();
this.chips = Parser.parseArray(data.chips, ChipView);
this.chip_bar_state_entity_key = data.chipBarStateEntityKey;
if ('rendererContext' in data) {
this.renderer_context = new RendererContext(data.rendererContext);
}
}
}

View File

@@ -5,16 +5,78 @@ import NavigationEndpoint from './NavigationEndpoint.ts';
export default class ChipView extends YTNode {
static type = 'ChipView';
text: string;
display_type: string;
endpoint: NavigationEndpoint;
chip_entity_key: string;
public accessibility_hint?: string;
public accessibility_label?: string;
public text?: string;
public trailing_text?: string;
public display_type?: 'CHIP_VIEW_MODEL_DISPLAY_TYPE_UNSPECIFIED'
| 'CHIP_VIEW_MODEL_DISPLAY_TYPE_DROP_DOWN'
| 'CHIP_VIEW_MODEL_DISPLAY_TYPE_DROP_DOWN_WITH_CLEAR'
| 'CHIP_VIEW_MODEL_DISPLAY_TYPE_FILTER'
| 'CHIP_VIEW_MODEL_DISPLAY_TYPE_NO_ICON'
| 'CHIP_VIEW_MODEL_DISPLAY_TYPE_ADJUST'
| 'CHIP_VIEW_MODEL_DISPLAY_TYPE_CLEAR'
| 'CHIP_VIEW_MODEL_DISPLAY_TYPE_ADD'
| 'CHIP_VIEW_MODEL_DISPLAY_TYPE_SPARK';
public max_text_width?: number;
public secondary_accessibility_label?: string;
public original_text?: string;
public tap_command?: NavigationEndpoint;
public secondary_tap_command?: NavigationEndpoint;
public chip_entity_key?: string;
public selected: boolean;
public get endpoint(): NavigationEndpoint | undefined {
return this.tap_command;
}
constructor(data: RawNode) {
super();
this.text = data.text;
this.display_type = data.displayType;
this.endpoint = new NavigationEndpoint(data.tapCommand);
this.chip_entity_key = data.chipEntityKey;
if ('accessibilityHint' in data) {
this.accessibility_hint = data.accessibilityHint;
}
if ('accessibilityLabel' in data) {
this.accessibility_label = data.accessibilityLabel;
}
if ('chipEntityKey' in data) {
this.chip_entity_key = data.chipEntityKey;
}
if ('text' in data) {
this.text = data.text;
}
if ('trailingText' in data) {
this.trailing_text = data.trailingText;
}
if ('displayType' in data) {
this.display_type = data.displayType;
}
if ('maxTextWidth' in data) {
this.max_text_width = data.maxTextWidth;
}
if ('originalText' in data) {
this.original_text = data.originalText;
}
if ('secondaryAccessibilityLabel' in data) {
this.secondary_accessibility_label = data.secondaryAccessibilityLabel;
}
if ('tapCommand' in data) {
this.tap_command = new NavigationEndpoint(data.tapCommand);
}
if ('secondaryTapCommand' in data) {
this.secondary_tap_command = new NavigationEndpoint(data.secondaryTapCommand);
}
this.selected = !!data.selected;
}
}

View File

@@ -0,0 +1,16 @@
import { YTNode } from '../helpers.ts';
import type { RawNode } from '../types/RawResponse.ts';
import RendererContext from './misc/RendererContext.ts';
export default class DownloadListItemView extends YTNode {
static type = 'DownloadListItemView';
public renderer_context?: RendererContext;
constructor(data: RawNode) {
super();
if ('rendererContext' in data) {
this.renderer_context = new RendererContext(data.rendererContext);
}
}
}

View File

@@ -0,0 +1,14 @@
import { YTNode } from '../helpers.ts';
import { Parser, type RawNode } from '../index.ts';
import SectionHeaderView from './SectionHeaderView.ts';
export default class HypeFanCreditsSectionView extends YTNode{
static type = 'HypeFanCreditsSectionView';
public header: SectionHeaderView | null;
constructor(data: RawNode) {
super();
this.header = Parser.parseItem(data.header, SectionHeaderView);
}
}

View File

@@ -5,18 +5,19 @@ import ItemSectionTabbedHeader from './ItemSectionTabbedHeader.ts';
import CommentsHeader from './comments/CommentsHeader.ts';
import SortFilterHeader from './SortFilterHeader.ts';
import FeedFilterChipBar from './FeedFilterChipBar.ts';
import ChipBarView from './ChipBarView.ts';
export default class ItemSection extends YTNode {
static type = 'ItemSection';
header: CommentsHeader | ItemSectionHeader | ItemSectionTabbedHeader | SortFilterHeader | FeedFilterChipBar | null;
header: CommentsHeader | ItemSectionHeader | ItemSectionTabbedHeader | SortFilterHeader | FeedFilterChipBar | ChipBarView | null;
contents: ObservedArray<YTNode>;
target_id?: string;
continuation?: string;
constructor(data: RawNode) {
super();
this.header = Parser.parseItem(data.header, [ CommentsHeader, ItemSectionHeader, ItemSectionTabbedHeader, SortFilterHeader, FeedFilterChipBar ]);
this.header = Parser.parseItem(data.header, [ CommentsHeader, ItemSectionHeader, ItemSectionTabbedHeader, SortFilterHeader, FeedFilterChipBar, ChipBarView ]);
this.contents = Parser.parseArray(data.contents);
if (data.targetId || data.sectionIdentifier) {

View File

@@ -13,9 +13,22 @@ export default class ListItemView extends YTNode {
public title?: Text;
public subtitle?: Text;
public selection_text?: Text;
public selection_style?:
| 'LIST_ITEM_SELECTION_STYLE_UNSPECIFIED'
| 'LIST_ITEM_SELECTION_STYLE_DEFAULT'
| 'LIST_ITEM_SELECTION_STYLE_CHECKBOX'
| 'LIST_ITEM_SELECTION_STYLE_RADIO'
| 'LIST_ITEM_SELECTION_STYLE_TOGGLE';
public background_color?: number;
public leading_accessory: AvatarView | null;
public renderer_context?: RendererContext;
public trailing_button: YTNode | null;
public trailing_buttons: ObservedArray<SubscribeButtonView>;
public is_disabled: boolean;
public is_selected: boolean;
public has_divider_below: boolean;
public renderer_context?: RendererContext;
constructor(data: RawNode) {
super();
@@ -27,12 +40,28 @@ export default class ListItemView extends YTNode {
this.subtitle = Text.fromAttributed(data.subtitle);
}
if ('selectionText' in data) {
this.selection_text = Text.fromAttributed(data.selectionText);
}
if ('selectionStyle' in data) {
this.selection_style = data.selectionStyle;
}
if ('backgroundColor' in data) {
this.background_color = parseInt(data.backgroundColor, 16);
}
this.leading_accessory = Parser.parseItem(data.leadingAccessory, AvatarView);
this.trailing_buttons = Parser.parseArray(data.trailingButtons?.buttons, SubscribeButtonView);
this.trailing_button = Parser.parseItem(data.trailingButton);
this.is_disabled = !!data.isDisabled;
this.is_selected = !!data.isSelected;
this.has_divider_below = !!data.hasDividerBelow;
if ('rendererContext' in data) {
this.renderer_context = new RendererContext(data.rendererContext);
}
this.trailing_buttons = Parser.parseArray(data.trailingButtons?.buttons, SubscribeButtonView);
}
}

View File

@@ -4,15 +4,22 @@ import { YTNode } from '../helpers.ts';
import { Parser } from '../index.ts';
import ListItemView from './ListItemView.ts';
import DownloadListItemView from './DownloadListItemView.ts';
import RendererContext from './misc/RendererContext.ts';
export default class ListView extends YTNode {
static type = 'ListView';
public items: ObservedArray<ListItemView>;
public items: ObservedArray<ListItemView | DownloadListItemView>;
public renderer_context?: RendererContext;
constructor(data: RawNode) {
super();
this.items = Parser.parseArray(data.listItems, ListItemView);
this.items = Parser.parseArray(data.listItems, [ ListItemView, DownloadListItemView ]);
if ('rendererContext' in data) {
this.renderer_context = new RendererContext(data.rendererContext);
}
}
}

View File

@@ -0,0 +1,15 @@
import { YTNode } from '../helpers.ts';
import type { RawNode } from '../index.ts';
export default class PageIndicatorView extends YTNode {
static type = 'PageIndicatorView';
public indicator_count: number;
public selected_index: number;
constructor(data: RawNode) {
super();
this.indicator_count = data.indicatorCount ?? 0;
this.selected_index = data.selectedIndex ?? 0;
}
}

View File

@@ -0,0 +1,23 @@
import { YTNode } from '../helpers.ts';
import { Parser, type RawNode } from '../index.ts';
import RendererContext from './misc/RendererContext.ts';
export default class SheetView extends YTNode {
static type = 'SheetView';
public content: YTNode | null;
public footer: YTNode | null;
public header: YTNode | null;
public renderer_context?: RendererContext;
constructor(data: RawNode) {
super();
this.content = Parser.parseItem(data.content);
this.footer = Parser.parseItem(data.footer);
this.header = Parser.parseItem(data.header);
if ('rendererContext' in data) {
this.renderer_context = new RendererContext(data.rendererContext);
}
}
}

View File

@@ -12,6 +12,7 @@ import HowThisWasMadeSectionView from './HowThisWasMadeSectionView.ts';
import ReelShelf from './ReelShelf.ts';
import ExpandableMetadata from './ExpandableMetadata.ts';
import MerchandiseShelf from './MerchandiseShelf.ts';
import HypeFanCreditsSectionView from './HypeFanCreditsSectionView.ts';
export default class StructuredDescriptionContent extends YTNode {
static type = 'StructuredDescriptionContent';
@@ -20,7 +21,7 @@ export default class StructuredDescriptionContent extends YTNode {
VideoDescriptionHeader | ExpandableVideoDescriptionBody | VideoDescriptionMusicSection |
VideoDescriptionInfocardsSection | VideoDescriptionTranscriptSection |
VideoDescriptionCourseSection | HorizontalCardList | ReelShelf | VideoAttributesSectionView |
HowThisWasMadeSectionView | ExpandableMetadata | MerchandiseShelf
HowThisWasMadeSectionView | ExpandableMetadata | MerchandiseShelf | HypeFanCreditsSectionView
>;
constructor(data: RawNode) {
@@ -29,7 +30,7 @@ export default class StructuredDescriptionContent extends YTNode {
VideoDescriptionHeader, ExpandableVideoDescriptionBody, VideoDescriptionMusicSection,
VideoDescriptionInfocardsSection, VideoDescriptionCourseSection, VideoDescriptionTranscriptSection,
VideoDescriptionTranscriptSection, HorizontalCardList, ReelShelf, VideoAttributesSectionView,
HowThisWasMadeSectionView, ExpandableMetadata, MerchandiseShelf
HowThisWasMadeSectionView, ExpandableMetadata, MerchandiseShelf, HypeFanCreditsSectionView
]);
}
}

View File

@@ -25,7 +25,7 @@ export default class ThumbnailBadgeView extends YTNode {
};
}
if (data.iconName) {
if (data.icon) {
this.icon_name = data.icon.sources[0].clientResource.imageName;
}
}

View File

@@ -0,0 +1,15 @@
import { YTNode } from '../helpers.ts';
import type { RawNode } from '../index.ts';
export default class ThumbnailOverlayTitleView extends YTNode {
static type = 'ThumbnailOverlayTitleView';
public title: string;
public subtitle: string;
constructor(data: RawNode) {
super();
this.title = data.title?.content ?? '';
this.subtitle = data.subtitle?.content ?? '';
}
}

View File

@@ -0,0 +1,15 @@
import { YTNode } from '../../helpers.ts';
import { Parser, type RawNode } from '../../index.ts';
export default class ShowSheetCommand extends YTNode {
static type = 'ShowSheetCommand';
public inline_content: YTNode | null;
public remove_default_padding: boolean;
constructor(data: RawNode) {
super();
this.inline_content = Parser.parseItem(data.panelLoadingStrategy?.inlineContent);
this.remove_default_padding = !!data.removeDefaultPadding;
}
}

View File

@@ -84,6 +84,7 @@ export { default as ContinuationCommand } from './classes/commands/ContinuationC
export { default as GetKidsBlocklistPickerCommand } from './classes/commands/GetKidsBlocklistPickerCommand.ts';
export { default as RunAttestationCommand } from './classes/commands/RunAttestationCommand.ts';
export { default as ShowDialogCommand } from './classes/commands/ShowDialogCommand.ts';
export { default as ShowSheetCommand } from './classes/commands/ShowSheetCommand.ts';
export { default as UpdateEngagementPanelContentCommand } from './classes/commands/UpdateEngagementPanelContentCommand.ts';
export { default as AuthorCommentBadge } from './classes/comments/AuthorCommentBadge.ts';
export { default as CommentActionButtons } from './classes/comments/CommentActionButtons.ts';
@@ -129,6 +130,7 @@ export { default as DislikeButtonView } from './classes/DislikeButtonView.ts';
export { default as DismissableDialog } from './classes/DismissableDialog.ts';
export { default as DismissableDialogContentSection } from './classes/DismissableDialogContentSection.ts';
export { default as DownloadButton } from './classes/DownloadButton.ts';
export { default as DownloadListItemView } from './classes/DownloadListItemView.ts';
export { default as Dropdown } from './classes/Dropdown.ts';
export { default as DropdownItem } from './classes/DropdownItem.ts';
export { default as DropdownView } from './classes/DropdownView.ts';
@@ -212,6 +214,7 @@ export { default as HorizontalCardList } from './classes/HorizontalCardList.ts';
export { default as HorizontalList } from './classes/HorizontalList.ts';
export { default as HorizontalMovieList } from './classes/HorizontalMovieList.ts';
export { default as HowThisWasMadeSectionView } from './classes/HowThisWasMadeSectionView.ts';
export { default as HypeFanCreditsSectionView } from './classes/HypeFanCreditsSectionView.ts';
export { default as HypePointsFactoid } from './classes/HypePointsFactoid.ts';
export { default as IconLink } from './classes/IconLink.ts';
export { default as ImageBannerView } from './classes/ImageBannerView.ts';
@@ -360,6 +363,7 @@ export { default as NotificationAction } from './classes/NotificationAction.ts';
export { default as OpenOnePickAddVideoModalCommand } from './classes/OpenOnePickAddVideoModalCommand.ts';
export { default as PageHeader } from './classes/PageHeader.ts';
export { default as PageHeaderView } from './classes/PageHeaderView.ts';
export { default as PageIndicatorView } from './classes/PageIndicatorView.ts';
export { default as PageIntroduction } from './classes/PageIntroduction.ts';
export { default as PanelFooterView } from './classes/PanelFooterView.ts';
export { default as PivotButton } from './classes/PivotButton.ts';
@@ -441,6 +445,7 @@ export { default as SharedPost } from './classes/SharedPost.ts';
export { default as SharePanelHeader } from './classes/SharePanelHeader.ts';
export { default as SharePanelTitleV15 } from './classes/SharePanelTitleV15.ts';
export { default as ShareTarget } from './classes/ShareTarget.ts';
export { default as SheetView } from './classes/SheetView.ts';
export { default as Shelf } from './classes/Shelf.ts';
export { default as ShortsLockupView } from './classes/ShortsLockupView.ts';
export { default as ShowCustomThumbnail } from './classes/ShowCustomThumbnail.ts';
@@ -489,6 +494,7 @@ export { default as ThumbnailOverlayProgressBarView } from './classes/ThumbnailO
export { default as ThumbnailOverlayResumePlayback } from './classes/ThumbnailOverlayResumePlayback.ts';
export { default as ThumbnailOverlaySidePanel } from './classes/ThumbnailOverlaySidePanel.ts';
export { default as ThumbnailOverlayTimeStatus } from './classes/ThumbnailOverlayTimeStatus.ts';
export { default as ThumbnailOverlayTitleView } from './classes/ThumbnailOverlayTitleView.ts';
export { default as ThumbnailOverlayToggleButton } from './classes/ThumbnailOverlayToggleButton.ts';
export { default as ThumbnailView } from './classes/ThumbnailView.ts';
export { default as TimedMarkerDecoration } from './classes/TimedMarkerDecoration.ts';

View File

@@ -141,7 +141,7 @@ export interface IStreamingData {
export type IPlayerResponse = Pick<IParsedResponse, 'captions' | 'cards' | 'endscreen' | 'microformat' | 'annotations' | 'playability_status' | 'streaming_data' | 'player_config' | 'playback_tracking' | 'storyboards' | 'video_details'>;
export type INextResponse = Pick<IParsedResponse, 'contents' | 'contents_memo' | 'continuation_contents' | 'continuation_contents_memo' | 'current_video_endpoint' | 'on_response_received_endpoints' | 'on_response_received_endpoints_memo' | 'player_overlays' | 'engagement_panels'>;
export type IBrowseResponse = Pick<IParsedResponse, 'background' | 'continuation_contents' | 'continuation_contents_memo' | 'on_response_received_actions' | 'on_response_received_actions_memo' | 'on_response_received_endpoints' | 'on_response_received_endpoints_memo' | 'contents' | 'contents_memo' | 'header' | 'header_memo' | 'metadata' | 'microformat' | 'alerts' | 'sidebar' | 'sidebar_memo'>;
export type ISearchResponse = Pick<IParsedResponse, 'header' | 'header_memo' | 'contents' | 'contents_memo' | 'on_response_received_commands' | 'continuation_contents' | 'continuation_contents_memo' | 'refinements' | 'estimated_results'>;
export type ISearchResponse = Pick<IParsedResponse, 'header' | 'header_memo' | 'contents' | 'contents_memo' | 'on_response_received_commands' | 'on_response_received_commands_memo' | 'continuation_contents' | 'continuation_contents_memo' | 'refinements' | 'estimated_results'>;
export type IResolveURLResponse = Pick<IParsedResponse, 'endpoint'>;
export type IGetTranscriptResponse = Pick<IParsedResponse, 'actions' | 'actions_memo'>;
export type IGetNotificationsMenuResponse = Pick<IParsedResponse, 'actions' | 'actions_memo'>;

View File

@@ -1,4 +1,5 @@
import Feed from '../../core/mixins/Feed.ts';
import type { FilterNodes } from '../../core/mixins/FilterableFeed.ts';
import FilterableFeed from '../../core/mixins/FilterableFeed.ts';
import { ChannelError, InnertubeError } from '../../utils/Utils.ts';
@@ -22,6 +23,12 @@ import ChannelSubMenu from '../classes/ChannelSubMenu.ts';
import SortFilterSubMenu from '../classes/SortFilterSubMenu.ts';
import ContinuationItem from '../classes/ContinuationItem.ts';
import NavigationEndpoint from '../classes/NavigationEndpoint.ts';
import SheetView from '../classes/SheetView.ts';
import ListView from '../classes/ListView.ts';
import ChipBarView from '../classes/ChipBarView.ts';
import ShowSheetCommand from '../classes/commands/ShowSheetCommand.ts';
import ChipView from '../classes/ChipView.ts';
import ListItemView from '../classes/ListItemView.ts';
import type {
AppendContinuationItemsAction,
@@ -29,9 +36,12 @@ import type {
ReloadContinuationItemsCommand,
ShowMiniplayerCommand
} from '../index.ts';
import type { ApiResponse, Actions } from '../../core/index.ts';
import type { IBrowseResponse } from '../types/index.ts';
import type OpenPopupAction from '../classes/actions/OpenPopupAction.ts';
import type { ObservedArray } from '../helpers.ts';
import { observe } from '../helpers.ts';
export default class Channel extends TabbedFeed<IBrowseResponse> {
public header?: C4TabbedHeader | CarouselHeader | InteractiveTabbedHeader | PageHeader;
@@ -39,6 +49,8 @@ export default class Channel extends TabbedFeed<IBrowseResponse> {
public subscribe_button?: SubscribeButton;
public current_tab?: Tab | ExpandableTab;
#filter_nodes?: FilterNodes;
constructor(actions: Actions, data: ApiResponse | IBrowseResponse, already_parsed = false) {
super(actions, data, already_parsed);
@@ -66,47 +78,89 @@ export default class Channel extends TabbedFeed<IBrowseResponse> {
}
/**
* Applies given filter to the list. Use {@link filters} to get available filters.
* @param filter - The filter to apply
* Applies a filter to the channel list. {@link filters}, {@link secondary_filters}, and {@link filter_nodes} can be used to get available filters.
*
* @param primaryFilter - The primary filter to apply. Can be a string representing the filter name,
* a {@link ChipView} instance, or a {@link ListItemView} instance.
* @param secondaryFilter - An optional secondary filter to apply after the primary filter.
* Can be a string representing the filter name or a {@link ChipView} instance.
*
* @example
* ```ts
* // Apply a primary filter by name.
* const filtered = await videos.applyFilter('Oldest');
*
* // Apply a primary and secondary filter by name.
* const filtered = await videos.applyFilter('Oldest', 'Members only');
*
* // Since we're using `filtered`, the following will return the latest members-only videos,
* // unless the secondary filter is explicitly changed.
* const latestMembersOnly = await filtered.applyFilter('Latest');
* ```
*/
async applyFilter(filter: string | ChipCloudChip): Promise<FilteredChannelList> {
let target_filter: ChipCloudChip | undefined;
async applyFilter(primaryFilter: string | ChipView | ListItemView, secondaryFilter?: string | ChipView) {
const chipBarView = this.memo.getType(ChipBarView)[0];
const filter_chipbar = this.memo.getType(FeedFilterChipBar)[0];
if (typeof filter === 'string') {
target_filter = filter_chipbar?.contents.find((chip) => chip.text === filter);
if (!target_filter)
throw new InnertubeError(`Filter ${filter} not found`, { available_filters: this.filters });
} else {
target_filter = filter;
if (!chipBarView) {
throw new InnertubeError('Filter chip bar not found');
}
if (!target_filter.endpoint)
throw new InnertubeError('Invalid filter', filter);
let endpoint: NavigationEndpoint | undefined;
const page = await target_filter.endpoint.call<IBrowseResponse>(this.actions, { parse: true });
if (typeof primaryFilter === 'string') {
if (!this.filters.includes(primaryFilter))
throw new InnertubeError(`Filter '${primaryFilter}' not found`, { available_filters: this.filters });
if (!page)
throw new InnertubeError('No page returned', { filter: target_filter });
const dropdownMenu = chipBarView.chips.find((chip) => chip.display_type === 'CHIP_VIEW_MODEL_DISPLAY_TYPE_DROP_DOWN');
return new FilteredChannelList(this.actions, page, true);
if (dropdownMenu) {
const tapCommand = dropdownMenu.tap_command?.command;
if (tapCommand?.is(ShowSheetCommand) && tapCommand.inline_content?.is(SheetView) && tapCommand.inline_content.content?.is(ListView)) {
const listViewItems = tapCommand.inline_content.content.items;
const matchingListItem = listViewItems.as(ListItemView).find((item) => item.title?.toString() === primaryFilter);
endpoint = matchingListItem?.renderer_context?.command_context?.on_tap?.as(NavigationEndpoint);
}
} else {
endpoint = chipBarView.chips.find((chip) => chip.text === primaryFilter)?.endpoint;
}
} else if (primaryFilter.is(ChipView)) {
endpoint = primaryFilter.endpoint;
} else if (primaryFilter.is(ListItemView)) {
endpoint = primaryFilter.renderer_context?.command_context?.on_tap?.as(NavigationEndpoint);
} else {
throw new InnertubeError('Invalid primary filter type');
}
if (!endpoint)
throw new InnertubeError('Could not find endpoint for the specified filter');
const page = await endpoint.call<IBrowseResponse>(this.actions, { parse: true });
let filteredChannelList = new FilteredChannelList(this.actions, page, true);
// Then apply secondary filter if provided.
if (secondaryFilter) {
filteredChannelList = await filteredChannelList.applyFilter(primaryFilter, secondaryFilter);
}
return filteredChannelList;
}
/**
* Applies given sort filter to the list. Use {@link sort_filters} to get available filters.
* @param sort - The sort filter to apply
* Applies a sort filter to the list. Use {@link sort_filters} to get available filters.
* @param sortFilter - The sort filter to apply
*/
async applySort(sort: string): Promise<Channel> {
async applySort(sortFilter: string): Promise<Channel> {
const sort_filter_sub_menu = this.memo.getType(SortFilterSubMenu)[0];
if (!sort_filter_sub_menu || !sort_filter_sub_menu.sub_menu_items)
throw new InnertubeError('No sort filter sub menu found');
const target_sort = sort_filter_sub_menu.sub_menu_items.find((item) => item.title === sort);
const target_sort = sort_filter_sub_menu.sub_menu_items.find((item) => item.title === sortFilter);
if (!target_sort)
throw new InnertubeError(`Sort filter ${sort} not found`, { available_sort_filters: this.sort_filters });
throw new InnertubeError(`Sort filter '${sortFilter}' not found`, { available_sort_filters: this.sort_filters });
if (target_sort.selected)
return this;
@@ -129,7 +183,7 @@ export default class Channel extends TabbedFeed<IBrowseResponse> {
const item = sub_menu.content_type_sub_menu_items.find((item) => item.title === content_type_filter);
if (!item)
throw new InnertubeError(`Sub menu item ${content_type_filter} not found`, { available_filters: this.content_type_filters });
throw new InnertubeError(`Sub menu item '${content_type_filter}' not found`, { available_filters: this.content_type_filters });
if (item.selected)
return this;
@@ -139,8 +193,77 @@ export default class Channel extends TabbedFeed<IBrowseResponse> {
return new Channel(this.actions, page, true);
}
/**
* Returns the InnerTube renderer nodes representing filters.
*/
get filter_nodes(): FilterNodes {
if (this.#filter_nodes)
return this.#filter_nodes;
let primary_filters: ObservedArray<ChipCloudChip | ListItemView | ChipView> | undefined;
let secondary_filters: ObservedArray<ChipView> | undefined;
if (this.memo.getType(FeedFilterChipBar)?.length > 1)
throw new InnertubeError('There are too many feed filter chipbars, you\'ll need to find the correct one yourself in this.page');
if (this.memo.has('FeedFilterChipBar')) {
primary_filters = this.memo.getType(ChipCloudChip);;
} else if (this.memo.has('ChipView')) {
const chips = this.memo.getType(ChipView);
const firstChip = chips[0];
if (firstChip.is(ChipView)) {
const hasDropdown =
firstChip.display_type === 'CHIP_VIEW_MODEL_DISPLAY_TYPE_DROP_DOWN' ||
firstChip.display_type === 'CHIP_VIEW_MODEL_DISPLAY_TYPE_DROP_DOWN_WITH_CLEAR';
if (hasDropdown) {
const tapCommand = firstChip.tap_command?.command;
if (
tapCommand?.is(ShowSheetCommand) &&
tapCommand.inline_content?.is(SheetView) &&
tapCommand.inline_content.content?.is(ListView)
) {
primary_filters = tapCommand.inline_content.content.items.as(ListItemView);
}
secondary_filters = observe(chips.slice(1) as ChipView[]);
} else primary_filters = chips;
}
}
this.#filter_nodes = {
primary_filters,
secondary_filters
};
return this.#filter_nodes;
}
/**
* Returns the available primary filters as strings.
*/
get filters(): string[] {
return this.memo.getType(FeedFilterChipBar)?.[0]?.contents.filterType(ChipCloudChip).map((chip) => chip.text) || [];
return this.filter_nodes?.primary_filters?.map((chip) => {
if (chip.is(ChipView) || chip.is(ChipCloudChip)) {
return chip.text?.toString() || '';
} else if (chip.is(ListItemView)) {
return chip.title?.toString() || '';
}
return '';
}) || [];
}
/**
* Returns the available secondary filters as strings.
*
* ---
*
* NOTE:
* Not all channels have secondary filters!
*/
get secondary_filters(): string[] {
return this.filter_nodes?.secondary_filters?.map((chip) => chip.text?.toString() || '') || [];
}
get sort_filters(): string[] {
@@ -324,13 +447,36 @@ export class ChannelListContinuation extends Feed<IBrowseResponse> {
}
export class FilteredChannelList extends FilterableFeed<IBrowseResponse> {
applied_filter?: ChipCloudChip;
contents?: AppendContinuationItemsAction | OpenPopupAction | NavigateAction | ShowMiniplayerCommand | ReloadContinuationItemsCommand;
public filter?: ChipView | ListItemView;
public secondary_filter?: ChipView;
public contents?: AppendContinuationItemsAction | OpenPopupAction | NavigateAction | ShowMiniplayerCommand | ReloadContinuationItemsCommand;
constructor(actions: Actions, data: ApiResponse | IBrowseResponse, already_parsed = false) {
super(actions, data, already_parsed);
this.applied_filter = this.memo.getType(ChipCloudChip).find((chip) => chip.is_selected);
const chipBarView = this.memo.getType(ChipBarView)[0];
if (chipBarView) {
const firstChip = chipBarView.chips[0];
const hasDropdown =
firstChip.display_type === 'CHIP_VIEW_MODEL_DISPLAY_TYPE_DROP_DOWN' ||
firstChip.display_type === 'CHIP_VIEW_MODEL_DISPLAY_TYPE_DROP_DOWN_WITH_CLEAR';
if (hasDropdown) {
this.secondary_filter = chipBarView.chips.slice(1).find((chip) => chip.selected);
const tapCommand = firstChip.tap_command?.command;
if (
tapCommand?.is(ShowSheetCommand) &&
tapCommand.inline_content?.is(SheetView) &&
tapCommand.inline_content.content?.is(ListView)
) {
this.filter = tapCommand.inline_content.content.items.as(ListItemView).find((item) => item.is_selected);
}
} else {
this.filter = chipBarView.chips.find((chip) => chip.selected);
}
}
// Removes the filter chipbar from the actions list
if (
@@ -347,8 +493,8 @@ export class FilteredChannelList extends FilterableFeed<IBrowseResponse> {
* Applies given filter to the list.
* @param filter - The filter to apply
*/
async applyFilter(filter: string | ChipCloudChip): Promise<FilteredChannelList> {
const feed = await super.getFilteredFeed(filter);
async applyFilter(filter: string | ChipView | ChipCloudChip | ListItemView, secondaryFilter?: string | ChipView): Promise<FilteredChannelList> {
const feed = await super.getFilteredFeed(filter, secondaryFilter);
return new FilteredChannelList(this.actions, feed.page, true);
}
@@ -358,9 +504,23 @@ export class FilteredChannelList extends FilterableFeed<IBrowseResponse> {
if (!page?.on_response_received_actions_memo)
throw new InnertubeError('Unexpected continuation data', page);
// Keep the filters
page.on_response_received_actions_memo.set('FeedFilterChipBar', this.memo.getType(FeedFilterChipBar));
page.on_response_received_actions_memo.set('ChipCloudChip', this.memo.getType(ChipCloudChip));
// Legacy filter system. Keep it here in case YouTube changes its mind.
if (this.memo.has('FeedFilterChipBar')) {
page.on_response_received_actions_memo.set('FeedFilterChipBar', this.memo.getType(FeedFilterChipBar));
}
if (this.memo.has('ChipCloudChip')) {
page.on_response_received_actions_memo.set('ChipCloudChip', this.memo.getType(ChipCloudChip));
}
// New filter system
if (this.memo.has('ChipBarView')) {
page.on_response_received_actions_memo.set('ChipBarView', this.memo.getType(ChipBarView));
}
if (this.memo.has('ChipView')) {
page.on_response_received_actions_memo.set('ChipView', this.memo.getType(ChipView));
}
return new FilteredChannelList(this.actions, page, true);
}

View File

@@ -1,20 +1,19 @@
import Feed from '../../core/mixins/Feed.ts';
import { InnertubeError } from '../../utils/Utils.ts';
import HorizontalCardList from '../classes/HorizontalCardList.ts';
import ItemSection from '../classes/ItemSection.ts';
import SearchHeader from '../classes/SearchHeader.ts';
import SearchRefinementCard from '../classes/SearchRefinementCard.ts';
import SearchSubMenu from '../classes/SearchSubMenu.ts';
import SectionList from '../classes/SectionList.ts';
import UniversalWatchCard from '../classes/UniversalWatchCard.ts';
import AppendContinuationItemsAction from '../classes/actions/AppendContinuationItemsAction.ts';
import ChipCloudChip from '../classes/ChipCloudChip.ts';
import type NavigationEndpoint from '../classes/NavigationEndpoint.ts';
import { ReloadContinuationItemsCommand } from '../continuations.ts';
import { observe } from '../helpers.ts';
import type { ApiResponse, Actions } from '../../core/index.ts';
import type { ObservedArray, YTNode } from '../helpers.ts';
import type { ISearchResponse } from '../types/index.ts';
import { ReloadContinuationItemsCommand } from '../index.ts';
import AppendContinuationItemsAction from '../classes/actions/AppendContinuationItemsAction.ts';
export default class Search extends Feed<ISearchResponse> {
public header?: SearchHeader;
@@ -23,23 +22,28 @@ export default class Search extends Feed<ISearchResponse> {
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<ISearchResponse> {
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<Search> {
let target_card: SearchRefinementCard | undefined;
async applyRefinement(refinementFilter: string | ChipCloudChip): Promise<Search> {
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<ISearchResponse>(this.actions, { parse: true });
if (!endpoint)
throw new InnertubeError('Could not find endpoint for the specified filter');
const page = await endpoint.call<ISearchResponse>(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) || [];
}
/**

View File

@@ -1,30 +1,31 @@
import { compress, decompress } from './LZW.ts';
import { gunzipSync, gzipSync } from 'fflate';
export const MAGIC_HEADER = 0x594254; // 'YTB' in hex...
export const VERSION = 1;
export const VERSION = 2;
export function serialize(data: any): ArrayBuffer {
const json_str = JSON.stringify(data);
const compressed = compress(json_str);
const compressed_bytes = new TextEncoder().encode(compressed);
const json = JSON.stringify(data);
const jsonBytes = new TextEncoder().encode(json);
const compressed = gzipSync(jsonBytes);
const buffer = new ArrayBuffer(12 + compressed_bytes.byteLength);
const buffer = new ArrayBuffer(12 + compressed.byteLength);
const view = new DataView(buffer);
view.setUint32(0, MAGIC_HEADER, true);
view.setUint32(4, VERSION, true);
view.setUint32(8, compressed_bytes.byteLength, true);
view.setUint32(8, compressed.byteLength, true);
new Uint8Array(buffer).set(compressed_bytes, 12);
new Uint8Array(buffer).set(compressed, 12);
return buffer;
}
export function deserialize<T>(buffer: Uint8Array): T {
if (buffer.byteLength < 12)
if (buffer.byteLength < 12) {
throw new Error('Invalid binary format: buffer too short');
}
const view = new DataView(buffer.buffer, buffer.byteOffset);
const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
const magic = view.getUint32(0, true);
if (magic !== MAGIC_HEADER) {
@@ -36,11 +37,14 @@ export function deserialize<T>(buffer: Uint8Array): T {
throw new Error(`Unsupported binary format version: ${version}`);
}
const data_length = view.getUint32(8, true);
const compressed_data = buffer.slice(12, 12 + data_length);
const length = view.getUint32(8, true);
if (12 + length > buffer.byteLength) {
throw new Error('Invalid binary format: data length out of bounds');
}
const compressed = new TextDecoder().decode(compressed_data);
const json_str = decompress(compressed);
const compressed = buffer.subarray(12, 12 + length);
const decompressed = gunzipSync(compressed);
const json = new TextDecoder().decode(decompressed);
return JSON.parse(json_str);
return JSON.parse(json) as T;
}

View File

@@ -1,64 +0,0 @@
/**
* Compresses a string using the LZW compression algorithm.
* @param input - The data to compress.
*/
export function compress(input: string): string {
const output: number[] = [];
const dictionary: Record<string, number> = {};
for (let i = 0; i < 256; i++) {
dictionary[String.fromCharCode(i)] = i;
}
let current_string = '';
let dictionary_size = 256;
for (let i = 0; i < input.length; i++) {
const current_char = input[i];
const combined_string = current_string + current_char;
if (dictionary.hasOwnProperty(combined_string)) {
current_string = combined_string;
} else {
output.push(dictionary[current_string]);
dictionary[combined_string] = dictionary_size++;
current_string = current_char;
}
}
if (current_string !== '') {
output.push(dictionary[current_string]);
}
return output.map((code) => String.fromCharCode(code)).join('');
}
/**
* Decompresses data that was compressed using the LZW compression algorithm.
* @param input - The data to be decompressed.
*/
export function decompress(input: string): string {
const dictionary: Record<number, string> = {};
const input_data = input.split('');
const output: string[] = [ input_data.shift() as string ];
const input_length = input_data.length >>> 0; // Convert to unsigned 32-bit integer
let dictionary_code = 256;
let current_char = output[0];
let current_string = current_char;
for (let i = 0; i < input_length; ++i) {
const current_code = input_data[i].charCodeAt(0);
const entry =
current_code < 256 ? input_data[i] : (dictionary[current_code] ?
dictionary[current_code] : (current_string + current_char));
output.push(entry);
current_char = entry.charAt(0);
dictionary[dictionary_code++] = current_string + current_char;
current_string = entry;
}
return output.join('');
}

View File

@@ -13,7 +13,6 @@ export { Platform } from './Utils.ts';
export * as Utils from './Utils.ts';
export * as Log from './Log.ts';
export * as LZW from './LZW.ts';
export * as BinarySerializer from './BinarySerializer.ts';
export * as ProtoUtils from './ProtoUtils.ts';