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

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();
}
}