Compare commits

...

15 Commits

Author SHA1 Message Date
github-actions[bot]
137464ca66 chore(main): release 5.7.0 (#451)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-07-24 20:48:09 -03:00
LuanRT
6997982cf2 feat(parser): Add SearchHeader
We may want to remove the old SearchSubMenu node in the future but YouTube still uses it sometimes, so we will keep it for now.

Closes #452
2023-07-24 20:26:05 -03:00
absidue
18cbc8c038 feat(parser): Add PageHeader (#450) 2023-07-19 19:00:26 -03:00
github-actions[bot]
30ff087587 chore(main): release 5.6.0 (#448)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-07-18 15:34:48 -03:00
absidue
1a034733f6 feat(toDash): Add option to include thumbnails in the manifest (#446)
* feat(toDash): Add option to include thumbnails in the manifest

* refactor: Move toDash function back to MediaInfo class
2023-07-18 02:08:02 -03:00
ChunkyProgrammer
c477b824c0 feat(parser): Add IncludingResultsFor (#447) 2023-07-18 01:53:44 -03:00
github-actions[bot]
7e5c3648c1 chore(main): release 5.5.0 (#441)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-07-16 17:57:11 -03:00
absidue
bdd98a3b9b feat(Format): Populate audio language from captions when available (#445) 2023-07-16 17:54:26 -03:00
LuanRT
06750aaa74 chore: lint 2023-07-16 17:40:05 -03:00
LuanRT
708c5f7394 feat(parser): add MacroMarkersList (#444)
This should fix a few parsing issues that were happening after recent updates.
2023-07-16 17:38:16 -03:00
LuanRT
a9cdbf7010 feat(parser): Add ShowMiniplayerCommand (#443) 2023-07-16 17:34:42 -03:00
LuanRT
b50d1ef67d fix(StructuredDescriptionContent): items can also be a HorizontalCardList 2023-07-16 17:00:36 -03:00
LuanRT
555d257459 feat(parser): Add CommentsSimplebox parser (#442) 2023-07-16 16:46:46 -03:00
titong0
2aef67876e fix(package): Bump Jinter to fix bad export order (#439)
Version 1.0.0 has an export order which crashes some webpack environments (at least I came across it when using next.js 13). Updating to 1.1.0 fixes it. A bit more context here https://github.com/LuanRT/YouTube.js/issues/432

* chore(package): update lock file

* chore: lint

---------

Co-authored-by: LuanRT <luan.lrt4@gmail.com>
2023-07-16 16:23:14 -03:00
ChunkyProgrammer
ae2557d15c feat(parser): Add HashtagTile (#440) 2023-07-16 15:35:55 -03:00
31 changed files with 1454 additions and 968 deletions

View File

@@ -1,5 +1,38 @@
# Changelog
## [5.7.0](https://github.com/LuanRT/YouTube.js/compare/v5.6.0...v5.7.0) (2023-07-24)
### Features
* **parser:** Add `PageHeader` ([#450](https://github.com/LuanRT/YouTube.js/issues/450)) ([18cbc8c](https://github.com/LuanRT/YouTube.js/commit/18cbc8c038ddddffa1ba1519e56a8054b2996e42))
* **parser:** Add `SearchHeader` ([6997982](https://github.com/LuanRT/YouTube.js/commit/6997982cf2db87edf4929e9a77e2690e7b630d3d)), closes [#452](https://github.com/LuanRT/YouTube.js/issues/452)
## [5.6.0](https://github.com/LuanRT/YouTube.js/compare/v5.5.0...v5.6.0) (2023-07-18)
### Features
* **parser:** Add `IncludingResultsFor` ([#447](https://github.com/LuanRT/YouTube.js/issues/447)) ([c477b82](https://github.com/LuanRT/YouTube.js/commit/c477b824c084552169062f72cde8890e77b31f59))
* **toDash:** Add option to include thumbnails in the manifest ([#446](https://github.com/LuanRT/YouTube.js/issues/446)) ([1a03473](https://github.com/LuanRT/YouTube.js/commit/1a034733f6bb641e2d97df12de81ae3516c1f703))
## [5.5.0](https://github.com/LuanRT/YouTube.js/compare/v5.4.0...v5.5.0) (2023-07-16)
### Features
* **Format:** Populate audio language from captions when available ([#445](https://github.com/LuanRT/YouTube.js/issues/445)) ([bdd98a3](https://github.com/LuanRT/YouTube.js/commit/bdd98a3b9be39c11942043a300a6ebce9a15efc6))
* **parser:** Add `CommentsSimplebox` parser ([#442](https://github.com/LuanRT/YouTube.js/issues/442)) ([555d257](https://github.com/LuanRT/YouTube.js/commit/555d257459b76d7c0158e9c6b189a75a82b10faf))
* **parser:** Add `HashtagTile` ([#440](https://github.com/LuanRT/YouTube.js/issues/440)) ([ae2557d](https://github.com/LuanRT/YouTube.js/commit/ae2557d15c9df09bb92e0dc6191670d72b36631a))
* **parser:** add `MacroMarkersList` ([#444](https://github.com/LuanRT/YouTube.js/issues/444)) ([708c5f7](https://github.com/LuanRT/YouTube.js/commit/708c5f7394b4ea140836b9483848cb61b97ea1af))
* **parser:** Add `ShowMiniplayerCommand` ([#443](https://github.com/LuanRT/YouTube.js/issues/443)) ([a9cdbf7](https://github.com/LuanRT/YouTube.js/commit/a9cdbf7010e7b9b9cfde5db645d51bdad51006c5))
### Bug Fixes
* **package:** Bump Jinter to fix bad export order ([#439](https://github.com/LuanRT/YouTube.js/issues/439)) ([2aef678](https://github.com/LuanRT/YouTube.js/commit/2aef67876ec19118b37d3cecd429ccf8239989e0))
* **StructuredDescriptionContent:** `items` can also be a `HorizontalCardList` ([b50d1ef](https://github.com/LuanRT/YouTube.js/commit/b50d1ef67d81276864818de10c61b5a7980cbc1a))
## [5.4.0](https://github.com/LuanRT/YouTube.js/compare/v5.3.0...v5.4.0) (2023-07-14)

1857
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "youtubei.js",
"version": "5.4.0",
"version": "5.7.0",
"description": "A wrapper around YouTube's private API. Supports YouTube, YouTube Music, YouTube Kids and YouTube Studio (WIP).",
"type": "module",
"types": "./dist/src/platform/lib.d.ts",
@@ -84,7 +84,7 @@
},
"license": "MIT",
"dependencies": {
"jintr": "^1.0.0",
"jintr": "^1.1.0",
"linkedom": "^0.14.12",
"tslib": "^2.5.0",
"undici": "^5.19.1"

View File

@@ -7,6 +7,8 @@ import { InnertubeError } from '../../utils/Utils.js';
import type Format from '../../parser/classes/misc/Format.js';
import type { INextResponse, IPlayerResponse } from '../../parser/index.js';
import Parser from '../../parser/index.js';
import type { DashOptions } from '../../types/DashOptions.js';
import PlayerStoryboardSpec from '../../parser/classes/PlayerStoryboardSpec.js';
export default class MediaInfo {
#page: [IPlayerResponse, INextResponse?];
@@ -37,10 +39,17 @@ export default class MediaInfo {
* Generates a DASH manifest from the streaming data.
* @param url_transformer - Function to transform the URLs.
* @param format_filter - Function to filter the formats.
* @param options - Additional options to customise the manifest generation
* @returns DASH manifest
*/
async toDash(url_transformer?: URLTransformer, format_filter?: FormatFilter): Promise<string> {
return FormatUtils.toDash(this.streaming_data, url_transformer, format_filter, this.#cpn, this.#actions.session.player, this.#actions);
async toDash(url_transformer?: URLTransformer, format_filter?: FormatFilter, options: DashOptions = { include_thumbnails: false }): Promise<string> {
let storyboards;
if (options.include_thumbnails && this.#page[0].storyboards?.is(PlayerStoryboardSpec)) {
storyboards = this.#page[0].storyboards;
}
return FormatUtils.toDash(this.streaming_data, url_transformer, format_filter, this.#cpn, this.#actions.session.player, this.#actions, storyboards);
}
/**

View File

@@ -15,26 +15,20 @@ export default class Button extends YTNode {
constructor(data: RawNode) {
super();
if (Reflect.has(data, 'text')) {
if (Reflect.has(data, 'text'))
this.text = new Text(data.text).toString();
}
if (Reflect.has(data, 'accessibility') && Reflect.has(data.accessibility, 'label')) {
if (Reflect.has(data, 'accessibility') && Reflect.has(data.accessibility, 'label'))
this.label = data.accessibility.label;
}
if (Reflect.has(data, 'tooltip')) {
if (Reflect.has(data, 'tooltip'))
this.tooltip = data.tooltip;
}
if (Reflect.has(data, 'icon') && Reflect.has(data.icon, 'iconType')) {
if (Reflect.has(data, 'icon') && Reflect.has(data.icon, 'iconType'))
this.icon_type = data.icon.iconType;
}
if (Reflect.has(data, 'isDisabled')) {
if (Reflect.has(data, 'isDisabled'))
this.is_disabled = data.isDisabled;
}
this.endpoint = new NavigationEndpoint(data.navigationEndpoint || data.serviceEndpoint || data.command);
}

View File

@@ -0,0 +1,16 @@
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import Thumbnail from './misc/Thumbnail.js';
export default class ContentPreviewImageView extends YTNode {
static type = 'ContentPreviewImageView';
image: Thumbnail[];
style: string;
constructor(data: RawNode) {
super();
this.image = data.image.sources.map((x: any) => new Thumbnail(x)).sort((a: Thumbnail, b: Thumbnail) => b.width - a.width);
this.style = data.style;
}
}

View File

@@ -0,0 +1,13 @@
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
export default class DynamicTextView extends YTNode {
static type = 'DynamicTextView';
text: string;
constructor(data: RawNode) {
super();
this.text = data.text.content;
}
}

View File

@@ -2,6 +2,7 @@ import { YTNode } from '../helpers.js';
import Parser, { type RawNode } from '../index.js';
import ContinuationItem from './ContinuationItem.js';
import EngagementPanelTitleHeader from './EngagementPanelTitleHeader.js';
import MacroMarkersList from './MacroMarkersList.js';
import SectionList from './SectionList.js';
import StructuredDescriptionContent from './StructuredDescriptionContent.js';
@@ -9,7 +10,7 @@ export default class EngagementPanelSectionList extends YTNode {
static type = 'EngagementPanelSectionList';
header: EngagementPanelTitleHeader | null;
content: SectionList | ContinuationItem | StructuredDescriptionContent | null;
content: SectionList | ContinuationItem | StructuredDescriptionContent | MacroMarkersList | null;
target_id?: string;
panel_identifier?: string;
visibility?: string;
@@ -17,7 +18,7 @@ export default class EngagementPanelSectionList extends YTNode {
constructor(data: RawNode) {
super();
this.header = Parser.parseItem(data.header, EngagementPanelTitleHeader);
this.content = Parser.parseItem(data.content, [ SectionList, ContinuationItem, StructuredDescriptionContent ]);
this.content = Parser.parseItem(data.content, [ SectionList, ContinuationItem, StructuredDescriptionContent, MacroMarkersList ]);
this.panel_identifier = data.panelIdentifier;
this.target_id = data.targetId;
this.visibility = data.visibility;

View File

@@ -0,0 +1,28 @@
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import { Thumbnail } from '../misc.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import Text from './misc/Text.js';
export default class HashtagTile extends YTNode {
static type = 'HashtagTile';
hashtag: Text;
hashtag_info_text: Text;
hashtag_thumbnail: Thumbnail[];
endpoint: NavigationEndpoint;
hashtag_background_color: number;
hashtag_video_count: Text;
hashtag_channel_count: Text;
constructor(data: RawNode) {
super();
this.hashtag = new Text(data.hashtag);
this.hashtag_info_text = new Text(data.hashtagInfoText);
this.hashtag_thumbnail = Thumbnail.fromResponse(data.hashtagThumbnail);
this.endpoint = new NavigationEndpoint(data.onTapCommand);
this.hashtag_background_color = data.hashtagBackgroundColor;
this.hashtag_video_count = new Text(data.hashtagVideoCount);
this.hashtag_channel_count = new Text(data.hashtagChannelCount);
}
}

View File

@@ -0,0 +1,25 @@
import { YTNode } from '../helpers.js';
import type { RawNode } from '../index.js';
import NavigationEndpoint from './NavigationEndpoint.js';
import Text from './misc/Text.js';
export default class IncludingResultsFor extends YTNode {
static type = 'IncludingResultsFor';
including_results_for: Text;
corrected_query: Text;
corrected_query_endpoint: NavigationEndpoint;
search_only_for?: Text;
original_query?: Text;
original_query_endpoint?: NavigationEndpoint;
constructor(data: RawNode) {
super();
this.including_results_for = new Text(data.includingResultsFor);
this.corrected_query = new Text(data.correctedQuery);
this.corrected_query_endpoint = new NavigationEndpoint(data.correctedQueryEndpoint);
this.search_only_for = Reflect.has(data, 'searchOnlyFor') ? new Text(data.searchOnlyFor) : undefined;
this.original_query = Reflect.has(data, 'originalQuery') ? new Text(data.originalQuery) : undefined;
this.original_query_endpoint = Reflect.has(data, 'originalQueryEndpoint') ? new NavigationEndpoint(data.originalQueryEndpoint) : undefined;
}
}

View File

@@ -0,0 +1,17 @@
import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import Menu from './menus/Menu.js';
import Text from './misc/Text.js';
export default class MacroMarkersInfoItem extends YTNode {
static type = 'MacroMarkersInfoItem';
info_text: Text;
menu: Menu | null;
constructor(data: RawNode) {
super();
this.info_text = new Text(data.infoText);
this.menu = Parser.parseItem(data.menu, Menu);
}
}

View File

@@ -0,0 +1,18 @@
import { YTNode, type ObservedArray } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import { Text } from '../misc.js';
import MacroMarkersInfoItem from './MacroMarkersInfoItem.js';
import MacroMarkersListItem from './MacroMarkersListItem.js';
export default class MacroMarkersList extends YTNode {
static type = 'MacroMarkersList';
contents: ObservedArray<MacroMarkersInfoItem | MacroMarkersListItem>;
sync_button_label: Text;
constructor(data: RawNode) {
super();
this.contents = Parser.parseArray(data.contents, [ MacroMarkersInfoItem, MacroMarkersListItem ]);
this.sync_button_label = new Text(data.syncButtonLabel);
}
}

View File

@@ -4,12 +4,14 @@ import { YTNode } from '../helpers.js';
import Parser, { type RawNode } from '../index.js';
import type { IParsedResponse } from '../types/ParsedResponse.js';
import CreatePlaylistDialog from './CreatePlaylistDialog.js';
import OpenPopupAction from './actions/OpenPopupAction.js';
export default class NavigationEndpoint extends YTNode {
static type = 'NavigationEndpoint';
payload;
dialog?: CreatePlaylistDialog | YTNode | null;
open_popup?: OpenPopupAction | null;
metadata: {
url?: string;
@@ -24,6 +26,9 @@ export default class NavigationEndpoint extends YTNode {
if (Reflect.has(data || {}, 'innertubeCommand'))
data = data.innertubeCommand;
if (Reflect.has(data || {}, 'openPopupAction'))
this.open_popup = new OpenPopupAction(data.openPopupAction);
const name = Object.keys(data || {})
.find((item) =>
item.endsWith('Endpoint') ||
@@ -36,6 +41,7 @@ export default class NavigationEndpoint extends YTNode {
this.dialog = Parser.parseItem(this.payload.dialog || this.payload.content);
}
if (data?.serviceEndpoint) {
data = data.serviceEndpoint;
}
@@ -85,9 +91,9 @@ export default class NavigationEndpoint extends YTNode {
}
}
call<T extends IParsedResponse>(actions: Actions, args: { [ key: string ]: any; parse: true }): Promise<T>;
call(actions: Actions, args?: { [ key: string ]: any; parse?: false }): Promise<ApiResponse>;
call(actions: Actions, args?: { [ key: string ]: any; parse?: boolean }): Promise<IParsedResponse | ApiResponse> {
call<T extends IParsedResponse>(actions: Actions, args: { [key: string]: any; parse: true }): Promise<T>;
call(actions: Actions, args?: { [key: string]: any; parse?: false }): Promise<ApiResponse>;
call(actions: Actions, args?: { [key: string]: any; parse?: boolean }): Promise<IParsedResponse | ApiResponse> {
if (!actions)
throw new Error('An active caller must be provided');
if (!this.metadata.api_url)

View File

@@ -0,0 +1,16 @@
import { YTNode } from '../helpers.js';
import Parser, { type RawNode } from '../index.js';
import PageHeaderView from './PageHeaderView.js';
export default class PageHeader extends YTNode {
static type = 'PageHeader';
page_title: string;
content: PageHeaderView | null;
constructor(data: RawNode) {
super();
this.page_title = data.pageTitle;
this.content = Parser.parseItem(data.content, PageHeaderView);
}
}

View File

@@ -0,0 +1,17 @@
import { YTNode } from '../helpers.js';
import Parser, { type RawNode } from '../index.js';
import ContentPreviewImageView from './ContentPreviewImageView.js';
import DynamicTextView from './DynamicTextView.js';
export default class PageHeaderView extends YTNode {
static type = 'PageHeaderView';
image: ContentPreviewImageView | null;
title: DynamicTextView | null;
constructor(data: RawNode) {
super();
this.image = Parser.parseItem(data.image, ContentPreviewImageView);
this.title = Parser.parseItem(data.title, DynamicTextView);
}
}

View File

@@ -17,10 +17,10 @@ export default class PlayerCaptionsTracklist extends YTNode {
audio_tracks?: {
audio_track_id: string;
captions_initial_state: string;
default_caption_track_index: number;
default_caption_track_index?: number;
has_default_track: boolean;
visibility: string;
caption_track_indices: number;
caption_track_indices: number[];
}[];
default_audio_track_index?: number;

View File

@@ -13,7 +13,7 @@ export default class PlayerStoryboardSpec extends YTNode {
columns: number;
rows: number;
storyboard_count: number;
};
}[];
constructor(data: RawNode) {
super();

View File

@@ -0,0 +1,18 @@
import type { ObservedArray } from '../helpers.js';
import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import SearchFilterGroup from './SearchFilterGroup.js';
import Text from './misc/Text.js';
export default class SearchFilterOptionsDialog extends YTNode {
static type = 'SearchFilterOptionsDialog';
title: Text;
groups: ObservedArray<SearchFilterGroup>;
constructor(data: RawNode) {
super();
this.title = new Text(data.title);
this.groups = Parser.parseArray(data.groups, SearchFilterGroup);
}
}

View File

@@ -0,0 +1,18 @@
import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import Button from './Button.js';
import ChipCloud from './ChipCloud.js';
export default class SearchHeader extends YTNode {
static type = 'SearchHeader';
chip_bar: ChipCloud | null;
search_filter_button: Button | null;
constructor(data: RawNode) {
super();
this.chip_bar = Parser.parseItem(data.chipBar, ChipCloud);
this.search_filter_button = Parser.parseItem(data.searchFilterButton, Button);
console.log(this.search_filter_button?.endpoint.open_popup);
}
}

View File

@@ -8,14 +8,19 @@ import ToggleButton from './ToggleButton.js';
export default class SearchSubMenu extends YTNode {
static type = 'SearchSubMenu';
title: Text;
groups: ObservedArray<SearchFilterGroup>;
button: ToggleButton | null;
title?: Text;
groups?: ObservedArray<SearchFilterGroup>;
button?: ToggleButton | null;
constructor(data: RawNode) {
super();
this.title = new Text(data.title);
this.groups = Parser.parseArray(data.groups, SearchFilterGroup);
this.button = Parser.parseItem(data.button, ToggleButton);
if (Reflect.has(data, 'title'))
this.title = new Text(data.title);
if (!Reflect.has(data, 'groups'))
this.groups = Parser.parseArray(data.groups, SearchFilterGroup);
if (Reflect.has(data, 'button'))
this.button = Parser.parseItem(data.button, ToggleButton);
}
}

View File

@@ -1,6 +1,7 @@
import { YTNode, type ObservedArray } from '../helpers.js';
import Parser, { type RawNode } from '../index.js';
import ExpandableVideoDescriptionBody from './ExpandableVideoDescriptionBody.js';
import HorizontalCardList from './HorizontalCardList.js';
import VideoDescriptionHeader from './VideoDescriptionHeader.js';
import VideoDescriptionInfocardsSection from './VideoDescriptionInfocardsSection.js';
import VideoDescriptionMusicSection from './VideoDescriptionMusicSection.js';
@@ -8,10 +9,10 @@ import VideoDescriptionMusicSection from './VideoDescriptionMusicSection.js';
export default class StructuredDescriptionContent extends YTNode {
static type = 'StructuredDescriptionContent';
items: ObservedArray<VideoDescriptionHeader | ExpandableVideoDescriptionBody | VideoDescriptionMusicSection | VideoDescriptionInfocardsSection>;
items: ObservedArray<VideoDescriptionHeader | ExpandableVideoDescriptionBody | VideoDescriptionMusicSection | VideoDescriptionInfocardsSection | HorizontalCardList>;
constructor(data: RawNode) {
super();
this.items = Parser.parseArray(data.items, [ VideoDescriptionHeader, ExpandableVideoDescriptionBody, VideoDescriptionMusicSection, VideoDescriptionInfocardsSection ]);
this.items = Parser.parseArray(data.items, [ VideoDescriptionHeader, ExpandableVideoDescriptionBody, VideoDescriptionMusicSection, VideoDescriptionInfocardsSection, HorizontalCardList ]);
}
}

View File

@@ -1,9 +1,10 @@
import Text from '../misc/Text.js';
import Thumbnail from '../misc/Thumbnail.js';
import CommentsSimplebox from './CommentsSimplebox.js';
import CommentsEntryPointTeaser from './CommentsEntryPointTeaser.js';
import { YTNode } from '../../helpers.js';
import type { RawNode } from '../../index.js';
import { Parser } from '../../index.js';
import CommentsEntryPointTeaser from './CommentsEntryPointTeaser.js';
export default class CommentsEntryPointHeader extends YTNode {
static type = 'CommentsEntryPointHeader';
@@ -12,7 +13,7 @@ export default class CommentsEntryPointHeader extends YTNode {
comment_count?: Text;
teaser_avatar?: Thumbnail[];
teaser_content?: Text;
content_renderer?: CommentsEntryPointTeaser | null;
content_renderer?: CommentsEntryPointTeaser | CommentsSimplebox | null;
simplebox_placeholder?: Text;
constructor(data: RawNode) {
@@ -35,7 +36,7 @@ export default class CommentsEntryPointHeader extends YTNode {
}
if (Reflect.has(data, 'contentRenderer')) {
this.content_renderer = Parser.parseItem(data.contentRenderer, CommentsEntryPointTeaser);
this.content_renderer = Parser.parseItem(data.contentRenderer, [ CommentsEntryPointTeaser, CommentsSimplebox ]);
}
if (Reflect.has(data, 'simpleboxPlaceholder')) {

View File

@@ -0,0 +1,17 @@
import { YTNode } from '../../helpers.js';
import Text from '../misc/Text.js';
import Thumbnail from '../misc/Thumbnail.js';
import type { RawNode } from '../../index.js';
export default class CommentsSimplebox extends YTNode {
static type = 'CommentsSimplebox';
simplebox_avatar: Thumbnail[];
simplebox_placeholder: Text;
constructor(data: RawNode) {
super();
this.simplebox_avatar = Thumbnail.fromResponse(data.simpleboxAvatar);
this.simplebox_placeholder = new Text(data.simpleboxPlaceholder);
}
}

View File

@@ -58,6 +58,7 @@ export { default as CommentsEntryPointHeader } from './classes/comments/Comments
export { default as CommentsEntryPointTeaser } from './classes/comments/CommentsEntryPointTeaser.js';
export { default as CommentsHeader } from './classes/comments/CommentsHeader.js';
export { default as CommentSimplebox } from './classes/comments/CommentSimplebox.js';
export { default as CommentsSimplebox } from './classes/comments/CommentsSimplebox.js';
export { default as CommentThread } from './classes/comments/CommentThread.js';
export { default as CreatorHeart } from './classes/comments/CreatorHeart.js';
export { default as EmojiPicker } from './classes/comments/EmojiPicker.js';
@@ -70,6 +71,7 @@ export { default as CompactPlaylist } from './classes/CompactPlaylist.js';
export { default as CompactStation } from './classes/CompactStation.js';
export { default as CompactVideo } from './classes/CompactVideo.js';
export { default as ConfirmDialog } from './classes/ConfirmDialog.js';
export { default as ContentPreviewImageView } from './classes/ContentPreviewImageView.js';
export { default as ContinuationItem } from './classes/ContinuationItem.js';
export { default as ConversationBar } from './classes/ConversationBar.js';
export { default as CopyLink } from './classes/CopyLink.js';
@@ -80,6 +82,7 @@ export { default as DidYouMean } from './classes/DidYouMean.js';
export { default as DownloadButton } from './classes/DownloadButton.js';
export { default as Dropdown } from './classes/Dropdown.js';
export { default as DropdownItem } from './classes/DropdownItem.js';
export { default as DynamicTextView } from './classes/DynamicTextView.js';
export { default as Element } from './classes/Element.js';
export { default as EmergencyOnebox } from './classes/EmergencyOnebox.js';
export { default as EmojiPickerCategory } from './classes/EmojiPickerCategory.js';
@@ -115,6 +118,7 @@ export { default as GuideEntry } from './classes/GuideEntry.js';
export { default as GuideSection } from './classes/GuideSection.js';
export { default as GuideSubscriptionsSection } from './classes/GuideSubscriptionsSection.js';
export { default as HashtagHeader } from './classes/HashtagHeader.js';
export { default as HashtagTile } from './classes/HashtagTile.js';
export { default as Heatmap } from './classes/Heatmap.js';
export { default as HeatMarker } from './classes/HeatMarker.js';
export { default as HeroPlaylistThumbnail } from './classes/HeroPlaylistThumbnail.js';
@@ -124,6 +128,7 @@ export { default as HorizontalCardList } from './classes/HorizontalCardList.js';
export { default as HorizontalList } from './classes/HorizontalList.js';
export { default as HorizontalMovieList } from './classes/HorizontalMovieList.js';
export { default as IconLink } from './classes/IconLink.js';
export { default as IncludingResultsFor } from './classes/IncludingResultsFor.js';
export { default as InfoPanelContainer } from './classes/InfoPanelContainer.js';
export { default as InfoPanelContent } from './classes/InfoPanelContent.js';
export { default as InfoRow } from './classes/InfoRow.js';
@@ -178,6 +183,8 @@ export { default as LiveChatItemList } from './classes/LiveChatItemList.js';
export { default as LiveChatMessageInput } from './classes/LiveChatMessageInput.js';
export { default as LiveChatParticipant } from './classes/LiveChatParticipant.js';
export { default as LiveChatParticipantsList } from './classes/LiveChatParticipantsList.js';
export { default as MacroMarkersInfoItem } from './classes/MacroMarkersInfoItem.js';
export { default as MacroMarkersList } from './classes/MacroMarkersList.js';
export { default as MacroMarkersListItem } from './classes/MacroMarkersListItem.js';
export { default as Menu } from './classes/menus/Menu.js';
export { default as MenuNavigationItem } from './classes/menus/MenuNavigationItem.js';
@@ -233,6 +240,8 @@ export { default as MusicTwoRowItem } from './classes/MusicTwoRowItem.js';
export { default as MusicVisualHeader } from './classes/MusicVisualHeader.js';
export { default as NavigationEndpoint } from './classes/NavigationEndpoint.js';
export { default as Notification } from './classes/Notification.js';
export { default as PageHeader } from './classes/PageHeader.js';
export { default as PageHeaderView } from './classes/PageHeaderView.js';
export { default as PageIntroduction } from './classes/PageIntroduction.js';
export { default as PlayerAnnotationsExpanded } from './classes/PlayerAnnotationsExpanded.js';
export { default as PlayerCaptionsTracklist } from './classes/PlayerCaptionsTracklist.js';
@@ -280,6 +289,8 @@ export { default as RichShelf } from './classes/RichShelf.js';
export { default as SearchBox } from './classes/SearchBox.js';
export { default as SearchFilter } from './classes/SearchFilter.js';
export { default as SearchFilterGroup } from './classes/SearchFilterGroup.js';
export { default as SearchFilterOptionsDialog } from './classes/SearchFilterOptionsDialog.js';
export { default as SearchHeader } from './classes/SearchHeader.js';
export { default as SearchRefinementCard } from './classes/SearchRefinementCard.js';
export { default as SearchSubMenu } from './classes/SearchSubMenu.js';
export { default as SearchSuggestion } from './classes/SearchSuggestion.js';

View File

@@ -413,6 +413,8 @@ export default class Parser {
return observe(actions.map((action: any) => {
if (action.navigateAction)
return new NavigateAction(action.navigateAction);
if (action.showMiniplayerCommand)
return new ShowMiniplayerCommand(action.showMiniplayerCommand);
if (action.reloadContinuationItemsCommand)
return new ReloadContinuationItemsCommand(action.reloadContinuationItemsCommand);
if (action.appendContinuationItemsAction)
@@ -574,6 +576,19 @@ export class NavigateAction extends YTNode {
}
}
export class ShowMiniplayerCommand extends YTNode {
static readonly type = 'showMiniplayerCommand';
miniplayer_command: NavigationEndpoint;
show_premium_branding: boolean;
constructor(data: RawNode) {
super();
this.miniplayer_command = new NavigationEndpoint(data.miniplayerCommand);
this.show_premium_branding = data.showPremiumBranding;
}
}
export class AppendContinuationItemsAction extends YTNode {
static readonly type = 'appendContinuationItemsAction';

View File

@@ -9,6 +9,7 @@ import SubscribeButton from '../classes/SubscribeButton.js';
import ExpandableTab from '../classes/ExpandableTab.js';
import SectionList from '../classes/SectionList.js';
import Tab from '../classes/Tab.js';
import PageHeader from '../classes/PageHeader.js';
import Feed from '../../core/mixins/Feed.js';
import FilterableFeed from '../../core/mixins/FilterableFeed.js';
@@ -25,7 +26,7 @@ import type { ApiResponse } from '../../core/Actions.js';
import type { IBrowseResponse } from '../types/index.js';
export default class Channel extends TabbedFeed<IBrowseResponse> {
header?: C4TabbedHeader | CarouselHeader | InteractiveTabbedHeader;
header?: C4TabbedHeader | CarouselHeader | InteractiveTabbedHeader | PageHeader;
metadata;
subscribe_button?: SubscribeButton;
current_tab?: Tab | ExpandableTab;
@@ -33,7 +34,7 @@ export default class Channel extends TabbedFeed<IBrowseResponse> {
constructor(actions: Actions, data: ApiResponse | IBrowseResponse, already_parsed = false) {
super(actions, data, already_parsed);
this.header = this.page.header?.item()?.as(C4TabbedHeader, CarouselHeader, InteractiveTabbedHeader);
this.header = this.page.header?.item()?.as(C4TabbedHeader, CarouselHeader, InteractiveTabbedHeader, PageHeader);
const metadata = this.page.metadata?.item().as(ChannelMetadata);
const microformat = this.page.microformat?.as(MicroformatData);

View File

@@ -2,6 +2,7 @@ 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';
@@ -11,8 +12,8 @@ import type Actions from '../../core/Actions.js';
import type { ApiResponse } from '../../core/Actions.js';
import type { ObservedArray, YTNode } from '../helpers.js';
import type { ISearchResponse } from '../types/ParsedResponse.js';
class Search extends Feed<ISearchResponse> {
header?: SearchHeader;
results?: ObservedArray<YTNode> | null;
refinements: string[];
estimated_results: number;
@@ -30,6 +31,9 @@ class Search extends Feed<ISearchResponse> {
if (!contents)
throw new InnertubeError('No contents found in search response');
if (this.page.header)
this.header = this.page.header.item().as(SearchHeader);
this.results = contents.find((content) => content.is(ItemSection) && content.contents && content.contents.length > 0)?.as(ItemSection).contents;
this.refinements = this.page.refinements || [];

View File

@@ -99,6 +99,28 @@ class VideoInfo extends MediaInfo {
this.captions = info.captions;
this.cards = info.cards;
if (this.streaming_data) {
const default_audio_track = this.streaming_data.adaptive_formats.find((format) => format.audio_track?.audio_is_default);
if (default_audio_track) {
// The combined formats only exist for the default language, even for videos with multiple audio tracks
// So we can copy the language from the default audio track to the combined formats
this.streaming_data.formats.forEach((format) => format.language = default_audio_track.language);
} else if (typeof this.captions?.default_audio_track_index !== 'undefined' && this.captions?.audio_tracks && this.captions.caption_tracks) {
// For videos with a single audio track and captions, we can use the captions to figure out the language of the audio and combined formats
const audioTrack = this.captions.audio_tracks[this.captions.default_audio_track_index];
const defaultCaptionTrackIndex = audioTrack.default_caption_track_index;
const index = audioTrack.caption_track_indices[defaultCaptionTrackIndex ? defaultCaptionTrackIndex : 0];
const language_code = this.captions.caption_tracks[index].language_code;
this.streaming_data.adaptive_formats.forEach((format) => {
if (format.has_audio) {
format.language = language_code;
}
});
this.streaming_data.formats.forEach((format) => format.language = language_code);
}
}
const two_col = next?.contents?.item().as(TwoColumnWatchNextResults);
const results = two_col?.results;

9
src/types/DashOptions.ts Normal file
View File

@@ -0,0 +1,9 @@
export interface DashOptions {
/**
* Include the storyboards in the DASH manifest when YouTube provides them.
* Not all players support parsing and displaying storyboards.
* If the player you are using doesn't support them you can leave this option disabled.
* They don't get included by default, as this requires making some extra requests while generating the manifest, which can slow down the manifest generation.
*/
include_thumbnails?: boolean
}

View File

@@ -3,6 +3,7 @@ import type Actions from '../core/Actions.js';
import type Format from '../parser/classes/misc/Format.js';
import type AudioOnlyPlayability from '../parser/classes/AudioOnlyPlayability.js';
import type PlayerStoryboardSpec from '../parser/classes/PlayerStoryboardSpec.js';
import type { YTNode } from '../parser/helpers.js';
import * as Constants from './Constants.js';
@@ -146,7 +147,7 @@ class FormatUtils {
chunk_end += chunk_size;
resolve();
return;
} catch (e: any) {
reject(e);
}
@@ -245,7 +246,7 @@ class FormatUtils {
adaptive_formats: Format[];
dash_manifest_url: string | null;
hls_manifest_url: string | null;
}, url_transformer: URLTransformer = (url) => url, format_filter?: FormatFilter, cpn?: string, player?: Player, actions?: Actions): Promise<string> {
}, url_transformer: URLTransformer = (url) => url, format_filter?: FormatFilter, cpn?: string, player?: Player, actions?: Actions, storyboards?: PlayerStoryboardSpec): Promise<string> {
if (!streaming_data)
throw new InnertubeError('Streaming data not available');
@@ -280,7 +281,7 @@ class FormatUtils {
period
]));
await this.#generateAdaptationSet(document, period, adaptive_formats, url_transformer, cpn, player, actions);
await this.#generateAdaptationSet(document, period, adaptive_formats, url_transformer, cpn, player, actions, storyboards);
return Platform.shim.serializeDOM(document);
}
@@ -297,7 +298,16 @@ class FormatUtils {
return el;
}
static async #generateAdaptationSet(document: XMLDocument, period: Element, formats: Format[], url_transformer: URLTransformer, cpn?: string, player?: Player, actions?: Actions) {
static async #generateAdaptationSet(
document: XMLDocument,
period: Element,
formats: Format[],
url_transformer: URLTransformer,
cpn?: string,
player?: Player,
actions?: Actions,
storyboards?: PlayerStoryboardSpec
) {
const mime_types: string[] = [];
const mime_objects: Format[][] = [ [] ];
@@ -491,6 +501,11 @@ class FormatUtils {
this.#hoistNumberAttributeIfPossible(set, mime_objects[i], 'audioSamplingRate', 'audio_sample_rate', hoisted);
this.#hoistAudioChannelsIfPossible(document, set, mime_objects[i], hoisted);
const language = mime_objects[i][0].language;
if (language) {
set.setAttribute('lang', language);
}
} else {
set.setAttribute('maxPlayoutRate', '1');
@@ -508,6 +523,64 @@ class FormatUtils {
}
}
}
// We need to make requests to get the image sizes, so we'll skip the storyboards if we don't have an Actions instance
if (storyboards && actions) {
const mime_types: string[] = [];
const mime_objects: {
template_url: string;
thumbnail_width: number;
thumbnail_height: number;
thumbnail_count: number;
interval: number;
columns: number;
rows: number;
storyboard_count: number;
}[][] = [ [] ];
for (const storyboard of storyboards.boards) {
const extension = new URL(storyboard.template_url).pathname.split('.').at(-1);
let mime_type = '';
switch (extension) {
case 'jpg':
mime_type = 'image/jpeg';
break;
case 'png':
mime_type = 'image/png';
break;
case 'webp':
mime_type = 'image/webp';
break;
}
const mime_type_index = mime_types.indexOf(mime_type);
if (mime_type_index > -1) {
mime_objects[mime_type_index].push(storyboard);
} else {
mime_types.push(mime_type);
mime_objects.push([]);
mime_objects[mime_types.length - 1].push(storyboard);
}
}
const duration = formats[0].approx_duration_ms / 1000;
for (let i = 0; i < mime_types.length; i++) {
const set = this.#el(document, 'AdaptationSet', {
id: `${set_id++}`,
mimeType: mime_types[i],
contentType: 'image'
});
period.appendChild(set);
for (const storyboard of mime_objects[i]) {
await this.#generateRepresentationImage(document, set, storyboard, duration, url_transformer, actions);
}
}
}
}
static #hoistCodecsIfPossible(set: Element, formats: Format[], hoisted: string[]) {
@@ -600,6 +673,75 @@ class FormatUtils {
await this.#generateSegmentInformation(document, representation, format, url_transformer(url)?.toString(), actions);
}
static async #generateRepresentationImage(document: XMLDocument, set: Element, storyboard: {
template_url: string;
thumbnail_width: number;
thumbnail_height: number;
thumbnail_count: number;
interval: number;
columns: number;
rows: number;
storyboard_count: number;
}, duration: number, url_transformer: URLTransformer, actions: Actions) {
const url = storyboard.template_url;
const response_promises: Promise<Response>[] = [];
// Set a limit so we don't take forever for long videos
const requestLimit = storyboard.storyboard_count > 10 ? 10 : storyboard.storyboard_count;
for (let i = 0; i < requestLimit; i++) {
const response_promise = actions.session.http.fetch_function(new URL(url.replace('$M', i.toString())), {
method: 'HEAD',
headers: Constants.STREAM_HEADERS
});
response_promises.push(response_promise);
}
// Run the requests in parallel to avoid causing too much delay
const responses = await Promise.all(response_promises);
const content_lengths = [];
for (const response of responses) {
content_lengths.push(parseInt(response.headers.get('Content-Length') || '0', 10));
const content_type = response.headers.get('Content-Type');
// Sometimes youtube returns webp instead of jpg despite the file extension being jpg
// So we need to update the mime type to reflect the actual mime type of the response
if (content_type && content_type.length > 0) {
if (set.getAttribute('mimeType') !== content_type) {
set.setAttribute('mimeType', content_type);
}
}
}
// This is a rough estimate, so it probably won't reflect that actual peak bitrate
// Hopefully it's close enough, because figuring out the actual peak bitrate would require downloading and analysing all storyboard tiles
const bandwidth = Math.ceil((Math.max(...content_lengths) / (storyboard.rows * storyboard.columns)) * 8);
const representation = this.#el(document, 'Representation', {
id: `thumbnails_${storyboard.thumbnail_width}x${storyboard.thumbnail_height}`,
bandwidth: bandwidth.toString(),
width: (storyboard.thumbnail_width * storyboard.columns).toString(),
height: (storyboard.thumbnail_height * storyboard.rows).toString()
}, [
this.#el(document, 'EssentialProperty', {
schemeIdUri: 'http://dashif.org/thumbnail_tile',
value: `${storyboard.columns}x${storyboard.rows}`
}),
this.#el(document, 'SegmentTemplate', {
media: url_transformer(new URL(url.replace('$M', '$Number$'))).toString(),
duration: (duration / storyboard.storyboard_count).toString(),
startNumber: '0'
})
]);
set.appendChild(representation);
}
static async #generateSegmentInformation(document: XMLDocument, representation: Element, format: Format, url: string, actions?: Actions) {
if (format.is_type_otf) {
if (!actions) {

View File

@@ -1,5 +1,5 @@
import { createWriteStream, existsSync } from 'node:fs';
import { Innertube, IBrowseResponse, Utils, YT, YTMusic, YTNodes } from '../bundle/node.cjs';
import { Innertube, Utils, YT, YTMusic, YTNodes } from '../bundle/node.cjs';
describe('YouTube.js Tests', () => {
let innertube: Innertube;
@@ -19,6 +19,30 @@ describe('YouTube.js Tests', () => {
expect(info.basic_info.id).toBe('bUHZ2k9DYHY');
});
describe('Innertube#getBasicInfo', () => {
test('Format#language multiple audio tracks', async () => {
const info = await innertube.getBasicInfo('Kn56bMZ9OE8')
expect(info.streaming_data).toBeDefined()
const default_track_format = info.streaming_data?.adaptive_formats.find(format => format.audio_track?.audio_is_default)
expect(default_track_format).toBeDefined()
expect(default_track_format?.language).toBe('en')
// check that the language is properly propagated to the non-dash formats
expect(info.streaming_data?.formats[0].language).toBe('en')
})
test('Format#language single audio track with captions', async () => {
const info = await innertube.getBasicInfo('gisdyTBMNyQ')
expect(info.streaming_data).toBeDefined()
const audio_format = info.streaming_data?.adaptive_formats.find(format => format.has_audio)
expect(audio_format).toBeDefined()
expect(audio_format?.language).toBe('en')
expect(info.streaming_data?.formats[0].language).toBe('en')
})
})
test('Innertube#search', async () => {
const search = await innertube.search('Anton Petrov');
expect(search.page).toBeDefined();