From eb72c2f6ef2fe3ff09c926965d970c141ff9ec5f Mon Sep 17 00:00:00 2001 From: LuanRT Date: Sun, 12 Feb 2023 07:04:17 -0300 Subject: [PATCH] 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 --- .gitignore | 3 + README.md | 12 +- docs/API/interaction-manager.md | 16 +- docs/API/playlist.md | 4 +- docs/API/studio.md | 6 +- examples/channel/basic-info.ts | 2 +- examples/comments/index.ts | 2 +- examples/download/index.ts | 4 +- examples/parser/index.ts | 4 +- examples/upload/index.ts | 2 +- src/Innertube.ts | 37 +-- src/core/AccountManager.ts | 6 +- src/core/Actions.ts | 57 +++- src/core/Feed.ts | 41 ++- src/core/FilterableFeed.ts | 13 +- src/core/Kids.ts | 6 +- src/core/Music.ts | 25 +- src/core/Player.ts | 2 - src/core/PlaylistManager.ts | 2 +- src/core/TabbedFeed.ts | 28 +- src/parser/README.md | 15 +- src/parser/classes/C4TabbedHeader.ts | 69 +++-- src/parser/classes/MusicShelf.ts | 32 +- src/parser/classes/NavigationEndpoint.ts | 13 +- src/parser/classes/SectionList.ts | 4 +- src/parser/classes/comments/CommentThread.ts | 12 +- src/parser/helpers.ts | 2 +- src/parser/index.ts | 3 +- src/parser/parser.ts | 310 +++++++++++++------ src/parser/types/ParsedResponse.ts | 159 ++++++++++ src/parser/types/RawResponse.ts | 55 ++++ src/parser/types/index.ts | 2 + src/parser/youtube/AccountInfo.ts | 12 +- src/parser/youtube/Analytics.ts | 13 +- src/parser/youtube/Channel.ts | 46 ++- src/parser/youtube/Comments.ts | 9 +- src/parser/youtube/History.ts | 14 +- src/parser/youtube/HomeFeed.ts | 14 +- src/parser/youtube/ItemMenu.ts | 14 +- src/parser/youtube/Library.ts | 36 +-- src/parser/youtube/LiveChat.ts | 7 +- src/parser/youtube/NotificationsMenu.ts | 15 +- src/parser/youtube/Playlist.ts | 28 +- src/parser/youtube/Search.ts | 33 +- src/parser/youtube/Settings.ts | 25 +- src/parser/youtube/TimeWatched.ts | 10 +- src/parser/youtube/VideoInfo.ts | 43 ++- src/parser/ytkids/Channel.ts | 10 +- src/parser/ytkids/HomeFeed.ts | 11 +- src/parser/ytkids/Search.ts | 8 +- src/parser/ytkids/VideoInfo.ts | 21 +- src/parser/ytmusic/Album.ts | 21 +- src/parser/ytmusic/Artist.ts | 19 +- src/parser/ytmusic/Explore.ts | 13 +- src/parser/ytmusic/HomeFeed.ts | 24 +- src/parser/ytmusic/Library.ts | 44 +-- src/parser/ytmusic/Playlist.ts | 23 +- src/parser/ytmusic/Recap.ts | 12 +- src/parser/ytmusic/Search.ts | 175 +++++++---- src/parser/ytmusic/TrackInfo.ts | 36 ++- src/utils/Utils.ts | 3 +- 61 files changed, 1116 insertions(+), 571 deletions(-) create mode 100644 src/parser/types/ParsedResponse.ts create mode 100644 src/parser/types/RawResponse.ts create mode 100644 src/parser/types/index.ts diff --git a/.gitignore b/.gitignore index f73e7805..987be5c2 100644 --- a/.gitignore +++ b/.gitignore @@ -70,5 +70,8 @@ bundle/*.cjs bundle/*.cjs.* deno/ +# VSCode files +.vscode/ + # MacOS .DS_Store diff --git a/README.md b/README.md index 9481bbef..e87e76c1 100644 --- a/README.md +++ b/README.md @@ -451,13 +451,13 @@ Retrieves watch history. ### getTrending() Retrieves trending content. -**Returns**: `Promise.` +**Returns**: `Promise.>` ### getSubscriptionsFeed() Retrieves subscriptions feed. -**Returns**: `Promise.` +**Returns**: `Promise.>` ### getChannel(id) @@ -584,7 +584,7 @@ Resolves a given url. ### call(endpoint, args?) Utility to call navigation endpoints. -**Returns**: `Promise.` +**Returns**: `Promise.` | 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); ``` diff --git a/docs/API/interaction-manager.md b/docs/API/interaction-manager.md index 2ad499df..43a2db33 100644 --- a/docs/API/interaction-manager.md +++ b/docs/API/interaction-manager.md @@ -19,7 +19,7 @@ Handles direct interactions. Likes given video. -**Returns:** `Promise.` +**Returns:** `Promise.` | Param | Type | Description | | --- | --- | --- | @@ -30,7 +30,7 @@ Likes given video. Dislikes given video. -**Returns:** `Promise.` +**Returns:** `Promise.` | Param | Type | Description | | --- | --- | --- | @@ -41,7 +41,7 @@ Dislikes given video. Remover like/dislike. -**Returns:** `Promise.` +**Returns:** `Promise.` | Param | Type | Description | | --- | --- | --- | @@ -52,7 +52,7 @@ Remover like/dislike. Subscribes to given channel. -**Returns:** `Promise.` +**Returns:** `Promise.` | Param | Type | Description | | --- | --- | --- | @@ -63,7 +63,7 @@ Subscribes to given channel. Unsubscribes from given channel. -**Returns:** `Promise.` +**Returns:** `Promise.` | Param | Type | Description | | --- | --- | --- | @@ -74,7 +74,7 @@ Unsubscribes from given channel. Posts a comment on given video. -**Returns:** `Promise.` +**Returns:** `Promise.` | Param | Type | Description | | --- | --- | --- | @@ -86,7 +86,7 @@ Posts a comment on given video. Translates given text using YouTube's comment translation feature. -**Returns:** `Promise.` +**Returns:** `Promise.` | 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.` +**Returns:** `Promise.` | Param | Type | Description | | --- | --- | --- | diff --git a/docs/API/playlist.md b/docs/API/playlist.md index 8db400a9..3f099df3 100644 --- a/docs/API/playlist.md +++ b/docs/API/playlist.md @@ -16,7 +16,7 @@ Playlist management class. Creates a playlist. -**Returns:** `Promise.` +**Returns:** `Promise.` | Param | Type | Description | | --- | --- | --- | @@ -28,7 +28,7 @@ Creates a playlist. Deletes given playlist. -**Returns:** `Promise.` +**Returns:** `Promise.` | Param | Type | Description | | --- | --- | --- | diff --git a/docs/API/studio.md b/docs/API/studio.md index 3257d9de..859fa865 100644 --- a/docs/API/studio.md +++ b/docs/API/studio.md @@ -14,7 +14,7 @@ YouTube Studio class (WIP). Uploads a custom thumbnail and sets it for a video. -**Returns:** `Promise.` +**Returns:** `Promise.` | Param | Type | Description | | --- | --- | --- | @@ -26,7 +26,7 @@ Uploads a custom thumbnail and sets it for a video. Updates given video's metadata. -**Returns:** `Promise.` +**Returns:** `Promise.` | Param | Type | Description | | --- | --- | --- | @@ -38,7 +38,7 @@ Updates given video's metadata. Uploads a video to YouTube. -**Returns:** `Promise.` +**Returns:** `Promise.` | Param | Type | Description | | --- | --- | --- | diff --git a/examples/channel/basic-info.ts b/examples/channel/basic-info.ts index 8f6ae760..1ff87dc9 100644 --- a/examples/channel/basic-info.ts +++ b/examples/channel/basic-info.ts @@ -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'); diff --git a/examples/comments/index.ts b/examples/comments/index.ts index 1c07a901..171ef8bd 100644 --- a/examples/comments/index.ts +++ b/examples/comments/index.ts @@ -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'); diff --git a/examples/download/index.ts b/examples/download/index.ts index 84229ba3..76e73ce5 100644 --- a/examples/download/index.ts +++ b/examples/download/index.ts @@ -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 }); diff --git a/examples/parser/index.ts b/examples/parser/index.ts index 50ceaf93..e8f65b9a 100644 --- a/examples/parser/index.ts +++ b/examples/parser/index.ts @@ -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); \ No newline at end of file diff --git a/examples/upload/index.ts b/examples/upload/index.ts index fbbeea64..86c7b173 100644 --- a/examples/upload/index.ts +++ b/examples/upload/index.ts @@ -31,5 +31,5 @@ const creds = existsSync(creds_path) ? JSON.parse(readFileSync(creds_path).toStr privacy: 'UNLISTED' }); - console.info('Done!'); + console.info('Done!', upload); })(); diff --git a/src/Innertube.ts b/src/Innertube.ts index 661ea6bd..e49b641d 100644 --- a/src/Innertube.ts +++ b/src/Innertube.ts @@ -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 { 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 { 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 { 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 { - const response = await this.actions.execute('/browse', { browseId: 'FEtrending' }); - return new TabbedFeed(this.actions, response.data); + async getTrending(): Promise> { + const response = await this.actions.execute('/browse', { browseId: 'FEtrending', parse: true }); + return new TabbedFeed(this.actions, response); } /** * Retrieves subscriptions feed. */ - async getSubscriptionsFeed(): Promise { - const response = await this.actions.execute('/browse', { browseId: 'FEsubscriptions' }); - return new Feed(this.actions, response.data); + async getSubscriptionsFeed(): Promise> { + 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 { 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 { 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; - call(endpoint: NavigationEndpoint, args?: { [key: string]: any; parse?: false }): Promise; - call(endpoint: NavigationEndpoint, args?: object): Promise { + call(endpoint: NavigationEndpoint, args: { [key: string]: any; parse: true }): Promise; + call(endpoint: NavigationEndpoint, args?: { [key: string]: any; parse?: false }): Promise; + call(endpoint: NavigationEndpoint, args?: object): Promise { return endpoint.call(this.actions, args); } } diff --git a/src/core/AccountManager.ts b/src/core/AccountManager.ts index 716c7872..0e35c0dd 100644 --- a/src/core/AccountManager.ts +++ b/src/core/AccountManager.ts @@ -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; - editDescription: (new_description: string) => Promise; + editName: (new_name: string) => Promise; + editDescription: (new_description: string) => Promise; getBasicAnalytics: () => Promise; }; diff --git a/src/core/Actions.ts b/src/core/Actions.ts index f883a38d..ed585a89 100644 --- a/src/core/Actions.ts +++ b/src/core/Actions.ts @@ -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; +export type InnertubeEndpoint = '/player' | '/search' | '/browse' | '/next' | '/updated_metadata' | '/notification/get_notification_menu' | string; + +export type ParsedResponse = + 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 { + async getVideoInfo(id: string, cpn?: string, client?: string, playlist_id?: string): Promise { const data: Record = { 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; - async execute(action: string, args?: { [key: string]: any; parse?: false; protobuf?: true; serialized_data?: any }) : Promise; - async execute(action: string, args?: { [key: string]: any; parse?: boolean; protobuf?: boolean; serialized_data?: any }): Promise { + async execute(endpoint: T, args: { [key: string]: any; parse: true; protobuf?: false; serialized_data?: any }): Promise>; + async execute(endpoint: T, args?: { [key: string]: any; parse?: false; protobuf?: true; serialized_data?: any }): Promise; + async execute(endpoint: T, args?: { [key: string]: any; parse?: boolean; protobuf?: boolean; serialized_data?: any }): Promise | 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>(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', diff --git a/src/core/Feed.ts b/src/core/Feed.ts index bb18e017..4ad4bab6 100644 --- a/src/core/Feed.ts +++ b/src/core/Feed.ts @@ -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 { + #page: T; #continuation?: ObservedArray; #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(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 | 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 { + async getContinuationData(): Promise { 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(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 { + async getContinuation(): Promise> { 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(this.actions, continuation_data, true); } } diff --git a/src/core/FilterableFeed.ts b/src/core/FilterableFeed.ts index 36c62946..9850bd38 100644 --- a/src/core/FilterableFeed.ts +++ b/src/core/FilterableFeed.ts @@ -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 extends Feed { #chips?: ObservedArray; - 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 { + async getFilteredFeed(filter: string | ChipCloudChip): Promise> { 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); } } diff --git a/src/core/Kids.ts b/src/core/Kids.ts index 2de148c4..956d4655 100644 --- a/src/core/Kids.ts +++ b/src/core/Kids.ts @@ -19,7 +19,7 @@ class Kids { */ async search(query: string): Promise { 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 { 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 { 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); } } diff --git a/src/core/Music.ts b/src/core/Music.ts index 47ce6fbf..f43f7f34 100644 --- a/src/core/Music.ts +++ b/src/core/Music.ts @@ -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[]); diff --git a/src/core/Player.ts b/src/core/Player.ts index 6ee6ac12..dee72e0d 100644 --- a/src/core/Player.ts +++ b/src/core/Player.ts @@ -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') diff --git a/src/core/PlaylistManager.ts b/src/core/PlaylistManager.ts index 63a83f3c..3d81ce55 100644 --- a/src/core/PlaylistManager.ts +++ b/src/core/PlaylistManager.ts @@ -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) diff --git a/src/core/TabbedFeed.ts b/src/core/TabbedFeed.ts index 1167850f..4fe129c5 100644 --- a/src/core/TabbedFeed.ts +++ b/src/core/TabbedFeed.ts @@ -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; +class TabbedFeed extends Feed { + #tabs?: ObservedArray; #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 { - const tab = this.#tabs.find((tab) => tab.title.toLowerCase() === title.toLowerCase()); + async getTabByName(title: string): Promise> { + 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(this.#actions, response, false); } - async getTabByURL(url: string): Promise { - const tab = this.#tabs.find((tab) => tab.endpoint.metadata.url?.split('/').pop() === url); + async getTabByURL(url: string): Promise> { + 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(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(); } } diff --git a/src/parser/README.md b/src/parser/README.md index c1586ca7..11f139d2 100644 --- a/src/parser/README.md +++ b/src/parser/README.md @@ -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
  1. diff --git a/src/parser/classes/C4TabbedHeader.ts b/src/parser/classes/C4TabbedHeader.ts index b5ea265c..18b5fad5 100644 --- a/src/parser/classes/C4TabbedHeader.ts +++ b/src/parser/classes/C4TabbedHeader.ts @@ -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