Compare commits

...

28 Commits

Author SHA1 Message Date
github-actions[bot]
401b4c3858 chore(main): release 13.1.0 (#880)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-02-21 19:15:11 -03:00
Muhammad Mujtaba Naveed
5aecd0ace9 fix(innertube): Allowing getStreamingData to use client (#895) 2025-02-21 19:12:19 -03:00
absidue
432571769e revert: "fix(toDash): Fix default audio stream for dubbed movie trailers (#858)" (#896)
This reverts commit 00546909c0.
2025-02-21 19:10:04 -03:00
Dave Nicolson
2b4219959c fix(Playlist): is_editable (#894)
* Fix is_editable

* Add can_reorder
2025-02-21 19:07:56 -03:00
Dave Nicolson
b731db86c5 fix(DialogView): Type mismatch (#897) 2025-02-21 19:04:21 -03:00
Dave Nicolson
e2ee822b9d Fix getPlaylist playlist ID (#898) 2025-02-21 19:02:29 -03:00
Luan
3243339b37 chore(tests): Remove unneeded continuation test 2025-02-21 18:59:58 -03:00
Luan
c9328c59f9 chore: fix test 2025-02-21 18:56:22 -03:00
Luan
a3fafe2f79 fix(music#getPlaylist): Handle ContinuationItem nodes
Closes #904
2025-02-21 18:52:34 -03:00
Luan
ca7c3164e1 chore(AnimatedThumbnailOverlayView): Refactor thumbnail handling to use Thumbnail class 2025-02-21 18:15:29 -03:00
Luan
02dfcae612 fix(dependencies): Update jintr to version 3.2.1 2025-02-21 18:13:10 -03:00
Izak Filmalter
0cb92d9620 feat(parser): Add AnimatedThumbnailOverlayView (#903)
* Add AnimatedThumbnailOverlayView parser.

* Update nodes.ts
2025-02-17 11:41:17 -03:00
Nansess
5394edc9bd chore(constants): update WEB_CREATOR version (#893)
* update WEB_CREATOR version

* Update Constants.ts
2025-02-15 11:07:51 -03:00
absidue
7d5c972c98 fix(Innertube): Properly encoded params in getPost() (#882) 2025-02-13 17:31:12 -03:00
EdamAmex
b5c9581bec docs(installation): add install command for deno (#885)
* docs(installation): add install command for deno

* Update README.md
2025-02-13 17:30:11 -03:00
Loyalsoldier
774b3a7524 fix(FormatUtils): itag matching (#886) 2025-02-13 17:29:36 -03:00
ChunkyProgrammer
b3a4862151 feat(Channel): Add getCourses method (#883) 2025-02-11 06:10:50 -03:00
absidue
75d39e7afb chore(parser): Simplify d0d48bf525 (#888) 2025-02-08 06:00:03 -03:00
absidue
c776b9f349 chore(Utils): Replace private static shim property with a variable (#887) 2025-02-08 05:58:01 -03:00
absidue
0e869020db refactor: Remove internal uses of the .first() and .matchCondition() helpers (#889) 2025-02-08 05:55:39 -03:00
Luan
d0d48bf525 feat(CommentView): Parse prepareAccountCommand 2025-02-05 15:02:48 -03:00
Luan
3f960effa2 fix(Parser): Add UpdateEngagementPanelContentCommand
This command is sometimes used to open the Transcript panel.
2025-02-05 06:55:13 -03:00
Loyalsoldier
1c1577e85f feat(FormatUtils): choose more specific format by itag or codec (#884) 2025-01-28 15:43:09 -03:00
Luan
424c65356c fix(LockupView): Add overlay nodes used by VIDEO views 2025-01-25 11:59:17 -03:00
Luan
3ffdee9554 fix(LockupMetadataView): Parse menuButton 2025-01-25 11:41:01 -03:00
Luan
083aec1c80 fix(LockupView): Fix content_image parsing 2025-01-25 11:33:38 -03:00
Luan
6d57353a80 feat(CompactLink): Parse subtitle, iconType, and iconType 2025-01-24 11:22:29 -03:00
Luan
32125c7045 feat(CommentView): Parse some extra tooltips 2025-01-21 07:51:55 -03:00
53 changed files with 375 additions and 171 deletions

3
.gitignore vendored
View File

@@ -74,6 +74,9 @@ deno/
# VSCode files
.vscode/
# Webstorm files
.idea/
# MacOS
.DS_Store

View File

@@ -1,5 +1,37 @@
# Changelog
## [13.1.0](https://github.com/LuanRT/YouTube.js/compare/v13.0.0...v13.1.0) (2025-02-21)
### Features
* **Channel:** Add `getCourses` method ([#883](https://github.com/LuanRT/YouTube.js/issues/883)) ([b3a4862](https://github.com/LuanRT/YouTube.js/commit/b3a48621518f09d1ce309071499d9626cc1a8488))
* **CommentView:** Parse `prepareAccountCommand` ([d0d48bf](https://github.com/LuanRT/YouTube.js/commit/d0d48bf525cc2a95dd3397a3142bea113ea9782e))
* **CommentView:** Parse some extra tooltips ([32125c7](https://github.com/LuanRT/YouTube.js/commit/32125c704565f425806a0721edd96e01028e3fdd))
* **CompactLink:** Parse `subtitle`, `iconType`, and `iconType` ([6d57353](https://github.com/LuanRT/YouTube.js/commit/6d57353a8021430a5253e2fb2c974ca98d731791))
* **FormatUtils:** choose more specific format by itag or codec ([#884](https://github.com/LuanRT/YouTube.js/issues/884)) ([1c1577e](https://github.com/LuanRT/YouTube.js/commit/1c1577e85fd46cbfa15bcee6531d9aafdda787e5))
* **parser:** `Add AnimatedThumbnailOverlayView` ([#903](https://github.com/LuanRT/YouTube.js/issues/903)) ([0cb92d9](https://github.com/LuanRT/YouTube.js/commit/0cb92d9620c13bf6b719b384f917ad2a658e15b1))
### Bug Fixes
* **dependencies:** Update `jintr` to version 3.2.1 ([02dfcae](https://github.com/LuanRT/YouTube.js/commit/02dfcae612dd528ce4f1f3f6c62ceefd02a5c790))
* **DialogView:** Type mismatch ([#897](https://github.com/LuanRT/YouTube.js/issues/897)) ([b731db8](https://github.com/LuanRT/YouTube.js/commit/b731db86c51ba292c848272f28b5a9aa2e2a6956))
* **FormatUtils:** itag matching ([#886](https://github.com/LuanRT/YouTube.js/issues/886)) ([774b3a7](https://github.com/LuanRT/YouTube.js/commit/774b3a75244db85ceb7b00f658dc2dfeb2eb4e7e))
* **innertube:** Allowing `getStreamingData` to use client ([#895](https://github.com/LuanRT/YouTube.js/issues/895)) ([5aecd0a](https://github.com/LuanRT/YouTube.js/commit/5aecd0ace96c371f0b15cdc6e45ef09beb5696af))
* **Innertube:** Properly encoded params in getPost() ([#882](https://github.com/LuanRT/YouTube.js/issues/882)) ([7d5c972](https://github.com/LuanRT/YouTube.js/commit/7d5c972c98d7c69b0b687b241c652f3098907a9f))
* **LockupMetadataView:** Parse `menuButton` ([3ffdee9](https://github.com/LuanRT/YouTube.js/commit/3ffdee9554b06db137d93e43b33fac124becf31f))
* **LockupView:** Add overlay nodes used by `VIDEO` views ([424c653](https://github.com/LuanRT/YouTube.js/commit/424c65356c24d19a921e24aadcbbb3cd03ab103a))
* **LockupView:** Fix `content_image` parsing ([083aec1](https://github.com/LuanRT/YouTube.js/commit/083aec1c805cce6b04a75e4f017b5cdf0bb6108e))
* **music#getPlaylist:** Handle `ContinuationItem` nodes ([a3fafe2](https://github.com/LuanRT/YouTube.js/commit/a3fafe2f7979313906dbaf1a7f9779f411266d6b)), closes [#904](https://github.com/LuanRT/YouTube.js/issues/904)
* **Parser:** Add `UpdateEngagementPanelContentCommand` ([3f960ef](https://github.com/LuanRT/YouTube.js/commit/3f960effa24c3b14fa3c6aadf4c7badf0ac965c9))
* **Playlist:** is_editable ([#894](https://github.com/LuanRT/YouTube.js/issues/894)) ([2b42199](https://github.com/LuanRT/YouTube.js/commit/2b4219959cbbb27cd80788e66b608fdeed3a1f1e))
### Reverts
* "fix(toDash): Fix default audio stream for dubbed movie trailers ([#858](https://github.com/LuanRT/YouTube.js/issues/858))" ([#896](https://github.com/LuanRT/YouTube.js/issues/896)) ([4325717](https://github.com/LuanRT/YouTube.js/commit/432571769ebc6634c2c9a4c1b5e53cfbbd2a5f0a))
## [13.0.0](https://github.com/LuanRT/YouTube.js/compare/v12.2.0...v13.0.0) (2025-01-20)

View File

@@ -37,9 +37,12 @@ yarn add youtubei.js@latest
# Git (edge version)
npm install github:LuanRT/YouTube.js
# Deno
deno add npm:youtubei.js@latest
```
Deno:
Deno (deprecated):
```ts
import { Innertube } from 'https://deno.land/x/youtubei/deno.ts';
```

13
package-lock.json generated
View File

@@ -1,19 +1,19 @@
{
"name": "youtubei.js",
"version": "13.0.0",
"version": "13.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "youtubei.js",
"version": "13.0.0",
"version": "13.1.0",
"funding": [
"https://github.com/sponsors/LuanRT"
],
"license": "MIT",
"dependencies": {
"@bufbuild/protobuf": "^2.0.0",
"jintr": "^3.2.0",
"jintr": "^3.2.1",
"tslib": "^2.5.0",
"undici": "^5.19.1"
},
@@ -6155,12 +6155,13 @@
}
},
"node_modules/jintr": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/jintr/-/jintr-3.2.0.tgz",
"integrity": "sha512-psD1yf05kMKDNsUdW1l5YhO59pHScQ6OIHHb8W5SKSM2dCOFPsqolmIuSHgVA8+3Dc47NJR181CXZ4alCAPTkA==",
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/jintr/-/jintr-3.2.1.tgz",
"integrity": "sha512-yjKUBuwTTg4nc4izMysxuIk0BKh45hnbc1KnXE6LxagIGZn5od+I2elpuRY9IIm3EiKiUZxhxV89a0iX+xoEZg==",
"funding": [
"https://github.com/sponsors/LuanRT"
],
"license": "MIT",
"dependencies": {
"acorn": "^8.8.0"
}

View File

@@ -1,6 +1,6 @@
{
"name": "youtubei.js",
"version": "13.0.0",
"version": "13.1.0",
"description": "A JavaScript client for YouTube's private API, known as InnerTube.",
"type": "module",
"types": "./dist/src/platform/lib.d.ts",
@@ -103,7 +103,7 @@
"license": "MIT",
"dependencies": {
"@bufbuild/protobuf": "^2.0.0",
"jintr": "^3.2.0",
"jintr": "^3.2.1",
"tslib": "^2.5.0",
"undici": "^5.19.1"
},

View File

@@ -2230,10 +2230,10 @@ function createBaseCommunityPostParams_Field2(): CommunityPostParams_Field2 {
export const CommunityPostParams_Field2: MessageFns<CommunityPostParams_Field2> = {
encode(message: CommunityPostParams_Field2, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.p1 !== 0) {
writer.uint32(16).int64(message.p1);
writer.uint32(16).uint32(message.p1);
}
if (message.p2 !== 0) {
writer.uint32(24).int64(message.p2);
writer.uint32(24).uint32(message.p2);
}
return writer;
},
@@ -2250,14 +2250,14 @@ export const CommunityPostParams_Field2: MessageFns<CommunityPostParams_Field2>
break;
}
message.p1 = longToNumber(reader.int64());
message.p1 = reader.uint32();
continue;
case 3:
if (tag !== 24) {
break;
}
message.p2 = longToNumber(reader.int64());
message.p2 = reader.uint32();
continue;
}
if ((tag & 7) === 4 || tag === 0) {
@@ -2636,17 +2636,6 @@ export const CommunityPostCommentsParam_CommentDataContainer_CommentData: Messag
},
};
function longToNumber(int64: { toString(): string }): number {
const num = globalThis.Number(int64.toString());
if (num > globalThis.Number.MAX_SAFE_INTEGER) {
throw new globalThis.Error("Value is larger than Number.MAX_SAFE_INTEGER");
}
if (num < globalThis.Number.MIN_SAFE_INTEGER) {
throw new globalThis.Error("Value is smaller than Number.MIN_SAFE_INTEGER");
}
return num;
}
export interface MessageFns<T> {
encode(message: T, writer?: BinaryWriter): BinaryWriter;
decode(input: BinaryReader | Uint8Array, length?: number): T;

View File

@@ -238,8 +238,8 @@ message CommunityPostParams {
}
message Field2 {
int64 p1 = 2;
int64 p2 = 3;
uint32 p1 = 2;
uint32 p2 = 3;
}
Field1 f1 = 25;

View File

@@ -431,7 +431,7 @@ export default class Innertube {
* @param options - Format options.
*/
async getStreamingData(video_id: string, options: FormatOptions = {}): Promise<Format> {
const info = await this.getBasicInfo(video_id);
const info = await this.getBasicInfo(video_id, options?.client);
const format = info.chooseFormat(options);
format.url = format.decipher(this.#session.player);
@@ -478,7 +478,7 @@ export default class Innertube {
}
});
const params = encodeURIComponent(u8ToBase64(writer.finish()));
const params = encodeURIComponent(u8ToBase64(writer.finish()).replace(/\+/g, '-').replace(/\//g, '_'));
const browse_endpoint = new NavigationEndpoint({ browseEndpoint: { browseId: channel_id, params: params } });
@@ -616,4 +616,4 @@ export default class Innertube {
get session() {
return this.#session;
}
}
}

View File

@@ -177,7 +177,7 @@ export default class Actions {
let parsed_response = Parser.parseResponse<ParsedResponse<T>>(await response.json());
// Handle redirects
if (this.#isBrowse(parsed_response) && parsed_response.on_response_received_actions?.first()?.type === 'navigateAction') {
if (this.#isBrowse(parsed_response) && parsed_response.on_response_received_actions?.[0]?.type === 'navigateAction') {
const navigate_action = parsed_response.on_response_received_actions.firstOfType(NavigateAction);
if (navigate_action) {
parsed_response = await navigate_action.endpoint.call(this, { parse: true });

View File

@@ -201,7 +201,7 @@ export default class Music {
const response = await watch_next_endpoint.call(this.#actions, { client: 'YTMUSIC', parse: true });
const tabs = response.contents_memo?.getType(Tab);
const tab = tabs?.first();
const tab = tabs?.[0];
if (!tab)
throw new InnertubeError('Could not find target tab.');
@@ -228,7 +228,7 @@ export default class Music {
if (!page || !page.contents_memo)
throw new InnertubeError('Could not fetch automix');
return page.contents_memo.getType(PlaylistPanel).first();
return page.contents_memo.getType(PlaylistPanel)[0];
}
return playlist_panel;
@@ -242,7 +242,7 @@ export default class Music {
const tabs = response.contents_memo?.getType(Tab);
const tab = tabs?.matchCondition((tab) => tab.endpoint.payload.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType === 'MUSIC_PAGE_TYPE_TRACK_RELATED');
const tab = tabs?.find((tab) => tab.endpoint.payload.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType === 'MUSIC_PAGE_TYPE_TRACK_RELATED');
if (!tab)
throw new InnertubeError('Could not find target tab.');
@@ -263,7 +263,7 @@ export default class Music {
const tabs = response.contents_memo?.getType(Tab);
const tab = tabs?.matchCondition((tab) => tab.endpoint.payload.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType === 'MUSIC_PAGE_TYPE_TRACK_LYRICS');
const tab = tabs?.find((tab) => tab.endpoint.payload.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType === 'MUSIC_PAGE_TYPE_TRACK_LYRICS');
if (!tab)
throw new InnertubeError('Could not find target tab.');

View File

@@ -237,12 +237,11 @@ export default class PlaylistManager {
}
async #getPlaylist(playlist_id: string): Promise<Playlist> {
let id = playlist_id;
if (!playlist_id.startsWith('VL')) {
playlist_id = `VL${playlist_id}`;
}
if (!id.startsWith('VL'))
id = `VL${id}`;
const browse_endpoint = new NavigationEndpoint({ browseEndpoint: { browseId: `VL${id}` } });
const browse_endpoint = new NavigationEndpoint({ browseEndpoint: { browseId: playlist_id } });
const browse_response = await browse_endpoint.call(this.#actions, { parse: true });
return new Playlist(this.#actions, browse_response, true);

View File

@@ -139,9 +139,9 @@ export default class Feed<T extends IParsedResponse = IParsedResponse> {
* Returns contents from the page.
*/
get page_contents(): SectionList | MusicQueue | RichGrid | ReloadContinuationItemsCommand {
const tab_content = this.#memo.getType(Tab)?.first().content;
const reload_continuation_items = this.#memo.getType(ReloadContinuationItemsCommand).first();
const append_continuation_items = this.#memo.getType(AppendContinuationItemsAction).first();
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;
}

View File

@@ -0,0 +1,14 @@
import { YTNode } from '../helpers.js';
import type { RawNode } from '../types/index.js';
import Thumbnail from './misc/Thumbnail.js';
export default class AnimatedThumbnailOverlayView extends YTNode {
static type = 'AnimatedThumbnailOverlayView';
public thumbnail: Thumbnail[];
constructor(data: RawNode) {
super();
this.thumbnail = Thumbnail.fromResponse(data.thumbnail);
}
}

View File

@@ -6,14 +6,27 @@ import type { RawNode } from '../index.js';
export default class CompactLink extends YTNode {
static type = 'CompactLink';
title: string;
endpoint: NavigationEndpoint;
style: string;
public title: string;
public subtitle?: Text;
public endpoint: NavigationEndpoint;
public style: string;
public icon_type?: string;
public secondary_icon_type?: string;
constructor(data: RawNode) {
super();
this.title = new Text(data.title).toString();
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
if ('subtitle' in data)
this.subtitle = new Text(data.subtitle);
if ('icon' in data && 'iconType' in data.icon)
this.icon_type = data.icon.iconType;
if ('secondaryIcon' in data && 'iconType' in data.secondaryIcon)
this.secondary_icon_type = data.secondaryIcon.iconType;
this.endpoint = new NavigationEndpoint(data.navigationEndpoint || data.serviceEndpoint);
this.style = data.style;
}
}

View File

@@ -3,18 +3,19 @@ import { Parser, type RawNode } from '../index.js';
import DialogHeaderView from './DialogHeaderView.js';
import FormFooterView from './FormFooterView.js';
import CreatePlaylistDialogFormView from './CreatePlaylistDialogFormView.js';
import PanelFooterView from './PanelFooterView.js';
export default class DialogView extends YTNode {
static type = 'DialogView';
public header: DialogHeaderView | null;
public footer: FormFooterView | null;
public footer: FormFooterView | PanelFooterView | null;
public custom_content: CreatePlaylistDialogFormView | null;
constructor (data: RawNode) {
super();
this.header = Parser.parseItem(data.header, DialogHeaderView);
this.footer = Parser.parseItem(data.footer, FormFooterView);
this.footer = Parser.parseItem(data.footer, [ FormFooterView, PanelFooterView ]);
this.custom_content = Parser.parseItem(data.customContent, CreatePlaylistDialogFormView);
}
}

View File

@@ -3,19 +3,21 @@ import { Parser, type RawNode } from '../index.js';
import ContentMetadataView from './ContentMetadataView.js';
import DecoratedAvatarView from './DecoratedAvatarView.js';
import Text from './misc/Text.js';
import ButtonView from './ButtonView.js';
export default class LockupMetadataView extends YTNode {
static type = 'LockupMetadataView';
title: Text;
metadata: ContentMetadataView | null;
image: DecoratedAvatarView | null;
public title: Text;
public metadata: ContentMetadataView | null;
public image: DecoratedAvatarView | null;
public menu_button: ButtonView | null;
constructor(data: RawNode) {
super();
this.title = Text.fromAttributed(data.title);
this.metadata = Parser.parseItem(data.metadata, ContentMetadataView);
this.image = Parser.parseItem(data.image, DecoratedAvatarView);
this.menu_button = Parser.parseItem(data.menuButton, ButtonView);
}
}

View File

@@ -1,5 +1,6 @@
import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import ThumbnailView from './ThumbnailView.js';
import CollectionThumbnailView from './CollectionThumbnailView.js';
import LockupMetadataView from './LockupMetadataView.js';
import RendererContext from './misc/RendererContext.js';
@@ -7,7 +8,7 @@ import RendererContext from './misc/RendererContext.js';
export default class LockupView extends YTNode {
static type = 'LockupView';
public content_image: CollectionThumbnailView | null;
public content_image: CollectionThumbnailView | ThumbnailView | null;
public metadata: LockupMetadataView | null;
public content_id: string;
public content_type: 'VIDEO' | 'MOVIE' | 'CHANNEL' | 'CLIP' | 'SOURCE' | 'PLAYLIST' | 'ALBUM' | 'PODCAST' | 'SHOPPING_COLLECTION' | 'SHORT' | 'GAME' | 'PRODUCT';
@@ -15,7 +16,7 @@ export default class LockupView extends YTNode {
constructor(data: RawNode) {
super();
this.content_image = Parser.parseItem(data.contentImage, CollectionThumbnailView);
this.content_image = Parser.parseItem(data.contentImage, [ CollectionThumbnailView, ThumbnailView ]);
this.metadata = Parser.parseItem(data.metadata, LockupMetadataView);
this.content_id = data.contentId;
this.content_type = data.contentType.replace('LOCKUP_CONTENT_TYPE_', '');

View File

@@ -1,19 +1,20 @@
import { YTNode, type ObservedArray } from '../helpers.js';
import { type ObservedArray, YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import MusicResponsiveListItem from './MusicResponsiveListItem.js';
import ContinuationItem from './ContinuationItem.js';
export default class MusicPlaylistShelf extends YTNode {
static type = 'MusicPlaylistShelf';
playlist_id: string;
contents: ObservedArray<MusicResponsiveListItem>;
collapsed_item_count: number;
continuation: string | null;
public playlist_id: string;
public contents: ObservedArray<MusicResponsiveListItem | ContinuationItem>;
public collapsed_item_count: number;
public continuation: string | null;
constructor(data: RawNode) {
super();
this.playlist_id = data.playlistId;
this.contents = Parser.parseArray(data.contents, MusicResponsiveListItem);
this.contents = Parser.parseArray(data.contents, [ MusicResponsiveListItem, ContinuationItem ]);
this.collapsed_item_count = data.collapsedItemCount;
this.continuation = data.continuations?.[0]?.nextContinuationData?.continuation || null;
}

View File

@@ -155,7 +155,7 @@ export default class MusicResponsiveListItem extends YTNode {
}
#parseOther() {
this.title = this.flex_columns.first().title.toString();
this.title = this.flex_columns[0].title.toString();
if (this.endpoint) {
this.item_type = 'endpoint';
@@ -183,10 +183,10 @@ export default class MusicResponsiveListItem extends YTNode {
#parseSong() {
this.id = this.#playlist_item_data.video_id || this.endpoint?.payload?.videoId;
this.title = this.flex_columns.first().title.toString();
this.title = this.flex_columns[0].title.toString();
const duration_text = this.flex_columns.at(1)?.title.runs?.find(
(run) => (/^\d+$/).test(run.text.replace(/:/g, '')))?.text || this.fixed_columns.first()?.title?.toString();
(run) => (/^\d+$/).test(run.text.replace(/:/g, '')))?.text || this.fixed_columns[0]?.title?.toString();
if (duration_text) {
this.duration = {
@@ -230,7 +230,7 @@ export default class MusicResponsiveListItem extends YTNode {
#parseVideo() {
this.id = this.#playlist_item_data.video_id;
this.title = this.flex_columns.first().title.toString();
this.title = this.flex_columns[0].title.toString();
this.views = this.flex_columns.at(1)?.title.runs?.find((run) => run.text.match(/(.*?) views/))?.toString();
const author_runs = this.flex_columns.at(1)?.title.runs?.filter(
@@ -250,7 +250,7 @@ export default class MusicResponsiveListItem extends YTNode {
}
const duration_text = this.flex_columns[1].title.runs?.find(
(run) => (/^\d+$/).test(run.text.replace(/:/g, '')))?.text || this.fixed_columns.first()?.title.runs?.find((run) => (/^\d+$/).test(run.text.replace(/:/g, '')))?.text;
(run) => (/^\d+$/).test(run.text.replace(/:/g, '')))?.text || this.fixed_columns[0]?.title.runs?.find((run) => (/^\d+$/).test(run.text.replace(/:/g, '')))?.text;
if (duration_text) {
this.duration = {
@@ -262,30 +262,30 @@ export default class MusicResponsiveListItem extends YTNode {
#parseArtist() {
this.id = this.endpoint?.payload?.browseId;
this.name = this.flex_columns.first().title.toString();
this.name = this.flex_columns[0].title.toString();
this.subtitle = this.flex_columns.at(1)?.title;
this.subscribers = this.subtitle?.runs?.find((run) => (/^(\d*\.)?\d+[M|K]? subscribers?$/i).test(run.text))?.text || '';
}
#parseLibraryArtist() {
this.name = this.flex_columns.first().title.toString();
this.name = this.flex_columns[0].title.toString();
this.subtitle = this.flex_columns.at(1)?.title;
this.song_count = this.subtitle?.runs?.find((run) => (/^\d+(,\d+)? songs?$/i).test(run.text))?.text || '';
}
#parseNonMusicTrack() {
this.id = this.#playlist_item_data.video_id || this.endpoint?.payload?.videoId;
this.title = this.flex_columns.first().title.toString();
this.title = this.flex_columns[0].title.toString();
}
#parsePodcastShow() {
this.id = this.endpoint?.payload?.browseId;
this.title = this.flex_columns.first().title.toString();
this.title = this.flex_columns[0].title.toString();
}
#parseAlbum() {
this.id = this.endpoint?.payload?.browseId;
this.title = this.flex_columns.first().title.toString();
this.title = this.flex_columns[0].title.toString();
const author_run = this.flex_columns.at(1)?.title.runs?.find(
(run) =>
@@ -308,7 +308,7 @@ export default class MusicResponsiveListItem extends YTNode {
#parsePlaylist() {
this.id = this.endpoint?.payload?.browseId;
this.title = this.flex_columns.first().title.toString();
this.title = this.flex_columns[0].title.toString();
const item_count_run = this.flex_columns.at(1)?.title
.runs?.find((run) => run.text.match(/\d+ (song|songs)/));

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 ThumbnailBadgeView from './ThumbnailBadgeView.js';
import ThumbnailOverlayProgressBarView from './ThumbnailOverlayProgressBarView.js';
export default class ThumbnailBottomOverlayView extends YTNode {
static type = 'ThumbnailBottomOverlayView';
public progress_bar: ThumbnailOverlayProgressBarView | null;
public badges: ObservedArray<ThumbnailBadgeView>;
constructor(data: RawNode) {
super();
this.progress_bar = Parser.parseItem(data.progressBar, ThumbnailOverlayProgressBarView);
this.badges = Parser.parseArray(data.badges, ThumbnailBadgeView);
}
}

View File

@@ -0,0 +1,15 @@
import type { ObservedArray } from '../helpers.js';
import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import ToggleButtonView from './ToggleButtonView.js';
export default class ThumbnailHoverOverlayToggleActionsView extends YTNode {
static type = 'ThumbnailHoverOverlayToggleActionsView';
public buttons: ObservedArray<ToggleButtonView>;
constructor(data: RawNode) {
super();
this.buttons = Parser.parseArray(data.buttons, ToggleButtonView);
}
}

View File

@@ -5,8 +5,8 @@ import ThumbnailBadgeView from './ThumbnailBadgeView.js';
export default class ThumbnailOverlayBadgeView extends YTNode {
static type = 'ThumbnailOverlayBadgeView';
badges: ThumbnailBadgeView[];
position: string;
public badges: ThumbnailBadgeView[];
public position: string;
constructor(data: RawNode) {
super();

View File

@@ -0,0 +1,13 @@
import { YTNode } from '../helpers.js';
import { type RawNode } from '../index.js';
export default class ThumbnailOverlayProgressBarView extends YTNode {
static type = 'ThumbnailOverlayProgressBarView';
public start_percent: number;
constructor(data: RawNode) {
super();
this.start_percent = data.startPercent;
}
}

View File

@@ -1,25 +1,40 @@
import type { ObservedArray } from '../helpers.js';
import { YTNode } from '../helpers.js';
import { Parser, type RawNode } from '../index.js';
import AnimatedThumbnailOverlayView from './AnimatedThumbnailOverlayView.js';
import ThumbnailHoverOverlayView from './ThumbnailHoverOverlayView.js';
import ThumbnailOverlayBadgeView from './ThumbnailOverlayBadgeView.js';
import Thumbnail from './misc/Thumbnail.js';
import ThumbnailHoverOverlayToggleActionsView from './ThumbnailHoverOverlayToggleActionsView.js';
import ThumbnailBottomOverlayView from './ThumbnailBottomOverlayView.js';
export type ThumbnailBackgroundColor = {
light_theme: number;
dark_theme: number;
};
export default class ThumbnailView extends YTNode {
static type = 'ThumbnailView';
image: Thumbnail[];
overlays: (ThumbnailOverlayBadgeView | ThumbnailHoverOverlayView)[];
background_color?: {
light_theme: number;
dark_theme: number;
};
public image: Thumbnail[];
public overlays: ObservedArray<
ThumbnailHoverOverlayToggleActionsView | ThumbnailBottomOverlayView |
ThumbnailOverlayBadgeView | ThumbnailHoverOverlayView
| AnimatedThumbnailOverlayView
>;
public background_color?: ThumbnailBackgroundColor;
constructor(data: RawNode) {
super();
this.image = Thumbnail.fromResponse(data.image);
this.overlays = Parser.parseArray(data.overlays, [ ThumbnailOverlayBadgeView, ThumbnailHoverOverlayView ]);
if (data.backgroundColor) {
this.overlays = Parser.parseArray(data.overlays, [
ThumbnailHoverOverlayToggleActionsView, ThumbnailBottomOverlayView,
ThumbnailOverlayBadgeView, ThumbnailHoverOverlayView,
AnimatedThumbnailOverlayView
]);
if ('backgroundColor' in data) {
this.background_color = {
light_theme: data.backgroundColor.lightTheme,
dark_theme: data.backgroundColor.darkTheme

View File

@@ -0,0 +1,20 @@
import { YTNode } from '../../helpers.js';
import { type RawNode } from '../../index.js';
type Identifier = {
surface: string,
tag: string;
}
export default class UpdateEngagementPanelContentCommand extends YTNode {
static type = 'UpdateEngagementPanelContentCommand';
public content_source_panel_identifier?: Identifier;
public target_panel_identifier?: Identifier;
constructor(data: RawNode) {
super();
this.content_source_panel_identifier = data.contentSourcePanelIdentifier;
this.target_panel_identifier = data.targetPanelIdentifier;
}
}

View File

@@ -58,7 +58,7 @@ export default class CommentThread extends YTNode {
throw new InnertubeError('Unexpected response.', response);
this.replies = this.#getPatchedReplies(response.on_response_received_endpoints_memo);
this.#continuation = response.on_response_received_endpoints_memo.getType(ContinuationItem).first();
this.#continuation = response.on_response_received_endpoints_memo.getType(ContinuationItem)[0];
return this;
}
@@ -87,7 +87,7 @@ export default class CommentThread extends YTNode {
throw new InnertubeError('Unexpected response.', response);
this.replies = this.#getPatchedReplies(response.on_response_received_endpoints_memo);
this.#continuation = response.on_response_received_endpoints_memo.getType(ContinuationItem).first();
this.#continuation = response.on_response_received_endpoints_memo.getType(ContinuationItem)[0];
return this;
}

View File

@@ -34,6 +34,7 @@ export default class CommentView extends YTNode {
public unlike_command?: NavigationEndpoint;
public undislike_command?: NavigationEndpoint;
public reply_command?: NavigationEndpoint;
public prepare_account_command?: NavigationEndpoint;
public comment_id: string;
public is_pinned: boolean;
@@ -42,8 +43,18 @@ export default class CommentView extends YTNode {
public content?: Text;
public published_time?: string;
public author_is_channel_owner?: boolean;
public creator_thumbnail_url?: string;
public like_button_a11y?: string;
public like_count?: string;
public like_count_liked?: string;
public like_count_a11y?: string;
public like_active_tooltip?: string;
public like_inactive_tooltip?: string;
public dislike_active_tooltip?: string;
public dislike_inactive_tooltip?: string;
public heart_active_tooltip?: string;
public reply_count?: string;
public reply_count_a11y?: string;
public is_member?: boolean;
public member_badge?: MemberBadge;
public author?: Author;
@@ -72,8 +83,18 @@ export default class CommentView extends YTNode {
this.content = Text.fromAttributed(comment.properties.content);
this.published_time = comment.properties.publishedTime;
this.author_is_channel_owner = !!comment.author.isCreator;
this.creator_thumbnail_url = comment.toolbar.creatorThumbnailUrl;
this.like_count = comment.toolbar.likeCountNotliked ? comment.toolbar.likeCountNotliked : '0';
this.like_count_liked = comment.toolbar.likeCountLiked ? comment.toolbar.likeCountLiked : '0';
this.like_count_a11y = comment.toolbar.likeCountA11y;
this.like_active_tooltip = comment.toolbar.likeActiveTooltip;
this.like_inactive_tooltip = comment.toolbar.likeInactiveTooltip;
this.dislike_active_tooltip = comment.toolbar.dislikeActiveTooltip;
this.dislike_inactive_tooltip = comment.toolbar.dislikeInactiveTooltip;
this.like_button_a11y = comment.toolbar.likeButtonA11y;
this.heart_active_tooltip = comment.toolbar.heartActiveTooltip;
this.reply_count_a11y = comment.toolbar.replyCountA11y;
this.reply_count = comment.toolbar.replyCount ? comment.toolbar.replyCount : '0';
this.is_member = !!comment.author.sponsorBadgeUrl;
@@ -97,12 +118,16 @@ export default class CommentView extends YTNode {
this.is_disliked = toolbar_state.likeState === 'TOOLBAR_LIKE_STATE_DISLIKED';
}
if (toolbar_surface && !Reflect.has(toolbar_surface, 'prepareAccountCommand')) {
this.like_command = new NavigationEndpoint(toolbar_surface.likeCommand);
this.dislike_command = new NavigationEndpoint(toolbar_surface.dislikeCommand);
this.unlike_command = new NavigationEndpoint(toolbar_surface.unlikeCommand);
this.undislike_command = new NavigationEndpoint(toolbar_surface.undislikeCommand);
this.reply_command = new NavigationEndpoint(toolbar_surface.replyCommand);
if (toolbar_surface) {
if ('prepareAccountCommand' in toolbar_surface) {
this.prepare_account_command = new NavigationEndpoint(toolbar_surface.prepareAccountCommand);
} else {
this.like_command = new NavigationEndpoint(toolbar_surface.likeCommand);
this.dislike_command = new NavigationEndpoint(toolbar_surface.dislikeCommand);
this.unlike_command = new NavigationEndpoint(toolbar_surface.unlikeCommand);
this.undislike_command = new NavigationEndpoint(toolbar_surface.undislikeCommand);
this.reply_command = new NavigationEndpoint(toolbar_surface.replyCommand);
}
}
}

View File

@@ -21,6 +21,7 @@ export { default as ActiveAccountHeader } from './classes/ActiveAccountHeader.js
export { default as AddToPlaylist } from './classes/AddToPlaylist.js';
export { default as Alert } from './classes/Alert.js';
export { default as AlertWithButton } from './classes/AlertWithButton.js';
export { default as AnimatedThumbnailOverlayView } from './classes/AnimatedThumbnailOverlayView.js';
export { default as AttributionView } from './classes/AttributionView.js';
export { default as AudioOnlyPlayability } from './classes/AudioOnlyPlayability.js';
export { default as AutomixPreviewVideo } from './classes/AutomixPreviewVideo.js';
@@ -83,6 +84,7 @@ export { default as ContinuationCommand } from './classes/commands/ContinuationC
export { default as GetKidsBlocklistPickerCommand } from './classes/commands/GetKidsBlocklistPickerCommand.js';
export { default as RunAttestationCommand } from './classes/commands/RunAttestationCommand.js';
export { default as ShowDialogCommand } from './classes/commands/ShowDialogCommand.js';
export { default as UpdateEngagementPanelContentCommand } from './classes/commands/UpdateEngagementPanelContentCommand.js';
export { default as AuthorCommentBadge } from './classes/comments/AuthorCommentBadge.js';
export { default as CommentActionButtons } from './classes/comments/CommentActionButtons.js';
export { default as CommentDialog } from './classes/comments/CommentDialog.js';
@@ -455,6 +457,8 @@ export { default as TextFieldView } from './classes/TextFieldView.js';
export { default as TextHeader } from './classes/TextHeader.js';
export { default as ThirdPartyShareTargetSection } from './classes/ThirdPartyShareTargetSection.js';
export { default as ThumbnailBadgeView } from './classes/ThumbnailBadgeView.js';
export { default as ThumbnailBottomOverlayView } from './classes/ThumbnailBottomOverlayView.js';
export { default as ThumbnailHoverOverlayToggleActionsView } from './classes/ThumbnailHoverOverlayToggleActionsView.js';
export { default as ThumbnailHoverOverlayView } from './classes/ThumbnailHoverOverlayView.js';
export { default as ThumbnailLandscapePortrait } from './classes/ThumbnailLandscapePortrait.js';
export { default as ThumbnailOverlayBadgeView } from './classes/ThumbnailOverlayBadgeView.js';
@@ -466,6 +470,7 @@ export { default as ThumbnailOverlayLoadingPreview } from './classes/ThumbnailOv
export { default as ThumbnailOverlayNowPlaying } from './classes/ThumbnailOverlayNowPlaying.js';
export { default as ThumbnailOverlayPinking } from './classes/ThumbnailOverlayPinking.js';
export { default as ThumbnailOverlayPlaybackStatus } from './classes/ThumbnailOverlayPlaybackStatus.js';
export { default as ThumbnailOverlayProgressBarView } from './classes/ThumbnailOverlayProgressBarView.js';
export { default as ThumbnailOverlayResumePlayback } from './classes/ThumbnailOverlayResumePlayback.js';
export { default as ThumbnailOverlaySidePanel } from './classes/ThumbnailOverlaySidePanel.js';
export { default as ThumbnailOverlayTimeStatus } from './classes/ThumbnailOverlayTimeStatus.js';

View File

@@ -17,12 +17,12 @@ export default class AccountInfo {
if (!this.#page.contents)
throw new InnertubeError('Page contents not found');
const account_section_list = this.#page.contents.array().as(AccountSectionList).first();
const account_section_list = this.#page.contents.array().as(AccountSectionList)[0];
if (!account_section_list)
throw new InnertubeError('Account section list not found');
this.contents = account_section_list.contents.first();
this.contents = account_section_list.contents[0];
}
get page(): IParsedResponse {

View File

@@ -48,7 +48,7 @@ export default class Channel extends TabbedFeed<IBrowseResponse> {
const microformat = this.page.microformat?.as(MicroformatData);
if (this.page.alerts) {
const alert = this.page.alerts.first();
const alert = this.page.alerts[0];
if (alert?.alert_type === 'ERROR') {
throw new ChannelError(alert.text.toString());
}
@@ -59,7 +59,7 @@ export default class Channel extends TabbedFeed<IBrowseResponse> {
this.metadata = { ...metadata, ...(microformat || {}) };
this.subscribe_button = this.page.header_memo?.getType(SubscribeButton).first();
this.subscribe_button = this.page.header_memo?.getType(SubscribeButton)[0];
if (this.page.contents)
this.current_tab = this.page.contents.item().as(TwoColumnBrowseResults).tabs.array().filterType(Tab, ExpandableTab).get({ selected: true });
@@ -72,7 +72,7 @@ export default class Channel extends TabbedFeed<IBrowseResponse> {
async applyFilter(filter: string | ChipCloudChip): Promise<FilteredChannelList> {
let target_filter: ChipCloudChip | undefined;
const filter_chipbar = this.memo.getType(FeedFilterChipBar).first();
const filter_chipbar = this.memo.getType(FeedFilterChipBar)[0];
if (typeof filter === 'string') {
target_filter = filter_chipbar?.contents.get({ text: filter });
@@ -98,7 +98,7 @@ export default class Channel extends TabbedFeed<IBrowseResponse> {
* @param sort - The sort filter to apply
*/
async applySort(sort: string): Promise<Channel> {
const sort_filter_sub_menu = this.memo.getType(SortFilterSubMenu).first();
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');
@@ -144,7 +144,7 @@ export default class Channel extends TabbedFeed<IBrowseResponse> {
}
get sort_filters(): string[] {
const sort_filter_sub_menu = this.memo.getType(SortFilterSubMenu).first();
const sort_filter_sub_menu = this.memo.getType(SortFilterSubMenu)[0];
return sort_filter_sub_menu?.sub_menu_items?.map((item) => item.title) || [];
}
@@ -183,6 +183,11 @@ export default class Channel extends TabbedFeed<IBrowseResponse> {
return new Channel(this.actions, tab.page, true);
}
async getCourses(): Promise<Channel> {
const tab = await this.getTabByURL('courses');
return new Channel(this.actions, tab.page, true);
}
async getPlaylists(): Promise<Channel> {
const tab = await this.getTabByURL('playlists');
return new Channel(this.actions, tab.page, true);
@@ -269,6 +274,10 @@ export default class Channel extends TabbedFeed<IBrowseResponse> {
return this.hasTabWithURL('podcasts');
}
get has_courses(): boolean {
return this.hasTabWithURL('courses');
}
get has_playlists(): boolean {
return this.hasTabWithURL('playlists');
}
@@ -302,8 +311,8 @@ export class ChannelListContinuation extends Feed<IBrowseResponse> {
constructor(actions: Actions, data: ApiResponse | IBrowseResponse, already_parsed = false) {
super(actions, data, already_parsed);
this.contents =
this.page.on_response_received_actions?.first() ||
this.page.on_response_received_endpoints?.first();
this.page.on_response_received_actions?.[0] ||
this.page.on_response_received_endpoints?.[0];
}
async getContinuation(): Promise<ChannelListContinuation> {
@@ -331,7 +340,7 @@ export class FilteredChannelList extends FilterableFeed<IBrowseResponse> {
this.page.on_response_received_actions.shift();
}
this.contents = this.page.on_response_received_actions?.first();
this.contents = this.page.on_response_received_actions?.[0];
}
/**

View File

@@ -19,7 +19,7 @@ export default class HashtagFeed extends FilterableFeed<IBrowseResponse> {
if (!this.page.contents_memo)
throw new InnertubeError('Unexpected response', this.page);
const tab = this.page.contents_memo.getType(Tab).first();
const tab = this.page.contents_memo.getType(Tab)[0];
if (!tab.content)
throw new InnertubeError('Content tab has no content', tab);

View File

@@ -15,7 +15,7 @@ export default class History extends Feed<IBrowseResponse> {
constructor(actions: Actions, data: ApiResponse | IBrowseResponse, already_parsed = false) {
super(actions, data, already_parsed);
this.sections = this.memo.getType(ItemSection);
this.feed_actions = this.memo.getType(BrowseFeedActions).first();
this.feed_actions = this.memo.getType(BrowseFeedActions)[0];
}
/**

View File

@@ -13,8 +13,8 @@ export default class HomeFeed extends FilterableFeed<IBrowseResponse> {
constructor(actions: Actions, data: ApiResponse | IBrowseResponse, already_parsed = false) {
super(actions, data, already_parsed);
this.header = this.memo.getType(FeedTabbedHeader).first();
this.contents = this.memo.getType(RichGrid).first() || this.page.on_response_received_actions?.first();
this.header = this.memo.getType(FeedTabbedHeader)[0];
this.contents = this.memo.getType(RichGrid)[0] || this.page.on_response_received_actions?.[0];
}
/**

View File

@@ -20,7 +20,7 @@ export default class Library extends Feed<IBrowseResponse> {
if (!this.page.contents_memo)
throw new InnertubeError('Page contents not found');
this.header = this.memo.getType(PageHeader).first();
this.header = this.memo.getType(PageHeader)[0];
const shelves = this.page.contents_memo.getType(Shelf);

View File

@@ -22,12 +22,12 @@ export default class NotificationsMenu {
if (!this.#page.actions_memo)
throw new InnertubeError('Page actions not found');
this.header = this.#page.actions_memo.getType(SimpleMenuHeader).first();
this.header = this.#page.actions_memo.getType(SimpleMenuHeader)[0];
this.contents = this.#page.actions_memo.getType(Notification);
}
async getContinuation(): Promise<NotificationsMenu> {
const continuation = this.#page.actions_memo?.getType(ContinuationItem).first();
const continuation = this.#page.actions_memo?.getType(ContinuationItem)[0];
if (!continuation)
throw new InnertubeError('Continuation not found');

View File

@@ -7,6 +7,7 @@ import PlaylistHeader from '../classes/PlaylistHeader.js';
import PlaylistMetadata from '../classes/PlaylistMetadata.js';
import PlaylistSidebarPrimaryInfo from '../classes/PlaylistSidebarPrimaryInfo.js';
import PlaylistSidebarSecondaryInfo from '../classes/PlaylistSidebarSecondaryInfo.js';
import PlaylistVideoList from '../classes/PlaylistVideoList.js';
import PlaylistVideoThumbnail from '../classes/PlaylistVideoThumbnail.js';
import ReelItem from '../classes/ReelItem.js';
import ShortsLockupView from '../classes/ShortsLockupView.js';
@@ -31,9 +32,10 @@ export default class Playlist extends Feed<IBrowseResponse> {
constructor(actions: Actions, data: ApiResponse | IBrowseResponse, already_parsed = false) {
super(actions, data, already_parsed);
const header = this.memo.getType(PlaylistHeader).first();
const primary_info = this.memo.getType(PlaylistSidebarPrimaryInfo).first();
const secondary_info = this.memo.getType(PlaylistSidebarSecondaryInfo).first();
const header = this.memo.getType(PlaylistHeader)[0];
const primary_info = this.memo.getType(PlaylistSidebarPrimaryInfo)[0];
const secondary_info = this.memo.getType(PlaylistSidebarSecondaryInfo)[0];
const video_list = this.memo.getType(PlaylistVideoList)[0];
const alert = this.page.alerts?.firstOfType(Alert);
if (alert && alert.alert_type === 'ERROR')
@@ -53,7 +55,8 @@ export default class Playlist extends Feed<IBrowseResponse> {
last_updated: this.#getStat(2, primary_info),
can_share: header?.can_share,
can_delete: header?.can_delete,
is_editable: header?.is_editable,
can_reorder: video_list?.can_reorder,
is_editable: video_list?.is_editable,
privacy: header?.privacy
}
};
@@ -68,7 +71,7 @@ export default class Playlist extends Feed<IBrowseResponse> {
}
get has_continuation() {
const section_list = this.memo.getType(SectionList).first();
const section_list = this.memo.getType(SectionList)[0];
if (!section_list)
return super.has_continuation;
@@ -77,7 +80,7 @@ export default class Playlist extends Feed<IBrowseResponse> {
}
async getContinuationData(): Promise<IBrowseResponse | undefined> {
const section_list = this.memo.getType(SectionList).first();
const section_list = this.memo.getType(SectionList)[0];
/**
* No section list means there can't be additional continuation nodes here,

View File

@@ -29,8 +29,8 @@ export default class Search extends Feed<ISearchResponse> {
super(actions, data, already_parsed);
const contents =
this.page.contents_memo?.getType(SectionList).first().contents ||
this.page.on_response_received_commands?.first().as(AppendContinuationItemsAction, ReloadContinuationItemsCommand).contents;
this.page.contents_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');
@@ -44,8 +44,8 @@ export default class Search extends Feed<ISearchResponse> {
this.estimated_results = this.page.estimated_results || 0;
if (this.page.contents_memo) {
this.sub_menu = this.page.contents_memo.getType(SearchSubMenu).first();
this.watch_card = this.page.contents_memo.getType(UniversalWatchCard).first();
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);

View File

@@ -16,7 +16,7 @@ export default class TranscriptInfo {
if (!this.#page.actions_memo)
throw new Error('Page actions not found');
this.transcript = this.#page.actions_memo.getType(Transcript).first();
this.transcript = this.#page.actions_memo.getType(Transcript)[0];
}
/**

View File

@@ -131,7 +131,7 @@ export default class VideoInfo extends MediaInfo {
const comments_entry_point = results.get({ target_id: 'comments-entry-point' })?.as(ItemSection);
this.comments_entry_point_header = comments_entry_point?.contents?.firstOfType(CommentsEntryPointHeader);
this.livechat = next?.contents_memo?.getType(LiveChat).first();
this.livechat = next?.contents_memo?.getType(LiveChat)[0];
}
}

View File

@@ -14,7 +14,7 @@ export default class Channel extends Feed<IBrowseResponse> {
constructor(actions: Actions, data: ApiResponse | IBrowseResponse, already_parsed = false) {
super(actions, data, already_parsed);
this.header = this.page.header?.item().as(C4TabbedHeader);
this.contents = this.memo.getType(ItemSection).first() || this.page.continuation_contents?.as(ItemSectionContinuation);
this.contents = this.memo.getType(ItemSection)[0] || this.page.continuation_contents?.as(ItemSectionContinuation);
}
/**

View File

@@ -14,7 +14,7 @@ export default class Search extends Feed<ISearchResponse> {
super(actions, data);
this.estimated_results = this.page.estimated_results;
const item_section = this.memo.getType(ItemSection).first();
const item_section = this.memo.getType(ItemSection)[0];
if (!item_section)
throw new InnertubeError('No item section found in search response.');

View File

@@ -27,8 +27,8 @@ export default class Album {
if (!this.#page.contents_memo)
throw new Error('No contents found in the response');
this.header = this.#page.contents_memo.getType(MusicDetailHeader, MusicResponsiveHeader)?.first();
this.contents = this.#page.contents_memo.getType(MusicShelf)?.first().contents || observe([]);
this.header = this.#page.contents_memo.getType(MusicDetailHeader, MusicResponsiveHeader)?.[0];
this.contents = this.#page.contents_memo.getType(MusicShelf)?.[0].contents || observe([]);
this.sections = this.#page.contents_memo.getType(MusicCarouselShelf) || observe([]);
this.background = this.#page.background;
this.url = this.#page.microformat?.as(MicroformatData).url_canonical;

View File

@@ -47,7 +47,7 @@ export default class Artist {
throw new InnertubeError('Target shelf (Songs) did not have an endpoint.');
const page = await shelf.endpoint.call(this.#actions, { client: 'YTMUSIC', parse: true });
return page.contents_memo?.getType(MusicPlaylistShelf)?.first();
return page.contents_memo?.getType(MusicPlaylistShelf)?.[0];
}
get page(): IBrowseResponse {

View File

@@ -30,7 +30,7 @@ export default class Library {
this.#page = Parser.parseResponse<IBrowseResponse>(response.data);
this.#actions = actions;
const section_list = this.#page.contents_memo?.getType(SectionList).first();
const section_list = this.#page.contents_memo?.getType(SectionList)[0];
this.header = section_list?.header?.as(MusicSideAlignedItem);
this.contents = section_list?.contents?.as(Grid, MusicShelf);
@@ -45,7 +45,7 @@ export default class Library {
let target_item: MusicMultiSelectMenuItem | undefined;
if (typeof sort_by === 'string') {
const button = this.#page.contents_memo?.getType(MusicSortFilterButton).first();
const button = this.#page.contents_memo?.getType(MusicSortFilterButton)[0];
const options = button?.menu?.options
.filter(
@@ -94,7 +94,7 @@ export default class Library {
async applyFilter(filter: string | ChipCloudChip): Promise<Library> {
let target_chip: ChipCloudChip | undefined;
const chip_cloud = this.#page.contents_memo?.getType(ChipCloud).first();
const chip_cloud = this.#page.contents_memo?.getType(ChipCloud)[0];
if (typeof filter === 'string') {
target_chip = chip_cloud?.chips.get({ text: filter });
@@ -134,13 +134,13 @@ export default class Library {
}
get sort_options(): string[] {
const button = this.#page.contents_memo?.getType(MusicSortFilterButton).first();
const button = this.#page.contents_memo?.getType(MusicSortFilterButton)[0];
const options = button?.menu?.options.filter((item: MusicMultiSelectMenuItem | MusicMenuItemDivider) => item instanceof MusicMultiSelectMenuItem) as MusicMultiSelectMenuItem[];
return options.map((item) => item.title);
}
get filters(): string[] {
return this.#page.contents_memo?.getType(ChipCloud)?.first().chips.map((chip: ChipCloudChip) => chip.text) || [];
return this.#page.contents_memo?.getType(ChipCloud)?.[0].chips.map((chip: ChipCloudChip) => chip.text) || [];
}
get page(): IBrowseResponse {

View File

@@ -1,6 +1,6 @@
// noinspection ES6MissingAwait
import { Parser, MusicPlaylistShelfContinuation, SectionListContinuation } from '../index.js';
import { MusicPlaylistShelfContinuation, Parser, SectionListContinuation } from '../index.js';
import MusicCarouselShelf from '../classes/MusicCarouselShelf.js';
import MusicDetailHeader from '../classes/MusicDetailHeader.js';
@@ -13,17 +13,19 @@ import MusicResponsiveHeader from '../classes/MusicResponsiveHeader.js';
import { InnertubeError } from '../../utils/Utils.js';
import { observe, type ObservedArray } from '../helpers.js';
import type { ApiResponse, Actions } from '../../core/index.js';
import type { Actions, ApiResponse } from '../../core/index.js';
import type { IBrowseResponse } from '../types/index.js';
import type MusicThumbnail from '../classes/MusicThumbnail.js';
import ContinuationItem from '../classes/ContinuationItem.js';
import AppendContinuationItemsAction from '../classes/actions/AppendContinuationItemsAction.js';
export default class Playlist {
readonly #page: IBrowseResponse;
readonly #actions: Actions;
readonly #continuation: string | null;
readonly #continuation?: string | ContinuationItem;
public header?: MusicResponsiveHeader | MusicDetailHeader | MusicEditablePlaylistDetailHeader;
public contents?: ObservedArray<MusicResponsiveListItem>;
public contents?: ObservedArray<MusicResponsiveListItem | ContinuationItem>;
public background?: MusicThumbnail;
#last_fetched_suggestions: ObservedArray<MusicResponsiveListItem> | null;
@@ -40,15 +42,19 @@ export default class Playlist {
const data = this.#page.continuation_contents?.as(MusicPlaylistShelfContinuation);
if (!data.contents)
throw new InnertubeError('No contents found in the response');
this.contents = data.contents.as(MusicResponsiveListItem);
this.#continuation = data.continuation;
} else {
if (!this.#page.contents_memo)
throw new InnertubeError('No contents found in the response');
this.header = this.#page.contents_memo.getType(MusicResponsiveHeader, MusicEditablePlaylistDetailHeader, MusicDetailHeader)?.first();
this.contents = this.#page.contents_memo.getType(MusicPlaylistShelf)?.first()?.contents || observe([]);
this.contents = data.contents.as(MusicResponsiveListItem, ContinuationItem);
const continuation_item = this.contents.firstOfType(ContinuationItem);
this.#continuation = data.continuation || continuation_item;
} else if (this.#page.contents_memo) {
this.header = this.#page.contents_memo.getType(MusicResponsiveHeader, MusicEditablePlaylistDetailHeader, MusicDetailHeader)?.[0];
this.contents = this.#page.contents_memo.getType(MusicPlaylistShelf)?.[0]?.contents.as(MusicResponsiveListItem, ContinuationItem) || observe([]);
this.background = this.#page.background;
this.#continuation = this.#page.contents_memo.getType(MusicPlaylistShelf)?.first()?.continuation || null;
const continuation_item = this.contents.firstOfType(ContinuationItem);
this.#continuation = this.#page.contents_memo.getType(MusicPlaylistShelf)?.[0]?.continuation || continuation_item;
} else if (this.#page.on_response_received_actions) {
const append_continuation_action = this.#page.on_response_received_actions.firstOfType(AppendContinuationItemsAction);
this.contents = append_continuation_action?.contents?.as(MusicResponsiveListItem, ContinuationItem);
this.#continuation = this.contents?.firstOfType(ContinuationItem);
}
}
@@ -59,11 +65,17 @@ export default class Playlist {
if (!this.#continuation)
throw new InnertubeError('Continuation not found.');
const response = await this.#actions.execute('/browse', {
client: 'YTMUSIC',
continuation: this.#continuation
});
let response: ApiResponse;
if (typeof this.#continuation === 'string') {
response = await this.#actions.execute('/browse', {
client: 'YTMUSIC',
continuation: this.#continuation
});
} else {
response = await this.#continuation.endpoint.call(this.#actions, { client: 'YTMUSIC' });
}
return new Playlist(response, this.#actions);
}
@@ -144,7 +156,7 @@ export default class Playlist {
return this.#page;
}
get items(): ObservedArray<MusicResponsiveListItem> {
get items(): ObservedArray<MusicResponsiveListItem | ContinuationItem> {
return this.contents || observe([]);
}

View File

@@ -115,15 +115,15 @@ export default class Search {
}
get did_you_mean(): DidYouMean | undefined {
return this.#page.contents_memo?.getType(DidYouMean).first();
return this.#page.contents_memo?.getType(DidYouMean)[0];
}
get showing_results_for(): ShowingResultsFor | undefined {
return this.#page.contents_memo?.getType(ShowingResultsFor).first();
return this.#page.contents_memo?.getType(ShowingResultsFor)[0];
}
get message(): Message | undefined {
return this.#page.contents_memo?.getType(Message).first();
return this.#page.contents_memo?.getType(Message)[0];
}
get songs(): MusicShelf | undefined {

View File

@@ -49,7 +49,7 @@ class TrackInfo extends MediaInfo {
const target_tab =
this.tabs.get({ title: title_or_page_type }) ||
this.tabs.matchCondition((tab) => tab.endpoint.payload.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType === title_or_page_type) ||
this.tabs.find((tab) => tab.endpoint.payload.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType === title_or_page_type) ||
this.tabs?.[0];
if (!target_tab)

View File

@@ -5,6 +5,10 @@ export type URLTransformer = (url: URL) => URL;
export type FormatFilter = (format: Format) => boolean;
export interface FormatOptions {
/**
* Video or audio itag
*/
itag?: number;
/**
* Video quality; 360p, 720p, 1080p, etc... also accepts 'best' and 'bestefficiency'.
*/
@@ -21,6 +25,10 @@ export interface FormatOptions {
* File format, use 'any' to download any format
*/
format?: string;
/**
* Video or audio codec, e.g. 'avc', 'vp9', 'av01' for video, 'opus', 'mp4a' for audio
*/
codec?: string;
/**
* InnerTube client.
*/
@@ -35,4 +43,4 @@ export interface DownloadOptions extends FormatOptions {
start: number;
end: number;
}
}
}

View File

@@ -48,7 +48,7 @@ export const CLIENTS = {
},
YTMUSIC: {
NAME: 'WEB_REMIX',
VERSION: '1.20211213.00.00'
VERSION: '1.20250219.01.00'
},
ANDROID: {
NAME: 'ANDROID',
@@ -82,7 +82,7 @@ export const CLIENTS = {
},
WEB_CREATOR: {
NAME: 'WEB_CREATOR',
VERSION: '1.20240918.03.00',
VERSION: '1.20241203.01.00',
API_KEY: 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8',
API_VERSION: 'v1',
STATIC_VISITOR_ID: '6zpwvWUNAco'
@@ -117,4 +117,4 @@ export const INNERTUBE_HEADERS_BASE = {
'content-type': 'application/json'
} as const;
export const SUPPORTED_CLIENTS = [ 'IOS', 'WEB', 'MWEB', 'YTKIDS', 'YTMUSIC', 'ANDROID', 'YTSTUDIO_ANDROID', 'YTMUSIC_ANDROID', 'TV', 'TV_EMBEDDED', 'WEB_EMBEDDED', 'WEB_CREATOR' ];
export const SUPPORTED_CLIENTS = [ 'IOS', 'WEB', 'MWEB', 'YTKIDS', 'YTMUSIC', 'ANDROID', 'YTSTUDIO_ANDROID', 'YTMUSIC_ANDROID', 'TV', 'TV_EMBEDDED', 'WEB_EMBEDDED', 'WEB_CREATOR' ];

View File

@@ -143,6 +143,14 @@ export function chooseFormat(options: FormatOptions, streaming_data?: IStreaming
...(streaming_data.formats || []),
...(streaming_data.adaptive_formats || [])
];
if (options.itag) {
const candidates = formats.filter((format) => format.itag === options.itag);
if (!candidates.length)
throw new InnertubeError('No matching formats found', { options });
return candidates[0];
}
const requires_audio = options.type ? options.type.includes('audio') : true;
const requires_video = options.type ? options.type.includes('video') : true;
@@ -159,6 +167,8 @@ export function chooseFormat(options: FormatOptions, streaming_data?: IStreaming
return false;
if (requires_video && !format.has_video)
return false;
if (options.codec && !format.mime_type.includes(options.codec))
return false;
if (options.format !== 'any' && !format.mime_type.includes(options.format || 'mp4'))
return false;
if (!is_best && format.quality_label !== quality)

View File

@@ -425,8 +425,7 @@ function getTrackRoles(format: Format, has_drc_streams: boolean) {
}
const roles: ('main' | 'dub' | 'description' | 'enhanced-audio-intelligibility' | 'alternate')[] = [
// movie trailers can have a dubbed track as the only audio track so is_original is false but format.audio_track.audio_is_default is true
format.is_original || format.audio_track?.audio_is_default ? 'main' : 'alternate'
format.is_original ? 'main' : 'alternate'
];
if (format.is_dubbed || format.is_auto_dubbed)

View File

@@ -11,16 +11,17 @@ import type { Node } from 'estree';
const TAG_ = 'Utils';
let shim: PlatformShim | undefined;
export class Platform {
static #shim: PlatformShim | undefined;
static load(platform: PlatformShim): void {
Platform.#shim = platform;
shim = platform;
}
static get shim(): PlatformShim {
if (!Platform.#shim) {
if (!shim) {
throw new Error('Platform is not loaded');
}
return Platform.#shim;
return shim;
}
}
export class InnertubeError extends Error {

View File

@@ -1,5 +1,4 @@
import { createWriteStream, existsSync } from 'node:fs';
import { Innertube, Utils, YT, YTMusic, YTNodes } from '../bundle/node.cjs';
import { Innertube, YT, YTMusic, YTNodes } from '../bundle/node.cjs';
jest.useRealTimers();
@@ -317,7 +316,7 @@ describe('YouTube.js Tests', () => {
expect(playlist.contents).toBeDefined();
expect(playlist.contents?.length).toBeGreaterThan(0);
const info = await innertube.music.getInfo(playlist.contents!.first())
const info = await innertube.music.getInfo(playlist.contents!.first().as(YTNodes.MusicResponsiveListItem))
expect(info).toBeDefined();
});
@@ -394,13 +393,6 @@ describe('YouTube.js Tests', () => {
expect(home.sections?.length).toBeGreaterThan(0);
});
test('HomeFeed#getContinuation', async () => {
const incremental_continuation = await home.getContinuation();
expect(incremental_continuation).toBeDefined();
expect(incremental_continuation.sections).toBeDefined();
expect(incremental_continuation.sections?.length).toBeGreaterThan(0);
});
test('HomeFeed#applyFilter', async () => {
home = await home.applyFilter(home.filters[1]);
expect(home).toBeDefined();