refactor(parser): improve typings and do some refactoring (#305)

* dev: add response types

* dev: refactor `Parser#parseResponse()`

* dev: update YouTube parsers

* dev: update YouTube Music classes

* dev: update YouTube Kids classes

* dev: update core classes

* dev(Parser): fix some inconsistencies

* chore: update docs

* chore: update docs x2

* fix: export response types 

* chore(docs): update parser example
This commit is contained in:
LuanRT
2023-02-12 07:04:17 -03:00
committed by GitHub
parent 2ccbe2ce62
commit eb72c2f6ef
61 changed files with 1116 additions and 571 deletions

3
.gitignore vendored
View File

@@ -70,5 +70,8 @@ bundle/*.cjs
bundle/*.cjs.*
deno/
# VSCode files
.vscode/
# MacOS
.DS_Store

View File

@@ -451,13 +451,13 @@ Retrieves watch history.
### getTrending()
Retrieves trending content.
**Returns**: `Promise.<TabbedFeed>`
**Returns**: `Promise.<TabbedFeed<IBrowseResponse>>`
<a name="getsubscriptionsfeed"></a>
### getSubscriptionsFeed()
Retrieves subscriptions feed.
**Returns**: `Promise.<Feed>`
**Returns**: `Promise.<Feed<IBrowseResponse>>`
<a name="getchannel"></a>
### getChannel(id)
@@ -584,7 +584,7 @@ Resolves a given url.
### call(endpoint, args?)
Utility to call navigation endpoints.
**Returns**: `Promise.<ActionsResponse | ParsedResponse>`
**Returns**: `Promise.<T extends IParsedResponse | IParsedResponse | ApiResponse>`
| Param | Type | Description |
| --- | --- | --- |
@@ -635,7 +635,7 @@ import { Innertube, YTNodes } from 'youtubei.js';
if (button) {
// After making sure it exists, we can call its navigation endpoint:
const page = await button.endpoint.call(yt.actions);
const page = await button.endpoint.call(yt.actions, { parse: true });
console.info(page);
}
})();
@@ -667,7 +667,7 @@ console.info('Header:', header);
* the parser to add type safety and many utility methods
* that make working with InnerTube much easier.
*/
const tab = page.contents.item().as(YTNodes.SingleColumnBrowseResults).tabs.firstOfType(YTNodes.Tab);
const tab = page.contents?.item().as(YTNodes.SingleColumnBrowseResults).tabs.firstOfType(YTNodes.Tab);
if (!tab)
@@ -676,7 +676,7 @@ if (!tab)
if (!tab.content)
throw new Error('Target tab appears to be empty');
const sections = tab.content?.as(YTNodes.SectionList).contents.array().as(YTNodes.MusicCarouselShelf, YTNodes.MusicDescriptionShelf, YTNodes.MusicShelf);
const sections = tab.content?.as(YTNodes.SectionList).contents.as(YTNodes.MusicCarouselShelf, YTNodes.MusicDescriptionShelf, YTNodes.MusicShelf);
console.info('Sections:', sections);
```

View File

@@ -19,7 +19,7 @@ Handles direct interactions.
Likes given video.
**Returns:** `Promise.<ActionsResponse>`
**Returns:** `Promise.<ApiResponse>`
| Param | Type | Description |
| --- | --- | --- |
@@ -30,7 +30,7 @@ Likes given video.
Dislikes given video.
**Returns:** `Promise.<ActionsResponse>`
**Returns:** `Promise.<ApiResponse>`
| Param | Type | Description |
| --- | --- | --- |
@@ -41,7 +41,7 @@ Dislikes given video.
Remover like/dislike.
**Returns:** `Promise.<ActionsResponse>`
**Returns:** `Promise.<ApiResponse>`
| Param | Type | Description |
| --- | --- | --- |
@@ -52,7 +52,7 @@ Remover like/dislike.
Subscribes to given channel.
**Returns:** `Promise.<ActionsResponse>`
**Returns:** `Promise.<ApiResponse>`
| Param | Type | Description |
| --- | --- | --- |
@@ -63,7 +63,7 @@ Subscribes to given channel.
Unsubscribes from given channel.
**Returns:** `Promise.<ActionsResponse>`
**Returns:** `Promise.<ApiResponse>`
| Param | Type | Description |
| --- | --- | --- |
@@ -74,7 +74,7 @@ Unsubscribes from given channel.
Posts a comment on given video.
**Returns:** `Promise.<ActionsResponse>`
**Returns:** `Promise.<ApiResponse>`
| Param | Type | Description |
| --- | --- | --- |
@@ -86,7 +86,7 @@ Posts a comment on given video.
Translates given text using YouTube's comment translation feature.
**Returns:** `Promise.<ActionsResponse>`
**Returns:** `Promise.<ApiResponse>`
| Param | Type | Description |
| --- | --- | --- |
@@ -100,7 +100,7 @@ Translates given text using YouTube's comment translation feature.
Changes notification preferences for a given channel.
Only works with channels you are subscribed to.
**Returns:** `Promise.<ActionsResponse>`
**Returns:** `Promise.<ApiResponse>`
| Param | Type | Description |
| --- | --- | --- |

View File

@@ -16,7 +16,7 @@ Playlist management class.
Creates a playlist.
**Returns:** `Promise.<ActionsResponse>`
**Returns:** `Promise.<ApiResponse>`
| Param | Type | Description |
| --- | --- | --- |
@@ -28,7 +28,7 @@ Creates a playlist.
Deletes given playlist.
**Returns:** `Promise.<ActionsResponse>`
**Returns:** `Promise.<ApiResponse>`
| Param | Type | Description |
| --- | --- | --- |

View File

@@ -14,7 +14,7 @@ YouTube Studio class (WIP).
Uploads a custom thumbnail and sets it for a video.
**Returns:** `Promise.<ActionsResponse>`
**Returns:** `Promise.<ApiResponse>`
| Param | Type | Description |
| --- | --- | --- |
@@ -26,7 +26,7 @@ Uploads a custom thumbnail and sets it for a video.
Updates given video's metadata.
**Returns:** `Promise.<ActionsResponse>`
**Returns:** `Promise.<ApiResponse>`
| Param | Type | Description |
| --- | --- | --- |
@@ -38,7 +38,7 @@ Updates given video's metadata.
Uploads a video to YouTube.
**Returns:** `Promise.<ActionsResponse>`
**Returns:** `Promise.<ApiResponse>`
| Param | Type | Description |
| --- | --- | --- |

View File

@@ -1,7 +1,7 @@
import { Innertube, UniversalCache, YTNodes } from 'youtubei.js';
(async () => {
const yt = await Innertube.create({ cache: new UniversalCache() });
const yt = await Innertube.create({ cache: new UniversalCache(), generate_session_locally: true });
const channel = await yt.getChannel('UCX6OQ3DkcsbYNE6H8uQQuVA');

View File

@@ -1,7 +1,7 @@
import { Innertube, UniversalCache } from 'youtubei.js';
(async () => {
const yt = await Innertube.create({ cache: new UniversalCache() });
const yt = await Innertube.create({ cache: new UniversalCache(), generate_session_locally: true });
const comment_section = await yt.getComments('a-rqu-hjobc');

View File

@@ -3,7 +3,7 @@ import { readFileSync, existsSync, mkdirSync, createWriteStream } from 'fs';
import { streamToIterable } from 'youtubei.js/dist/src/utils/Utils';
(async () => {
const yt = await Innertube.create({ cache: new UniversalCache() });
const yt = await Innertube.create({ cache: new UniversalCache(), generate_session_locally: true });
const search = await yt.music.search('No Copyright Background Music', { type: 'album' });
@@ -19,7 +19,7 @@ import { streamToIterable } from 'youtubei.js/dist/src/utils/Utils';
for (const song of album.contents) {
const stream = await yt.download(song.id as string, {
type: 'audio', // audio, video or audio+video
type: 'audio', // audio, video or video+audio
quality: 'best', // best, bestefficiency, 144p, 240p, 480p, 720p and so on.
format: 'mp4' // media container format
});

View File

@@ -13,7 +13,7 @@ console.info('Header:', header);
// A proxy intercepts access to the actual data, allowing
// the parser to add type safety and many utility methods
// that make working with InnerTube much easier.
const tab = page.contents.item().as(YTNodes.SingleColumnBrowseResults).tabs.firstOfType(YTNodes.Tab);
const tab = page.contents?.item().as(YTNodes.SingleColumnBrowseResults).tabs.firstOfType(YTNodes.Tab);
if (!tab)
throw new Error('Target tab not found');
@@ -21,6 +21,6 @@ if (!tab)
if (!tab.content)
throw new Error('Target tab appears to be empty');
const sections = tab.content?.as(YTNodes.SectionList).contents.array().as(YTNodes.MusicCarouselShelf, YTNodes.MusicDescriptionShelf, YTNodes.MusicShelf);
const sections = tab.content?.as(YTNodes.SectionList).contents.as(YTNodes.MusicCarouselShelf, YTNodes.MusicDescriptionShelf, YTNodes.MusicShelf);
console.info('Sections:', sections);

View File

@@ -31,5 +31,5 @@ const creds = existsSync(creds_path) ? JSON.parse(readFileSync(creds_path).toStr
privacy: 'UNLISTED'
});
console.info('Done!');
console.info('Done!', upload);
})();

View File

@@ -1,7 +1,5 @@
import Session, { SessionOptions } from './core/Session.js';
import type { ParsedResponse } from './parser/index.js';
import type { ActionsResponse } from './core/Actions.js';
import NavigationEndpoint from './parser/classes/NavigationEndpoint.js';
import Channel from './parser/youtube/Channel.js';
@@ -30,6 +28,8 @@ import type Format from './parser/classes/misc/Format.js';
import { generateRandomString, throwIfMissing } from './utils/Utils.js';
import type { FormatOptions, DownloadOptions } from './utils/FormatUtils.js';
import type { ApiResponse } from './core/Actions.js';
import type { IBrowseResponse, IParsedResponse } from './parser/types/ParsedResponse.js';
export type InnertubeConfig = SessionOptions;
@@ -116,7 +116,7 @@ class Innertube {
const response = await this.actions.execute('/search', args);
return new Search(this.actions, response.data);
return new Search(this.actions, response);
}
/**
@@ -166,7 +166,7 @@ class Innertube {
*/
async getHomeFeed(): Promise<HomeFeed> {
const response = await this.actions.execute('/browse', { browseId: 'FEwhat_to_watch' });
return new HomeFeed(this.actions, response.data);
return new HomeFeed(this.actions, response);
}
/**
@@ -174,7 +174,7 @@ class Innertube {
*/
async getLibrary(): Promise<Library> {
const response = await this.actions.execute('/browse', { browseId: 'FElibrary' });
return new Library(response.data, this.actions);
return new Library(this.actions, response);
}
/**
@@ -183,23 +183,23 @@ class Innertube {
*/
async getHistory(): Promise<History> {
const response = await this.actions.execute('/browse', { browseId: 'FEhistory' });
return new History(this.actions, response.data);
return new History(this.actions, response);
}
/**
* Retrieves trending content.
*/
async getTrending(): Promise<TabbedFeed> {
const response = await this.actions.execute('/browse', { browseId: 'FEtrending' });
return new TabbedFeed(this.actions, response.data);
async getTrending(): Promise<TabbedFeed<IBrowseResponse>> {
const response = await this.actions.execute('/browse', { browseId: 'FEtrending', parse: true });
return new TabbedFeed(this.actions, response);
}
/**
* Retrieves subscriptions feed.
*/
async getSubscriptionsFeed(): Promise<Feed> {
const response = await this.actions.execute('/browse', { browseId: 'FEsubscriptions' });
return new Feed(this.actions, response.data);
async getSubscriptionsFeed(): Promise<Feed<IBrowseResponse>> {
const response = await this.actions.execute('/browse', { browseId: 'FEsubscriptions', parse: true });
return new Feed(this.actions, response);
}
/**
@@ -209,7 +209,7 @@ class Innertube {
async getChannel(id: string): Promise<Channel> {
throwIfMissing({ id });
const response = await this.actions.execute('/browse', { browseId: id });
return new Channel(this.actions, response.data);
return new Channel(this.actions, response);
}
/**
@@ -241,7 +241,8 @@ class Innertube {
}
const response = await this.actions.execute('/browse', { browseId: id });
return new Playlist(this.actions, response.data);
return new Playlist(this.actions, response);
}
/**
@@ -274,7 +275,7 @@ class Innertube {
*/
async resolveURL(url: string): Promise<NavigationEndpoint> {
const response = await this.actions.execute('/navigation/resolve_url', { url, parse: true });
return response.endpoint as NavigationEndpoint;
return response.endpoint;
}
/**
@@ -282,9 +283,9 @@ class Innertube {
* @param endpoint -The endpoint to call.
* @param args - Call arguments.
*/
call(endpoint: NavigationEndpoint, args: { [key: string]: any; parse: true }): Promise<ParsedResponse>;
call(endpoint: NavigationEndpoint, args?: { [key: string]: any; parse?: false }): Promise<ActionsResponse>;
call(endpoint: NavigationEndpoint, args?: object): Promise<ActionsResponse | ParsedResponse> {
call<T extends IParsedResponse>(endpoint: NavigationEndpoint, args: { [key: string]: any; parse: true }): Promise<T>;
call(endpoint: NavigationEndpoint, args?: { [key: string]: any; parse?: false }): Promise<ApiResponse>;
call(endpoint: NavigationEndpoint, args?: object): Promise<IParsedResponse | ApiResponse> {
return endpoint.call(this.actions, args);
}
}

View File

@@ -1,6 +1,6 @@
import Proto from '../proto/index.js';
import type Actions from './Actions.js';
import type { ActionsResponse } from './Actions.js';
import type { ApiResponse } from './Actions.js';
import Analytics from '../parser/youtube/Analytics.js';
import TimeWatched from '../parser/youtube/TimeWatched.js';
@@ -13,8 +13,8 @@ class AccountManager {
#actions: Actions;
channel: {
editName: (new_name: string) => Promise<ActionsResponse>;
editDescription: (new_description: string) => Promise<ActionsResponse>;
editName: (new_name: string) => Promise<ApiResponse>;
editDescription: (new_description: string) => Promise<ApiResponse>;
getBasicAnalytics: () => Promise<Analytics>;
};

View File

@@ -1,14 +1,35 @@
import Parser, { ParsedResponse } from '../parser/index.js';
import Parser, { NavigateAction } from '../parser/index.js';
import { InnertubeError } from '../utils/Utils.js';
import type Session from './Session.js';
import type {
IBrowseResponse, IGetNotificationsMenuResponse,
INextResponse, IParsedResponse, IPlayerResponse,
IResolveURLResponse, ISearchResponse,
IUpdatedMetadataResponse
} from '../parser/types/ParsedResponse.js';
import type { IRawResponse } from '../parser/types/RawResponse.js';
export interface ApiResponse {
success: boolean;
status_code: number;
data: any;
data: IRawResponse;
}
export type ActionsResponse = Promise<ApiResponse>;
export type InnertubeEndpoint = '/player' | '/search' | '/browse' | '/next' | '/updated_metadata' | '/notification/get_notification_menu' | string;
export type ParsedResponse<T> =
T extends '/player' ? IPlayerResponse :
T extends '/search' ? ISearchResponse :
T extends '/browse' ? IBrowseResponse :
T extends '/next' ? INextResponse :
T extends '/updated_metadata' ? IUpdatedMetadataResponse :
T extends '/navigation/resolve_url' ? IResolveURLResponse :
T extends '/notification/get_notification_menu' ? IGetNotificationsMenuResponse :
IParsedResponse;
class Actions {
#session: Session;
@@ -40,7 +61,7 @@ class Actions {
* @param client - The client to use.
* @param playlist_id - The playlist ID.
*/
async getVideoInfo(id: string, cpn?: string, client?: string, playlist_id?: string): Promise<ActionsResponse> {
async getVideoInfo(id: string, cpn?: string, client?: string, playlist_id?: string): Promise<ApiResponse> {
const data: Record<string, any> = {
playbackContext: {
contentPlaybackContext: {
@@ -109,12 +130,12 @@ class Actions {
/**
* Executes an API call.
* @param action - The endpoint to call.
* @param endpoint - The endpoint to call.
* @param args - Call arguments
*/
async execute(action: string, args: { [key: string]: any; parse: true; protobuf?: false; serialized_data?: any }) : Promise<ParsedResponse>;
async execute(action: string, args?: { [key: string]: any; parse?: false; protobuf?: true; serialized_data?: any }) : Promise<ActionsResponse>;
async execute(action: string, args?: { [key: string]: any; parse?: boolean; protobuf?: boolean; serialized_data?: any }): Promise<ParsedResponse | ActionsResponse> {
async execute<T extends InnertubeEndpoint>(endpoint: T, args: { [key: string]: any; parse: true; protobuf?: false; serialized_data?: any }): Promise<ParsedResponse<T>>;
async execute<T extends InnertubeEndpoint>(endpoint: T, args?: { [key: string]: any; parse?: false; protobuf?: true; serialized_data?: any }): Promise<ApiResponse>;
async execute<T extends InnertubeEndpoint>(endpoint: T, args?: { [key: string]: any; parse?: boolean; protobuf?: boolean; serialized_data?: any }): Promise<ParsedResponse<T> | ApiResponse> {
let data;
if (args && !args.protobuf) {
@@ -162,9 +183,9 @@ class Actions {
data = args.serialized_data;
}
const endpoint = Reflect.has(args || {}, 'override_endpoint') ? args?.override_endpoint : action;
const target_endpoint = Reflect.has(args || {}, 'override_endpoint') ? args?.override_endpoint : endpoint;
const response = await this.#session.http.fetch(endpoint, {
const response = await this.#session.http.fetch(target_endpoint, {
method: 'POST',
body: args?.protobuf ? data : JSON.stringify((data || {})),
headers: {
@@ -175,12 +196,26 @@ class Actions {
});
if (args?.parse) {
return Parser.parseResponse(await response.json());
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') {
const navigate_action = parsed_response.on_response_received_actions.firstOfType(NavigateAction);
if (navigate_action) {
parsed_response = await navigate_action.endpoint.call(this, { parse: true });
}
}
return parsed_response;
}
return this.#wrap(response);
}
#isBrowse(response: IParsedResponse): response is IBrowseResponse {
return 'on_response_received_actions' in response;
}
#needsLogin(id: string) {
return [
'FElibrary',

View File

@@ -1,5 +1,5 @@
import type { Memo, ObservedArray, SuperParsedResult, YTNode } from '../parser/helpers.js';
import Parser, { ParsedResponse, ReloadContinuationItemsCommand } from '../parser/index.js';
import Parser, { ReloadContinuationItemsCommand } from '../parser/index.js';
import { concatMemos, InnertubeError } from '../utils/Utils.js';
import type Actions from './Actions.js';
@@ -30,20 +30,23 @@ import type MusicQueue from '../parser/classes/MusicQueue.js';
import type RichGrid from '../parser/classes/RichGrid.js';
import type SectionList from '../parser/classes/SectionList.js';
class Feed {
#page: ParsedResponse;
import type { IParsedResponse } from '../parser/types/ParsedResponse.js';
import type { ApiResponse } from './Actions.js';
class Feed<T extends IParsedResponse = IParsedResponse> {
#page: T;
#continuation?: ObservedArray<ContinuationItem>;
#actions: Actions;
#memo: Memo;
constructor(actions: Actions, data: any, already_parsed = false) {
if (data.on_response_received_actions || data.on_response_received_endpoints || already_parsed) {
this.#page = data;
constructor(actions: Actions, response: ApiResponse | IParsedResponse, already_parsed = false) {
if (this.#isParsed(response) || already_parsed) {
this.#page = response as T;
} else {
this.#page = Parser.parseResponse(data);
this.#page = Parser.parseResponse<T>(response.data);
}
const memo = concatMemos(
const memo = concatMemos(...[
this.#page.contents_memo,
this.#page.continuation_contents_memo,
this.#page.on_response_received_commands_memo,
@@ -51,7 +54,7 @@ class Feed {
this.#page.on_response_received_actions_memo,
this.#page.sidebar_memo,
this.#page.header_memo
);
]);
if (!memo)
throw new InnertubeError('No memo found in feed');
@@ -60,6 +63,10 @@ class Feed {
this.#actions = actions;
}
#isParsed(response: IParsedResponse | ApiResponse): response is IParsedResponse {
return !('data' in response);
}
/**
* Get all videos on a given page via memo
*/
@@ -143,10 +150,10 @@ class Feed {
* Returns secondary contents from the page.
*/
get secondary_contents(): SuperParsedResult<YTNode> | undefined {
if (!this.#page.contents.is_node)
if (!this.#page.contents?.is_node)
return undefined;
const node = this.#page.contents.item();
const node = this.#page.contents?.item();
if (!node.is(TwoColumnBrowseResults, TwoColumnSearchResults))
return undefined;
@@ -161,7 +168,7 @@ class Feed {
/**
* Get the original page data
*/
get page(): ParsedResponse {
get page(): T {
return this.#page;
}
@@ -175,14 +182,14 @@ class Feed {
/**
* Retrieves continuation data as it is.
*/
async getContinuationData(): Promise<ParsedResponse | undefined> {
async getContinuationData(): Promise<T | undefined> {
if (this.#continuation) {
if (this.#continuation.length > 1)
throw new InnertubeError('There are too many continuations, you\'ll need to find the correct one yourself in this.page');
if (this.#continuation.length === 0)
throw new InnertubeError('There are no continuations');
const response = await this.#continuation[0].endpoint.call(this.#actions, { parse: true });
const response = await this.#continuation[0].endpoint.call<T>(this.#actions, { parse: true });
return response;
}
@@ -196,9 +203,11 @@ class Feed {
/**
* Retrieves next batch of contents and returns a new {@link Feed} object.
*/
async getContinuation(): Promise<Feed> {
async getContinuation(): Promise<Feed<T>> {
const continuation_data = await this.getContinuationData();
return new Feed(this.actions, continuation_data, true);
if (!continuation_data)
throw new InnertubeError('Could not get continuation data');
return new Feed<T>(this.actions, continuation_data, true);
}
}

View File

@@ -1,15 +1,17 @@
import ChipCloudChip from '../parser/classes/ChipCloudChip.js';
import FeedFilterChipBar from '../parser/classes/FeedFilterChipBar.js';
import { InnertubeError } from '../utils/Utils.js';
import Feed from './Feed.js';
import type { ObservedArray } from '../parser/helpers.js';
import { InnertubeError } from '../utils/Utils.js';
import type { IParsedResponse } from '../parser/types/ParsedResponse.js';
import type Actions from './Actions.js';
import type { ApiResponse } from './Actions.js';
class FilterableFeed extends Feed {
class FilterableFeed<T extends IParsedResponse> extends Feed<T> {
#chips?: ObservedArray<ChipCloudChip>;
constructor(actions: Actions, data: any, already_parsed = false) {
constructor(actions: Actions, data: ApiResponse | T, already_parsed = false) {
super(actions, data, already_parsed);
}
@@ -41,7 +43,7 @@ class FilterableFeed extends Feed {
/**
* Applies given filter and returns a new {@link Feed} object.
*/
async getFilteredFeed(filter: string | ChipCloudChip): Promise<Feed> {
async getFilteredFeed(filter: string | ChipCloudChip): Promise<Feed<T>> {
let target_filter: ChipCloudChip | undefined;
if (typeof filter === 'string') {
@@ -62,6 +64,9 @@ class FilterableFeed extends Feed {
const response = await target_filter.endpoint?.call(this.actions, { parse: true });
if (!response)
throw new InnertubeError('Failed to get filtered feed');
return new Feed(this.actions, response, true);
}
}

View File

@@ -19,7 +19,7 @@ class Kids {
*/
async search(query: string): Promise<Search> {
const response = await this.#session.actions.execute('/search', { query, client: 'YTKIDS' });
return new Search(this.#session.actions, response.data);
return new Search(this.#session.actions, response);
}
/**
@@ -53,7 +53,7 @@ class Kids {
*/
async getChannel(channel_id: string): Promise<Channel> {
const response = await this.#session.actions.execute('/browse', { browseId: channel_id, client: 'YTKIDS' });
return new Channel(this.#session.actions, response.data);
return new Channel(this.#session.actions, response);
}
/**
@@ -61,7 +61,7 @@ class Kids {
*/
async getHomeFeed(): Promise<HomeFeed> {
const response = await this.#session.actions.execute('/browse', { browseId: 'FEkids_home', client: 'YTKIDS' });
return new HomeFeed(this.#session.actions, response.data);
return new HomeFeed(this.#session.actions, response);
}
}

View File

@@ -125,7 +125,7 @@ class Music {
const response = await this.#actions.execute('/search', payload);
return new Search(response, this.#actions, { is_filtered: Reflect.has(filters, 'type') && filters.type !== 'all' });
return new Search(response, this.#actions, Reflect.has(filters, 'type') && filters.type !== 'all');
}
/**
@@ -198,7 +198,7 @@ class Music {
browseId: album_id
});
return new Album(response, this.#actions);
return new Album(response);
}
/**
@@ -234,9 +234,9 @@ class Music {
parse: true
});
const tabs = data.contents_memo.getType(Tab);
const tabs = data.contents_memo?.getType(Tab);
const tab = tabs?.[0];
const tab = tabs?.first();
if (!tab)
throw new InnertubeError('Could not find target tab.');
@@ -260,10 +260,10 @@ class Music {
parse: true
});
if (!page)
if (!page || !page.contents_memo)
throw new InnertubeError('Could not fetch automix');
return page.contents_memo.getType(PlaylistPanel)?.[0];
return page.contents_memo.getType(PlaylistPanel).first();
}
return playlist_panel;
@@ -282,7 +282,7 @@ class Music {
parse: true
});
const tabs = data.contents_memo.getType(Tab);
const tabs = data.contents_memo?.getType(Tab);
const tab = tabs?.matchCondition((tab) => tab.endpoint.payload.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType === 'MUSIC_PAGE_TYPE_TRACK_RELATED');
@@ -291,6 +291,9 @@ class Music {
const page = await tab.endpoint.call(this.#actions, { client: 'YTMUSIC', parse: true });
if (!page.contents)
throw new InnertubeError('Unexpected response', page);
const shelves = page.contents.item().as(SectionList).contents.as(MusicCarouselShelf, MusicDescriptionShelf);
return shelves;
@@ -309,7 +312,7 @@ class Music {
parse: true
});
const tabs = data.contents_memo.getType(Tab);
const tabs = data.contents_memo?.getType(Tab);
const tab = tabs?.matchCondition((tab) => tab.endpoint.payload.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType === 'MUSIC_PAGE_TYPE_TRACK_LYRICS');
@@ -318,10 +321,14 @@ class Music {
const page = await tab.endpoint.call(this.#actions, { client: 'YTMUSIC', parse: true });
if (!page.contents)
throw new InnertubeError('Unexpected response', page);
if (page.contents.item().key('type').string() === 'Message')
throw new InnertubeError(page.contents.item().as(Message).text, video_id);
const section_list = page.contents.item().as(SectionList).contents;
return section_list.firstOfType(MusicDescriptionShelf);
}
@@ -348,7 +355,7 @@ class Music {
client: 'YTMUSIC'
});
const search_suggestions_section = response.contents_memo.getType(SearchSuggestionsSection)?.[0];
const search_suggestions_section = response.contents_memo?.getType(SearchSuggestionsSection)?.[0];
if (!search_suggestions_section?.contents.is_array)
return observe([] as YTNode[]);

View File

@@ -72,8 +72,6 @@ export default class Player {
const args = new URLSearchParams(url);
const url_components = new URL(args.get('url') || url);
url_components.searchParams.set('alr', 'yes');
if (signature_cipher || cipher) {
const signature = Platform.shim.eval(this.#sig_sc, {
sig: args.get('s')

View File

@@ -16,7 +16,7 @@ class PlaylistManager {
* @param title - The title of the playlist.
* @param video_ids - An array of video IDs to add to the playlist.
*/
async create(title: string, video_ids: string[]): Promise<{ success: boolean; status_code: number; playlist_id: string; data: any }> {
async create(title: string, video_ids: string[]): Promise<{ success: boolean; status_code: number; playlist_id?: string; data: any }> {
throwIfMissing({ title, video_ids });
if (!this.#actions.session.logged_in)

View File

@@ -4,23 +4,25 @@ import { InnertubeError } from '../utils/Utils.js';
import type Actions from './Actions.js';
import type { ObservedArray } from '../parser/helpers.js';
import type { IParsedResponse } from '../parser/types/ParsedResponse.js';
import type { ApiResponse } from './Actions.js';
class TabbedFeed extends Feed {
#tabs: ObservedArray<Tab>;
class TabbedFeed<T extends IParsedResponse> extends Feed<T> {
#tabs?: ObservedArray<Tab>;
#actions: Actions;
constructor(actions: Actions, data: any, already_parsed = false) {
constructor(actions: Actions, data: ApiResponse | IParsedResponse, already_parsed = false) {
super(actions, data, already_parsed);
this.#actions = actions;
this.#tabs = this.page.contents_memo.getType(Tab);
this.#tabs = this.page.contents_memo?.getType(Tab);
}
get tabs(): string[] {
return this.#tabs.map((tab) => tab.title.toString());
return this.#tabs?.map((tab) => tab.title.toString()) ?? [];
}
async getTabByName(title: string): Promise<TabbedFeed> {
const tab = this.#tabs.find((tab) => tab.title.toLowerCase() === title.toLowerCase());
async getTabByName(title: string): Promise<TabbedFeed<T>> {
const tab = this.#tabs?.find((tab) => tab.title.toLowerCase() === title.toLowerCase());
if (!tab)
throw new InnertubeError(`Tab "${title}" not found`);
@@ -30,11 +32,11 @@ class TabbedFeed extends Feed {
const response = await tab.endpoint.call(this.#actions);
return new TabbedFeed(this.#actions, response.data, false);
return new TabbedFeed<T>(this.#actions, response, false);
}
async getTabByURL(url: string): Promise<TabbedFeed> {
const tab = this.#tabs.find((tab) => tab.endpoint.metadata.url?.split('/').pop() === url);
async getTabByURL(url: string): Promise<TabbedFeed<T>> {
const tab = this.#tabs?.find((tab) => tab.endpoint.metadata.url?.split('/').pop() === url);
if (!tab)
throw new InnertubeError(`Tab "${url}" not found`);
@@ -44,15 +46,15 @@ class TabbedFeed extends Feed {
const response = await tab.endpoint.call(this.#actions);
return new TabbedFeed(this.#actions, response.data, false);
return new TabbedFeed<T>(this.#actions, response, false);
}
hasTabWithURL(url: string): boolean {
return this.#tabs.some((tab) => tab.endpoint.metadata.url?.split('/').pop() === url);
return this.#tabs?.some((tab) => tab.endpoint.metadata.url?.split('/').pop() === url) ?? false;
}
get title(): string | undefined {
return this.page.contents_memo.getType(Tab)?.find((tab) => tab.selected)?.title.toString();
return this.page.contents_memo?.getType(Tab)?.find((tab) => tab.selected)?.title.toString();
}
}

View File

@@ -1,6 +1,19 @@
# Parser
Sanitizes and standardizes InnerTube responses while maintaining the integrity of the data. Also [drastically improves](https://github.com/LuanRT/YouTube.js/blob/main/src/parser/youtube/Library.ts#L69) how API calls are made and handled.
Sanitizes and standardizes InnerTube responses while maintaining the integrity of the data.
Structure:
* [`/classes`](https://github.com/LuanRT/YouTube.js/blob/main/src/parser/classes) - InnerTube nodes.
* [`/types`](https://github.com/LuanRT/YouTube.js/blob/main/src/parser/types) - General response types.
* [`/youtube`](https://github.com/LuanRT/YouTube.js/blob/main/src/parser/youtube) - Contains the logic for parsing YouTube responses.
* [`/ytmusic`](https://github.com/LuanRT/YouTube.js/blob/main/src/parser/ytmusic) - Contains the logic for parsing YouTube Music responses.
* [`/ytkids`](https://github.com/LuanRT/YouTube.js/blob/main/src/parser/ytkids) - Contains the logic for parsing YouTube Kids responses.
* [`helpers.ts`](https://github.com/LuanRT/YouTube.js/blob/main/src/parser/helpers.ts) - Helper functions/classes for the parser.
* [`parser.ts`](https://github.com/LuanRT/YouTube.js/blob/main/src/parser/index.ts) - The core of the parser.
* [`map.ts`](https://github.com/LuanRT/YouTube.js/blob/main/src/parser/map.ts) - A list of all InnerTube nodes, it is used to determine which node to use for a given renderer. Note that this file is auto-generated and should not be edited manually.
## Table of Contents
<ol>
<li>

View File

@@ -13,16 +13,16 @@ class C4TabbedHeader extends YTNode {
static type = 'C4TabbedHeader';
author: Author;
banner: Thumbnail[];
tv_banner: Thumbnail[];
mobile_banner: Thumbnail[];
subscribers: Text;
videos_count: Text;
sponsor_button: Button | null;
subscribe_button: SubscribeButton | null;
header_links: ChannelHeaderLinks | null;
channel_handle: Text;
channel_id: string;
banner?: Thumbnail[];
tv_banner?: Thumbnail[];
mobile_banner?: Thumbnail[];
subscribers?: Text;
videos_count?: Text;
sponsor_button?: Button | null;
subscribe_button?: SubscribeButton | null;
header_links?: ChannelHeaderLinks | null;
channel_handle?: Text;
channel_id?: string;
constructor(data: any) {
super();
@@ -31,16 +31,45 @@ class C4TabbedHeader extends YTNode {
navigationEndpoint: data.navigationEndpoint
}, data.badges, data.avatar);
this.banner = Thumbnail.fromResponse(data.banner);
this.tv_banner = Thumbnail.fromResponse(data.tvBanner);
this.mobile_banner = Thumbnail.fromResponse(data.mobileBanner);
this.subscribers = new Text(data.subscriberCountText);
this.videos_count = new Text(data.videosCountText);
this.sponsor_button = Parser.parseItem<Button>(data.sponsorButton);
this.subscribe_button = Parser.parseItem<SubscribeButton>(data.subscribeButton);
this.header_links = Parser.parseItem<ChannelHeaderLinks>(data.headerLinks);
this.channel_handle = new Text(data.channelHandleText);
this.channel_id = data.channelId;
if (data.banner) {
this.banner = Thumbnail.fromResponse(data.banner);
}
if (data.tv_banner) {
this.tv_banner = Thumbnail.fromResponse(data.tvBanner);
}
if (data.mobile_banner) {
this.mobile_banner = Thumbnail.fromResponse(data.mobileBanner);
}
if (data.subscriberCountText) {
this.subscribers = new Text(data.subscriberCountText);
}
if (data.videosCountText) {
this.videos_count = new Text(data.videosCountText);
}
if (data.sponsorButton) {
this.sponsor_button = Parser.parseItem<Button>(data.sponsorButton);
}
if (data.subscribeButton) {
this.subscribe_button = Parser.parseItem<SubscribeButton>(data.subscribeButton);
}
if (data.headerLinks) {
this.header_links = Parser.parseItem<ChannelHeaderLinks>(data.headerLinks);
}
if (data.channelHandleText) {
this.channel_handle = new Text(data.channelHandleText);
}
if (data.channelId) {
this.channel_id = data.channelId;
}
}
}

View File

@@ -11,23 +11,35 @@ class MusicShelf extends YTNode {
title: Text;
contents;
endpoint: NavigationEndpoint | null;
continuation: string | null;
bottom_text: Text | null;
endpoint?: NavigationEndpoint;
continuation?: string;
bottom_text?: Text;
bottom_button?: Button | null;
subheaders?: Array<any>;
constructor(data: any) {
super();
this.title = new Text(data.title);
this.contents = Parser.parseArray<MusicResponsiveListItem>(data.contents, MusicResponsiveListItem);
this.endpoint = Reflect.has(data, 'bottomEndpoint') ? new NavigationEndpoint(data.bottomEndpoint) : null;
this.continuation =
data.continuations?.[0].nextContinuationData?.continuation ||
data.continuations?.[0].reloadContinuationData?.continuation || null;
this.bottom_text = Reflect.has(data, 'bottomText') ? new Text(data.bottomText) : null;
this.bottom_button = Parser.parseItem(data.bottomButton, Button);
if (data.bottomEndpoint) {
this.endpoint = new NavigationEndpoint(data.bottomEndpoint);
}
if (data.continuations) {
this.continuation =
data.continuations?.[0].nextContinuationData?.continuation ||
data.continuations?.[0].reloadContinuationData?.continuation;
}
if (data.bottomText) {
this.bottom_text = new Text(data.bottomText);
}
if (data.bottomButton) {
this.bottom_button = Parser.parseItem<Button>(data.bottomButton);
}
if (data.subheaders) {
this.subheaders = Parser.parseArray(data.subheaders);
}

View File

@@ -1,7 +1,8 @@
import Parser, { ParsedResponse } from '../index.js';
import Actions, { ActionsResponse } from '../../core/Actions.js';
import Parser from '../index.js';
import type Actions from '../../core/Actions.js';
import type { ApiResponse } from '../../core/Actions.js';
import type { IParsedResponse } from '../types/ParsedResponse.js';
import { YTNode } from '../helpers.js';
import CreatePlaylistDialog from './CreatePlaylistDialog.js';
class NavigationEndpoint extends YTNode {
@@ -85,9 +86,9 @@ class NavigationEndpoint extends YTNode {
}
}
call(actions: Actions, args: { [ key: string ]: any; parse: true }): Promise<ParsedResponse>;
call(actions: Actions, args?: { [ key: string ]: any; parse?: false }): Promise<ActionsResponse>;
call(actions: Actions, args?: { [ key: string ]: any; parse?: boolean }): Promise<ParsedResponse | ActionsResponse> {
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

@@ -7,8 +7,8 @@ class SectionList extends YTNode {
target_id?: string;
contents;
continuation?: string;
header;
sub_menu;
header?;
sub_menu?;
constructor(data: any) {
super();

View File

@@ -45,7 +45,10 @@ class CommentThread extends YTNode {
const response = await continuation.endpoint.call(this.#actions, { parse: true });
this.replies = observe(response.on_response_received_endpoints_memo?.getType(Comment).map((comment) => {
if (!response.on_response_received_endpoints_memo)
throw new InnertubeError('Unexpected response.', response);
this.replies = observe(response.on_response_received_endpoints_memo.getType(Comment).map((comment) => {
comment.setActions(this.#actions);
return comment;
}));
@@ -75,12 +78,15 @@ class CommentThread extends YTNode {
const response = await load_more_button.endpoint.call(this.#actions, { parse: true });
this.replies = observe(response?.on_response_received_endpoints_memo.getType(Comment).map((comment) => {
if (!response.on_response_received_endpoints_memo)
throw new InnertubeError('Unexpected response.', response);
this.replies = observe(response.on_response_received_endpoints_memo.getType(Comment).map((comment) => {
comment.setActions(this.#actions);
return comment;
}));
this.#continuation = response?.on_response_received_endpoints_memo.getType(ContinuationItem)?.[0];
this.#continuation = response.on_response_received_endpoints_memo.getType(ContinuationItem)?.[0];
return this;
}

View File

@@ -378,7 +378,7 @@ export type ObservedArray<T extends YTNode = YTNode> = Array<T> & {
/**
* Get the first item
*/
first: () => T | undefined;
first: () => T;
/**
* This is similar to filter but throws if there's a type mismatch.
*/

View File

@@ -1,9 +1,10 @@
export { default as Parser } from './parser.js';
export * from './parser.js';
export * from './types/index.js';
export { YTNodes, Misc } from '../parser/map.js';
export * as YT from './youtube/index.js';
export * as YTMusic from './ytmusic/index.js';
export * as YTKids from './ytkids/index.js';
import Parser from './parser.js';
export default Parser;
export default Parser;

View File

@@ -11,6 +11,9 @@ import type LiveChatHeader from './classes/LiveChatHeader.js';
import type LiveChatItemList from './classes/LiveChatItemList.js';
import type Alert from './classes/Alert.js';
import type { IParsedResponse } from './types/ParsedResponse.js';
import type { IRawResponse, RawData, RawNode } from './types/RawResponse.js';
import MusicMultiSelectMenuItem from './classes/menus/MusicMultiSelectMenuItem.js';
import Format from './classes/misc/Format.js';
import VideoDetails from './classes/misc/VideoDetails.js';
@@ -58,115 +61,209 @@ export default class Parser {
}
/**
* Parses InnerTube response.
* @param data - The response data.
* Parses given InnerTube response.
* @param data - Raw data.
*/
static parseResponse(data: any) {
// Memoize the response objects by classname
static parseResponse<T extends IParsedResponse = IParsedResponse>(data: IRawResponse): T {
const parsed_data = {} as T;
this.#createMemo();
// TODO: should this parseItem?
const contents = Parser.parse(data.contents);
const contents = this.parse(data.contents);
const contents_memo = this.#getMemo();
if (contents) {
parsed_data.contents = contents;
parsed_data.contents_memo = contents_memo;
}
this.#clearMemo();
this.#createMemo();
const on_response_received_actions = data.onResponseReceivedActions ? Parser.parseRR(data.onResponseReceivedActions) : null;
const on_response_received_actions = data.onResponseReceivedActions ? this.parseRR(data.onResponseReceivedActions) : null;
const on_response_received_actions_memo = this.#getMemo();
if (on_response_received_actions) {
parsed_data.on_response_received_actions = on_response_received_actions;
parsed_data.on_response_received_actions_memo = on_response_received_actions_memo;
}
this.#clearMemo();
this.#createMemo();
const on_response_received_endpoints = data.onResponseReceivedEndpoints ? Parser.parseRR(data.onResponseReceivedEndpoints) : null;
const on_response_received_endpoints = data.onResponseReceivedEndpoints ? this.parseRR(data.onResponseReceivedEndpoints) : null;
const on_response_received_endpoints_memo = this.#getMemo();
if (on_response_received_endpoints) {
parsed_data.on_response_received_endpoints = on_response_received_endpoints;
parsed_data.on_response_received_endpoints_memo = on_response_received_endpoints_memo;
}
this.#clearMemo();
this.#createMemo();
const on_response_received_commands = data.onResponseReceivedCommands ? Parser.parseRR(data.onResponseReceivedCommands) : null;
const on_response_received_commands = data.onResponseReceivedCommands ? this.parseRR(data.onResponseReceivedCommands) : null;
const on_response_received_commands_memo = this.#getMemo();
if (on_response_received_commands) {
parsed_data.on_response_received_commands = on_response_received_commands;
parsed_data.on_response_received_commands_memo = on_response_received_commands_memo;
}
this.#clearMemo();
this.#createMemo();
const continuation_contents = data.continuationContents ? Parser.parseLC(data.continuationContents) : null;
const continuation_contents = data.continuationContents ? this.parseLC(data.continuationContents) : null;
const continuation_contents_memo = this.#getMemo();
if (continuation_contents) {
parsed_data.continuation_contents = continuation_contents;
parsed_data.continuation_contents_memo = continuation_contents_memo;
}
this.#clearMemo();
this.#createMemo();
const actions = data.actions ? Parser.parseActions(data.actions) : null;
const actions = data.actions ? this.parseActions(data.actions) : null;
const actions_memo = this.#getMemo();
if (actions) {
parsed_data.actions = actions;
parsed_data.actions_memo = actions_memo;
}
this.#clearMemo();
this.#createMemo();
const live_chat_item_context_menu_supported_renderers = data.liveChatItemContextMenuSupportedRenderers
? Parser.parseItem(data.liveChatItemContextMenuSupportedRenderers)
: null;
const live_chat_item_context_menu_supported_renderers = data.liveChatItemContextMenuSupportedRenderers ? this.parseItem(data.liveChatItemContextMenuSupportedRenderers) : null;
const live_chat_item_context_menu_supported_renderers_memo = this.#getMemo();
if (live_chat_item_context_menu_supported_renderers) {
parsed_data.live_chat_item_context_menu_supported_renderers = live_chat_item_context_menu_supported_renderers;
parsed_data.live_chat_item_context_menu_supported_renderers_memo = live_chat_item_context_menu_supported_renderers_memo;
}
this.#clearMemo();
this.#createMemo();
const header = data.header ? Parser.parse(data.header) : null;
const header = data.header ? this.parse(data.header) : null;
const header_memo = this.#getMemo();
if (header) {
parsed_data.header = header;
parsed_data.header_memo = header_memo;
}
this.#clearMemo();
this.#createMemo();
const sidebar = data.sidebar ? Parser.parseItem(data.sidebar) : null;
const sidebar = data.sidebar ? this.parseItem(data.sidebar) : null;
const sidebar_memo = this.#getMemo();
if (sidebar) {
parsed_data.sidebar = sidebar;
parsed_data.sidebar_memo = sidebar_memo;
}
this.#clearMemo();
this.applyMutations(contents_memo, data.frameworkUpdates?.entityBatchUpdate?.mutations);
return {
actions,
actions_memo,
contents,
contents_memo,
header,
header_memo,
sidebar,
sidebar_memo,
live_chat_item_context_menu_supported_renderers,
live_chat_item_context_menu_supported_renderers_memo,
on_response_received_actions,
on_response_received_actions_memo,
on_response_received_endpoints,
on_response_received_endpoints_memo,
on_response_received_commands,
on_response_received_commands_memo,
continuation: data.continuation ? Parser.parseC(data.continuation) : null,
continuation_contents,
continuation_contents_memo,
metadata: Parser.parse(data.metadata),
microformat: data.microformat ? Parser.parseItem(data.microformat) : null,
overlay: Parser.parseItem(data.overlay),
alerts: Parser.parseArray<Alert>(data.alerts),
refinements: data.refinements || null,
estimated_results: data.estimatedResults ? parseInt(data.estimatedResults) : null,
player_overlays: Parser.parse(data.playerOverlays),
playback_tracking: data.playbackTracking ? {
videostats_watchtime_url: data.playbackTracking.videostatsWatchtimeUrl.baseUrl as string,
videostats_playback_url: data.playbackTracking.videostatsPlaybackUrl.baseUrl as string
} : null,
playability_status: data.playabilityStatus ? {
status: data.playabilityStatus.status as string,
error_screen: Parser.parseItem(data.playabilityStatus.errorScreen),
audio_only_playablility: Parser.parseItem<AudioOnlyPlayability>(data.playabilityStatus.audioOnlyPlayability),
embeddable: !!data.playabilityStatus.playableInEmbed || false,
reason: data.playabilityStatus?.reason || ''
} : undefined,
streaming_data: data.streamingData ? {
expires: new Date(Date.now() + parseInt(data.streamingData.expiresInSeconds) * 1000),
formats: Parser.parseFormats(data.streamingData.formats),
adaptive_formats: Parser.parseFormats(data.streamingData.adaptiveFormats),
dash_manifest_url: data.streamingData?.dashManifestUrl as string || null,
hls_manifest_url: data.streamingData?.hlsManifestUrl as string || null
} : undefined,
current_video_endpoint: data.currentVideoEndpoint ? new NavigationEndpoint(data.currentVideoEndpoint) : null,
endpoint: data.endpoint ? new NavigationEndpoint(data.endpoint) : null,
captions: Parser.parseItem<PlayerCaptionsTracklist>(data.captions),
video_details: data.videoDetails ? new VideoDetails(data.videoDetails) : undefined,
annotations: Parser.parseArray<PlayerAnnotationsExpanded>(data.annotations),
storyboards: Parser.parseItem<PlayerStoryboardSpec | PlayerLiveStoryboardSpec>(data.storyboards),
endscreen: Parser.parseItem<Endscreen>(data.endscreen),
cards: Parser.parseItem<CardCollection>(data.cards)
};
const continuation = data.continuation ? this.parseC(data.continuation) : null;
if (continuation) {
parsed_data.continuation = continuation;
}
const metadata = this.parse(data.metadata);
if (metadata) {
parsed_data.metadata = metadata;
}
const microformat = this.parseItem(data.microformat);
if (microformat) {
parsed_data.microformat = microformat;
}
const overlay = this.parseItem(data.overlay);
if (overlay) {
parsed_data.overlay = overlay;
}
const alerts = this.parseArray<Alert>(data.alerts);
if (alerts.length) {
parsed_data.alerts = alerts;
}
const refinements = data.refinements;
if (refinements) {
parsed_data.refinements = refinements;
}
const estimated_results = data.estimatedResults ? parseInt(data.estimatedResults) : null;
if (estimated_results) {
parsed_data.estimated_results = estimated_results;
}
const player_overlays = this.parse(data.playerOverlays);
if (player_overlays) {
parsed_data.player_overlays = player_overlays;
}
const playback_tracking = data.playbackTracking ? {
videostats_watchtime_url: data.playbackTracking.videostatsWatchtimeUrl.baseUrl,
videostats_playback_url: data.playbackTracking.videostatsPlaybackUrl.baseUrl
} : null;
if (playback_tracking) {
parsed_data.playback_tracking = playback_tracking;
}
const playability_status = data.playabilityStatus ? {
status: data.playabilityStatus.status,
reason: data.playabilityStatus.reason || '',
embeddable: !!data.playabilityStatus.playableInEmbed || false,
audio_only_playablility: this.parseItem<AudioOnlyPlayability>(data.playabilityStatus.audioOnlyPlayability),
error_screen: this.parseItem(data.playabilityStatus.errorScreen)
} : null;
if (playability_status) {
parsed_data.playability_status = playability_status;
}
const streaming_data = data.streamingData ? {
expires: new Date(Date.now() + parseInt(data.streamingData.expiresInSeconds) * 1000),
formats: Parser.parseFormats(data.streamingData.formats),
adaptive_formats: Parser.parseFormats(data.streamingData.adaptiveFormats),
dash_manifest_url: data.streamingData.dashManifestUrl || null,
hls_manifest_url: data.streamingData.hlsManifestUrl || null
} : undefined;
if (streaming_data) {
parsed_data.streaming_data = streaming_data;
}
const current_video_endpoint = data.currentVideoEndpoint ? new NavigationEndpoint(data.currentVideoEndpoint) : null;
if (current_video_endpoint) {
parsed_data.current_video_endpoint = current_video_endpoint;
}
const endpoint = data.endpoint ? new NavigationEndpoint(data.endpoint) : null;
if (endpoint) {
parsed_data.endpoint = endpoint;
}
const captions = this.parseItem<PlayerCaptionsTracklist>(data.captions);
if (captions) {
parsed_data.captions = captions;
}
const video_details = data.videoDetails ? new VideoDetails(data.videoDetails) : null;
if (video_details) {
parsed_data.video_details = video_details;
}
const annotations = this.parseArray<PlayerAnnotationsExpanded>(data.annotations);
if (annotations.length) {
parsed_data.annotations = annotations;
}
const storyboards = this.parseItem<PlayerStoryboardSpec | PlayerLiveStoryboardSpec>(data.storyboards);
if (storyboards) {
parsed_data.storyboards = storyboards;
}
const endscreen = this.parseItem<Endscreen>(data.endscreen);
if (endscreen) {
parsed_data.endscreen = endscreen;
}
const cards = this.parseItem<CardCollection>(data.cards);
if (cards) {
parsed_data.cards = cards;
}
return parsed_data;
}
/**
@@ -174,7 +271,7 @@ export default class Parser {
* @param data - The data to parse.
* @param validTypes - YTNode types that are allowed to be parsed.
*/
static parseItem<T extends YTNode = YTNode>(data: any, validTypes?: YTNodeConstructor<T> | YTNodeConstructor<T>[]) {
static parseItem<T extends YTNode = YTNode>(data?: RawNode, validTypes?: YTNodeConstructor<T> | YTNodeConstructor<T>[]) {
if (!data) return null;
const keys = Object.keys(data);
@@ -214,7 +311,7 @@ export default class Parser {
* @param data - The data to parse.
* @param validTypes - YTNode types that are allowed to be parsed.
*/
static parseArray<T extends YTNode = YTNode>(data: any[], validTypes?: YTNodeConstructor<T> | YTNodeConstructor<T>[]) {
static parseArray<T extends YTNode = YTNode>(data?: RawNode[], validTypes?: YTNodeConstructor<T> | YTNodeConstructor<T>[]) {
if (Array.isArray(data)) {
const results: T[] = [];
@@ -238,9 +335,9 @@ export default class Parser {
* @param requireArray - Whether the data should be parsed as an array.
* @param validTypes - YTNode types that are allowed to be parsed.
*/
static parse<T extends YTNode = YTNode>(data: any, requireArray: true, validTypes?: YTNodeConstructor<T> | YTNodeConstructor<T>[]): ObservedArray<T> | null;
static parse<T extends YTNode = YTNode>(data: any, requireArray?: false | undefined, validTypes?: YTNodeConstructor<T> | YTNodeConstructor<T>[]): SuperParsedResult<T>;
static parse<T extends YTNode = YTNode>(data: any, requireArray?: boolean, validTypes?: YTNodeConstructor<T> | YTNodeConstructor<T>[]) {
static parse<T extends YTNode = YTNode>(data: RawData, requireArray: true, validTypes?: YTNodeConstructor<T> | YTNodeConstructor<T>[]): ObservedArray<T> | null;
static parse<T extends YTNode = YTNode>(data?: RawData, requireArray?: false | undefined, validTypes?: YTNodeConstructor<T> | YTNodeConstructor<T>[]): SuperParsedResult<T>;
static parse<T extends YTNode = YTNode>(data?: RawData, requireArray?: boolean, validTypes?: YTNodeConstructor<T> | YTNodeConstructor<T>[]) {
if (!data) return null;
if (Array.isArray(data)) {
@@ -263,12 +360,13 @@ export default class Parser {
return new SuperParsedResult(this.parseItem(data, validTypes));
}
static parseC(data: any) {
static parseC(data: RawNode) {
if (data.timedContinuationData)
return new Continuation({ continuation: data.timedContinuationData, type: 'timed' });
return null;
}
static parseLC(data: any) {
static parseLC(data: RawNode) {
if (data.itemSectionContinuation)
return new ItemSectionContinuation(data.itemSectionContinuation);
if (data.sectionListContinuation)
@@ -283,10 +381,14 @@ export default class Parser {
return new GridContinuation(data.gridContinuation);
if (data.playlistPanelContinuation)
return new PlaylistPanelContinuation(data.playlistPanelContinuation);
return null;
}
static parseRR(actions: any[]) {
static parseRR(actions: RawNode[]) {
return observe(actions.map((action: any) => {
if (action.navigateAction)
return new NavigateAction(action.navigateAction);
if (action.reloadContinuationItemsCommand)
return new ReloadContinuationItemsCommand(action.reloadContinuationItemsCommand);
if (action.appendContinuationItemsAction)
@@ -294,21 +396,21 @@ export default class Parser {
}).filter((item) => item) as (ReloadContinuationItemsCommand | AppendContinuationItemsAction)[]);
}
static parseActions(data: any) {
static parseActions(data: RawData) {
if (Array.isArray(data)) {
return Parser.parse(data.map((action) => {
delete action.clickTrackingParams;
return action;
}));
}
return new SuperParsedResult(Parser.parseItem(data));
return new SuperParsedResult(this.parseItem(data));
}
static parseFormats(formats: any[]) {
static parseFormats(formats: RawNode[]): Format[] {
return formats?.map((format) => new Format(format)) || [];
}
static applyMutations(memo: Memo, mutations: Array<any>) {
static applyMutations(memo: Memo, mutations: RawNode[]) {
// Apply mutations to MusicMultiSelectMenuItems
const music_multi_select_menu_items = memo.getType(MusicMultiSelectMenuItem);
@@ -351,7 +453,7 @@ export default class Parser {
return console.warn(
new InnertubeError(
`${classname} not found!\n` +
`This is a bug, want to help us fix it? Follow the instructions at ${Platform.shim.info.repo_url}/blob/main/docs/updating-the-parser.md or report it at ${Platform.shim.info.bugs_url}!`, classdata
`This is a bug, want to help us fix it? Follow the instructions at ${Platform.shim.info.repo_url.split('#')[0]}/blob/main/docs/updating-the-parser.md or report it at ${Platform.shim.info.bugs_url}!`, classdata
)
);
}
@@ -379,7 +481,8 @@ export default class Parser {
'PromotedSparklesWeb',
'RunAttestationCommand',
'CompactPromotedVideo',
'StatementBanner'
'StatementBanner',
'SearchSubMenu'
]);
static shouldIgnore(classname: string) {
@@ -387,8 +490,6 @@ export default class Parser {
}
}
export type ParsedResponse = ReturnType<typeof Parser.parseResponse>;
// Continuation
export class ItemSectionContinuation extends YTNode {
@@ -397,21 +498,32 @@ export class ItemSectionContinuation extends YTNode {
contents: ObservedArray<YTNode> | null;
continuation?: string;
constructor(data: any) {
constructor(data: RawNode) {
super();
this.contents = Parser.parseArray(data.contents);
if (data.continuations) {
if (Array.isArray(data.continuations)) {
this.continuation = data.continuations?.at(0)?.nextContinuationData?.continuation;
}
}
}
export class NavigateAction extends YTNode {
static readonly type = 'navigateAction';
endpoint: NavigationEndpoint;
constructor(data: RawNode) {
super();
this.endpoint = new NavigationEndpoint(data.endpoint);
}
}
export class AppendContinuationItemsAction extends YTNode {
static readonly type = 'appendContinuationItemsAction';
contents: ObservedArray<YTNode> | null;
constructor(data: any) {
constructor(data: RawNode) {
super();
this.contents = Parser.parse(data.continuationItems, true);
}
@@ -424,7 +536,7 @@ export class ReloadContinuationItemsCommand extends YTNode {
contents: ObservedArray<YTNode> | null;
slot?: string;
constructor(data: any) {
constructor(data: RawNode) {
super();
this.target_id = data.targetId;
this.contents = Parser.parse(data.continuationItems, true);
@@ -438,7 +550,7 @@ export class SectionListContinuation extends YTNode {
continuation: string;
contents: ObservedArray<YTNode> | null;
constructor(data: any) {
constructor(data: RawNode) {
super();
this.contents = Parser.parse(data.contents, true);
this.continuation =
@@ -453,7 +565,7 @@ export class MusicPlaylistShelfContinuation extends YTNode {
continuation: string;
contents: ObservedArray<YTNode> | null;
constructor(data: any) {
constructor(data: RawNode) {
super();
this.contents = Parser.parse(data.contents, true);
this.continuation = data.continuations?.[0].nextContinuationData.continuation || null;
@@ -466,9 +578,9 @@ export class MusicShelfContinuation extends YTNode {
continuation: string;
contents: ObservedArray<YTNode> | null;
constructor(data: any) {
constructor(data: RawNode) {
super();
this.contents = Parser.parse(data.contents, true);
this.contents = Parser.parseArray(data.contents);
this.continuation =
data.continuations?.[0].nextContinuationData?.continuation ||
data.continuations?.[0].reloadContinuationData?.continuation || null;
@@ -481,7 +593,7 @@ export class GridContinuation extends YTNode {
continuation: string;
items: ObservedArray<YTNode> | null;
constructor(data: any) {
constructor(data: RawNode) {
super();
this.items = Parser.parse(data.items, true);
this.continuation = data.continuations?.[0].nextContinuationData.continuation || null;
@@ -498,9 +610,9 @@ export class PlaylistPanelContinuation extends YTNode {
continuation: string;
contents: ObservedArray<YTNode> | null;
constructor(data: any) {
constructor(data: RawNode) {
super();
this.contents = Parser.parse(data.contents, true);
this.contents = Parser.parseArray(data.contents);
this.continuation = data.continuations?.[0]?.nextContinuationData?.continuation ||
data.continuations?.[0]?.nextRadioContinuationData?.continuation || null;
}
@@ -514,7 +626,7 @@ export class Continuation extends YTNode {
time_until_last_message_ms?: number;
token: string;
constructor(data: any) {
constructor(data: RawNode) {
super();
this.continuation_type = data.type;
this.timeout_ms = data.continuation?.timeoutMs;
@@ -541,7 +653,7 @@ export class LiveChatContinuation extends YTNode {
continuation: Continuation;
viewer_name: string;
constructor(data: any) {
constructor(data: RawNode) {
super();
this.actions = Parser.parse(data.actions?.map((action: any) => {
delete action.clickTrackingParams;

View File

@@ -0,0 +1,159 @@
import { Memo, ObservedArray, SuperParsedResult, YTNode } from '../helpers.js';
import type {
ReloadContinuationItemsCommand, AppendContinuationItemsAction, Continuation, GridContinuation,
ItemSectionContinuation, LiveChatContinuation, MusicPlaylistShelfContinuation, MusicShelfContinuation,
PlaylistPanelContinuation, SectionListContinuation
} from '../index.js';
import type PlayerCaptionsTracklist from '../classes/PlayerCaptionsTracklist.js';
import type CardCollection from '../classes/CardCollection.js';
import type Endscreen from '../classes/Endscreen.js';
import type AudioOnlyPlayability from '../classes/AudioOnlyPlayability.js';
import type Format from '../classes/misc/Format.js';
import type PlayerLiveStoryboardSpec from '../classes/PlayerLiveStoryboardSpec.js';
import type PlayerStoryboardSpec from '../classes/PlayerStoryboardSpec.js';
import type VideoDetails from '../classes/misc/VideoDetails.js';
import type Alert from '../classes/Alert.js';
import type NavigationEndpoint from '../classes/NavigationEndpoint.js';
import type PlayerAnnotationsExpanded from '../classes/PlayerAnnotationsExpanded.js';
export interface IParsedResponse {
actions?: SuperParsedResult<YTNode>;
actions_memo?: Memo;
contents?: SuperParsedResult<YTNode>;
contents_memo?: Memo;
header?: SuperParsedResult<YTNode>;
header_memo?: Memo;
sidebar?: YTNode;
sidebar_memo?: Memo;
live_chat_item_context_menu_supported_renderers?: YTNode;
live_chat_item_context_menu_supported_renderers_memo?: Memo;
on_response_received_actions?: ObservedArray<ReloadContinuationItemsCommand | AppendContinuationItemsAction>;
on_response_received_actions_memo?: Memo;
on_response_received_endpoints?: ObservedArray<ReloadContinuationItemsCommand | AppendContinuationItemsAction>;
on_response_received_endpoints_memo?: Memo;
on_response_received_commands?: ObservedArray<ReloadContinuationItemsCommand | AppendContinuationItemsAction>;
on_response_received_commands_memo?: Memo;
continuation?: Continuation;
continuation_contents?: ItemSectionContinuation | SectionListContinuation | LiveChatContinuation | MusicPlaylistShelfContinuation |
MusicShelfContinuation | GridContinuation | PlaylistPanelContinuation;
continuation_contents_memo?: Memo;
metadata?: SuperParsedResult<YTNode>;
microformat?: YTNode;
overlay?: YTNode;
alerts?: ObservedArray<Alert>;
refinements?: string[];
estimated_results?: number;
player_overlays?: SuperParsedResult<YTNode>;
playback_tracking?: {
videostats_watchtime_url: string;
videostats_playback_url: string;
};
playability_status?: {
status: string;
error_screen: YTNode | null;
audio_only_playablility: AudioOnlyPlayability | null;
embeddable: boolean;
reason: string;
};
streaming_data?: {
expires: Date;
formats: Format[];
adaptive_formats: Format[];
dash_manifest_url: string | null;
hls_manifest_url: string | null;
};
current_video_endpoint?: NavigationEndpoint;
endpoint?: NavigationEndpoint;
captions?: PlayerCaptionsTracklist;
video_details?: VideoDetails;
annotations?: ObservedArray<PlayerAnnotationsExpanded>;
storyboards?: PlayerStoryboardSpec | PlayerLiveStoryboardSpec;
endscreen?: Endscreen;
cards?: CardCollection;
}
export interface IPlayerResponse {
captions?: PlayerCaptionsTracklist;
cards?: CardCollection;
endscreen?: Endscreen;
microformat?: YTNode;
annotations?: ObservedArray<PlayerAnnotationsExpanded>;
playability_status: {
status: string;
error_screen: YTNode | null;
audio_only_playablility: AudioOnlyPlayability | null;
embeddable: boolean;
reason: string;
};
streaming_data?: {
expires: Date;
formats: Format[];
adaptive_formats: Format[];
dash_manifest_url: string | null;
hls_manifest_url: string | null;
};
playback_tracking?: {
videostats_watchtime_url: string;
videostats_playback_url: string;
};
storyboards?: PlayerStoryboardSpec | PlayerLiveStoryboardSpec;
video_details?: VideoDetails;
}
export interface INextResponse {
contents?: SuperParsedResult<YTNode>;
contents_memo?: Memo;
current_video_endpoint?: NavigationEndpoint;
on_response_received_endpoints?: ObservedArray<ReloadContinuationItemsCommand | AppendContinuationItemsAction>;
on_response_received_endpoints_memo?: Memo;
player_overlays?: SuperParsedResult<YTNode>;
}
export interface IBrowseResponse {
continuation_contents?: ItemSectionContinuation | SectionListContinuation | LiveChatContinuation | MusicPlaylistShelfContinuation |
MusicShelfContinuation | GridContinuation | PlaylistPanelContinuation;
continuation_contents_memo?: Memo;
on_response_received_actions: ObservedArray<ReloadContinuationItemsCommand | AppendContinuationItemsAction>;
on_response_received_actions_memo: Memo;
on_response_received_endpoints?: ObservedArray<ReloadContinuationItemsCommand | AppendContinuationItemsAction>;
on_response_received_endpoints_memo?: Memo;
contents?: SuperParsedResult<YTNode>;
contents_memo?: Memo;
header?: SuperParsedResult<YTNode>;
header_memo?: Memo;
metadata?: SuperParsedResult<YTNode>;
microformat?: YTNode;
alerts?: ObservedArray<Alert>;
sidebar?: YTNode;
sidebar_memo?: Memo;
}
export interface ISearchResponse {
header?: SuperParsedResult<YTNode>;
header_memo?: Memo;
contents?: SuperParsedResult<YTNode>;
contents_memo?: Memo;
on_response_received_commands?: ObservedArray<ReloadContinuationItemsCommand | AppendContinuationItemsAction>;
continuation_contents?: ItemSectionContinuation | SectionListContinuation | LiveChatContinuation | MusicPlaylistShelfContinuation |
MusicShelfContinuation | GridContinuation | PlaylistPanelContinuation;
continuation_contents_memo?: Memo;
refinements?: string[];
estimated_results: number;
}
export interface IResolveURLResponse {
endpoint: NavigationEndpoint;
}
export interface IGetNotificationsMenuResponse {
actions: SuperParsedResult<YTNode>;
actions_memo: Memo;
}
export interface IUpdatedMetadataResponse {
actions: SuperParsedResult<YTNode>;
actions_memo: Memo;
continuation?: Continuation;
}

View File

@@ -0,0 +1,55 @@
export type RawNode = Record<string, any>;
export type RawData = RawNode | RawNode[];
export interface IRawResponse {
contents?: RawData;
onResponseReceivedActions?: RawNode[];
onResponseReceivedEndpoints?: RawNode[];
onResponseReceivedCommands?: RawNode[];
continuationContents?: RawNode;
actions?: RawNode[];
liveChatItemContextMenuSupportedRenderers?: RawNode;
header?: RawNode;
sidebar?: RawNode;
continuation?: RawNode;
metadata?: RawNode;
microformat?: RawNode;
overlay?: RawNode;
alerts?: RawNode[];
refinements?: string[];
estimatedResults?: string;
playerOverlays?: RawNode;
playbackTracking?: {
videostatsWatchtimeUrl: {
baseUrl: string;
};
videostatsPlaybackUrl: {
baseUrl: string;
};
};
playabilityStatus?: {
status: string;
reason?: string;
errorScreen?: RawNode;
audioOnlyPlayability?: RawNode;
playableInEmbed?: boolean;
};
streamingData?: {
expiresInSeconds: string;
formats: RawNode[];
adaptiveFormats: RawNode[];
dashManifestUrl?: string;
hlsManifestUrl?: string;
};
currentVideoEndpoint?: RawNode;
unseenCount?: number;
playlistId?: string;
endpoint?: RawNode;
captions?: RawNode;
videoDetails?: RawNode;
annotations?: RawNode[];
storyboards?: RawNode;
endscreen?: RawNode;
cards?: RawNode;
frameworkUpdates?: any;
}

View File

@@ -0,0 +1,2 @@
export * from './RawResponse.js';
export * from './ParsedResponse.js';

View File

@@ -1,5 +1,6 @@
import Parser, { ParsedResponse } from '../index.js';
import Parser from '../index.js';
import type { ApiResponse } from '../../core/Actions.js';
import type { IParsedResponse } from '../types/ParsedResponse.js';
import AccountSectionList from '../classes/AccountSectionList.js';
import AccountItemSection from '../classes/AccountItemSection.js';
@@ -8,7 +9,7 @@ import AccountChannel from '../classes/AccountChannel.js';
import { InnertubeError } from '../../utils/Utils.js';
class AccountInfo {
#page: ParsedResponse;
#page: IParsedResponse;
contents: AccountItemSection | null;
footers: AccountChannel | null;
@@ -16,7 +17,10 @@ class AccountInfo {
constructor(response: ApiResponse) {
this.#page = Parser.parseResponse(response.data);
const account_section_list = this.#page.contents.array().as(AccountSectionList)?.[0];
if (!this.#page.contents)
throw new InnertubeError('Page contents not found');
const account_section_list = this.#page.contents.array().as(AccountSectionList).first();
if (!account_section_list)
throw new InnertubeError('Account section list not found');
@@ -25,7 +29,7 @@ class AccountInfo {
this.footers = account_section_list.footers;
}
get page(): ParsedResponse {
get page(): IParsedResponse {
return this.#page;
}
}

View File

@@ -1,17 +1,18 @@
import Parser, { ParsedResponse } from '../index.js';
import type { ApiResponse } from '../../core/Actions.js';
import Parser from '../index.js';
import Element from '../classes/Element.js';
import type { ApiResponse } from '../../core/Actions.js';
import type { IBrowseResponse } from '../types/ParsedResponse.js';
class Analytics {
#page: ParsedResponse;
#page: IBrowseResponse;
sections;
constructor(response: ApiResponse) {
this.#page = Parser.parseResponse(response.data);
this.sections = this.#page.contents_memo?.getType(Element).map((el) => el.model?.item());
this.#page = Parser.parseResponse<IBrowseResponse>(response.data);
this.sections = this.#page.contents_memo?.getType(Element).map((el) => el.model?.item()).flatMap((el) => !el ? [] : el);
}
get page(): ParsedResponse {
get page(): IBrowseResponse {
return this.#page;
}
}

View File

@@ -1,4 +1,3 @@
import type Actions from '../../core/Actions.js';
import TabbedFeed from '../../core/TabbedFeed.js';
import C4TabbedHeader from '../classes/C4TabbedHeader.js';
import CarouselHeader from '../classes/CarouselHeader.js';
@@ -21,14 +20,17 @@ import SortFilterSubMenu from '../classes/SortFilterSubMenu.js';
import { ChannelError, InnertubeError } from '../../utils/Utils.js';
import type { AppendContinuationItemsAction, ReloadContinuationItemsCommand } from '../index.js';
import type Actions from '../../core/Actions.js';
import type { ApiResponse } from '../../core/Actions.js';
import type { IBrowseResponse } from '../types/ParsedResponse.js';
export default class Channel extends TabbedFeed {
export default class Channel extends TabbedFeed<IBrowseResponse> {
header?: C4TabbedHeader | CarouselHeader | InteractiveTabbedHeader;
metadata;
subscribe_button?: SubscribeButton;
current_tab?: Tab | ExpandableTab;
constructor(actions: Actions, data: any, already_parsed = false) {
constructor(actions: Actions, data: ApiResponse | IBrowseResponse, already_parsed = false) {
super(actions, data, already_parsed);
this.header = this.page.header?.item()?.as(C4TabbedHeader, CarouselHeader, InteractiveTabbedHeader);
@@ -48,9 +50,9 @@ export default class Channel extends TabbedFeed {
this.metadata = { ...metadata, ...(microformat || {}) };
this.subscribe_button = this.page.header_memo.getType(SubscribeButton)?.[0];
this.subscribe_button = this.page.header_memo?.getType(SubscribeButton).first();
this.current_tab = this.page.contents.item().key('tabs').parsed().array().filterType(Tab, ExpandableTab).get({ selected: true });
this.current_tab = this.page.contents?.item().key('tabs').parsed().array().filterType(Tab, ExpandableTab).get({ selected: true });
}
/**
@@ -73,7 +75,10 @@ export default class Channel extends TabbedFeed {
if (!target_filter)
throw new InnertubeError('Invalid filter', filter);
const page = await target_filter.endpoint?.call(this.actions, { parse: true });
const page = await target_filter.endpoint?.call<IBrowseResponse>(this.actions, { parse: true });
if (!page)
throw new InnertubeError('No page returned', { filter: target_filter });
return new FilteredChannelList(this.actions, page, true);
}
@@ -96,7 +101,7 @@ export default class Channel extends TabbedFeed {
if (target_sort.selected)
return this;
const page = await target_sort.endpoint?.call(this.actions, { parse: true });
const page = await target_sort.endpoint?.call<IBrowseResponse>(this.actions, { parse: true });
return new Channel(this.actions, page, true);
}
@@ -119,7 +124,7 @@ export default class Channel extends TabbedFeed {
if (item.selected)
return this;
const page = await item.endpoint?.call(this.actions, { parse: true });
const page = await item.endpoint?.call<IBrowseResponse>(this.actions, { parse: true });
return new Channel(this.actions, page, true);
}
@@ -191,7 +196,7 @@ export default class Channel extends TabbedFeed {
if (!tab)
throw new InnertubeError('Search tab not found', this);
const page = await tab.endpoint?.call(this.actions, { query, parse: true });
const page = await tab.endpoint?.call<IBrowseResponse>(this.actions, { query, parse: true });
return new Channel(this.actions, page, true);
}
@@ -237,14 +242,16 @@ export default class Channel extends TabbedFeed {
*/
async getContinuation(): Promise<ChannelListContinuation> {
const page = await super.getContinuationData();
if (!page)
throw new InnertubeError('Could not get continuation data');
return new ChannelListContinuation(this.actions, page, true);
}
}
export class ChannelListContinuation extends Feed {
export class ChannelListContinuation extends Feed<IBrowseResponse> {
contents: ReloadContinuationItemsCommand | AppendContinuationItemsAction | undefined;
constructor(actions: Actions, data: any, already_parsed = false) {
constructor(actions: Actions, data: ApiResponse | IBrowseResponse, already_parsed = false) {
super(actions, data, already_parsed);
this.contents =
this.page.on_response_received_actions?.[0] ||
@@ -256,15 +263,17 @@ export class ChannelListContinuation extends Feed {
*/
async getContinuation(): Promise<ChannelListContinuation> {
const page = await super.getContinuationData();
if (!page)
throw new InnertubeError('Could not get continuation data');
return new ChannelListContinuation(this.actions, page, true);
}
}
export class FilteredChannelList extends FilterableFeed {
export class FilteredChannelList extends FilterableFeed<IBrowseResponse> {
applied_filter?: ChipCloudChip;
contents;
contents: ReloadContinuationItemsCommand | AppendContinuationItemsAction;
constructor(actions: Actions, data: any, already_parsed = false) {
constructor(actions: Actions, data: ApiResponse | IBrowseResponse, already_parsed = false) {
super(actions, data, already_parsed);
this.applied_filter = this.memo.getType(ChipCloudChip).get({ is_selected: true });
@@ -277,7 +286,7 @@ export class FilteredChannelList extends FilterableFeed {
this.page.on_response_received_actions.shift();
}
this.contents = this.page.on_response_received_actions?.[0];
this.contents = this.page.on_response_received_actions.first();
}
/**
@@ -295,9 +304,12 @@ export class FilteredChannelList extends FilterableFeed {
async getContinuation(): Promise<FilteredChannelList> {
const page = await super.getContinuationData();
if (!page?.on_response_received_actions_memo)
throw new InnertubeError('Unexpected continuation data', page);
// Keep the filters
page?.on_response_received_actions_memo.set('FeedFilterChipBar', this.memo.getType(FeedFilterChipBar));
page?.on_response_received_actions_memo.set('ChipCloudChip', this.memo.getType(ChipCloudChip));
page.on_response_received_actions_memo.set('FeedFilterChipBar', this.memo.getType(FeedFilterChipBar));
page.on_response_received_actions_memo.set('ChipCloudChip', this.memo.getType(ChipCloudChip));
return new FilteredChannelList(this.actions, page, true);
}

View File

@@ -1,8 +1,9 @@
import Parser, { ParsedResponse } from '../index.js';
import Parser from '../index.js';
import type Actions from '../../core/Actions.js';
import type { ApiResponse } from '../../core/Actions.js';
import { InnertubeError } from '../../utils/Utils.js';
import { observe, ObservedArray } from '../helpers.js';
import type { INextResponse } from '../types/ParsedResponse.js';
import CommentsHeader from '../classes/comments/CommentsHeader.js';
import CommentSimplebox from '../classes/comments/CommentSimplebox.js';
@@ -10,7 +11,7 @@ import CommentThread from '../classes/comments/CommentThread.js';
import ContinuationItem from '../classes/ContinuationItem.js';
class Comments {
#page: ParsedResponse;
#page: INextResponse;
#actions: Actions;
#continuation?: ContinuationItem;
@@ -18,7 +19,7 @@ class Comments {
contents: ObservedArray<CommentThread>;
constructor(actions: Actions, data: any, already_parsed = false) {
this.#page = already_parsed ? data : Parser.parseResponse(data);
this.#page = already_parsed ? data : Parser.parseResponse<INextResponse>(data);
this.#actions = actions;
const contents = this.#page.on_response_received_endpoints;
@@ -116,7 +117,7 @@ class Comments {
return !!this.#continuation;
}
get page(): ParsedResponse {
get page(): INextResponse {
return this.#page;
}
}

View File

@@ -2,24 +2,28 @@ import type Actions from '../../core/Actions.js';
import Feed from '../../core/Feed.js';
import ItemSection from '../classes/ItemSection.js';
import BrowseFeedActions from '../classes/BrowseFeedActions.js';
import type { IBrowseResponse } from '../types/ParsedResponse.js';
import type { ApiResponse } from '../../core/Actions.js';
// TODO: make feed actions usable
class History extends Feed {
class History extends Feed<IBrowseResponse> {
sections: ItemSection[];
feed_actions: BrowseFeedActions;
constructor(actions: Actions, data: any, already_parsed = false) {
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)?.[0];
this.feed_actions = this.memo.getType(BrowseFeedActions).first();
}
/**
* Retrieves next batch of contents.
*/
async getContinuation(): Promise<History> {
const continuation = await this.getContinuationData();
return new History(this.actions, continuation, true);
const response = await this.getContinuationData();
if (!response)
throw new Error('No continuation data found');
return new History(this.actions, response, true);
}
}

View File

@@ -4,18 +4,18 @@ import ChipCloudChip from '../classes/ChipCloudChip.js';
import FeedTabbedHeader from '../classes/FeedTabbedHeader.js';
import RichGrid from '../classes/RichGrid.js';
import type { IBrowseResponse } from '../types/ParsedResponse.js';
import type { AppendContinuationItemsAction, ReloadContinuationItemsCommand } from '../index.js';
import type { ApiResponse } from '../../core/Actions.js';
export default class HomeFeed extends FilterableFeed {
export default class HomeFeed extends FilterableFeed<IBrowseResponse> {
contents: RichGrid | AppendContinuationItemsAction | ReloadContinuationItemsCommand;
header: FeedTabbedHeader;
constructor(actions: Actions, data: any, already_parsed = false) {
constructor(actions: Actions, data: ApiResponse | IBrowseResponse, already_parsed = false) {
super(actions, data, already_parsed);
this.header = this.memo.getType<FeedTabbedHeader>(FeedTabbedHeader)?.[0];
this.contents =
this.memo.getType<RichGrid>(RichGrid)?.[0] ||
this.page.on_response_received_actions?.[0];
this.header = this.memo.getType(FeedTabbedHeader).first();
this.contents = this.memo.getType(RichGrid).first() || this.page.on_response_received_actions.first();
}
/**
@@ -35,7 +35,7 @@ export default class HomeFeed extends FilterableFeed {
// Keep the page header
feed.page.header = this.page.header;
feed.page.header_memo.set(this.header.type, [ this.header ]);
feed.page.header_memo?.set(this.header.type, [ this.header ]);
return new HomeFeed(this.actions, feed.page, true);
}

View File

@@ -4,16 +4,16 @@ import MenuServiceItem from '../classes/menus/MenuServiceItem.js';
import NavigationEndpoint from '../classes/NavigationEndpoint.js';
import type Actions from '../../core/Actions.js';
import type { ParsedResponse } from '../index.js';
import { InnertubeError } from '../../utils/Utils.js';
import type { ObservedArray, YTNode } from '../helpers.js';
import type { IParsedResponse } from '../types/ParsedResponse.js';
class ItemMenu {
#page: ParsedResponse;
#page: IParsedResponse;
#actions: Actions;
#items: ObservedArray<YTNode>;
constructor(data: ParsedResponse, actions: Actions) {
constructor(data: IParsedResponse, actions: Actions) {
this.#page = data;
this.#actions = actions;
@@ -25,9 +25,9 @@ class ItemMenu {
this.#items = menu.as(Menu).items;
}
async selectItem(icon_type: string): Promise<ParsedResponse>
async selectItem(button: Button): Promise<ParsedResponse>
async selectItem(item: string | Button): Promise<ParsedResponse> {
async selectItem(icon_type: string): Promise<IParsedResponse>
async selectItem(button: Button): Promise<IParsedResponse>
async selectItem(item: string | Button): Promise<IParsedResponse> {
let endpoint: NavigationEndpoint | undefined;
if (item instanceof Button) {
@@ -62,7 +62,7 @@ class ItemMenu {
return this.#items;
}
page(): ParsedResponse {
page(): IParsedResponse {
return this.#page;
}
}

View File

@@ -1,6 +1,4 @@
import Parser, { ParsedResponse } from '../index.js';
import type Actions from '../../core/Actions.js';
import type { ApiResponse } from '../../core/Actions.js';
import { InnertubeError } from '../../utils/Utils.js';
import Feed from '../../core/Feed.js';
@@ -13,10 +11,10 @@ import Button from '../classes/Button.js';
import ProfileColumnStats from '../classes/ProfileColumnStats.js';
import ProfileColumnUserInfo from '../classes/ProfileColumnUserInfo.js';
class Library {
#actions: Actions;
#page: ParsedResponse;
import type { IBrowseResponse } from '../types/ParsedResponse.js';
import { ApiResponse } from '../../core/Actions.js';
class Library extends Feed<IBrowseResponse> {
profile: {
stats?: ProfileColumnStats;
user_info?: ProfileColumnUserInfo;
@@ -24,16 +22,18 @@ class Library {
sections;
constructor(response: ApiResponse, actions: Actions) {
this.#actions = actions;
this.#page = Parser.parseResponse(response);
constructor(actions: Actions, data: ApiResponse | IBrowseResponse) {
super(actions, data);
const stats = this.#page.contents_memo.getType(ProfileColumnStats)?.[0];
const user_info = this.#page.contents_memo.getType(ProfileColumnUserInfo)?.[0];
if (!this.page.contents_memo)
throw new InnertubeError('Page contents not found');
const stats = this.page.contents_memo.getType(ProfileColumnStats)?.[0];
const user_info = this.page.contents_memo.getType(ProfileColumnUserInfo)?.[0];
this.profile = { stats, user_info };
const shelves = this.#page.contents_memo.getType(Shelf);
const shelves = this.page.contents_memo.getType(Shelf);
this.sections = shelves.map((shelf: Shelf) => ({
type: shelf.icon_type,
@@ -52,16 +52,16 @@ class Library {
if (!button)
throw new InnertubeError('Did not find target button.');
const page = await button.as(Button).endpoint.call(this.#actions, { parse: true });
const page = await button.as(Button).endpoint.call<IBrowseResponse>(this.actions, { parse: true });
switch (shelf.icon_type) {
case 'LIKE':
case 'WATCH_LATER':
return new Playlist(this.#actions, page, true);
return new Playlist(this.actions, page, true);
case 'WATCH_HISTORY':
return new History(this.#actions, page, true);
return new History(this.actions, page, true);
case 'CONTENT_CUT':
return new Feed(this.#actions, page, true);
return new Feed(this.actions, page, true);
default:
throw new InnertubeError('Target shelf not implemented.');
}
@@ -79,17 +79,13 @@ class Library {
return this.sections.find((section) => section.type === 'LIKE');
}
get playlists() {
get playlists_section() {
return this.sections.find((section) => section.type === 'PLAYLISTS');
}
get clips() {
return this.sections.find((section) => section.type === 'CONTENT_CUT');
}
get page(): ParsedResponse {
return this.#page;
}
}
export default Library;

View File

@@ -1,5 +1,5 @@
import EventEmitter from '../../utils/EventEmitterLike.js';
import Parser, { LiveChatContinuation, ParsedResponse } from '../index.js';
import Parser, { LiveChatContinuation } from '../index.js';
import VideoInfo from './VideoInfo.js';
import SmoothedQueue from './SmoothedQueue.js';
@@ -35,6 +35,7 @@ import LiveChatViewerEngagementMessage from '../classes/livechat/items/LiveChatV
import ItemMenu from './ItemMenu.js';
import type Actions from '../../core/Actions.js';
import type { IParsedResponse, IUpdatedMetadataResponse } from '../types/ParsedResponse.js';
export type ChatAction =
AddChatItemAction | AddBannerToLiveChatCommand | AddLiveChatTickerItemAction |
@@ -219,7 +220,7 @@ class LiveChat extends EventEmitter {
}
const response = await this.#actions.execute('/updated_metadata', payload);
const data = Parser.parseResponse(response.data);
const data = Parser.parseResponse<IUpdatedMetadataResponse>(response.data);
this.#mcontinuation = data.continuation?.token;
@@ -301,7 +302,7 @@ class LiveChat extends EventEmitter {
/**
* Equivalent to "clicking" a button.
*/
async selectButton(button: Button): Promise<ParsedResponse> {
async selectButton(button: Button): Promise<IParsedResponse> {
const response = await button.endpoint.call(this.#actions, { parse: true });
return response;
}

View File

@@ -1,5 +1,4 @@
import Parser, { ParsedResponse } from '../index.js';
import { InnertubeError } from '../../utils/Utils.js';
import Parser from '../index.js';
import ContinuationItem from '../classes/ContinuationItem.js';
import SimpleMenuHeader from '../classes/menus/SimpleMenuHeader.js';
@@ -7,9 +6,11 @@ import Notification from '../classes/Notification.js';
import type Actions from '../../core/Actions.js';
import type { ApiResponse } from '../../core/Actions.js';
import type { IGetNotificationsMenuResponse } from '../types/ParsedResponse.js';
import { InnertubeError } from '../../utils/Utils.js';
class NotificationsMenu {
#page: ParsedResponse;
#page: IGetNotificationsMenuResponse;
#actions: Actions;
header: SimpleMenuHeader;
@@ -17,14 +18,14 @@ class NotificationsMenu {
constructor(actions: Actions, response: ApiResponse) {
this.#actions = actions;
this.#page = Parser.parseResponse(response.data);
this.#page = Parser.parseResponse<IGetNotificationsMenuResponse>(response.data);
this.header = this.#page.actions_memo.getType(SimpleMenuHeader)?.[0];
this.header = this.#page.actions_memo.getType(SimpleMenuHeader).first();
this.contents = this.#page.actions_memo.getType(Notification);
}
async getContinuation(): Promise<NotificationsMenu> {
const continuation = this.#page.actions_memo.getType(ContinuationItem)?.[0];
const continuation = this.#page.actions_memo.getType(ContinuationItem).first();
if (!continuation)
throw new InnertubeError('Continuation not found');
@@ -34,7 +35,7 @@ class NotificationsMenu {
return new NotificationsMenu(this.#actions, response);
}
get page(): ParsedResponse {
get page(): IGetNotificationsMenuResponse {
return this.#page;
}
}

View File

@@ -1,29 +1,29 @@
import Feed from '../../core/Feed.js';
import Message from '../classes/Message.js';
import Thumbnail from '../classes/misc/Thumbnail.js';
import VideoOwner from '../classes/VideoOwner.js';
import NavigationEndpoint from '../classes/NavigationEndpoint.js';
import PlaylistCustomThumbnail from '../classes/PlaylistCustomThumbnail.js';
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 PlaylistCustomThumbnail from '../classes/PlaylistCustomThumbnail.js';
import PlaylistVideoThumbnail from '../classes/PlaylistVideoThumbnail.js';
import PlaylistHeader from '../classes/PlaylistHeader.js';
import Message from '../classes/Message.js';
import VideoOwner from '../classes/VideoOwner.js';
import { InnertubeError } from '../../utils/Utils.js';
import { ObservedArray } from '../helpers.js';
import type Actions from '../../core/Actions.js';
import { ObservedArray } from '../helpers.js';
import NavigationEndpoint from '../classes/NavigationEndpoint.js';
import type { ApiResponse } from '../../core/Actions.js';
import type { IBrowseResponse } from '../types/ParsedResponse.js';
class Playlist extends Feed {
class Playlist extends Feed<IBrowseResponse> {
info;
menu;
endpoint?: NavigationEndpoint;
messages: ObservedArray<Message>;
constructor(actions: Actions, data: any, already_parsed = false) {
constructor(actions: Actions, data: ApiResponse | IBrowseResponse, already_parsed = false) {
super(actions, data, already_parsed);
const header = this.memo.getType(PlaylistHeader).first();
@@ -34,7 +34,7 @@ class Playlist extends Feed {
throw new InnertubeError('This playlist does not exist');
this.info = {
...this.page.metadata.item().as(PlaylistMetadata),
...this.page.metadata?.item().as(PlaylistMetadata),
...{
author: secondary_info?.owner.item().as(VideoOwner).author ?? header?.author,
thumbnails: primary_info?.thumbnail_renderer.item().as(PlaylistVideoThumbnail, PlaylistCustomThumbnail).thumbnail as Thumbnail[],
@@ -63,8 +63,10 @@ class Playlist extends Feed {
}
async getContinuation(): Promise<Playlist> {
const response = await this.getContinuationData();
return new Playlist(this.actions, response, true);
const page = await this.getContinuationData();
if (!page)
throw new InnertubeError('Could not get continuation data');
return new Playlist(this.actions, page, true);
}
}

View File

@@ -4,31 +4,36 @@ import ItemSection from '../classes/ItemSection.js';
import SearchRefinementCard from '../classes/SearchRefinementCard.js';
import SectionList from '../classes/SectionList.js';
import UniversalWatchCard from '../classes/UniversalWatchCard.js';
import { InnertubeError } from '../../utils/Utils.js';
import type Actions from '../../core/Actions.js';
import { InnertubeError } from '../../utils/Utils.js';
import type { ObservedArray, YTNode } from '../helpers.js';
import type { ISearchResponse } from '../types/ParsedResponse.js';
import type { ApiResponse } from '../../core/Actions.js';
export default class Search extends Feed {
class Search extends Feed<ISearchResponse> {
results?: ObservedArray<YTNode> | null;
refinements: string[];
estimated_results: number | null;
watch_card: UniversalWatchCard | null;
estimated_results: number;
watch_card?: UniversalWatchCard;
refinement_cards?: HorizontalCardList | null;
constructor(actions: Actions, data: any, already_parsed = false) {
constructor(actions: Actions, data: ApiResponse | ISearchResponse, already_parsed = false) {
super(actions, data, already_parsed);
const contents =
this.page.contents_memo.getType(SectionList)?.[0]?.contents ||
this.page.on_response_received_commands?.[0].contents;
this.page.contents_memo?.getType(SectionList).first().contents ||
this.page.on_response_received_commands?.first().contents;
if (!contents)
throw new InnertubeError('No contents found in search response');
this.results = contents.firstOfType(ItemSection)?.contents;
this.refinements = this.page.refinements || [];
this.estimated_results = this.page.estimated_results;
this.watch_card = this.page?.contents_memo.getType(UniversalWatchCard)?.[0];
this.watch_card = this.page.contents_memo?.getType(UniversalWatchCard).first();
this.refinement_cards = this.results?.get({ type: 'HorizontalCardList' }, true)?.as(HorizontalCardList);
}
@@ -49,7 +54,7 @@ export default class Search extends Feed {
throw new InnertubeError('Invalid refinement card!');
}
const page = await target_card.endpoint.call(this.actions, { parse: true });
const page = await target_card.endpoint.call<ISearchResponse>(this.actions, { parse: true });
return new Search(this.actions, page, true);
}
@@ -65,7 +70,11 @@ export default class Search extends Feed {
* Retrieves next batch of results.
*/
async getContinuation(): Promise<Search> {
const continuation = await this.getContinuationData();
return new Search(this.actions, continuation, true);
const response = await this.getContinuationData();
if (!response)
throw new InnertubeError('Could not get continuation data');
return new Search(this.actions, response, true);
}
}
}
export default Search;

View File

@@ -1,34 +1,37 @@
import Parser, { ParsedResponse } from '../index.js';
import Parser from '../index.js';
import { InnertubeError } from '../../utils/Utils.js';
import ItemSection from '../classes/ItemSection.js';
import SectionList from '../classes/SectionList.js';
import Tab from '../classes/Tab.js';
import TwoColumnBrowseResults from '../classes/TwoColumnBrowseResults.js';
import CompactLink from '../classes/CompactLink.js';
import ItemSection from '../classes/ItemSection.js';
import PageIntroduction from '../classes/PageIntroduction.js';
import SectionList from '../classes/SectionList.js';
import SettingsOptions from '../classes/SettingsOptions.js';
import SettingsSidebar from '../classes/SettingsSidebar.js';
import SettingsSwitch from '../classes/SettingsSwitch.js';
import Tab from '../classes/Tab.js';
import TwoColumnBrowseResults from '../classes/TwoColumnBrowseResults.js';
import type Actions from '../../core/Actions.js';
import type { ApiResponse } from '../../core/Actions.js';
import type { IBrowseResponse } from '../types/ParsedResponse.js';
class Settings {
#page: ParsedResponse;
#page: IBrowseResponse;
#actions: Actions;
sidebar: SettingsSidebar | null | undefined;
introduction: PageIntroduction | null | undefined;
sidebar?: SettingsSidebar;
introduction?: PageIntroduction;
sections;
constructor(actions: Actions, response: ApiResponse) {
this.#actions = actions;
this.#page = Parser.parseResponse(response.data);
this.#page = Parser.parseResponse<IBrowseResponse>(response.data);
this.sidebar = this.#page.sidebar?.as(SettingsSidebar);
if (!this.#page.contents)
throw new InnertubeError('Page contents not found');
const tab = this.#page.contents.item().as(TwoColumnBrowseResults).tabs.array().as(Tab).get({ selected: true });
if (!tab)
@@ -124,7 +127,7 @@ class Settings {
return this.sidebar.items.map((item) => item.title.toString());
}
get page(): ParsedResponse {
get page(): IBrowseResponse {
return this.#page;
}
}

View File

@@ -1,4 +1,4 @@
import Parser, { ParsedResponse } from '../index.js';
import Parser from '../index.js';
import ItemSection from '../classes/ItemSection.js';
import SectionList from '../classes/SectionList.js';
import SingleColumnBrowseResults from '../classes/SingleColumnBrowseResults.js';
@@ -6,14 +6,18 @@ import SingleColumnBrowseResults from '../classes/SingleColumnBrowseResults.js';
import { InnertubeError } from '../../utils/Utils.js';
import type { ApiResponse } from '../../core/Actions.js';
import type { ObservedArray } from '../helpers.js';
import type { IBrowseResponse } from '../types/ParsedResponse.js';
class TimeWatched {
#page: ParsedResponse;
#page: IBrowseResponse;
contents?: ObservedArray<ItemSection>;
constructor(response: ApiResponse) {
this.#page = Parser.parseResponse(response.data);
if (!this.#page.contents)
throw new InnertubeError('Page contents not found');
const tab = this.#page.contents.item().as(SingleColumnBrowseResults).tabs.get({ selected: true });
if (!tab)
@@ -22,7 +26,7 @@ class TimeWatched {
this.contents = tab.content?.as(SectionList).contents.as(ItemSection);
}
get page(): ParsedResponse {
get page(): IBrowseResponse {
return this.#page;
}
}

View File

@@ -1,12 +1,5 @@
import Constants from '../../utils/Constants.js';
import Parser, { ParsedResponse } from '../index.js';
import TwoColumnWatchNextResults from '../classes/TwoColumnWatchNextResults.js';
import VideoPrimaryInfo from '../classes/VideoPrimaryInfo.js';
import VideoSecondaryInfo from '../classes/VideoSecondaryInfo.js';
import MerchandiseShelf from '../classes/MerchandiseShelf.js';
import RelatedChipCloud from '../classes/RelatedChipCloud.js';
import Parser from '../index.js';
import ChipCloud from '../classes/ChipCloud.js';
import ChipCloudChip from '../classes/ChipCloudChip.js';
@@ -14,11 +7,16 @@ import CommentsEntryPointHeader from '../classes/comments/CommentsEntryPointHead
import ContinuationItem from '../classes/ContinuationItem.js';
import ItemSection from '../classes/ItemSection.js';
import LiveChat from '../classes/LiveChat.js';
import MerchandiseShelf from '../classes/MerchandiseShelf.js';
import MicroformatData from '../classes/MicroformatData.js';
import PlayerMicroformat from '../classes/PlayerMicroformat.js';
import PlayerOverlay from '../classes/PlayerOverlay.js';
import RelatedChipCloud from '../classes/RelatedChipCloud.js';
import SegmentedLikeDislikeButton from '../classes/SegmentedLikeDislikeButton.js';
import ToggleButton from '../classes/ToggleButton.js';
import TwoColumnWatchNextResults from '../classes/TwoColumnWatchNextResults.js';
import VideoPrimaryInfo from '../classes/VideoPrimaryInfo.js';
import VideoSecondaryInfo from '../classes/VideoSecondaryInfo.js';
import LiveChatWrap from './LiveChat.js';
import type CardCollection from '../classes/CardCollection.js';
@@ -29,17 +27,18 @@ import type PlayerCaptionsTracklist from '../classes/PlayerCaptionsTracklist.js'
import type PlayerLiveStoryboardSpec from '../classes/PlayerLiveStoryboardSpec.js';
import type PlayerStoryboardSpec from '../classes/PlayerStoryboardSpec.js';
import type Player from '../../core/Player.js';
import type Actions from '../../core/Actions.js';
import type { ApiResponse } from '../../core/Actions.js';
import type Player from '../../core/Player.js';
import type { ObservedArray, YTNode } from '../helpers.js';
import type { INextResponse, IPlayerResponse } from '../types/ParsedResponse.js';
import FormatUtils, { FormatOptions, DownloadOptions, URLTransformer, FormatFilter } from '../../utils/FormatUtils.js';
import FormatUtils, { DownloadOptions, FormatFilter, FormatOptions, URLTransformer } from '../../utils/FormatUtils.js';
import { InnertubeError } from '../../utils/Utils.js';
class VideoInfo {
#page: [ParsedResponse, ParsedResponse?];
#page: [IPlayerResponse, INextResponse?];
#actions: Actions;
#player?: Player;
@@ -49,11 +48,11 @@ class VideoInfo {
basic_info;
streaming_data;
playability_status;
annotations: ObservedArray<PlayerAnnotationsExpanded>;
storyboards: PlayerStoryboardSpec | PlayerLiveStoryboardSpec | null;
endscreen: Endscreen | null;
captions: PlayerCaptionsTracklist | null;
cards: CardCollection | null;
annotations?: ObservedArray<PlayerAnnotationsExpanded>;
storyboards?: PlayerStoryboardSpec | PlayerLiveStoryboardSpec;
endscreen?: Endscreen;
captions?: PlayerCaptionsTracklist;
cards?: CardCollection;
#playback_tracking;
@@ -77,8 +76,8 @@ class VideoInfo {
this.#player = player;
this.#cpn = cpn;
const info = Parser.parseResponse(data[0].data);
const next = data?.[1]?.data ? Parser.parseResponse(data[1].data) : undefined;
const info = Parser.parseResponse<IPlayerResponse>(data[0].data);
const next = data?.[1]?.data ? Parser.parseResponse<INextResponse>(data[1].data) : undefined;
this.#page = [ info, next ];
@@ -117,7 +116,7 @@ class VideoInfo {
this.#playback_tracking = info.playback_tracking;
const two_col = next?.contents.item().as(TwoColumnWatchNextResults);
const two_col = next?.contents?.item().as(TwoColumnWatchNextResults);
const results = two_col?.results;
const secondary_results = two_col?.secondary_results;
@@ -133,7 +132,7 @@ class VideoInfo {
if (this.watch_next_feed && Array.isArray(this.watch_next_feed) && this.watch_next_feed.at(-1)?.is(ContinuationItem))
this.#watch_next_continuation = this.watch_next_feed.pop()?.as(ContinuationItem);
this.player_overlays = next?.player_overlays.item().as(PlayerOverlay);
this.player_overlays = next?.player_overlays?.item().as(PlayerOverlay);
const segmented_like_dislike_button = this.primary_info?.menu?.top_level_buttons.firstOfType(SegmentedLikeDislikeButton);
@@ -144,7 +143,7 @@ class VideoInfo {
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).first();
}
}
@@ -394,7 +393,7 @@ class VideoInfo {
/**
* Original parsed InnerTube response.
*/
get page(): [ParsedResponse, ParsedResponse?] {
get page(): [IPlayerResponse, INextResponse?] {
return this.#page;
}
}

View File

@@ -1,14 +1,16 @@
import Feed from '../../core/Feed.js';
import Actions from '../../core/Actions.js';
import C4TabbedHeader from '../classes/C4TabbedHeader.js';
import ItemSection from '../classes/ItemSection.js';
import { ItemSectionContinuation } from '../index.js';
import type { IBrowseResponse } from '../types/ParsedResponse.js';
import type Actions from '../../core/Actions.js';
import type { ApiResponse } from '../../core/Actions.js';
class Channel extends Feed {
class Channel extends Feed<IBrowseResponse> {
header?: C4TabbedHeader;
contents?: ItemSection | ItemSectionContinuation;
constructor(actions: Actions, data: any, already_parsed = false) {
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);
@@ -23,7 +25,7 @@ class Channel extends Feed {
client: 'YTKIDS'
});
return new Channel(this.actions, response.data);
return new Channel(this.actions, response);
}
get has_continuation(): boolean {

View File

@@ -1,15 +1,18 @@
import Feed from '../../core/Feed.js';
import Actions from '../../core/Actions.js';
import KidsCategoriesHeader from '../classes/ytkids/KidsCategoriesHeader.js';
import KidsCategoryTab from '../classes/ytkids/KidsCategoryTab.js';
import KidsHomeScreen from '../classes/ytkids/KidsHomeScreen.js';
import { InnertubeError } from '../../utils/Utils.js';
class HomeFeed extends Feed {
import type Actions from '../../core/Actions.js';
import type { ApiResponse } from '../../core/Actions.js';
import type { IBrowseResponse } from '../types/ParsedResponse.js';
class HomeFeed extends Feed<IBrowseResponse> {
header?: KidsCategoriesHeader;
contents?: KidsHomeScreen;
constructor(actions: Actions, data: any, already_parsed = false) {
constructor(actions: Actions, data: ApiResponse | IBrowseResponse, already_parsed = false) {
super(actions, data, already_parsed);
this.header = this.page.header?.item().as(KidsCategoriesHeader);
this.contents = this.page.contents?.item().as(KidsHomeScreen);
@@ -31,7 +34,7 @@ class HomeFeed extends Feed {
if (!target_tab)
throw new InnertubeError(`Tab "${tab}" not found`);
const page = await target_tab.endpoint.call(this.actions, { client: 'YTKIDS', parse: true });
const page = await target_tab.endpoint.call<IBrowseResponse>(this.actions, { client: 'YTKIDS', parse: true });
// Copy over the header and header memo
page.header = this.page.header;

View File

@@ -3,12 +3,14 @@ import ItemSection from '../classes/ItemSection.js';
import { InnertubeError } from '../../utils/Utils.js';
import type Actions from '../../core/Actions.js';
import type { ObservedArray, YTNode } from '../helpers.js';
import type { ISearchResponse } from '../types/ParsedResponse.js';
import type { ApiResponse } from '../../core/Actions.js';
class Search extends Feed {
estimated_results: number | null;
class Search extends Feed<ISearchResponse> {
estimated_results: number;
contents: ObservedArray<YTNode> | null;
constructor(actions: Actions, data: any) {
constructor(actions: Actions, data: ApiResponse | ISearchResponse) {
super(actions, data);
this.estimated_results = this.page.estimated_results;

View File

@@ -1,4 +1,4 @@
import Parser, { ParsedResponse } from '../index.js';
import Parser from '../index.js';
import ItemSection from '../classes/ItemSection.js';
import NavigationEndpoint from '../classes/NavigationEndpoint.js';
@@ -10,6 +10,7 @@ import type Format from '../classes/misc/Format.js';
import type Actions from '../../core/Actions.js';
import type { ApiResponse } from '../../core/Actions.js';
import type { ObservedArray, YTNode } from '../helpers.js';
import type { INextResponse, IPlayerResponse } from '../types/ParsedResponse.js';
import { Constants } from '../../utils/index.js';
import { InnertubeError } from '../../utils/Utils.js';
@@ -17,7 +18,7 @@ import { InnertubeError } from '../../utils/Utils.js';
import FormatUtils, { DownloadOptions, FormatFilter, FormatOptions, URLTransformer } from '../../utils/FormatUtils.js';
class VideoInfo {
#page: [ParsedResponse, ParsedResponse?];
#page: [IPlayerResponse, INextResponse?];
#actions: Actions;
#cpn: string;
@@ -28,16 +29,16 @@ class VideoInfo {
#playback_tracking;
slim_video_metadata?: SlimVideoMetadata | null;
watch_next_feed?: ObservedArray<YTNode> | null;
current_video_endpoint?: NavigationEndpoint | null;
slim_video_metadata?: SlimVideoMetadata;
watch_next_feed?: ObservedArray<YTNode>;
current_video_endpoint?: NavigationEndpoint;
player_overlays?: PlayerOverlay;
constructor(data: [ApiResponse, ApiResponse?], actions: Actions, cpn: string) {
this.#actions = actions;
const info = Parser.parseResponse(data[0].data);
const next = data?.[1]?.data ? Parser.parseResponse(data[1].data) : undefined;
const info = Parser.parseResponse<IPlayerResponse>(data[0].data);
const next = data?.[1]?.data ? Parser.parseResponse<INextResponse>(data[1].data) : undefined;
this.#page = [ info, next ];
this.#cpn = cpn;
@@ -53,7 +54,7 @@ class VideoInfo {
this.#playback_tracking = info.playback_tracking;
const two_col = next?.contents.item().as(TwoColumnWatchNextResults);
const two_col = next?.contents?.item().as(TwoColumnWatchNextResults);
const results = two_col?.results;
const secondary_results = two_col?.secondary_results;
@@ -62,7 +63,7 @@ class VideoInfo {
this.slim_video_metadata = results.firstOfType(ItemSection)?.contents?.firstOfType(SlimVideoMetadata);
this.watch_next_feed = secondary_results.firstOfType(ItemSection)?.contents || secondary_results;
this.current_video_endpoint = next?.current_video_endpoint;
this.player_overlays = next?.player_overlays.item().as(PlayerOverlay);
this.player_overlays = next?.player_overlays?.item().as(PlayerOverlay);
}
}
@@ -130,7 +131,7 @@ class VideoInfo {
return this.#cpn;
}
get page(): [ParsedResponse, ParsedResponse?] {
get page(): [IPlayerResponse, INextResponse?] {
return this.#page;
}
}

View File

@@ -1,7 +1,6 @@
import type Actions from '../../core/Actions.js';
import type { ApiResponse } from '../../core/Actions.js';
import type { ObservedArray } from '../helpers.js';
import Parser, { ParsedResponse } from '../index.js';
import Parser from '../index.js';
import MicroformatData from '../classes/MicroformatData.js';
import MusicCarouselShelf from '../classes/MusicCarouselShelf.js';
@@ -9,29 +8,31 @@ import MusicDetailHeader from '../classes/MusicDetailHeader.js';
import MusicShelf from '../classes/MusicShelf.js';
import type MusicResponsiveListItem from '../classes/MusicResponsiveListItem.js';
import type { IBrowseResponse } from '../types/ParsedResponse.js';
class Album {
#page: ParsedResponse;
#actions: Actions;
#page: IBrowseResponse;
header?: MusicDetailHeader | null;
header?: MusicDetailHeader;
contents: ObservedArray<MusicResponsiveListItem>;
sections: ObservedArray<MusicCarouselShelf>;
url: string | null;
constructor(response: ApiResponse, actions: Actions) {
this.#page = Parser.parseResponse(response.data);
this.#actions = actions;
constructor(response: ApiResponse) {
this.#page = Parser.parseResponse<IBrowseResponse>(response.data);
this.header = this.#page.header?.item().as(MusicDetailHeader);
this.url = this.#page.microformat?.as(MicroformatData).url_canonical || null;
this.contents = this.#page.contents_memo.getType(MusicShelf)?.[0].contents;
if (!this.#page.contents_memo)
throw new Error('No contents found in the response');
this.contents = this.#page.contents_memo.getType(MusicShelf)?.first().contents;
this.sections = this.#page.contents_memo.getType(MusicCarouselShelf) || [];
}
get page(): ParsedResponse {
get page(): IBrowseResponse {
return this.#page;
}
}

View File

@@ -1,4 +1,4 @@
import Parser, { ParsedResponse } from '../index.js';
import Parser from '../index.js';
import type Actions from '../../core/Actions.js';
import type { ApiResponse } from '../../core/Actions.js';
import { InnertubeError } from '../../utils/Utils.js';
@@ -9,27 +9,28 @@ import MusicPlaylistShelf from '../classes/MusicPlaylistShelf.js';
import MusicImmersiveHeader from '../classes/MusicImmersiveHeader.js';
import MusicVisualHeader from '../classes/MusicVisualHeader.js';
import MusicHeader from '../classes/MusicHeader.js';
import type { IBrowseResponse } from '../types/ParsedResponse.js';
class Artist {
#page: ParsedResponse;
#page: IBrowseResponse;
#actions: Actions;
header?: MusicImmersiveHeader | MusicVisualHeader | MusicHeader;
sections: (MusicCarouselShelf | MusicShelf)[];
constructor(response: ApiResponse | ParsedResponse, actions: Actions) {
this.#page = Parser.parseResponse((response as ApiResponse).data);
constructor(response: ApiResponse, actions: Actions) {
this.#page = Parser.parseResponse<IBrowseResponse>(response.data);
this.#actions = actions;
this.header = this.page.header?.item().as(MusicImmersiveHeader, MusicVisualHeader, MusicHeader);
const music_shelf = this.#page.contents_memo.getType(MusicShelf) || [];
const music_carousel_shelf = this.#page.contents_memo.getType(MusicCarouselShelf) || [];
const music_shelf = this.#page.contents_memo?.getType(MusicShelf) || [];
const music_carousel_shelf = this.#page.contents_memo?.getType(MusicCarouselShelf) || [];
this.sections = [ ...music_shelf, ...music_carousel_shelf ];
}
async getAllSongs(): Promise<MusicPlaylistShelf | null> {
async getAllSongs(): Promise<MusicPlaylistShelf | undefined> {
const music_shelves = this.sections.filter((section) => section.type === 'MusicShelf') as MusicShelf[];
if (!music_shelves.length)
@@ -44,12 +45,12 @@ 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 });
const contents = page.contents_memo.getType(MusicPlaylistShelf)?.[0] || null;
const contents = page.contents_memo?.getType(MusicPlaylistShelf)?.first();
return contents;
}
get page(): ParsedResponse {
get page(): IBrowseResponse {
return this.#page;
}
}

View File

@@ -1,4 +1,4 @@
import Parser, { ParsedResponse } from '../index.js';
import Parser from '../index.js';
import Grid from '../classes/Grid.js';
import MusicCarouselShelf from '../classes/MusicCarouselShelf.js';
@@ -7,19 +7,20 @@ import SectionList from '../classes/SectionList.js';
import SingleColumnBrowseResults from '../classes/SingleColumnBrowseResults.js';
import type { ApiResponse } from '../../core/Actions.js';
import type { ObservedArray } from '../helpers.js';
import { InnertubeError } from '../../utils/Utils.js';
import type { ObservedArray } from '../helpers.js';
import type { IBrowseResponse } from '../types/ParsedResponse.js';
class Explore {
#page: ParsedResponse;
#page: IBrowseResponse;
top_buttons: MusicNavigationButton[];
sections: ObservedArray<MusicCarouselShelf>;
constructor(response: ApiResponse) {
this.#page = Parser.parseResponse(response.data);
this.#page = Parser.parseResponse<IBrowseResponse>(response.data);
const tab = this.#page.contents.item().as(SingleColumnBrowseResults).tabs.get({ selected: true });
const tab = this.#page.contents?.item().as(SingleColumnBrowseResults).tabs.get({ selected: true });
if (!tab)
throw new InnertubeError('Could not find target tab.');
@@ -33,7 +34,7 @@ class Explore {
this.sections = section_list.contents.filterType(MusicCarouselShelf);
}
get page(): ParsedResponse {
get page(): IBrowseResponse {
return this.#page;
}
}

View File

@@ -1,29 +1,29 @@
import type Actions from '../../core/Actions.js';
import type { ApiResponse } from '../../core/Actions.js';
import type { ObservedArray } from '../helpers.js';
import { InnertubeError } from '../../utils/Utils.js';
import Parser, { ParsedResponse, SectionListContinuation } from '../index.js';
import MusicCarouselShelf from '../classes/MusicCarouselShelf.js';
import SectionList from '../classes/SectionList.js';
import SingleColumnBrowseResults from '../classes/SingleColumnBrowseResults.js';
import Parser, { SectionListContinuation } from '../index.js';
import type Actions from '../../core/Actions.js';
import type { ApiResponse } from '../../core/Actions.js';
import type { ObservedArray } from '../helpers.js';
import type { IBrowseResponse } from '../types/ParsedResponse.js';
class HomeFeed {
#page: ParsedResponse;
#page: IBrowseResponse;
#actions: Actions;
#continuation?: string;
sections?: ObservedArray<MusicCarouselShelf>;
constructor(response: ApiResponse | ParsedResponse, actions: Actions) {
constructor(response: ApiResponse, actions: Actions) {
this.#actions = actions;
this.#page = Parser.parseResponse((response as ApiResponse).data);
this.#page = Parser.parseResponse<IBrowseResponse>(response.data);
const tab = this.#page.contents.item().as(SingleColumnBrowseResults).tabs.get({ selected: true });
const tab = this.#page.contents?.item().as(SingleColumnBrowseResults).tabs.get({ selected: true });
if (!tab)
throw new InnertubeError('Could not get Home tab.');
throw new InnertubeError('Could not find Home tab.');
if (tab.key('content').isNull()) {
if (!this.#page.continuation_contents)
@@ -58,7 +58,7 @@ class HomeFeed {
return !!this.#continuation;
}
get page(): ParsedResponse {
get page(): IBrowseResponse {
return this.#page;
}
}

View File

@@ -1,4 +1,4 @@
import Parser, { ParsedResponse, SectionListContinuation } from '../index.js';
import Parser, { GridContinuation, MusicShelfContinuation, SectionListContinuation } from '../index.js';
import type Actions from '../../core/Actions.js';
import type { ApiResponse } from '../../core/Actions.js';
@@ -16,9 +16,10 @@ import MusicMenuItemDivider from '../classes/menus/MusicMenuItemDivider.js';
import { InnertubeError } from '../../utils/Utils.js';
import type { ObservedArray } from '../helpers.js';
import type { IBrowseResponse } from '../types/ParsedResponse.js';
class Library {
#page: ParsedResponse;
#page: IBrowseResponse;
#actions: Actions;
#continuation?: string | null;
@@ -26,10 +27,10 @@ class Library {
contents?: ObservedArray<Grid | MusicShelf>;
constructor(response: ApiResponse, actions: Actions) {
this.#page = Parser.parseResponse(response.data);
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).first();
this.header = section_list?.header?.item().as(MusicSideAlignedItem);
this.contents = section_list?.contents?.as(Grid, MusicShelf);
@@ -44,9 +45,9 @@ class Library {
let target_item: MusicMultiSelectMenuItem | undefined;
if (typeof sort_by === 'string') {
const button = this.#page.contents_memo.getType(MusicSortFilterButton)?.[0];
const button = this.#page.contents_memo?.getType(MusicSortFilterButton).first();
const options = button.menu?.options
const options = button?.menu?.options
.filter(
(item: MusicMultiSelectMenuItem | MusicMenuItemDivider) => item instanceof MusicMultiSelectMenuItem
) as MusicMultiSelectMenuItem[];
@@ -76,7 +77,7 @@ class Library {
parse: true
});
const previously_selected_item = this.#page.contents_memo.getType(MusicMultiSelectMenuItem)?.find((item) => item.selected);
const previously_selected_item = this.#page.contents_memo?.getType(MusicMultiSelectMenuItem)?.find((item) => item.selected);
if (previously_selected_item)
previously_selected_item.selected = false;
@@ -93,10 +94,10 @@ class Library {
async applyFilter(filter: string | ChipCloudChip): Promise<Library> {
let target_chip: ChipCloudChip | undefined;
const chip_cloud = this.#page.contents_memo.getType(ChipCloud)?.[0];
const chip_cloud = this.#page.contents_memo?.getType(ChipCloud).first();
if (typeof filter === 'string') {
target_chip = chip_cloud.chips.get({ text: filter });
target_chip = chip_cloud?.chips.get({ text: filter });
if (!target_chip)
throw new InnertubeError(`Filter "${filter}" not found`, { available_filters: this.filters });
@@ -133,16 +134,16 @@ class Library {
}
get sort_options(): string[] {
const button = this.#page.contents_memo.getType(MusicSortFilterButton)?.[0];
const options = button.menu?.options.filter((item: MusicMultiSelectMenuItem | MusicMenuItemDivider) => item instanceof MusicMultiSelectMenuItem) as MusicMultiSelectMenuItem[];
const button = this.#page.contents_memo?.getType(MusicSortFilterButton).first();
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)?.first().chips.map((chip: ChipCloudChip) => chip.text) || [];
}
get page(): ParsedResponse {
get page(): IBrowseResponse {
return this.#page;
}
}
@@ -152,15 +153,16 @@ class LibraryContinuation {
#actions;
#continuation;
contents;
contents: GridContinuation | MusicShelfContinuation;
constructor(response: ApiResponse, actions: Actions) {
this.#page = Parser.parseResponse(response.data);
this.#page = Parser.parseResponse<IBrowseResponse>(response.data);
this.#actions = actions;
this.contents = this.#page.continuation_contents?.hasKey('contents')
? this.#page.continuation_contents?.key('contents').array() :
this.#page.continuation_contents?.key('items').array();
if (!this.#page.continuation_contents)
throw new InnertubeError('No continuation contents found');
this.contents = this.#page.continuation_contents.as(MusicShelfContinuation, GridContinuation);
this.#continuation = this.#page.continuation_contents?.key('continuation').isNull()
? null : this.#page.continuation_contents?.key('continuation').string();
@@ -170,19 +172,19 @@ class LibraryContinuation {
if (!this.#continuation)
throw new InnertubeError('No continuation available');
const page = await this.#actions.execute('/browse', {
const response = await this.#actions.execute('/browse', {
client: 'YTMUSIC',
continuation: this.#continuation
});
return new LibraryContinuation(page, this.#actions);
return new LibraryContinuation(response, this.#actions);
}
get has_continuation(): boolean {
return !!this.#continuation;
}
get page(): ParsedResponse {
get page(): IBrowseResponse {
return this.#page;
}
}

View File

@@ -1,4 +1,4 @@
import Parser, { MusicPlaylistShelfContinuation, ParsedResponse, SectionListContinuation } from '../index.js';
import Parser, { MusicPlaylistShelfContinuation, SectionListContinuation } from '../index.js';
import MusicCarouselShelf from '../classes/MusicCarouselShelf.js';
import MusicDetailHeader from '../classes/MusicDetailHeader.js';
@@ -12,20 +12,21 @@ import { InnertubeError } from '../../utils/Utils.js';
import type { ObservedArray, YTNode } from '../helpers.js';
import type Actions from '../../core/Actions.js';
import type { ApiResponse } from '../../core/Actions.js';
import type { IBrowseResponse } from '../types/ParsedResponse.js';
class Playlist {
#page: ParsedResponse;
#page: IBrowseResponse;
#actions: Actions;
#continuation: string | null;
#last_fetched_suggestions: any;
#suggestions_continuation: any;
header?: MusicDetailHeader | null;
items: ObservedArray<YTNode> | null;
header?: MusicDetailHeader;
items?: ObservedArray<YTNode> | null;
constructor(response: ApiResponse, actions: Actions) {
this.#actions = actions;
this.#page = Parser.parseResponse(response.data);
this.#page = Parser.parseResponse<IBrowseResponse>(response.data);
this.#last_fetched_suggestions = null;
this.#suggestions_continuation = null;
@@ -38,10 +39,10 @@ class Playlist {
if (this.#page.header?.item().type === 'MusicEditablePlaylistDetailHeader') {
this.header = this.#page.header?.item().as(MusicEditablePlaylistDetailHeader).header.item().as(MusicDetailHeader);
} else {
this.header = this.#page.header?.item().as(MusicDetailHeader) || null;
this.header = this.#page.header?.item().as(MusicDetailHeader);
}
this.items = this.#page.contents_memo.getType(MusicPlaylistShelf)?.[0].contents;
this.#continuation = this.#page.contents_memo.getType(MusicPlaylistShelf)?.[0].continuation || null;
this.items = this.#page.contents_memo?.getType(MusicPlaylistShelf).first().contents || null;
this.#continuation = this.#page.contents_memo?.getType(MusicPlaylistShelf).first().continuation || null;
}
}
@@ -64,7 +65,7 @@ class Playlist {
* Retrieves related playlists
*/
async getRelated(): Promise<MusicCarouselShelf> {
let section_continuation = this.#page.contents_memo.getType(SectionList)?.[0].continuation;
let section_continuation = this.#page.contents_memo?.getType(SectionList)?.[0].continuation;
while (section_continuation) {
const data = await this.#actions.execute('/browse', {
@@ -101,7 +102,7 @@ class Playlist {
}
async #fetchSuggestions(): Promise<{ items: never[] | ObservedArray<MusicResponsiveListItem>, continuation: string | null }> {
const continuation = this.#suggestions_continuation || this.#page.contents_memo.get('SectionList')?.[0].as(SectionList).continuation;
const continuation = this.#suggestions_continuation || this.#page.contents_memo?.get('SectionList')?.[0].as(SectionList).continuation;
if (continuation) {
const page = await this.#actions.execute('/browse', {
@@ -127,7 +128,7 @@ class Playlist {
};
}
get page(): ParsedResponse {
get page(): IBrowseResponse {
return this.#page;
}

View File

@@ -1,5 +1,4 @@
import Parser, { ParsedResponse } from '../index.js';
import Parser from '../index.js';
import type Actions from '../../core/Actions.js';
import type { ApiResponse } from '../../core/Actions.js';
@@ -17,16 +16,17 @@ import Tab from '../classes/Tab.js';
import { InnertubeError } from '../../utils/Utils.js';
import type { ObservedArray } from '../helpers.js';
import type { IBrowseResponse } from '../types/ParsedResponse.js';
class Recap {
#page: ParsedResponse;
#page: IBrowseResponse;
#actions: Actions;
header?: HighlightsCarousel | MusicHeader;
sections?: ObservedArray<ItemSection | MusicCarouselShelf | Message>;
constructor(response: ApiResponse, actions: Actions) {
this.#page = Parser.parseResponse(response.data);
this.#page = Parser.parseResponse<IBrowseResponse>(response.data);
this.#actions = actions;
const header = this.#page.header?.item();
@@ -35,7 +35,7 @@ class Recap {
this.#page.header?.item().as(MusicElementHeader).element?.model?.item().as(HighlightsCarousel) :
this.#page.header?.item().as(MusicHeader);
const tab = this.#page.contents.item().as(SingleColumnBrowseResults).tabs.firstOfType(Tab);
const tab = this.#page.contents?.item().as(SingleColumnBrowseResults).tabs.firstOfType(Tab);
if (!tab)
throw new InnertubeError('Target tab not found');
@@ -59,7 +59,7 @@ class Recap {
return new Playlist(response, this.#actions);
}
get page(): ParsedResponse {
get page(): IBrowseResponse {
return this.#page;
}
}

View File

@@ -1,44 +1,37 @@
import type Actions from '../../core/Actions.js';
import type { ApiResponse } from '../../core/Actions.js';
import Parser, { ParsedResponse } from '../index.js';
import { InnertubeError } from '../../utils/Utils.js';
import SectionList from '../classes/SectionList.js';
import TabbedSearchResults from '../classes/TabbedSearchResults.js';
import DidYouMean from '../classes/DidYouMean.js';
import MusicResponsiveListItem from '../classes/MusicResponsiveListItem.js';
import MusicShelf from '../classes/MusicShelf.js';
import ShowingResultsFor from '../classes/ShowingResultsFor.js';
import Parser, { MusicShelfContinuation } from '../index.js';
import ChipCloud from '../classes/ChipCloud.js';
import ChipCloudChip from '../classes/ChipCloudChip.js';
import DidYouMean from '../classes/DidYouMean.js';
import ItemSection from '../classes/ItemSection.js';
import Message from '../classes/Message.js';
import MusicHeader from '../classes/MusicHeader.js';
import MusicResponsiveListItem from '../classes/MusicResponsiveListItem.js';
import MusicShelf from '../classes/MusicShelf.js';
import SectionList from '../classes/SectionList.js';
import ShowingResultsFor from '../classes/ShowingResultsFor.js';
import TabbedSearchResults from '../classes/TabbedSearchResults.js';
import type { ObservedArray } from '../helpers.js';
import type { ISearchResponse } from '../types/ParsedResponse.js';
import type { ApiResponse } from '../../core/Actions.js';
class Search {
#page: ParsedResponse;
#page: ISearchResponse;
#actions: Actions;
#continuation?: string | null;
#continuation?: string;
header?: ChipCloud | null;
header?: ChipCloud;
contents?: ObservedArray<MusicShelf | ItemSection>;
did_you_mean: DidYouMean | null;
showing_results_for: ShowingResultsFor | null;
message: Message | null;
results?: ObservedArray<MusicResponsiveListItem>;
sections?: ObservedArray<MusicShelf>;
constructor(response: ApiResponse | ParsedResponse, actions: Actions, args: { is_continuation?: boolean, is_filtered?: boolean } = {}) {
constructor(response: ApiResponse, actions: Actions, is_filtered?: boolean) {
this.#actions = actions;
this.#page = Parser.parseResponse<ISearchResponse>(response.data);
this.#page = args.is_continuation ?
response as ParsedResponse :
Parser.parseResponse((response as ApiResponse).data);
if (!this.#page.contents || !this.#page.contents_memo)
throw new InnertubeError('Response did not contain any contents.');
const tab = this.#page.contents.item().as(TabbedSearchResults).tabs.get({ selected: true });
@@ -50,42 +43,33 @@ class Search {
if (!tab_content)
throw new InnertubeError('Target tab did not have any content.');
this.header = tab_content.hasKey('header') ? tab_content.header?.item().as(ChipCloud) : null;
this.header = tab_content.header?.item().as(ChipCloud);
this.contents = tab_content.contents.as(MusicShelf, ItemSection);
const shelves = tab_content.contents.as(MusicShelf, ItemSection);
const item_section = shelves.firstOfType(ItemSection);
this.did_you_mean = item_section?.contents?.firstOfType(DidYouMean) || null;
this.showing_results_for = item_section?.contents?.firstOfType(ShowingResultsFor) || null;
this.message = item_section?.contents?.firstOfType(Message) || null;
if (args.is_continuation || args.is_filtered) {
this.results = shelves.firstOfType(MusicShelf)?.contents;
this.#continuation = shelves.firstOfType(MusicShelf)?.continuation;
} else {
this.sections = shelves.filterType(MusicShelf);
if (is_filtered) {
this.#continuation = this.contents.firstOfType(MusicShelf)?.continuation;
}
}
/**
* Equivalent to clicking on the shelf to load more items.
* Loads more items for the given shelf.
*/
async getMore(shelf: MusicShelf | undefined): Promise<Search> {
if (!shelf || !shelf.endpoint)
throw new InnertubeError('Cannot retrieve more items for this shelf because it does not have an endpoint.');
const response = await shelf.endpoint.call(this.#actions, { parse: true, client: 'YTMUSIC' });
const response = await shelf.endpoint.call(this.#actions, { client: 'YTMUSIC' });
if (!response)
throw new InnertubeError('Endpoint did not return any data');
return new Search(response, this.#actions, { is_continuation: true });
return new Search(response, this.#actions, true);
}
/**
* Retrieves continuation, only works for individual sections or filtered results.
* Retrieves search continuation. Only available for filtered searches and shelf continuations.
*/
async getContinuation(): Promise<Search> {
async getContinuation(): Promise<SearchContinuation> {
if (!this.#continuation)
throw new InnertubeError('Continuation not found.');
@@ -94,18 +78,13 @@ class Search {
client: 'YTMUSIC'
});
const data = response.data.continuationContents.musicShelfContinuation;
this.results = Parser.parse(data.contents).array().as(MusicResponsiveListItem);
this.#continuation = data?.continuations?.[0]?.nextContinuationData?.continuation;
return this;
return new SearchContinuation(this.#actions, response);
}
/**
* Applies given filter to the search.
*/
async selectFilter(target_filter: string | ChipCloudChip): Promise<Search> {
async applyFilter(target_filter: string | ChipCloudChip): Promise<Search> {
let cloud_chip: ChipCloudChip | undefined;
if (typeof target_filter === 'string') {
@@ -114,51 +93,111 @@ class Search {
throw new InnertubeError('Could not find filter with given name.', { available_filters: this.filters });
} else if (target_filter?.is(ChipCloudChip)) {
cloud_chip = target_filter;
} else {
throw new InnertubeError('Invalid filter', { available_filters: this.filters });
}
if (!cloud_chip)
throw new InnertubeError('Invalid filter', { available_filters: this.filters });
if (cloud_chip?.is_selected) return this;
const response = await cloud_chip?.endpoint?.call(this.#actions, { parse: true, client: 'YTMUSIC' });
if (!cloud_chip.endpoint)
throw new InnertubeError('Selected filter does not have an endpoint.');
if (!response)
throw new InnertubeError('Endpoint did not return any data');
return new Search(response, this.#actions, { is_continuation: true });
}
get has_continuation(): boolean {
return !!this.#continuation;
const response = await cloud_chip.endpoint.call(this.#actions, { client: 'YTMUSIC' });
return new Search(response, this.#actions, true);
}
get filters(): string[] {
return this.header?.chips?.as(ChipCloudChip).map((chip) => chip.text) || [];
}
get has_continuation(): boolean {
return !!this.#continuation;
}
get did_you_mean(): DidYouMean | undefined {
return this.#page.contents_memo?.getType(DidYouMean).first();
}
get showing_results_for(): ShowingResultsFor | undefined {
return this.#page.contents_memo?.getType(ShowingResultsFor).first();
}
get message(): Message | undefined {
return this.#page.contents_memo?.getType(Message).first();
}
get songs(): MusicShelf | undefined {
return this.sections?.find((section) => section.title.toString() === 'Songs');
return this.contents?.filterType(MusicShelf).find((section) => section.title.toString() === 'Songs');
}
get videos(): MusicShelf | undefined {
return this.sections?.find((section) => section.title.toString() === 'Videos');
return this.contents?.filterType(MusicShelf).find((section) => section.title.toString() === 'Videos');
}
get albums(): MusicShelf | undefined {
return this.sections?.find((section) => section.title.toString() === 'Albums');
return this.contents?.filterType(MusicShelf).find((section) => section.title.toString() === 'Albums');
}
get artists(): MusicShelf | undefined {
return this.sections?.find((section) => section.title.toString() === 'Artists');
return this.contents?.filterType(MusicShelf).find((section) => section.title.toString() === 'Artists');
}
get playlists(): MusicShelf | undefined {
return this.sections?.find((section) => section.title.toString() === 'Community playlists');
return this.contents?.filterType(MusicShelf).find((section) => section.title.toString() === 'Community playlists');
}
get page(): ParsedResponse {
/**
* @deprecated Use {@link Search.contents} instead.
*/
get results(): ObservedArray<MusicResponsiveListItem> | undefined {
return this.contents?.firstOfType(MusicShelf)?.contents;
}
/**
* @deprecated Use {@link Search.contents} instead.
*/
get sections(): ObservedArray<MusicShelf> | undefined {
return this.contents?.filterType(MusicShelf);
}
get page(): ISearchResponse {
return this.#page;
}
}
export default Search;
export default Search;
export class SearchContinuation {
#actions: Actions;
#page: ISearchResponse;
header?: MusicHeader;
contents?: MusicShelfContinuation;
constructor(actions: Actions, response: ApiResponse) {
this.#actions = actions;
this.#page = Parser.parseResponse<ISearchResponse>(response.data);
this.header = this.#page.header?.item().as(MusicHeader);
this.contents = this.#page.continuation_contents?.as(MusicShelfContinuation);
}
async getContinuation(): Promise<SearchContinuation> {
if (!this.contents?.continuation)
throw new InnertubeError('Continuation not found.');
const response = await this.#actions.execute('/search', {
continuation: this.contents.continuation,
client: 'YTMUSIC'
});
return new SearchContinuation(this.#actions, response);
}
get has_continuation(): boolean {
return !!this.contents?.continuation;
}
get page(): ISearchResponse {
return this.#page;
}
}

View File

@@ -1,4 +1,4 @@
import Parser, { ParsedResponse } from '../index.js';
import Parser from '../index.js';
import type Actions from '../../core/Actions.js';
import type { ApiResponse } from '../../core/Actions.js';
@@ -19,36 +19,37 @@ import SectionList from '../classes/SectionList.js';
import Tab from '../classes/Tab.js';
import WatchNextTabbedResults from '../classes/WatchNextTabbedResults.js';
import type Format from '../classes/misc/Format.js';
import type NavigationEndpoint from '../classes/NavigationEndpoint.js';
import type PlayerLiveStoryboardSpec from '../classes/PlayerLiveStoryboardSpec.js';
import type PlayerStoryboardSpec from '../classes/PlayerStoryboardSpec.js';
import type Format from '../classes/misc/Format.js';
import type { ObservedArray, YTNode } from '../helpers.js';
import FormatUtils, { URLTransformer, FormatOptions, DownloadOptions, FormatFilter } from '../../utils/FormatUtils.js';
import type { INextResponse, IPlayerResponse } from '../types/ParsedResponse.js';
import FormatUtils, { DownloadOptions, FormatFilter, FormatOptions, URLTransformer } from '../../utils/FormatUtils.js';
class TrackInfo {
#page: [ ParsedResponse, ParsedResponse? ];
#page: [ IPlayerResponse, INextResponse? ];
#actions: Actions;
#cpn: string;
basic_info;
streaming_data;
playability_status;
storyboards: PlayerStoryboardSpec | PlayerLiveStoryboardSpec | null;
endscreen: Endscreen | null;
storyboards?: PlayerStoryboardSpec | PlayerLiveStoryboardSpec;
endscreen?: Endscreen;
#playback_tracking;
tabs?: ObservedArray<Tab>;
current_video_endpoint?: NavigationEndpoint | null;
current_video_endpoint?: NavigationEndpoint;
player_overlays?: PlayerOverlay;
constructor(data: [ApiResponse, ApiResponse?], actions: Actions, cpn: string) {
this.#actions = actions;
const info = Parser.parseResponse(data[0].data);
const next = data?.[1]?.data ? Parser.parseResponse(data[1].data) : undefined;
const info = Parser.parseResponse<IPlayerResponse>(data[0].data);
const next = data?.[1]?.data ? Parser.parseResponse<INextResponse>(data[1].data) : undefined;
this.#page = [ info, next ];
this.#cpn = cpn;
@@ -78,13 +79,13 @@ class TrackInfo {
this.#playback_tracking = info.playback_tracking;
if (next) {
const tabbed_results = next.contents_memo.getType(WatchNextTabbedResults)?.[0];
const tabbed_results = next.contents_memo?.getType(WatchNextTabbedResults)?.[0];
this.tabs = tabbed_results.tabs.array().as(Tab);
this.tabs = tabbed_results?.tabs.array().as(Tab);
this.current_video_endpoint = next.current_video_endpoint;
// TODO: update PlayerOverlay, YTMusic's is a little bit different.
this.player_overlays = next.player_overlays.item().as(PlayerOverlay);
this.player_overlays = next.player_overlays?.item().as(PlayerOverlay);
}
}
@@ -134,9 +135,12 @@ class TrackInfo {
const page = await target_tab.endpoint.call(this.#actions, { client: 'YTMUSIC', parse: true });
if (page.contents.item().key('type').string() === 'Message')
if (page.contents?.item().key('type').string() === 'Message')
return page.contents.item().as(Message);
if (!page.contents)
throw new InnertubeError('Page contents was empty', page);
return page.contents.item().as(SectionList).contents;
}
@@ -163,7 +167,7 @@ class TrackInfo {
parse: true
});
if (!page)
if (!page || !page.contents_memo)
throw new InnertubeError('Could not fetch automix');
return page.contents_memo.getType(PlaylistPanel)?.[0];
@@ -216,7 +220,7 @@ class TrackInfo {
return this.tabs ? this.tabs.map((tab) => tab.title) : [];
}
get page(): [ParsedResponse, ParsedResponse?] {
get page(): [IPlayerResponse, INextResponse?] {
return this.#page;
}
}

View File

@@ -129,10 +129,11 @@ export function timeToSeconds(time: string): number {
}
}
export function concatMemos(...iterables: Memo[]): Memo {
export function concatMemos(...iterables: Array<Memo | undefined>): Memo {
const memo = new Memo();
for (const iterable of iterables) {
if (!iterable) continue;
for (const item of iterable) {
memo.set(...item);
}