Compare commits

..

6 Commits

Author SHA1 Message Date
LuanRT
ea1d206b26 2.3.3 2022-11-06 03:38:47 -03:00
LuanRT
aa334aacbd refactor: clean up, fix & remove outdated code (#228)
* dev: refactor and remove redundant code

* docs(music): update `Library` API ref

* docs: update examples

* chore: update lock file
2022-11-06 03:32:16 -03:00
LuanRT
1eda93ee08 fix(session): visitorData and originalUrl 2022-10-21 14:42:34 -03:00
LuanRT
fe0ac0a961 chore(studio): fix a small typo 2022-10-19 17:11:50 -03:00
Daniel Wykerd
8740deb1f2 feat: custom parser error handler (#222)
As suggested in issue #218
2022-10-18 18:44:22 -03:00
mdashlw
d71b762df5 fix: don't remove "VL" from playlist id (#223) 2022-10-18 18:42:55 -03:00
52 changed files with 2022 additions and 2411 deletions

View File

@@ -600,7 +600,7 @@ const button = albums.as(MusicCarouselShelf).header?.more_content;
if (button) {
// To do that, we can call its navigation endpoint:
const page = await button.endpoint.call(yt.actions, 'YTMUSIC', true);
const page = await button.endpoint.call(yt.actions, { parse: true, client: 'YTMUSIC' });
console.info(page);
}
```

View File

@@ -155,20 +155,26 @@ Retrieves library.
<summary>Methods & Getters</summary>
<p>
- `<library>#getPlaylists(args?)`
- Retrieves the library's playlists.
- `<library>#applyFilter(filter)`
- Applies given filter to the library.
- `<library>#getAlbums(args?)`
- Retrieves the library's albums.
- `<library>#applySortFilter(filter)`
- Applies given sort filter to the library items.
- `<library>#getArtists(args?)`
- Retrieves the library's artists.
- `<library>#getContinuation()`
- Retrieves continuation of the library items.
- `<library>#getSongs(args?)`
- Retrieves the library's songs.
- `<library>#has_continuation`
- Checks if continuation is available.
- `<library>#getRecentActivity(args)`
- Retrieves recent activity.
- `<library>#filters`
- Returns available filters.
- `<library>#sort_filters`
- Returns available sort filters.
- `<library>#page`
- Returns original InnerTube response (sanitized).
</p>
</details>

2150
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "youtubei.js",
"version": "2.3.2",
"version": "2.3.3",
"description": "Full-featured wrapper around YouTube's private API.",
"main": "./dist/index.js",
"browser": "./bundle/browser.js",

View File

@@ -27,25 +27,13 @@ import Proto from './proto/index';
import { throwIfMissing, generateRandomString } from './utils/Utils';
export type InnertubeConfig = SessionOptions
export type InnertubeConfig = SessionOptions;
export interface SearchFilters {
/**
* Filter videos by upload date, can be: any | last_hour | today | this_week | this_month | this_year
*/
upload_date?: 'any' | 'last_hour' | 'today' | 'this_week' | 'this_month' | 'this_year';
/**
* Filter results by type, can be: any | video | channel | playlist | movie
*/
type?: 'any' | 'video' | 'channel' | 'playlist' | 'movie';
/**
* Filter videos by duration, can be: any | short | medium | long
*/
duration?: 'any' | 'short' | 'medium' | 'long';
/**
* Filter video results by order, can be: relevance | rating | upload_date | view_count
*/
sort_by?: 'relevance' | 'rating' | 'upload_date' | 'view_count';
upload_date?: 'all' | 'hour' | 'today' | 'week' | 'month' | 'year',
type?: 'all' | 'video' | 'channel' | 'playlist' | 'movie',
duration?: 'all' | 'short' | 'medium' | 'long',
sort_by?: 'relevance' | 'rating' | 'upload_date' | 'view_count'
}
export type InnerTubeClient = 'WEB' | 'ANDROID' | 'YTMUSIC_ANDROID' | 'YTMUSIC' | 'TV_EMBEDDED';
@@ -75,12 +63,14 @@ class Innertube {
/**
* Retrieves video info.
* @param video_id - The video id.
* @param client - The client to use.
*/
async getInfo(video_id: string, client?: InnerTubeClient) {
const cpn = generateRandomString(16);
const initial_info = await this.actions.getVideoInfo(video_id, cpn, client);
const continuation = this.actions.next({ video_id });
const continuation = this.actions.execute('/next', { videoId: video_id });
const response = await Promise.all([ initial_info, continuation ]);
return new VideoInfo(response, this.actions, this.session.player, cpn);
@@ -88,6 +78,8 @@ class Innertube {
/**
* Retrieves basic video info.
* @param video_id - The video id.
* @param client - The client to use.
*/
async getBasicInfo(video_id: string, client?: InnerTubeClient) {
const cpn = generateRandomString(16);
@@ -98,18 +90,27 @@ class Innertube {
/**
* Searches a given query.
* @param query - search query.
* @param filters - search filters.
* @param query - The search query.
* @param filters - Search filters.
*/
async search(query: string, filters: SearchFilters = {}) {
throwIfMissing({ query });
const response = await this.actions.search({ query, filters });
const args = {
query,
...{
params: filters ? Proto.encodeSearchFilters(filters) : undefined
}
};
const response = await this.actions.execute('/search', args);
return new Search(this.actions, response.data);
}
/**
* Retrieves search suggestions for a given query.
* @param query - the search query.
* @param query - The search query.
*/
async getSearchSuggestions(query: string): Promise<string[]> {
throwIfMissing({ query });
@@ -134,8 +135,8 @@ class Innertube {
/**
* Retrieves comments for a video.
* @param video_id - the video id.
* @param sort_by - can be: `TOP_COMMENTS` or `NEWEST_FIRST`.
* @param video_id - The video id.
* @param sort_by - Sorting options.
*/
async getComments(video_id: string, sort_by?: 'TOP_COMMENTS' | 'NEWEST_FIRST') {
throwIfMissing({ video_id });
@@ -144,7 +145,8 @@ class Innertube {
sort_by: sort_by || 'TOP_COMMENTS'
});
const response = await this.actions.next({ ctoken: payload });
const response = await this.actions.execute('/next', { continuation: payload });
return new Comments(this.actions, response.data);
}
@@ -152,7 +154,7 @@ class Innertube {
* Retrieves YouTube's home feed (aka recommendations).
*/
async getHomeFeed() {
const response = await this.actions.browse('FEwhat_to_watch');
const response = await this.actions.execute('/browse', { browseId: 'FEwhat_to_watch' });
return new FilterableFeed(this.actions, response.data);
}
@@ -160,7 +162,7 @@ class Innertube {
* Returns the account's library.
*/
async getLibrary() {
const response = await this.actions.browse('FElibrary');
const response = await this.actions.execute('/browse', { browseId: 'FElibrary' });
return new Library(response.data, this.actions);
}
@@ -169,7 +171,7 @@ class Innertube {
* Which can also be achieved with {@link getLibrary}.
*/
async getHistory() {
const response = await this.actions.browse('FEhistory');
const response = await this.actions.execute('/browse', { browseId: 'FEhistory' });
return new History(this.actions, response.data);
}
@@ -177,7 +179,7 @@ class Innertube {
* Retrieves trending content.
*/
async getTrending() {
const response = await this.actions.browse('FEtrending');
const response = await this.actions.execute('/browse', { browseId: 'FEtrending' });
return new TabbedFeed(this.actions, response.data);
}
@@ -185,7 +187,7 @@ class Innertube {
* Retrieves subscriptions feed.
*/
async getSubscriptionsFeed() {
const response = await this.actions.browse('FEsubscriptions');
const response = await this.actions.execute('/browse', { browseId: 'FEsubscriptions' });
return new Feed(this.actions, response.data);
}
@@ -195,7 +197,7 @@ class Innertube {
*/
async getChannel(id: string) {
throwIfMissing({ id });
const response = await this.actions.browse(id);
const response = await this.actions.execute('/browse', { browseId: id });
return new Channel(this.actions, response.data);
}
@@ -203,7 +205,7 @@ class Innertube {
* Retrieves notifications.
*/
async getNotifications() {
const response = await this.actions.notifications('get_notification_menu');
const response = await this.actions.execute('/notification/get_notification_menu', { notificationsMenuRequestType: 'NOTIFICATIONS_MENU_REQUEST_TYPE_INBOX' });
return new NotificationsMenu(this.actions, response);
}
@@ -211,7 +213,7 @@ class Innertube {
* Retrieves unseen notifications count.
*/
async getUnseenNotificationsCount() {
const response = await this.actions.notifications('get_unseen_count');
const response = await this.actions.execute('/notification/get_unseen_count');
return response.data.unseenCount;
}
@@ -220,7 +222,12 @@ class Innertube {
*/
async getPlaylist(id: string) {
throwIfMissing({ id });
const response = await this.actions.browse(`VL${id.replace(/VL/g, '')}`);
if (!id.startsWith('VL')) {
id = `VL${id}`;
}
const response = await this.actions.execute('/browse', { browseId: id });
return new Playlist(this.actions, response.data);
}
@@ -245,11 +252,16 @@ class Innertube {
return info.download(options);
}
/**
* Utility method to call an endpoint without having to use {@link Actions}.
* @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> {
return endpoint.callTest(this.actions, args);
return endpoint.call(this.actions, args);
}
}
export default Innertube;
export default Innertube;

View File

@@ -5,6 +5,7 @@ import Analytics from '../parser/youtube/Analytics';
import TimeWatched from '../parser/youtube/TimeWatched';
import AccountInfo from '../parser/youtube/AccountInfo';
import Settings from '../parser/youtube/Settings';
import { InnertubeError } from '../utils/Utils';
class AccountManager {
#actions;
@@ -16,13 +17,30 @@ class AccountManager {
this.channel = {
/**
* Edits channel name.
* @param new_name - The new channel name.
*/
editName: (new_name: string) => this.#actions.channel('channel/edit_name', { new_name }),
editName: (new_name: string) => {
if (!this.#actions.session.logged_in)
throw new InnertubeError('You are not signed in');
return this.#actions.execute('/channel/edit_name', {
givenName: new_name,
client: 'ANDROID'
});
},
/**
* Edits channel description.
*
* @param new_description - The new description.
*/
editDescription: (new_description: string) => this.#actions.channel('channel/edit_description', { new_description }),
editDescription: (new_description: string) => {
if (!this.#actions.session.logged_in)
throw new InnertubeError('You are not signed in');
return this.#actions.execute('/channel/edit_description', {
givenDescription: new_description,
client: 'ANDROID'
});
},
/**
* Retrieves basic channel analytics.
*/
@@ -34,6 +52,9 @@ class AccountManager {
* Retrieves channel info.
*/
async getInfo() {
if (!this.#actions.session.logged_in)
throw new InnertubeError('You are not signed in');
const response = await this.#actions.execute('/account/accounts_list', { client: 'ANDROID' });
return new AccountInfo(response);
}
@@ -68,7 +89,12 @@ class AccountManager {
const info = await this.getInfo();
const params = Proto.encodeChannelAnalyticsParams(info.footers?.endpoint.payload.browseId);
const response = await this.#actions.browse('FEanalytics_screen', { params, client: 'ANDROID' });
const response = await this.#actions.execute('/browse', {
browseId: 'FEanalytics_screen',
client: 'ANDROID',
params
});
return new Analytics(response);
}

View File

@@ -1,54 +1,14 @@
import Proto from '../proto/index';
import Session from './Session';
import Parser, { ParsedResponse } from '../parser/index';
import { InnertubeError } from '../utils/Utils';
import { hasKeys, InnertubeError, MissingParamError, uuidv4 } from '../utils/Utils';
export interface BrowseArgs {
params?: string | null;
is_ytm?: boolean;
is_ctoken?: boolean;
form_data?: {};
client?: string;
}
export interface EngageArgs {
video_id?: string;
channel_id?: string;
comment_id?: string;
comment_action?: string;
params?: string;
text?: string;
target_language?: string;
}
export interface AccountArgs {
new_value?: string | boolean; // TODO: is this correct?
setting_item_id?: string;
client?: string;
}
export interface SearchArgs {
query?: string,
options?: {
period?: string,
duration?: string,
order?: string
},
client?: string,
ctoken?: string,
params?: string
filters?: any // TODO: what is this type??
}
export interface AxioslikeResponse {
export interface ApiResponse {
success: boolean;
status_code: number;
data: any;
}
export type ActionsResponse = Promise<AxioslikeResponse>;
export type ActionsResponse = Promise<ApiResponse>;
class Actions {
#session;
@@ -63,6 +23,7 @@ class Actions {
/**
* Mimmics the Axios API using Fetch's Response object.
* @param response - The response object.
*/
async #wrap(response: Response) {
return {
@@ -72,550 +33,12 @@ class Actions {
};
}
/**
* Covers `/browse` endpoint, mostly used to access
* YouTube's sections such as the home feed, etc
* and sometimes to retrieve continuations.
*
* @param id - browseId or a continuation token
* @param args - additional arguments
*/
async browse(id: string, args: BrowseArgs = {}) {
if (this.#needsLogin(id) && !this.#session.logged_in)
throw new InnertubeError('You are not signed in');
const data: Record<string, any> = {};
if (args.params)
data.params = args.params;
if (args.is_ctoken) {
data.continuation = id;
} else {
data.browseId = id;
}
if (args.form_data) {
data.formData = args.form_data;
}
if (args.client) {
data.client = args.client;
}
const response = await this.#session.http.fetch('/browse', {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
}
});
return this.#wrap(response);
}
/**
* Covers endpoints used to perform direct interactions
* on YouTube.
*/
async engage(action: string, args: EngageArgs = {}) {
if (!this.#session.logged_in && !args.hasOwnProperty('text'))
throw new InnertubeError('You are not signed in');
const data: Record<string, any> = {};
switch (action) {
case 'like/like':
case 'like/dislike':
case 'like/removelike':
if (!hasKeys(args, 'video_id'))
throw new MissingParamError('Arguments lacks video_id');
data.target = {};
data.target.videoId = args.video_id;
if (args.params) {
data.params = args.params;
}
break;
case 'subscription/subscribe':
case 'subscription/unsubscribe':
if (!hasKeys(args, 'channel_id'))
throw new MissingParamError('Arguments lacks channel_id');
data.channelIds = [ args.channel_id ];
data.params = action === 'subscription/subscribe' ? 'EgIIAhgA' : 'CgIIAhgA';
break;
case 'comment/create_comment':
data.commentText = args.text;
if (!hasKeys(args, 'video_id'))
throw new MissingParamError('Arguments lacks video_id');
data.createCommentParams = Proto.encodeCommentParams(args.video_id);
break;
case 'comment/create_comment_reply':
if (!hasKeys(args, 'comment_id', 'video_id', 'text'))
throw new MissingParamError('Arguments lacks comment_id, video_id or text');
data.createReplyParams = Proto.encodeCommentReplyParams(args.comment_id, args.video_id);
data.commentText = args.text;
break;
case 'comment/perform_comment_action':
const target_action = (() => {
switch (args.comment_action) {
case 'like':
return Proto.encodeCommentActionParams(5, args);
case 'dislike':
return Proto.encodeCommentActionParams(4, args);
case 'translate':
return Proto.encodeCommentActionParams(22, args);
default:
break;
}
})();
data.actions = [ target_action ];
break;
default:
throw new InnertubeError('Action not implemented', action);
}
const response = await this.#session.http.fetch(`/${action}`, {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
}
});
return this.#wrap(response);
}
/**
* Covers endpoints related to account management.
*/
async account(action: string, args: AccountArgs = {}) {
if (!this.#session.logged_in)
throw new InnertubeError('You are not signed in');
const data: Record<string, any> = { client: args.client };
switch (action) {
case 'account/set_setting':
data.newValue = {
boolValue: args.new_value
};
data.settingItemId = args.setting_item_id;
break;
case 'account/accounts_list':
break;
default:
throw new InnertubeError('Action not implemented', action);
}
const response = await this.#session.http.fetch(`/${action}`, {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
}
});
return this.#wrap(response);
}
/**
* Endpoint used for search.
*/
async search(args: SearchArgs = {}) {
const data: Record<string, any> = { client: args.client };
if (args.query) {
data.query = args.query;
}
if (args.ctoken) {
data.continuation = args.ctoken;
}
if (args.params) {
data.params = args.params;
}
if (args.filters) {
if (args.client == 'YTMUSIC' && args.filters?.type && args.filters.type !== 'all') {
data.params = Proto.encodeMusicSearchFilters(args.filters);
} else {
data.params = Proto.encodeSearchFilters(args.filters);
}
}
const response = await this.#session.http.fetch('/search', {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
}
});
return this.#wrap(response);
}
/**
* Endpoint used fo Shorts' sound search.
*/
async searchSound(args: { query: string; }) {
const data = {
query: args.query,
client: 'ANDROID'
};
const response = await this.#session.http.fetch('/sfv/search', {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
}
});
return this.#wrap(response);
}
/**
* Channel management endpoints.
*/
async channel(action: string, args: { new_name?: string; new_description?: string; client?: string; } = {}) {
if (!this.#session.logged_in)
throw new InnertubeError('You are not signed in');
const data: Record<string, any> = { client: args.client || 'ANDROID' };
switch (action) {
case 'channel/edit_name':
data.givenName = args.new_name;
break;
case 'channel/edit_description':
data.description = args.new_description;
break;
case 'channel/get_profile_editor':
break;
default:
throw new InnertubeError('Action not implemented', action);
}
const response = await this.#session.http.fetch(`/${action}`, {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
}
});
return this.#wrap(response);
}
/**
* Covers endpoints used for playlist management.
*/
async playlist(action: string, args: {
title?: string;
ids?: string[];
playlist_id?: string;
action?: string;
} = {}) {
if (!this.#session.logged_in)
throw new InnertubeError('You are not signed in');
const data: Record<string, any> = {};
switch (action) {
case 'playlist/create':
data.title = args.title;
data.videoIds = args.ids;
break;
case 'playlist/delete':
data.playlistId = args.playlist_id;
break;
case 'browse/edit_playlist':
if (!hasKeys(args, 'ids'))
throw new MissingParamError('Arguments lacks ids');
data.playlistId = args.playlist_id;
data.actions = args.ids.map((id) => {
switch (args.action) {
case 'ACTION_ADD_VIDEO':
return {
action: args.action,
addedVideoId: id
};
case 'ACTION_REMOVE_VIDEO':
return {
action: args.action,
setVideoId: id
};
default:
break;
}
});
break;
default:
throw new InnertubeError('Action not implemented', action);
}
const response = await this.#session.http.fetch(`/${action}`, {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
}
});
return this.#wrap(response);
}
/**
* Covers endpoints used for notifications management.
*/
async notifications(action: string, args: {
pref?: string;
channel_id?: string;
ctoken?: string;
params?: string
} = {}) {
if (!this.#session.logged_in)
throw new InnertubeError('You are not signed in');
const data: Record<string, any> = {};
switch (action) {
case 'modify_channel_preference':
if (!hasKeys(args, 'channel_id', 'pref'))
throw new MissingParamError('Arguments lacks channel_id or pref');
const pref_types = {
PERSONALIZED: 1,
ALL: 2,
NONE: 3
};
if (!Object.keys(pref_types).includes(args.pref.toUpperCase()))
throw new InnertubeError('Invalid preference type', args.pref);
data.params = Proto.encodeNotificationPref(args.channel_id, pref_types[args.pref.toUpperCase() as keyof typeof pref_types]);
break;
case 'get_notification_menu':
data.notificationsMenuRequestType = 'NOTIFICATIONS_MENU_REQUEST_TYPE_INBOX';
if (args.ctoken)
data.ctoken = args.ctoken;
break;
case 'record_interactions':
data.serializedRecordNotificationInteractionsRequest = args.params;
break;
case 'get_unseen_count':
break;
default:
throw new InnertubeError('Action not implemented', action);
}
const response = await this.#session.http.fetch(`/notification/${action}`, {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
}
});
return this.#wrap(response);
}
/**
* Covers livechat endpoints.
*/
async livechat(action: string, args: {
text?: string;
video_id?: string;
channel_id?: string;
ctoken?: string;
params?: string;
client?: string;
} = {}) {
// TODO: should client be required?
const data: Record<string, any> = { client: args.client };
switch (action) {
case 'live_chat/get_live_chat':
case 'live_chat/get_live_chat_replay':
data.continuation = args.ctoken;
break;
case 'live_chat/send_message':
if (!hasKeys(args, 'channel_id', 'video_id', 'text'))
throw new MissingParamError('Arguments lacks channel_id, video_id or text');
data.params = Proto.encodeMessageParams(args.channel_id, args.video_id);
data.clientMessageId = uuidv4();
data.richMessage = {
textSegments: [ {
text: args.text
} ]
};
break;
case 'live_chat/get_item_context_menu':
data.params = args.params;
break;
case 'live_chat/moderate':
data.params = args.params;
break;
case 'updated_metadata':
data.videoId = args.video_id;
if (args.ctoken)
data.continuation = args.ctoken;
break;
default:
throw new InnertubeError('Action not implemented', action);
}
const response = await this.#session.http.fetch(`/${action}`, {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
}
});
return this.#wrap(response);
}
/**
* Endpoint used to retrieve video thumbnails.
*/
async thumbnails(args: { video_id: string; }) {
const data = {
client: 'ANDROID',
videoId: args.video_id
};
const response = await this.#session.http.fetch('/thumbnails', {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
}
});
return this.#wrap(response);
}
/**
* Place Autocomplete endpoint, found it in the APK but
* doesn't seem to be used anywhere on YouTube (maybe for ads?).
*
* Ex:
* ```js
* const places = await session.actions.geo('place_autocomplete', { input: 'San diego cafe' });
* console.info(places.data);
* ```
*/
async geo(action: string, args: { input: string; }) {
if (!this.#session.logged_in)
throw new InnertubeError('You are not signed in');
const data = {
input: args.input,
client: 'ANDROID'
};
const response = await this.#session.http.fetch(`/geo/${action}`, {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
}
});
return this.#wrap(response);
}
/**
* Covers endpoints used to report content.
*/
async flag(action: string, args: { action: string; params?: string; }) {
if (!this.#session.logged_in)
throw new InnertubeError('You are not signed in');
const data: Record<string, any> = {};
switch (action) {
case 'flag/flag':
data.action = args.action;
break;
case 'flag/get_form':
data.params = args.params;
break;
default:
throw new InnertubeError('Action not implemented', action);
}
const response = await this.#session.http.fetch(`/${action}`, {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
}
});
return this.#wrap(response);
}
/**
* Covers specific YouTube Music endpoints.
*/
async music(action: string, args: { input?: string; }) {
const data = {
input: args.input || '',
client: 'YTMUSIC'
};
const response = await this.#session.http.fetch(`/music/${action}`, {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
}
});
return this.#wrap(response);
}
/**
* Mostly used for pagination and specific operations.
*/
async next(args: { video_id?: string; ctoken?: string; client?: string; playlist_id?: string; params?: string } = {}) {
const data: Record<string, any> = { client: args.client };
if (args.ctoken) {
data.continuation = args.ctoken;
}
if (args.video_id) {
data.videoId = args.video_id;
}
if (args.playlist_id) {
data.playlistId = args.playlist_id;
}
if (args.params) {
data.params = args.params;
}
const response = await this.#session.http.fetch('/next', {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
}
});
return this.#wrap(response);
}
/**
* Used to retrieve video info.
* @param id - The video ID.
* @param cpn - Content Playback Nonce.
* @param client - The client to use.
* @param playlist_id - The playlist ID.
*/
async getVideoInfo(id: string, cpn?: string, client?: string, playlist_id?: string) {
const data: Record<string, any> = {
@@ -661,31 +84,11 @@ class Actions {
return this.#wrap(response);
}
/**
* Endpoint used to retrieve user mention suggestions.
*/
async getUserMentionSuggestions(args: { input: string; }) {
if (!this.#session.logged_in)
throw new InnertubeError('You are not signed in');
const data = {
input: args.input,
client: 'ANDROID'
};
const response = await this.#session.http.fetch('/get_user_mention_suggestions', {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
}
});
return this.#wrap(response);
}
/**
* Makes calls to the playback tracking API.
* @param url - The URL to call.
* @param client - The client to use.
* @param params - Call parameters.
*/
async stats(url: string, client: { client_name: string; client_version: string }, params: { [key: string]: any }) {
const s_url = new URL(url);
@@ -706,15 +109,15 @@ class Actions {
/**
* Executes an API call.
* @param action - endpoint
* @param args - call arguments
* @param action - 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(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> {
let data;
if (!args.protobuf) {
if (args && !args.protobuf) {
data = { ...args };
if (Reflect.has(data, 'browseId')) {
@@ -755,23 +158,23 @@ class Actions {
if (data?.client === 'YTMUSIC') {
data.isAudioOnly = true;
}
} else {
} else if (args) {
data = args.serialized_data;
}
const endpoint = Reflect.has(args, 'override_endpoint') ? args.override_endpoint : action;
const endpoint = Reflect.has(args || {}, 'override_endpoint') ? args?.override_endpoint : action;
const response = await this.#session.http.fetch(endpoint, {
method: 'POST',
body: args.protobuf ? data : JSON.stringify(data),
body: args?.protobuf ? data : JSON.stringify(data),
headers: {
'Content-Type': args.protobuf ?
'Content-Type': args?.protobuf ?
'application/x-protobuf' :
'application/json'
}
});
if (args.parse) {
if (args?.parse) {
return Parser.parseResponse(await response.json());
}
@@ -784,6 +187,7 @@ class Actions {
'FEhistory',
'FEsubscriptions',
'FEmusic_listening_review',
'FEmusic_library_landing',
'SPaccount_notifications',
'SPaccount_privacy',
'SPtime_watched'
@@ -791,5 +195,4 @@ class Actions {
}
}
// TODO: maybe do this inferrance in a more elegant way
export default Actions;

View File

@@ -1,6 +1,6 @@
import Parser, { ParsedResponse, ReloadContinuationItemsCommand } from '../parser/index';
import { Memo, ObservedArray } from '../parser/helpers';
import { InnertubeError } from '../utils/Utils';
import { concatMemos, InnertubeError } from '../utils/Utils';
import Actions from './Actions';
import Post from '../parser/classes/Post';
@@ -30,7 +30,6 @@ import ContinuationItem from '../parser/classes/ContinuationItem';
import Video from '../parser/classes/Video';
// TODO: add a way subdivide into sections and return subfeeds?
class Feed {
#page: ParsedResponse;
#continuation?: ObservedArray<ContinuationItem>;
@@ -44,16 +43,14 @@ class Feed {
this.#page = Parser.parseResponse(data);
}
// Xxx: this can be extremely confusing — maybe refactor?
const memo =
this.#page.on_response_received_commands ?
this.#page.on_response_received_commands_memo :
this.#page.on_response_received_endpoints ?
this.#page.on_response_received_endpoints_memo :
this.#page.contents ?
this.#page.contents_memo :
this.#page.on_response_received_actions ?
this.#page.on_response_received_actions_memo : undefined;
const memo = concatMemos(
this.#page.contents_memo,
this.#page.on_response_received_commands_memo,
this.#page.on_response_received_endpoints_memo,
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');
@@ -183,7 +180,7 @@ class Feed {
if (this.#continuation.length === 0)
throw new InnertubeError('There are no continuations');
const response = await this.#continuation[0].endpoint.call(this.#actions, undefined, true);
const response = await this.#continuation[0].endpoint.call(this.#actions, { parse: true });
return response;
}

View File

@@ -57,7 +57,8 @@ class FilterableFeed extends Feed {
if (target_filter.is_selected)
return this;
const response = await target_filter.endpoint?.call(this.actions, undefined, true);
const response = await target_filter.endpoint?.call(this.actions, { parse: true });
return new Feed(this.actions, response, true);
}
}

View File

@@ -1,5 +1,6 @@
import { throwIfMissing } from '../utils/Utils';
import Proto from '../proto';
import Actions from './Actions';
import { throwIfMissing } from '../utils/Utils';
class InteractionManager {
#actions;
@@ -10,55 +11,119 @@ class InteractionManager {
/**
* Likes a given video.
* @param video_id - The video ID
*/
async like(video_id: string) {
throwIfMissing({ video_id });
const action = await this.#actions.engage('like/like', { video_id });
if (!this.#actions.session.logged_in)
throw new Error('You are not signed in');
const action = await this.#actions.execute('/like/like', {
client: 'ANDROID',
target: {
videoId: video_id
}
});
return action;
}
/**
* Dislikes a given video.
* @param video_id - The video ID
*/
async dislike(video_id: string) {
throwIfMissing({ video_id });
const action = await this.#actions.engage('like/dislike', { video_id });
if (!this.#actions.session.logged_in)
throw new Error('You are not signed in');
const action = await this.#actions.execute('/like/dislike', {
client: 'ANDROID',
target: {
videoId: video_id
}
});
return action;
}
/**
* Removes a like/dislike.
* @param video_id - The video ID
*/
async removeLike(video_id: string) {
async removeRating(video_id: string) {
throwIfMissing({ video_id });
const action = await this.#actions.engage('like/removelike', { video_id });
if (!this.#actions.session.logged_in)
throw new Error('You are not signed in');
const action = await this.#actions.execute('/like/removelike', {
client: 'ANDROID',
target: {
videoId: video_id
}
});
return action;
}
/**
* Subscribes to a given channel.
* @param channel_id - The channel ID
*/
async subscribe(channel_id: string) {
throwIfMissing({ channel_id });
const action = await this.#actions.engage('subscription/subscribe', { channel_id });
if (!this.#actions.session.logged_in)
throw new Error('You are not signed in');
const action = await this.#actions.execute('/subscription/subscribe', {
client: 'ANDROID',
channelIds: [ channel_id ],
params: 'EgIIAhgA'
});
return action;
}
/**
* Unsubscribes from a given channel.
* @param channel_id - The channel ID
*/
async unsubscribe(channel_id: string) {
throwIfMissing({ channel_id });
const action = await this.#actions.engage('subscription/unsubscribe', { channel_id });
if (!this.#actions.session.logged_in)
throw new Error('You are not signed in');
const action = await this.#actions.execute('/subscription/unsubscribe', {
client: 'ANDROID',
channelIds: [ channel_id ],
params: 'CgIIAhgA'
});
return action;
}
/**
* Posts a comment on a given video.
* @param video_id - The video ID
* @param text - The comment text
*/
async comment(video_id: string, text: string) {
throwIfMissing({ video_id, text });
const action = await this.#actions.engage('comment/create_comment', { video_id, text });
if (!this.#actions.session.logged_in)
throw new Error('You are not signed in');
const action = await this.#actions.execute('/comment/create_comment', {
client: 'ANDROID',
commentText: text,
createCommentParams: Proto.encodeCommentParams(video_id)
});
return action;
}
@@ -71,12 +136,11 @@ class InteractionManager {
async translate(text: string, target_language: string, args: { video_id?: string; comment_id?: string; } = {}) {
throwIfMissing({ text, target_language });
const response = await await this.#actions.engage('comment/perform_comment_action', {
video_id: args.video_id,
comment_id: args.comment_id,
target_language: target_language,
comment_action: 'translate',
text
const target_action = Proto.encodeCommentActionParams(22, { text, target_language, ...args });
const response = await this.#actions.execute('/comment/perform_comment_action', {
client: 'ANDROID',
actions: [ target_action ]
});
const mutation = response.data.frameworkUpdates.entityBatchUpdate.mutations[0].payload.commentEntityPayload;
@@ -92,10 +156,29 @@ class InteractionManager {
/**
* Changes notification preferences for a given channel.
* Only works with channels you are subscribed to.
* @param channel_id - The channel ID.
* @param type - The notification type.
*/
async setNotificationPreferences(channel_id: string, type: 'PERSONALIZED' | 'ALL' | 'NONE') {
throwIfMissing({ channel_id, type });
const action = await this.#actions.notifications('modify_channel_preference', { channel_id, pref: type || 'NONE' });
if (!this.#actions.session.logged_in)
throw new Error('You are not signed in');
const pref_types = {
PERSONALIZED: 1,
ALL: 2,
NONE: 3
};
if (!Object.keys(pref_types).includes(type.toUpperCase()))
throw new Error(`Invalid notification preference type: ${type}`);
const action = await this.#actions.execute('/notification/modify_channel_preference', {
client: 'ANDROID',
params: Proto.encodeNotificationPref(channel_id, pref_types[type.toUpperCase() as keyof typeof pref_types])
});
return action;
}
}

View File

@@ -27,6 +27,7 @@ import MusicTwoRowItem from '../parser/classes/MusicTwoRowItem';
import { observe, ObservedArray, YTNode } from '../parser/helpers';
import { InnertubeError, throwIfMissing, generateRandomString } from '../utils/Utils';
import Proto from '../proto';
class Music {
#session;
@@ -39,7 +40,7 @@ class Music {
/**
* Retrieves track info. Passing a list item of type MusicTwoRowItem automatically starts a radio.
* @param target - video id or a list item.
* @param target - Video id or a list item.
*/
getInfo(target: string | MusicTwoRowItem): Promise<TrackInfo> {
if (target instanceof MusicTwoRowItem) {
@@ -83,7 +84,7 @@ class Music {
const cpn = generateRandomString(16);
const initial_info = list_item.endpoint.callTest(this.#actions, {
const initial_info = list_item.endpoint.call(this.#actions, {
cpn,
client: 'YTMUSIC',
playbackContext: {
@@ -93,7 +94,7 @@ class Music {
}
});
const continuation = list_item.endpoint.callTest(this.#actions, {
const continuation = list_item.endpoint.call(this.#actions, {
client: 'YTMUSIC',
enablePersistentPlaylistPanel: true,
override_endpoint: '/next'
@@ -105,12 +106,26 @@ class Music {
/**
* Searches on YouTube Music.
* @param query - Search query.
* @param filters - Search filters.
*/
async search(query: string, filters: {
type?: 'all' | 'song' | 'video' | 'album' | 'playlist' | 'artist';
} = {}): Promise<Search> {
throwIfMissing({ query });
const response = await this.#actions.search({ query, filters, client: 'YTMUSIC' });
const payload: {
query: string;
client: string;
params?: string;
} = { query, client: 'YTMUSIC' };
if (filters.type && filters.type !== 'all') {
payload.params = Proto.encodeMusicSearchFilters(filters);
}
const response = await this.#actions.execute('/search', payload);
return new Search(response, this.#actions, { is_filtered: Reflect.has(filters, 'type') && filters.type !== 'all' });
}
@@ -118,7 +133,11 @@ class Music {
* Retrieves the home feed.
*/
async getHomeFeed(): Promise<HomeFeed> {
const response = await this.#actions.browse('FEmusic_home', { client: 'YTMUSIC' });
const response = await this.#actions.execute('/browse', {
client: 'YTMUSIC',
browseId: 'FEmusic_home'
});
return new HomeFeed(response, this.#actions);
}
@@ -126,20 +145,30 @@ class Music {
* Retrieves the Explore feed.
*/
async getExplore(): Promise<Explore> {
const response = await this.#actions.browse('FEmusic_explore', { client: 'YTMUSIC' });
const response = await this.#actions.execute('/browse', {
client: 'YTMUSIC',
browseId: 'FEmusic_explore'
});
return new Explore(response);
// TODO: return new Explore(response, this.#actions);
}
/**
* Retrieves the Library.
* Retrieves the library.
*/
getLibrary() {
return new Library(this.#actions);
async getLibrary(): Promise<Library> {
const response = await this.#actions.execute('/browse', {
client: 'YTMUSIC',
browseId: 'FEmusic_library_landing'
});
return new Library(response, this.#actions);
}
/**
* Retrieves artist's info & content.
* @param artist_id - The artist id.
*/
async getArtist(artist_id: string): Promise<Artist> {
throwIfMissing({ artist_id });
@@ -147,12 +176,17 @@ class Music {
if (!artist_id.startsWith('UC') && !artist_id.startsWith('FEmusic_library_privately_owned_artist'))
throw new InnertubeError('Invalid artist id', artist_id);
const response = await this.#actions.browse(artist_id, { client: 'YTMUSIC' });
const response = await this.#actions.execute('/browse', {
client: 'YTMUSIC',
browseId: artist_id
});
return new Artist(response, this.#actions);
}
/**
* Retrieves album.
* @param album_id - The album id.
*/
async getAlbum(album_id: string): Promise<Album> {
throwIfMissing({ album_id });
@@ -160,12 +194,17 @@ class Music {
if (!album_id.startsWith('MPR') && !album_id.startsWith('FEmusic_library_privately_owned_release'))
throw new InnertubeError('Invalid album id', album_id);
const response = await this.#actions.browse(album_id, { client: 'YTMUSIC' });
const response = await this.#actions.execute('/browse', {
client: 'YTMUSIC',
browseId: album_id
});
return new Album(response, this.#actions);
}
/**
* Retrieves playlist.
* @param playlist_id - The playlist id.
*/
async getPlaylist(playlist_id: string): Promise<Playlist> {
throwIfMissing({ playlist_id });
@@ -174,12 +213,18 @@ class Music {
playlist_id = `VL${playlist_id}`;
}
const response = await this.#actions.browse(playlist_id, { client: 'YTMUSIC' });
const response = await this.#actions.execute('/browse', {
client: 'YTMUSIC',
browseId: playlist_id
});
return new Playlist(response, this.#actions);
}
/**
* Retrieves up next.
* @param video_id - The video id.
* @param automix - Whether to enable automix.
*/
async getUpNext(video_id: string, automix = true): Promise<PlaylistPanel> {
throwIfMissing({ video_id });
@@ -214,7 +259,7 @@ class Music {
if (!automix_preview_video)
throw new InnertubeError('Automix item not found');
const page = await automix_preview_video.playlist_video?.endpoint.callTest(this.#actions, {
const page = await automix_preview_video.playlist_video?.endpoint.call(this.#actions, {
videoId: video_id,
client: 'YTMUSIC',
parse: true
@@ -231,6 +276,7 @@ class Music {
/**
* Retrieves related content.
* @param video_id - The video id.
*/
async getRelated(video_id: string): Promise<ObservedArray<MusicCarouselShelf | MusicDescriptionShelf>> {
throwIfMissing({ video_id });
@@ -252,10 +298,7 @@ class Music {
if (!tab)
throw new InnertubeError('Could not find target tab.');
const page = await tab.endpoint.call(this.#actions, 'YTMUSIC', true);
if (!page)
throw new InnertubeError('Could not retrieve tab contents, the given id may be invalid or is not a song.');
const page = await tab.endpoint.call(this.#actions, { client: 'YTMUSIC', parse: true });
const shelves = page.contents.item().as(SectionList).contents.array().as(MusicCarouselShelf, MusicDescriptionShelf);
@@ -264,6 +307,7 @@ class Music {
/**
* Retrieves song lyrics.
* @param video_id - The video id.
*/
async getLyrics(video_id: string): Promise<MusicDescriptionShelf | undefined> {
throwIfMissing({ video_id });
@@ -285,10 +329,7 @@ class Music {
if (!tab)
throw new InnertubeError('Could not find target tab.');
const page = await tab.endpoint.call(this.#actions, 'YTMUSIC', true);
if (!page)
throw new InnertubeError('Could not retrieve tab contents, the given id may be invalid or is not a song.');
const page = await tab.endpoint.call(this.#actions, { client: 'YTMUSIC', parse: true });
if (page.contents.item().key('type').string() === 'Message')
throw new InnertubeError(page.contents.item().as(Message).text, video_id);
@@ -311,6 +352,7 @@ class Music {
/**
* Retrieves search suggestions for the given query.
* @param query - The query.
*/
async getSearchSuggestions(query: string) {
const response = await this.#actions.execute('/music/get_search_suggestions', {
@@ -321,7 +363,7 @@ class Music {
const search_suggestions_section = response.contents_memo.getType(SearchSuggestionsSection)?.[0];
if (!search_suggestions_section.contents.is_array)
if (!search_suggestions_section?.contents.is_array)
return observe([] as YTNode[]);
return search_suggestions_section?.contents.array();

View File

@@ -241,7 +241,6 @@ class OAuth {
.replace(/\n/g, '')
.match(Constants.OAUTH.REGEX.CLIENT_IDENTITY);
// TODO: check this.
const groups = client_identity?.groups;
if (!groups)

View File

@@ -13,11 +13,20 @@ class PlaylistManager {
/**
* Creates a playlist.
* @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[]) {
throwIfMissing({ title, video_ids });
const response = await this.#actions.execute('/playlist/create', { title, ids: video_ids, parse: false });
if (!this.#actions.session.logged_in)
throw new InnertubeError('You are not signed in');
const response = await this.#actions.execute('/playlist/create', {
title,
ids: video_ids,
parse: false
});
return {
success: response.success,
@@ -29,10 +38,14 @@ class PlaylistManager {
/**
* Deletes a given playlist.
* @param playlist_id - The playlist ID.
*/
async delete(playlist_id: string) {
throwIfMissing({ playlist_id });
if (!this.#actions.session.logged_in)
throw new InnertubeError('You are not signed in');
const response = await this.#actions.execute('playlist/delete', { playlistId: playlist_id });
return {
@@ -45,10 +58,15 @@ class PlaylistManager {
/**
* Adds videos to a given playlist.
* @param playlist_id - The playlist ID.
* @param video_ids - An array of video IDs to add to the playlist.
*/
async addVideos(playlist_id: string, video_ids: string[]) {
throwIfMissing({ playlist_id, video_ids });
if (!this.#actions.session.logged_in)
throw new InnertubeError('You are not signed in');
const response = await this.#actions.execute('/browse/edit_playlist', {
playlistId: playlist_id,
actions: video_ids.map((id) => ({
@@ -66,11 +84,20 @@ class PlaylistManager {
/**
* Removes videos from a given playlist.
* @param playlist_id - The playlist ID.
* @param video_ids - An array of video IDs to remove from the playlist.
*/
async removeVideos(playlist_id: string, video_ids: string[]) {
throwIfMissing({ playlist_id, video_ids });
const info = await this.#actions.execute('/browse', { browseId: `VL${playlist_id}`, parse: true });
if (!this.#actions.session.logged_in)
throw new InnertubeError('You are not signed in');
const info = await this.#actions.execute('/browse', {
browseId: `VL${playlist_id}`,
parse: true
});
const playlist = new Playlist(this.#actions, info, true);
if (!playlist.info.is_editable)
@@ -115,11 +142,21 @@ class PlaylistManager {
/**
* Moves a video to a new position within a given playlist.
* @param playlist_id - The playlist ID.
* @param moved_video_id - The video ID to move.
* @param predecessor_video_id - The video ID to move the moved video before.
*/
async moveVideo(playlist_id: string, moved_video_id: string, predecessor_video_id: string) {
throwIfMissing({ playlist_id, moved_video_id, predecessor_video_id });
const info = await this.#actions.execute('/browse', { browseId: `VL${playlist_id}`, parse: true });
if (!this.#actions.session.logged_in)
throw new InnertubeError('You are not signed in');
const info = await this.#actions.execute('/browse', {
browseId: `VL${playlist_id}`,
parse: true
});
const playlist = new Playlist(this.#actions, info, true);
if (!playlist.info.is_editable)
@@ -157,7 +194,10 @@ class PlaylistManager {
movedSetVideoIdPredecessor: set_video_id_1
});
const response = await this.#actions.execute('/browse/edit_playlist', { ...payload, parse: false });
const response = await this.#actions.execute('/browse/edit_playlist', {
...payload,
parse: false
});
return {
playlist_id,

View File

@@ -144,7 +144,7 @@ export default class Session extends EventEmitterLike {
hl: device_info[0],
gl: device_info[2],
remoteHost: device_info[3],
visitorData: data[3],
visitorData: device_info[13],
userAgent: device_info[14],
clientName: client_name,
clientVersion: device_info[16],
@@ -156,7 +156,7 @@ export default class Session extends EventEmitterLike {
timeZone: device_info[79],
browserName: device_info[86],
browserVersion: device_info[87],
originalUrl: Constants.URLS.API.BASE,
originalUrl: Constants.URLS.YT_BASE,
deviceMake: device_info[11],
deviceModel: device_info[12],
utcOffsetMinutes: new Date().getTimezoneOffset()

View File

@@ -1,6 +1,6 @@
import Proto from '../proto';
import Session from './Session';
import { AxioslikeResponse } from './Actions';
import { ApiResponse } from './Actions';
import { InnertubeError, MissingParamError, uuidv4 } from '../utils/Utils';
import { Constants } from '../utils';
@@ -50,7 +50,10 @@ class Studio {
* const response = await yt.studio.setThumbnail(video_id, buffer);
* ```
*/
async setThumbnail(video_id: string, buffer: Uint8Array): Promise<AxioslikeResponse> {
async setThumbnail(video_id: string, buffer: Uint8Array): Promise<ApiResponse> {
if (!this.#session.logged_in)
throw new InnertubeError('You are not signed in');
if (!video_id || !buffer)
throw new MissingParamError('One or more parameters are missing.');
@@ -73,12 +76,15 @@ class Studio {
* title: 'Artemis Mission',
* description: 'A nicely written description...',
* category: 27,
* licence: 'creative_commons'
* license: 'creative_commons'
* // ...
* });
* ```
*/
async updateVideoMetadata(video_id: string, metadata: VideoMetadata) {
if (!this.#session.logged_in)
throw new InnertubeError('You are not signed in');
const payload = Proto.encodeVideoMetadataPayload(video_id, metadata);
const response = await this.#session.actions.execute('/video_manager/metadata_update', {
@@ -97,7 +103,10 @@ class Studio {
* const response = await yt.studio.upload(file.buffer, { title: 'Wow!' });
* ```
*/
async upload(file: BodyInit, metadata: UploadedVideoMetadata = {}): Promise<AxioslikeResponse> {
async upload(file: BodyInit, metadata: UploadedVideoMetadata = {}): Promise<ApiResponse> {
if (!this.#session.logged_in)
throw new InnertubeError('You are not signed in');
const initial_data = await this.#getInitialUploadData();
const upload_result = await this.#uploadVideo(initial_data.upload_url, file);

View File

@@ -5,23 +5,34 @@ class Grid extends YTNode {
static type = 'Grid';
items;
is_collapsible: boolean;
visible_row_count: string;
target_id: string;
is_collapsible?: boolean;
visible_row_count?: string;
target_id?: string;
continuation: string | null;
header?;
constructor(data: any) {
super();
this.items = Parser.parse(data.items);
this.is_collapsible = data.isCollapsible;
this.visible_row_count = data.visibleRowCount;
this.target_id = data.targetId;
this.continuation = data.continuations?.[0]?.nextContinuationData?.continuation || null;
this.items = Parser.parseArray(data.items);
if (data.header) {
this.header = Parser.parse(data.header);
}
if (data.isCollapsible) {
this.is_collapsible = data.isCollapsible;
}
if (data.visibleRowCount) {
this.visible_row_count = data.visibleRowCount;
}
if (data.targetId) {
this.target_id = data.targetId;
}
this.continuation = data.continuations?.[0]?.nextContinuationData?.continuation || null;
}
// XXX: alias for consistency

View File

@@ -36,12 +36,12 @@ class MusicDetailHeader extends YTNode {
this.thumbnails = Thumbnail.fromResponse(data.thumbnail.croppedSquareThumbnailRenderer.thumbnail);
this.badges = Parser.parse(data.subtitleBadges);
const author = this.subtitle.runs?.find((run) => (run as TextRun)?.endpoint?.browse?.id.startsWith('UC'));
const author = this.subtitle.runs?.find((run) => (run as TextRun)?.endpoint?.payload?.browseId.startsWith('UC'));
if (author) {
this.author = {
name: (author as TextRun).text,
channel_id: (author as TextRun).endpoint?.browse?.id,
channel_id: (author as TextRun).endpoint?.payload?.browseId,
endpoint: (author as TextRun).endpoint
};
}

View File

@@ -80,7 +80,9 @@ class MusicResponsiveListItem extends YTNode {
this.endpoint = data.navigationEndpoint ? new NavigationEndpoint(data.navigationEndpoint) : null;
switch (this.endpoint?.browse?.page_type) {
const page_type = this.endpoint?.payload?.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType;
switch (page_type) {
case 'MUSIC_PAGE_TYPE_ALBUM':
this.item_type = 'album';
this.#parseAlbum();
@@ -139,7 +141,7 @@ class MusicResponsiveListItem extends YTNode {
}
#parseSong() {
this.id = this.#playlist_item_data.video_id || this.endpoint?.watch?.video_id;
this.id = this.#playlist_item_data.video_id || this.endpoint?.payload?.videoId;
this.title = this.#flex_columns[0].key('title').instanceof(Text).toString();
const duration_text =
@@ -151,21 +153,21 @@ class MusicResponsiveListItem extends YTNode {
seconds: timeToSeconds(duration_text)
});
const album = this.#flex_columns[1].key('title').instanceof(Text).runs?.find((run) => Reflect.get(run, 'endpoint')?.browse?.id.startsWith('MPR')) as TextRun ||
this.#flex_columns[2]?.key('title').instanceof(Text).runs?.find((run) => Reflect.get(run, 'endpoint')?.browse?.id.startsWith('MPR')) as TextRun;
const album = this.#flex_columns[1].key('title').instanceof(Text).runs?.find((run) => Reflect.get(run, 'endpoint')?.payload?.browseId.startsWith('MPR')) as TextRun ||
this.#flex_columns[2]?.key('title').instanceof(Text).runs?.find((run) => Reflect.get(run, 'endpoint')?.payload?.browseId.startsWith('MPR')) as TextRun;
if (album) {
this.album = {
id: album.endpoint?.browse?.id,
id: album.endpoint?.payload?.browseId,
name: album.text,
endpoint: album.endpoint
};
}
const artists = this.#flex_columns[1].key('title').instanceof(Text).runs?.filter((run) => Reflect.get(run, 'endpoint')?.browse?.id.startsWith('UC')) as TextRun[];
const artists = this.#flex_columns[1].key('title').instanceof(Text).runs?.filter((run) => Reflect.get(run, 'endpoint')?.payload?.browseId.startsWith('UC')) as TextRun[];
if (artists) {
this.artists = artists.map((artist) => ({
name: artist.text,
channel_id: artist.endpoint?.browse?.id,
channel_id: artist.endpoint?.payload?.browseId,
endpoint: artist.endpoint
}));
}
@@ -176,11 +178,11 @@ class MusicResponsiveListItem extends YTNode {
this.title = this.#flex_columns[0].key('title').instanceof(Text).toString();
this.views = this.#flex_columns[1].key('title').instanceof(Text).runs?.find((run) => run.text.match(/(.*?) views/))?.text;
const authors = this.#flex_columns[1].key('title').instanceof(Text).runs?.filter((run) => Reflect.get(run, 'endpoint')?.browse?.id.startsWith('UC')) as TextRun[];
const authors = this.#flex_columns[1].key('title').instanceof(Text).runs?.filter((run) => Reflect.get(run, 'endpoint')?.payload?.browseId.startsWith('UC')) as TextRun[];
if (authors) {
this.authors = authors.map((author) => ({
name: author.text,
channel_id: author.endpoint?.browse?.id,
channel_id: author.endpoint?.payload?.browseId,
endpoint: author.endpoint
}));
}
@@ -194,7 +196,7 @@ class MusicResponsiveListItem extends YTNode {
}
#parseArtist() {
this.id = this.endpoint?.browse?.id;
this.id = this.endpoint?.payload?.browseId;
this.name = this.#flex_columns[0].key('title').instanceof(Text).toString();
this.subtitle = this.#flex_columns[1].key('title').instanceof(Text);
this.subscribers = this.subtitle.runs?.find((run) => (/^(\d*\.)?\d+[M|K]? subscribers?$/i).test(run.text))?.text || '';
@@ -207,13 +209,13 @@ class MusicResponsiveListItem extends YTNode {
}
#parseAlbum() {
this.id = this.endpoint?.browse?.id;
this.id = this.endpoint?.payload?.browseId;
this.title = this.#flex_columns[0].key('title').instanceof(Text).toString();
const author = this.#flex_columns[1].key('title').instanceof(Text).runs?.find((run) => Reflect.get(run, 'endpoint')?.browse?.id.startsWith('UC')) as TextRun;
const author = this.#flex_columns[1].key('title').instanceof(Text).runs?.find((run) => Reflect.get(run, 'endpoint')?.payload?.browseId.startsWith('UC')) as TextRun;
author && (this.author = {
name: author.text,
channel_id: author.endpoint?.browse?.id,
channel_id: author.endpoint?.payload?.browseId,
endpoint: author.endpoint
});
@@ -221,7 +223,7 @@ class MusicResponsiveListItem extends YTNode {
}
#parsePlaylist() {
this.id = this.endpoint?.browse?.id;
this.id = this.endpoint?.payload?.browseId;
this.title = this.#flex_columns[0].key('title').instanceof(Text).toString();
const item_count_run = this.#flex_columns[1].key('title')
@@ -229,12 +231,12 @@ class MusicResponsiveListItem extends YTNode {
this.item_count = item_count_run ? item_count_run.text : undefined;
const author = this.#flex_columns[1].key('title').instanceof(Text).runs?.find((run) => Reflect.get(run, 'endpoint')?.browse?.id.startsWith('UC')) as TextRun;
const author = this.#flex_columns[1].key('title').instanceof(Text).runs?.find((run) => Reflect.get(run, 'endpoint')?.payload?.browseId.startsWith('UC')) as TextRun;
if (author) {
this.author = {
name: author.text,
channel_id: author.endpoint?.browse?.id,
channel_id: author.endpoint?.payload?.browseId,
endpoint: author.endpoint
};
}

View File

@@ -6,6 +6,7 @@ class MusicSideAlignedItem extends YTNode {
static type = 'MusicSideAlignedItem';
start_items?;
end_items?;
constructor(data: any) {
super();
@@ -13,6 +14,10 @@ class MusicSideAlignedItem extends YTNode {
if (data.startItems) {
this.start_items = Parser.parseArray(data.startItems);
}
if (data.endItems) {
this.end_items = Parser.parseArray(data.endItems);
}
}
}

View File

@@ -48,13 +48,15 @@ class MusicTwoRowItem extends YTNode {
this.endpoint = new NavigationEndpoint(data.navigationEndpoint);
this.id =
this.endpoint?.browse?.id ||
this.endpoint?.watch?.video_id;
this.endpoint?.payload?.browseId ||
this.endpoint?.payload?.videoId;
this.subtitle = new Text(data.subtitle);
this.badges = Parser.parse(data.subtitleBadges);
switch (this.endpoint?.browse?.page_type) {
const page_type = this.endpoint?.payload?.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType;
switch (page_type) {
case 'MUSIC_PAGE_TYPE_ARTIST':
this.item_type = 'artist';
break;
@@ -65,7 +67,7 @@ class MusicTwoRowItem extends YTNode {
this.item_type = 'album';
break;
default:
if (this.endpoint?.watch_playlist) {
if (this.endpoint?.metadata?.api_url === '/next') {
this.item_type = 'endpoint';
} else if (this.subtitle.runs?.[0]) {
if (this.subtitle.runs[0].text !== 'Song') {
@@ -87,11 +89,11 @@ class MusicTwoRowItem extends YTNode {
const item_count_run = this.subtitle.runs?.find((run) => run.text.match(/\d+ songs|song/));
this.item_count = item_count_run ? (item_count_run as TextRun).text : null;
} else if (this.item_type == 'album') {
const artists = this.subtitle.runs?.filter((run: any) => run.endpoint?.browse?.id.startsWith('UC'));
const artists = this.subtitle.runs?.filter((run: any) => run.endpoint?.payload?.browseId.startsWith('UC'));
if (artists) {
this.artists = artists.map((artist: any) => ({
name: artist.text,
channel_id: artist.endpoint.browse.id,
channel_id: artist.endpoint?.payload?.browseId,
endpoint: artist.endpoint
}));
}
@@ -101,20 +103,20 @@ class MusicTwoRowItem extends YTNode {
} else if (this.item_type == 'video') {
this.views = this?.subtitle.runs?.find((run) => run?.text.match(/(.*?) views/))?.text || 'N/A';
const author = this.subtitle.runs?.find((run: any) => run.endpoint?.browse?.id?.startsWith('UC'));
const author = this.subtitle.runs?.find((run: any) => run.endpoint?.payload?.browseId?.startsWith('UC'));
if (author) {
this.author = {
name: (author as TextRun)?.text,
channel_id: (author as TextRun)?.endpoint?.browse?.id,
channel_id: (author as TextRun)?.endpoint?.payload?.browseId,
endpoint: (author as TextRun)?.endpoint
};
}
} else if (this.item_type == 'song') {
const artists = this.subtitle.runs?.filter((run: any) => run.endpoint?.browse?.id.startsWith('UC'));
const artists = this.subtitle.runs?.filter((run: any) => run.endpoint?.payload?.browseId.startsWith('UC'));
if (artists) {
this.artists = artists.map((artist: any) => ({
name: (artist as TextRun)?.text,
channel_id: (artist as TextRun)?.endpoint?.browse?.id,
channel_id: (artist as TextRun)?.endpoint?.payload?.browseId,
endpoint: (artist as TextRun)?.endpoint
}));
}

View File

@@ -1,7 +1,7 @@
// TODO: refactor this
import { YTNode } from '../helpers';
import Parser, { ParsedResponse } from '../index';
import Actions, { ActionsResponse } from '../../core/Actions';
import { YTNode } from '../helpers';
import CreatePlaylistDialog from './CreatePlaylistDialog';
class NavigationEndpoint extends YTNode {
@@ -14,34 +14,9 @@ class NavigationEndpoint extends YTNode {
url?: string;
api_url?: string;
page_type?: string;
send_post?: boolean; // TODO: is this a boolean?
send_post?: boolean;
};
// TODO: these should be given proper types, currently infered
browse?: {
id: string,
params: string | null,
base_url: string | null,
page_type: string | null,
form_data?: {}
};
watch;
search;
subscribe;
unsubscribe;
like;
perform_comment_action;
offline_video;
continuation;
feedback;
watch_playlist;
playlist_edit;
add_to_playlist;
create_playlist;
get_report_form;
live_chat_item_context_menu;
send_live_chat_vote;
constructor(data: any) {
super();
@@ -85,150 +60,10 @@ class NavigationEndpoint extends YTNode {
this.metadata.send_post = data.commandMetadata.webCommandMetadata.sendPost;
}
if (data?.browseEndpoint) {
const configs = data?.browseEndpoint?.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig;
this.browse = {
id: data?.browseEndpoint?.browseId || null,
params: data?.browseEndpoint.params || null,
base_url: data?.browseEndpoint?.canonicalBaseUrl || null,
page_type: configs?.pageType || null
};
}
if (data?.watchEndpoint) {
const configs = data?.watchEndpoint?.watchEndpointMusicSupportedConfigs?.watchEndpointMusicConfig;
this.watch = {
video_id: data?.watchEndpoint?.videoId,
playlist_id: data?.watchEndpoint.playlistId || null,
params: data?.watchEndpoint.params || null,
index: data?.watchEndpoint.index || null,
supported_onesie_config: data?.watchEndpoint?.watchEndpointSupportedOnesieConfig,
music_video_type: configs?.musicVideoType || null
};
}
if (data?.searchEndpoint) {
this.search = {
query: data.searchEndpoint.query,
params: data.searchEndpoint.params
};
}
if (data?.subscribeEndpoint) {
this.subscribe = {
channel_ids: data.subscribeEndpoint.channelIds,
params: data.subscribeEndpoint.params
};
}
if (data?.unsubscribeEndpoint) {
this.unsubscribe = {
channel_ids: data.unsubscribeEndpoint.channelIds,
params: data.unsubscribeEndpoint.params
};
}
if (data?.likeEndpoint) {
this.like = {
status: data.likeEndpoint.status,
target: {
video_id: data.likeEndpoint.target.videoId,
playlist_id: data.likeEndpoint.target.playlistId
},
params:
data.likeEndpoint?.removeLikeParams ||
data.likeEndpoint?.likeParams ||
data.likeEndpoint?.dislikeParams
};
}
if (data?.performCommentActionEndpoint) {
this.perform_comment_action = {
action: data?.performCommentActionEndpoint.action
};
}
if (data?.offlineVideoEndpoint) {
this.offline_video = {
video_id: data.offlineVideoEndpoint.videoId,
on_add_command: {
get_download_action: {
video_id: data.offlineVideoEndpoint.videoId,
params: data.offlineVideoEndpoint.onAddCommand.getDownloadActionCommand.params
}
}
};
}
if (data?.continuationCommand) {
this.continuation = {
request: data?.continuationCommand?.request || null,
token: data?.continuationCommand?.token || null
};
}
if (data?.feedbackEndpoint) {
this.feedback = {
token: data.feedbackEndpoint.feedbackToken
};
}
if (data?.watchPlaylistEndpoint) {
this.watch_playlist = {
playlist_id: data.watchPlaylistEndpoint?.playlistId,
params: data.watchPlaylistEndpoint?.params || null
};
}
if (data?.playlistEditEndpoint) {
this.playlist_edit = {
playlist_id: data.playlistEditEndpoint.playlistId,
actions: data.playlistEditEndpoint.actions.map((item: any) => ({
action: item.action,
removed_video_id: item.removedVideoId
}))
};
}
if (data?.addToPlaylistEndpoint) {
this.add_to_playlist = {
video_id: data.addToPlaylistEndpoint.videoId
};
}
if (data?.addToPlaylistServiceEndpoint) {
this.add_to_playlist = {
video_id: data.addToPlaylistServiceEndpoint.videoId
};
}
if (data?.createPlaylistEndpoint) {
if (data?.createPlaylistEndpoint.createPlaylistDialog) {
this.dialog = Parser.parseItem(data?.createPlaylistEndpoint.createPlaylistDialog, CreatePlaylistDialog);
}
this.create_playlist = {
// Nothing to put here - data.createPlaylistEndpoint has only one prop `createPlaylistDialog`
// Which was already parsed and referred to by `this.dialog`. But still useful to have this as
// A quick indicator of what the endpoint does.
};
}
if (data?.getReportFormEndpoint) {
this.get_report_form = {
params: data.getReportFormEndpoint.params
};
}
if (data?.liveChatItemContextMenuEndpoint) {
this.live_chat_item_context_menu = {
params: data?.liveChatItemContextMenuEndpoint?.params
};
}
if (data?.sendLiveChatVoteEndpoint) {
this.send_live_chat_vote = {
params: data.sendLiveChatVoteEndpoint.params
};
}
}
@@ -248,72 +83,15 @@ class NavigationEndpoint extends YTNode {
}
}
callTest(actions: Actions, args: { [ key: string ]: any; parse: true }): Promise<ParsedResponse>;
callTest(actions: Actions, args?: { [ key: string ]: any; parse?: false }): Promise<ActionsResponse>;
callTest(actions: Actions, args?: { [ key: string ]: any; parse?: boolean }): Promise<ParsedResponse | ActionsResponse> {
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> {
if (!actions)
throw new Error('An active caller must be provided');
if (!this.metadata.api_url)
throw new Error('Expected an api_url, but none was found, this is a bug.');
return actions.execute(this.metadata.api_url, { ...this.payload, ...args });
}
// TODO: replace client with an enum or something
async #call(actions: Actions, client?: string) {
if (!actions)
throw new Error('An active caller must be provided');
if (this.continuation) {
switch (this.continuation.request) {
case 'CONTINUATION_REQUEST_TYPE_BROWSE': {
return await actions.browse(this.continuation.token, { is_ctoken: true });
}
case 'CONTINUATION_REQUEST_TYPE_SEARCH': {
return await actions.search({ ctoken: this.continuation.token });
}
case 'CONTINUATION_REQUEST_TYPE_WATCH_NEXT': {
return await actions.next({ ctoken: this.continuation.token });
}
default:
throw new Error(`${this.continuation.request} not implemented`);
}
}
if (this.search) {
return await actions.search({ query: this.search.query, params: this.search.params, client });
}
if (this.browse) {
return await actions.browse(this.browse.id, { ...this.browse, client });
}
if (this.like) {
if (!this.metadata.api_url)
throw new Error('Like endpoint requires an api_url, but was not parsed from the response.');
const response = await actions.engage(this.metadata.api_url, { video_id: this.like.target.video_id, params: this.like.params });
return response;
}
if (this.live_chat_item_context_menu) {
if (!this.metadata.api_url)
throw new Error('Live Chat Item Context Menu endpoint requires an api_url, but was not parsed from the response.');
const response = await actions.livechat(this.metadata.api_url, {
params: this.live_chat_item_context_menu.params
});
return response;
}
}
async call(actions: Actions, client: string | undefined, parse: true) : Promise<ParsedResponse | undefined>;
async call(actions: Actions, client?: string, parse?: false) : Promise<ActionsResponse | undefined>;
async call(actions: Actions, client?: string, parse?: boolean): Promise<ParsedResponse | ActionsResponse | undefined> {
const result = await this.#call(actions, client);
if (parse && result)
return Parser.parseResponse(result.data);
return result;
}
}
export default NavigationEndpoint;

View File

@@ -54,14 +54,14 @@ class PlaylistPanelVideo extends YTNode {
seconds: timeToSeconds(new Text(data.lengthText).toString())
};
const album = new Text(data.longBylineText).runs?.find((run: any) => run.endpoint?.browse?.id.startsWith('MPR'));
const artists = new Text(data.longBylineText).runs?.filter((run: any) => run.endpoint?.browse?.id.startsWith('UC'));
const album = new Text(data.longBylineText).runs?.find((run: any) => run.endpoint?.payload?.browseId?.startsWith('MPR'));
const artists = new Text(data.longBylineText).runs?.filter((run: any) => run.endpoint?.payload?.browseId?.startsWith('UC'));
this.author = new Text(data.shortBylineText).toString();
if (album) {
this.album = {
id: (album as TextRun).endpoint?.browse?.id,
id: (album as TextRun).endpoint?.payload?.browseId,
name: (album as TextRun).text,
year: new Text(data.longBylineText).runs?.slice(-1)[0].text,
endpoint: (album as TextRun).endpoint
@@ -71,7 +71,7 @@ class PlaylistPanelVideo extends YTNode {
if (artists) {
this.artists = artists.map((artist) => ({
name: (artist as TextRun).text,
channel_id: (artist as TextRun).endpoint?.browse?.id,
channel_id: (artist as TextRun).endpoint?.payload?.browseId,
endpoint: (artist as TextRun).endpoint
}));
}

View File

@@ -82,7 +82,7 @@ class Comment extends YTNode {
if (button.is_toggled)
throw new InnertubeError('This comment is already liked', { comment_id: this.comment_id });
const response = await button.endpoint.callTest(this.#actions, { parse: false });
const response = await button.endpoint.call(this.#actions, { parse: false });
return response;
}
@@ -98,7 +98,7 @@ class Comment extends YTNode {
if (button.is_toggled)
throw new InnertubeError('This comment is already disliked', { comment_id: this.comment_id });
const response = await button.endpoint.callTest(this.#actions, { parse: false });
const response = await button.endpoint.call(this.#actions, { parse: false });
return response;
}
@@ -125,7 +125,7 @@ class Comment extends YTNode {
commentText: text
};
const response = await dialog_button.endpoint.callTest(this.#actions, payload);
const response = await dialog_button.endpoint.call(this.#actions, payload);
return response;
}

View File

@@ -35,7 +35,7 @@ class CommentThread extends YTNode {
throw new InnertubeError('This comment has no replies.', { comment_id: this.comment?.comment_id });
const continuation = this.#replies.key('contents').parsed().array().get({ type: 'ContinuationItem' })?.as(ContinuationItem);
const response = await continuation?.endpoint.callTest(this.#actions, { parse: true });
const response = await continuation?.endpoint.call(this.#actions, { parse: true });
this.replies = response?.on_response_received_endpoints_memo?.getType(Comment).map((comment) => {
comment.setActions(this.#actions);
@@ -60,7 +60,7 @@ class CommentThread extends YTNode {
if (!this.#actions)
throw new InnertubeError('Actions not set for this CommentThread.');
const response = await this.#continuation.button?.item().key('endpoint').nodeOfType(NavigationEndpoint).callTest(this.#actions, { parse: true });
const response = await this.#continuation.button?.item().key('endpoint').nodeOfType(NavigationEndpoint).call(this.#actions, { parse: true });
this.replies = response?.on_response_received_endpoints_memo.getType(Comment).map((comment) => {
comment.setActions(this.#actions);

View File

@@ -8,7 +8,7 @@ class MusicMultiSelectMenuItem extends YTNode {
title: string;
form_item_entity_key: string;
selected_icon_type: string;
endpoint?: NavigationEndpoint;
endpoint?: NavigationEndpoint | null;
selected: boolean;
constructor(data: any) {
@@ -17,19 +17,7 @@ class MusicMultiSelectMenuItem extends YTNode {
this.title = new Text(data.title).text;
this.form_item_entity_key = data.formItemEntityKey;
this.selected_icon_type = data.selectedIcon?.iconType || null;
const command = data.selectedCommand?.commandExecutorCommand?.commands?.find((command: any) => command.musicBrowseFormBinderCommand?.browseEndpoint);
if (command) {
/**
* At this point, endpoint will still be missing `form_data` field which is required for
* selection to take effect. This can only be obtained from the response data which
* we don't have here. We shall delegate this task back to `Parser`.
*/
this.endpoint = new NavigationEndpoint(command.musicBrowseFormBinderCommand);
}
/**
* Inferring selected state from existence of endpoint. `Parser` shall
* update this with the definitive value obtained from response data.
*/
this.endpoint = data.selectedCommand ? new NavigationEndpoint(data.selectedCommand) : null;
this.selected = !!this.endpoint;
}
}

View File

@@ -21,8 +21,8 @@ class Author {
this.#nav_text = new NavigatableText(item);
this.id =
(this.#nav_text.runs?.[0] as TextRun)?.endpoint?.browse?.id ||
this.#nav_text?.endpoint?.browse?.id || 'N/A';
(this.#nav_text.runs?.[0] as TextRun)?.endpoint?.payload?.browseId ||
this.#nav_text?.endpoint?.payload?.browseId || 'N/A';
this.name = this.#nav_text.text || 'N/A';
this.thumbnails = thumbs ? Thumbnail.fromResponse(thumbs) : [];
@@ -32,9 +32,9 @@ class Author {
this.is_verified_artist = this.badges?.some((badge: any) => badge.style == 'BADGE_STYLE_TYPE_VERIFIED_ARTIST') || null;
this.url =
(this.#nav_text?.runs?.[0] as TextRun)?.endpoint?.browse &&
`${Constants.URLS.YT_BASE}${(this.#nav_text?.runs?.[0] as TextRun)?.endpoint?.browse?.base_url || `/u/${(this.#nav_text?.runs?.[0] as TextRun)?.endpoint?.browse?.id}`}` ||
`${Constants.URLS.YT_BASE}${this.#nav_text?.endpoint?.browse?.base_url || `/u/${this.#nav_text?.endpoint?.browse?.id}`}` ||
(this.#nav_text?.runs?.[0] as TextRun)?.endpoint?.metadata?.api_url === '/browse' &&
`${Constants.URLS.YT_BASE}${(this.#nav_text?.runs?.[0] as TextRun)?.endpoint?.payload?.canonicalBaseUrl || `/u/${(this.#nav_text?.runs?.[0] as TextRun)?.endpoint?.payload?.browseId}`}` ||
`${Constants.URLS.YT_BASE}${this.#nav_text?.endpoint?.payload?.canonicalBaseUrl || `/u/${this.#nav_text?.endpoint?.payload?.browseId}`}` ||
null;
}

View File

@@ -50,7 +50,9 @@ export class SectionListContinuation extends YTNode {
constructor(data: any) {
super();
this.contents = Parser.parse(data.contents, true);
this.continuation = data.continuations?.[0].nextContinuationData.continuation || null;
this.continuation =
data.continuations?.[0]?.nextContinuationData?.continuation ||
data.continuations?.[0]?.reloadContinuationData?.continuation || null;
}
}
@@ -170,9 +172,17 @@ export class LiveChatContinuation extends YTNode {
}
}
export type ParserError = { classname: string, classdata: any, err: any };
export type ParserErrorHandler = (error: ParserError) => void;
export default class Parser {
static #errorHandler: ParserErrorHandler = Parser.#printError;
static #memo: Memo | null = null;
static setParserErrorHandler(handler: ParserErrorHandler) {
this.#errorHandler = handler;
}
static #clearMemo() {
Parser.#memo = null;
}
@@ -203,11 +213,11 @@ export default class Parser {
*/
static parseResponse(data: any) {
// Memoize the response objects by classname
this.#createMemo();
// TODO: is this parseItem?
const contents = Parser.parse(data.contents);
const contents_memo = this.#getMemo();
// End of memoization
this.#clearMemo();
this.#createMemo();
@@ -237,6 +247,17 @@ export default class Parser {
const live_chat_item_context_menu_supported_renderers_memo = this.#getMemo();
this.#clearMemo();
this.#createMemo();
const header = data.header ? Parser.parse(data.header) : null;
const header_memo = this.#getMemo();
this.#clearMemo();
this.#createMemo();
const sidebar = data.sidebar ? Parser.parseItem(data.sidebar) : null;
const sidebar_memo = this.#getMemo();
this.#clearMemo();
this.applyMutations(contents_memo, data.frameworkUpdates?.entityBatchUpdate?.mutations);
return {
@@ -244,6 +265,10 @@ export default class Parser {
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,
@@ -255,9 +280,7 @@ export default class Parser {
continuation: data.continuation ? Parser.parseC(data.continuation) : null,
continuation_contents: data.continuationContents ? Parser.parseLC(data.continuationContents) : null,
metadata: Parser.parse(data.metadata),
header: Parser.parse(data.header),
microformat: data.microformat ? Parser.parseItem(data.microformat) : null,
sidebar: Parser.parseItem(data.sidebar),
overlay: Parser.parseItem(data.overlay),
refinements: data.refinements || null,
estimated_results: data.estimatedResults ? parseInt(data.estimatedResults) : null,
@@ -356,7 +379,7 @@ export default class Parser {
return result as T;
} catch (err) {
this.printError({ classname, classdata: data[keys[0]], err });
this.#errorHandler({ classname, classdata: data[keys[0]], err });
return null;
}
}
@@ -382,8 +405,8 @@ export default class Parser {
throw new ParsingError('Expected array but got a single item');
}
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: 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>[]) {
if (!data) return null;
@@ -409,8 +432,9 @@ export default class Parser {
static applyMutations(memo: Memo, mutations: Array<any>) {
// Apply mutations to MusicMultiSelectMenuItems
const musicMultiSelectMenuItems = memo.getType(MusicMultiSelectMenuItem);
if (musicMultiSelectMenuItems.length > 0 && !mutations) {
const music_multi_select_menu_items = memo.getType(MusicMultiSelectMenuItem);
if (music_multi_select_menu_items.length > 0 && !mutations) {
console.warn(
new InnertubeError(
'Mutation data required for processing MusicMultiSelectMenuItems, but none found.\n' +
@@ -418,26 +442,25 @@ export default class Parser {
)
);
} else {
const missingOrInvalidMutations = [];
for (const menuItem of musicMultiSelectMenuItems) {
const mutation = mutations.find((mutation) => mutation.payload?.musicFormBooleanChoice?.id === menuItem.form_item_entity_key);
const missing_or_invalid_mutations = [];
for (const menu_item of music_multi_select_menu_items) {
const mutation = mutations
.find((mutation) => mutation.payload?.musicFormBooleanChoice?.id === menu_item.form_item_entity_key);
const choice = mutation?.payload.musicFormBooleanChoice;
if (choice?.selected !== undefined && choice?.opaqueToken) {
menuItem.selected = choice.selected;
if (menuItem.endpoint?.browse) {
menuItem.endpoint.browse.form_data = {
selectedValues: [ choice.opaqueToken ]
};
}
menu_item.selected = choice.selected;
} else {
missingOrInvalidMutations.push(`'${menuItem.title}'`);
missing_or_invalid_mutations.push(`'${menu_item.title}'`);
}
}
if (missingOrInvalidMutations.length > 0) {
if (missing_or_invalid_mutations.length > 0) {
console.warn(
new InnertubeError(
`Mutation data missing or invalid for ${missingOrInvalidMutations.length} out of ${musicMultiSelectMenuItems.length} MusicMultiSelectMenuItems. ` +
`The titles of the failed items are: ${missingOrInvalidMutations.join(', ')}.\n` +
`Mutation data missing or invalid for ${missing_or_invalid_mutations.length} out of ${music_multi_select_menu_items.length} MusicMultiSelectMenuItems. ` +
`The titles of the failed items are: ${missing_or_invalid_mutations.join(', ')}.\n` +
`This is a bug, please report it at ${package_json.bugs.url}`
)
);
@@ -445,7 +468,7 @@ export default class Parser {
}
}
static printError({ classname, classdata, err }: { classname: string, classdata: any, err: any }) {
static #printError({ classname, classdata, err }: ParserError) {
if (err.code == 'MODULE_NOT_FOUND') {
return console.warn(
new InnertubeError(

View File

@@ -1,5 +1,5 @@
import Parser, { ParsedResponse } from '..';
import { AxioslikeResponse } from '../../core/Actions';
import { ApiResponse } from '../../core/Actions';
import AccountSectionList from '../classes/AccountSectionList';
import AccountItemSection from '../classes/AccountItemSection';
@@ -11,7 +11,7 @@ class AccountInfo {
contents: AccountItemSection | null;
footers: AccountChannel | null;
constructor(response: AxioslikeResponse) {
constructor(response: ApiResponse) {
this.#page = Parser.parseResponse(response.data);
const account_section_list = this.#page.contents.array().as(AccountSectionList)[0];

View File

@@ -1,12 +1,12 @@
import Parser, { ParsedResponse } from '..';
import { AxioslikeResponse } from '../../core/Actions';
import { ApiResponse } from '../../core/Actions';
import Element from '../classes/Element';
class Analytics {
#page;
sections;
constructor(response: AxioslikeResponse) {
constructor(response: ApiResponse) {
this.#page = Parser.parseResponse(response.data);
this.sections = this.#page.contents_memo?.get('Element')
?.map((el) => el.as(Element).model?.item());

View File

@@ -16,13 +16,13 @@ class Channel extends TabbedFeed {
constructor(actions: Actions, data: any, already_parsed = false) {
super(actions, data, already_parsed);
this.header = this.page.header.item().as(C4TabbedHeader);
this.header = this.page.header?.item().as(C4TabbedHeader);
const metadata = this.page.metadata.item().as(ChannelMetadata);
const microformat = this.page.microformat?.as(MicroformatData);
this.metadata = { ...metadata, ...(microformat || {}) };
this.sponsor_button = this.header.sponsor_button;
this.subscribe_button = this.header.subscribe_button;
this.sponsor_button = this.header?.sponsor_button;
this.subscribe_button = this.header?.subscribe_button;
const tab = this.page.contents.item().key('tabs').parsed().array().filterType(Tab).get({ selected: true });

View File

@@ -40,19 +40,18 @@ class Comments {
/**
* Creates a top-level comment.
* @param text - Comment text.
*/
async createComment(text: string): Promise<ActionsResponse> {
if (!this.header)
throw new InnertubeError('Page header is missing.');
throw new InnertubeError('Page header is missing. Cannot create comment.');
const button = this.header.create_renderer?.as(CommentSimplebox).submit_button.item().as(Button);
const button = this.header.create_renderer?.as(CommentSimplebox).submit_button?.item().as(Button);
if (!button)
throw new InnertubeError('Could not find target button.');
throw new InnertubeError('Could not find target button. You are probably not logged in.');
const response = await button.endpoint.callTest(this.#actions, {
commentText: text
});
const response = await button.endpoint.call(this.#actions, { commentText: text });
return response;
}
@@ -64,13 +63,13 @@ class Comments {
if (!this.#continuation)
throw new InnertubeError('Continuation not found');
const data = await this.#continuation.endpoint.callTest(this.#actions, { parse: true });
const data = await this.#continuation.endpoint.call(this.#actions, { parse: true });
// Copy the previous page so we can keep the header.
const page = Object.assign({}, this.#page);
if (!page.on_response_received_endpoints || !data.on_response_received_endpoints)
throw new InnertubeError('Invalid reponse format, missing on_response_received_endpoints');
throw new InnertubeError('Invalid reponse format, missing on_response_received_endpoints.');
// Remove previous items and append the continuation.
page.on_response_received_endpoints.pop();

View File

@@ -48,7 +48,7 @@ class ItemMenu {
endpoint = button.as(MenuServiceItem).endpoint;
}
const response = await endpoint.callTest(this.#actions, { parse: true });
const response = await endpoint.call(this.#actions, { parse: true });
return response;
}

View File

@@ -1,5 +1,5 @@
import Parser, { ParsedResponse } from '..';
import Actions, { AxioslikeResponse } from '../../core/Actions';
import Actions, { ApiResponse } from '../../core/Actions';
import { InnertubeError } from '../../utils/Utils';
import Feed from '../../core/Feed';
@@ -25,7 +25,7 @@ class Library {
profile;
sections;
constructor(response: AxioslikeResponse, actions: Actions) {
constructor(response: ApiResponse, actions: Actions) {
this.#actions = actions;
this.#page = Parser.parseResponse(response);
@@ -66,7 +66,7 @@ class Library {
if (!button)
throw new InnertubeError('Did not find target button.');
const page = await button.as(Button).endpoint.callTest(this.#actions, { parse: true });
const page = await button.as(Button).endpoint.call(this.#actions, { parse: true });
switch (shelf.icon_type) {
case 'LIKE':

View File

@@ -192,7 +192,7 @@ class LiveChat extends EventEmitter {
if (!item.menu_endpoint)
throw new InnertubeError('This item does not have a menu.', item);
const response = await item.menu_endpoint.call(this.#actions, undefined, true);
const response = await item.menu_endpoint.call(this.#actions, { parse: true });
if (!response)
throw new InnertubeError('Could not retrieve item menu.', item);
@@ -204,7 +204,7 @@ class LiveChat extends EventEmitter {
* Equivalent to "clicking" a button.
*/
async selectButton(button: Button): Promise<ParsedResponse> {
const response = await button.endpoint.callTest(this.#actions, { parse: true });
const response = await button.endpoint.call(this.#actions, { parse: true });
return response;
}

View File

@@ -1,5 +1,5 @@
import Parser from '..';
import Actions, { AxioslikeResponse } from '../../core/Actions';
import Actions, { ApiResponse } from '../../core/Actions';
import { InnertubeError } from '../../utils/Utils';
import Notification from '../classes/Notification';
@@ -13,7 +13,7 @@ class NotificationsMenu {
header;
contents;
constructor(actions: Actions, response: AxioslikeResponse) {
constructor(actions: Actions, response: ApiResponse) {
this.#actions = actions;
this.#page = Parser.parseResponse(response.data);
@@ -27,7 +27,7 @@ class NotificationsMenu {
if (!continuation)
throw new InnertubeError('Continuation not found');
const response = await continuation.endpoint.callTest(this.#actions, { parse: false });
const response = await continuation.endpoint.call(this.#actions, { parse: false });
return new NotificationsMenu(this.#actions, response);
}
}

View File

@@ -4,13 +4,14 @@ import Feed from '../../core/Feed';
import Thumbnail from '../classes/misc/Thumbnail';
import VideoOwner from '../classes/VideoOwner';
import PlaylistSidebar from '../classes/PlaylistSidebar';
import PlaylistMetadata from '../classes/PlaylistMetadata';
import PlaylistSidebarPrimaryInfo from '../classes/PlaylistSidebarPrimaryInfo';
import PlaylistSidebarSecondaryInfo from '../classes/PlaylistSidebarSecondaryInfo';
import PlaylistVideoThumbnail from '../classes/PlaylistVideoThumbnail';
import PlaylistHeader from '../classes/PlaylistHeader';
import { InnertubeError } from '../../utils/Utils';
class Playlist extends Feed {
info;
menu;
@@ -19,9 +20,12 @@ class Playlist extends Feed {
constructor(actions: Actions, data: any, already_parsed = false) {
super(actions, data, already_parsed);
const header = this.page.header.item().as(PlaylistHeader);
const primary_info = this.page.sidebar?.as(PlaylistSidebar).contents.array().firstOfType(PlaylistSidebarPrimaryInfo);
const secondary_info = this.page.sidebar?.as(PlaylistSidebar).contents.array().firstOfType(PlaylistSidebarSecondaryInfo);
const header = this.memo.getType(PlaylistHeader)?.[0];
const primary_info = this.memo.getType(PlaylistSidebarPrimaryInfo)?.[0];
const secondary_info = this.memo.getType(PlaylistSidebarSecondaryInfo)?.[0];
if (!primary_info && !secondary_info)
throw new InnertubeError('This playlist does not exist');
this.info = {
...this.page.metadata.item().as(PlaylistMetadata),
@@ -31,14 +35,14 @@ class Playlist extends Feed {
total_items: this.#getStat(0, primary_info),
views: this.#getStat(1, primary_info),
last_updated: this.#getStat(2, primary_info),
can_share: header.can_share,
can_delete: header.can_delete,
is_editable: header.is_editable,
privacy: header.privacy
can_share: header?.can_share,
can_delete: header?.can_delete,
is_editable: header?.is_editable,
privacy: header?.privacy
}
};
this.menu = primary_info?.menu;
this.menu = primary_info?.menu.item();
this.endpoint = primary_info?.endpoint;
}
@@ -50,6 +54,11 @@ class Playlist extends Feed {
get items() {
return this.videos;
}
async getContinuation(): Promise<Playlist> {
const response = await this.getContinuationData();
return new Playlist(this.actions, response);
}
}
export default Playlist;

View File

@@ -66,7 +66,7 @@ class Search extends Feed {
throw new InnertubeError('Invalid refinement card!');
}
const page = await target_card.endpoint.call(this.actions, undefined, true);
const page = await target_card.endpoint.call(this.actions, { parse: true });
return new Search(this.actions, page, true);
}

View File

@@ -1,5 +1,5 @@
import Parser from '..';
import Actions, { AxioslikeResponse } from '../../core/Actions';
import Actions, { ApiResponse } from '../../core/Actions';
import { InnertubeError } from '../../utils/Utils';
import Tab from '../classes/Tab';
@@ -20,7 +20,7 @@ class Settings {
introduction: PageIntroduction | null | undefined;
sections;
constructor(actions: Actions, response: AxioslikeResponse) {
constructor(actions: Actions, response: ApiResponse) {
this.#actions = actions;
this.#page = Parser.parseResponse(response.data);
@@ -53,7 +53,7 @@ class Settings {
if (!item)
throw new InnertubeError(`Item "${name}" not found`, { available_items: this.sidebar_items });
const response = await item.endpoint.callTest(this.#actions, { parse: false });
const response = await item.endpoint.call(this.#actions, { parse: false });
return new Settings(this.#actions, response);
}

View File

@@ -1,5 +1,5 @@
import Parser, { ParsedResponse } from '..';
import { AxioslikeResponse } from '../../core/Actions';
import { ApiResponse } from '../../core/Actions';
import ItemSection from '../classes/ItemSection';
import SingleColumnBrowseResults from '../classes/SingleColumnBrowseResults';
@@ -11,7 +11,7 @@ class TimeWatched {
#page;
contents;
constructor(response: AxioslikeResponse) {
constructor(response: ApiResponse) {
this.#page = Parser.parseResponse(response.data);
const tab = this.#page.contents.item().as(SingleColumnBrowseResults).tabs.get({ selected: true });

View File

@@ -1,6 +1,6 @@
import Parser, { ParsedResponse } from '../index';
import Constants from '../../utils/Constants';
import Actions, { AxioslikeResponse } from '../../core/Actions';
import Actions, { ApiResponse } from '../../core/Actions';
import Player from '../../core/Player';
import TwoColumnWatchNextResults from '../classes/TwoColumnWatchNextResults';
@@ -92,7 +92,7 @@ class VideoInfo {
* @param data - API response.
* @param cpn - Client Playback Nonce
*/
constructor(data: [AxioslikeResponse, AxioslikeResponse?], actions: Actions, player: Player, cpn: string) {
constructor(data: [ApiResponse, ApiResponse?], actions: Actions, player: Player, cpn: string) {
this.#actions = actions;
this.#player = player;
this.#cpn = cpn;
@@ -177,7 +177,7 @@ class VideoInfo {
const filter = this.related_chip_cloud?.chips?.get({ text: name });
if (filter?.is_selected) return this;
const response = await filter?.endpoint?.call(this.#actions, undefined, true);
const response = await filter?.endpoint?.call(this.#actions, { parse: true });
const data = response?.on_response_received_endpoints?.get({ target_id: 'watch-next-feed' });
this.watch_next_feed = data?.contents;
@@ -213,7 +213,7 @@ class VideoInfo {
* Retrieves watch next feed continuation.
*/
async getWatchNextContinuation() {
const response = await this.#watch_next_continuation?.endpoint.call(this.#actions, undefined, true);
const response = await this.#watch_next_continuation?.endpoint.call(this.#actions, { parse: true });
const data = response?.on_response_received_endpoints?.get({ type: 'appendContinuationItemsAction' });
if (!data)

View File

@@ -1,5 +1,5 @@
import Parser, { ParsedResponse } from '../index';
import Actions, { AxioslikeResponse } from '../../core/Actions';
import Actions, { ApiResponse } from '../../core/Actions';
import MusicDetailHeader from '../classes/MusicDetailHeader';
import MicroformatData from '../classes/MicroformatData';
@@ -16,11 +16,11 @@ class Album {
url: string | null;
constructor(response: AxioslikeResponse, actions: Actions) {
constructor(response: ApiResponse, actions: Actions) {
this.#page = Parser.parseResponse(response.data);
this.#actions = actions;
this.header = this.#page.header.item().as(MusicDetailHeader);
this.header = this.#page.header?.item().as(MusicDetailHeader);
this.url = this.#page.microformat?.as(MicroformatData).url_canonical || null;
this.contents = this.#page.contents_memo.get('MusicShelf')?.[0].as(MusicShelf).contents;

View File

@@ -1,5 +1,5 @@
import Parser, { ParsedResponse } from '../index';
import Actions, { AxioslikeResponse } from '../../core/Actions';
import Actions, { ApiResponse } from '../../core/Actions';
import { InnertubeError } from '../../utils/Utils';
import MusicShelf from '../classes/MusicShelf';
@@ -16,11 +16,11 @@ class Artist {
header;
sections;
constructor(response: AxioslikeResponse | ParsedResponse, actions: Actions) {
this.#page = Parser.parseResponse((response as AxioslikeResponse).data);
constructor(response: ApiResponse | ParsedResponse, actions: Actions) {
this.#page = Parser.parseResponse((response as ApiResponse).data);
this.#actions = actions;
this.header = this.page.header.item().as(MusicImmersiveHeader, MusicVisualHeader, MusicHeader);
this.header = this.page.header?.item().as(MusicImmersiveHeader, MusicVisualHeader, MusicHeader);
const music_shelf = this.#page.contents_memo.get('MusicShelf') as MusicShelf[] || [];
const music_carousel_shelf = this.#page.contents_memo.get('MusicCarouselShelf') as MusicCarouselShelf[] || [];
@@ -42,7 +42,7 @@ class Artist {
if (!shelf.endpoint)
throw new InnertubeError('Target shelf (Songs) did not have an endpoint.');
const page = await shelf.endpoint.call(this.#actions, 'YTMUSIC', true) as ParsedResponse;
const page = await shelf.endpoint.call(this.#actions, { client: 'YTMUSIC', parse: true });
const contents = page.contents_memo.get('MusicPlaylistShelf')?.[0]?.as(MusicPlaylistShelf) || null;
return contents;

View File

@@ -1,7 +1,7 @@
import Parser, { ParsedResponse } from '..';
import { InnertubeError } from '../../utils/Utils';
import { AxioslikeResponse } from '../../core/Actions';
import { ApiResponse } from '../../core/Actions';
import Grid from '../classes/Grid';
import SectionList from '../classes/SectionList';
@@ -15,7 +15,7 @@ class Explore {
top_buttons;
sections;
constructor(response: AxioslikeResponse) {
constructor(response: ApiResponse) {
this.#page = Parser.parseResponse(response.data);
const tab = this.#page.contents.item().as(SingleColumnBrowseResults).tabs.get({ selected: true });
@@ -28,7 +28,7 @@ class Explore {
if (!section_list)
throw new InnertubeError('Target tab did not have any content.');
this.top_buttons = section_list.contents.array().firstOfType(Grid)?.items.array().as(MusicNavigationButton) || ([] as MusicNavigationButton[]);
this.top_buttons = section_list.contents.array().firstOfType(Grid)?.items.as(MusicNavigationButton) || ([] as MusicNavigationButton[]);
this.sections = section_list.contents.array().getAll({ type: 'MusicCarouselShelf' }) as MusicCarouselShelf[];
}

View File

@@ -1,5 +1,5 @@
import Parser, { ParsedResponse, SectionListContinuation } from '../index';
import Actions, { AxioslikeResponse } from '../../core/Actions';
import Actions, { ApiResponse } from '../../core/Actions';
import { InnertubeError } from '../../utils/Utils';
import SectionList from '../classes/SectionList';
@@ -13,9 +13,9 @@ class HomeFeed {
sections;
constructor(response: AxioslikeResponse | ParsedResponse, actions: Actions) {
constructor(response: ApiResponse | ParsedResponse, actions: Actions) {
this.#actions = actions;
this.#page = Parser.parseResponse((response as AxioslikeResponse).data);
this.#page = Parser.parseResponse((response as ApiResponse).data);
const tab = this.#page.contents.item().as(SingleColumnBrowseResults).tabs.get({ selected: true });
@@ -43,7 +43,11 @@ class HomeFeed {
if (!this.#continuation)
throw new InnertubeError('Continuation not found.');
const response = await this.#actions.browse(this.#continuation, { is_ctoken: true, client: 'YTMUSIC' });
const response = await this.#actions.execute('/browse', {
client: 'YTMUSIC',
continuation: this.#continuation
});
return new HomeFeed(response, this.#actions);
}

View File

@@ -1,323 +1,180 @@
import Parser, { GridContinuation, MusicShelfContinuation, ParsedResponse, PlaylistPanelContinuation, SectionListContinuation } from '..';
import Actions from '../../core/Actions';
import { InnertubeError } from '../../utils/Utils';
import Parser, { ParsedResponse } from '..';
import Actions, { ApiResponse } from '../../core/Actions';
import DropdownItem from '../classes/DropdownItem';
import Grid from '../classes/Grid';
import MusicShelf from '../classes/MusicShelf';
import MusicSideAlignedItem from '../classes/MusicSideAlignedItem';
import NavigationEndpoint from '../classes/NavigationEndpoint';
import PlaylistPanel from '../classes/PlaylistPanel';
import SectionList from '../classes/SectionList';
type ContentType = 'history' | 'playlists' | 'albums' | 'songs' | 'artists' | 'subscriptions';
import ChipCloud from '../classes/ChipCloud';
import ChipCloudChip from '../classes/ChipCloudChip';
import MusicMultiSelectMenuItem from '../classes/menus/MusicMultiSelectMenuItem';
import MusicSortFilterButton from '../classes/MusicSortFilterButton';
import MusicMenuItemDivider from '../classes/menus/MusicMenuItemDivider';
type Continuation = {
type: 'browse' | 'next';
token: string,
payload?: {}
};
type ItemFilter = ((item: any) => boolean) | null;
type SortBy = 'recently_added' | 'a_z' | 'z_a';
const BROWSE_IDS: { [key: string]: string } = {
'history': 'FEmusic_history',
'playlists': 'FEmusic_liked_playlists',
'albums': 'FEmusic_liked_albums',
'songs': 'FEmusic_liked_videos',
'artists': 'FEmusic_library_corpus_track_artists',
'subscriptions': 'FEmusic_library_corpus_artists'
};
const SORT_BY_TEXTS: { [key: string]: string } = {
'recently_added': 'Recently added',
'a_z': 'A to Z',
'z_a': 'Z to A'
};
const SORT_BY_TEXTS_R: { [key: string]: string } = {};
for (const [ key, value ] of Object.entries(SORT_BY_TEXTS)) {
SORT_BY_TEXTS_R[value] = key;
}
import { InnertubeError } from '../../utils/Utils';
class Library {
#actions;
constructor(actions: Actions) {
this.#actions = actions;
}
#getBrowseId(type: ContentType) {
return BROWSE_IDS[type];
}
async #fetchPage(browse_id: string, fetchArgs = {}) {
const response = await this.#actions.browse(browse_id, { ...fetchArgs, client: 'YTMUSIC' });
return Parser.parseResponse(response.data);
}
/**
* Fetches the list of library items from the endpoint given by `browse_id`
* @param browse_id - id of browse endpoint from which contents are fetched
* @param filter - The filter to apply to fetched items (`null` for no filtering)
* @param fetchArgs - Args to be included in the fetch payload
*/
async #fetchAndParseTabContents(browse_id: string, filter: ItemFilter = null, fetchArgs = {}) {
const getItemsFromDataNode = (node: any) => {
switch (node?.type) {
case 'Grid':
return node.contents?.array();
case 'MusicShelf':
return node.contents;
default:
return [];
}
};
const page = await this.#fetchPage(browse_id, fetchArgs);
const sections = page.contents_memo.get('SectionList')?.[0].as(SectionList).contents.array() as Array<any> || [];
const contents_section = sections.find((section) => section.header?.type === 'ItemSectionTabbedHeader');
const data_node = contents_section?.contents?.[0];
const continuation = data_node?.continuation ? {
type: 'browse',
token: data_node?.continuation
} as Continuation : null;
return new LibraryItemList(getItemsFromDataNode(data_node) || [], filter, continuation, page, this.#actions);
}
/**
* Retrieves the library's playlists
*/
async getPlaylists(args?: { sort_by?: SortBy }) {
const data = await this.#fetchAndParseTabContents(this.#getBrowseId('playlists'), (item) => item.item_type === 'playlist');
const sort_by = args?.sort_by || null;
return sort_by ? this.#applySortBy(data, sort_by) : data;
}
/**
* Retrieves the library's albums
*/
async getAlbums(args?: { sort_by?: SortBy }) {
const data = await this.#fetchAndParseTabContents(this.#getBrowseId('albums'), (item) => item.item_type === 'album');
const sort_by = args?.sort_by || null;
return sort_by ? this.#applySortBy(data, sort_by) : data;
}
/**
* Retrieves the library's artists
*/
async getArtists(args?: { sort_by?: SortBy }) {
const data = await this.#fetchAndParseTabContents(this.#getBrowseId('artists'), (item) => item.item_type === 'library_artist');
const sort_by = args?.sort_by || null;
return sort_by ? this.#applySortBy(data, sort_by) : data;
}
/**
* Retrieves the library's songs
*/
async getSongs(args?: { sort_by?: SortBy | 'random' }) {
const data = await this.#fetchAndParseTabContents(this.#getBrowseId('songs'), (item) => (item.item_type === 'song' || item.item_type === 'video'));
const sort_by = args?.sort_by || null;
const shuffle = (sort_by === 'random');
const shuffle_endpoint = shuffle ?
data.all_items.find((item) =>
item.item_type === 'endpoint' && item.title.toString() === 'Shuffle all'
)?.endpoint as NavigationEndpoint : null;
if (shuffle) {
if (!shuffle_endpoint) {
if (data.items.length <= 1) {
return data;
}
throw new InnertubeError('Unable to obtain endpoint for sort_by value \'random\'');
}
return this.#fetchAndParseShuffledSongs(shuffle_endpoint);
}
return sort_by ? this.#applySortBy(data, sort_by) : data;
}
/**
* Fetches and returns a list of shuffled songs
* @param endpoint - The endpoint of the playlist containing the shuffled songs
*/
async #fetchAndParseShuffledSongs(endpoint: NavigationEndpoint) {
const payload = {
playlist_id: endpoint.payload.playlistId,
params: endpoint.payload.params
};
const response = await this.#actions.next({ ...payload, client: 'YTMUSIC' });
const page = Parser.parseResponse(response.data);
const playlist_panel = page.contents_memo.get('PlaylistPanel')?.[0].as(PlaylistPanel);
const items = playlist_panel?.contents || [];
const continuation = playlist_panel?.continuation ? {
type: 'next',
token: playlist_panel?.continuation,
payload
} as Continuation : null;
const filter = (item: any) => item.type === 'PlaylistPanelVideo';
return new LibraryItemList(items, filter, continuation, page, this.#actions, { sort_by: 'random' });
}
/**
* Retrieves the library's subscriptions
*/
async getSubscriptions(args?: { sort_by?: SortBy }) {
const data = await this.#fetchAndParseTabContents(this.#getBrowseId('subscriptions'));
const sort_by = args?.sort_by || null;
return sort_by ? this.#applySortBy(data, sort_by) : data;
}
/**
* Applies `sort_by` to `data` and returns the result. Original `data` is not modified.
*/
async #applySortBy(data: LibraryItemList, sort_by: SortBy) {
const page = data.page;
const dropdownItem = page?.contents_memo.get('DropdownItem')?.find(
(item) => item.as(DropdownItem).label === SORT_BY_TEXTS[sort_by])?.as(DropdownItem);
if (!dropdownItem?.endpoint?.browse) {
if (data.items.length <= 1) {
return data;
}
throw new InnertubeError(`Unable to obtain browse endpoint for sort_by value '${sort_by}'`);
}
if (dropdownItem?.selected) {
return data;
}
const fetchArgs = { params: dropdownItem.endpoint.browse.params };
return this.#fetchAndParseTabContents(dropdownItem.endpoint.browse.id, data.filter, fetchArgs);
}
/**
* Retrieves recent activity
*/
async getRecentActivity(args: {all: boolean}) {
const all = !!args?.all;
if (all) {
const page = await this.#fetchPage(this.#getBrowseId('history'));
const section_list = page.contents_memo.get('SectionList')?.[0].as(SectionList);
const sections = section_list?.contents?.array() || [];
const continuation = section_list?.continuation ? {
type: 'browse',
token: section_list?.continuation
} as Continuation : null;
return new LibrarySectionList(sections, continuation, page, this.#actions);
}
const page = await this.#fetchPage(this.#getBrowseId('songs'));
const sections = page.contents_memo.get('SectionList')?.[0].as(SectionList).contents.array() as Array<any> || [];
const contents_section = sections.find(
(section) => section.header?.type === 'MusicCarouselShelfBasicHeader' && section.header?.title.toString() === 'Recent activity');
const items = contents_section?.contents || [];
return new LibraryItemList(items, null, null, page, this.#actions, { sort_by: null });
}
}
abstract class LibraryResultsBase {
#continuation;
#page;
#actions;
has_continuation: boolean;
#continuation;
constructor(continuation: Continuation | null, page: ParsedResponse, actions: Actions) {
this.#continuation = continuation;
this.#page = page;
header;
contents;
constructor(response: ApiResponse, actions: Actions) {
this.#page = Parser.parseResponse(response.data);
this.#actions = actions;
this.has_continuation = !!continuation;
const section_list = this.#page.contents_memo.getType(SectionList)?.[0];
this.header = section_list?.header?.item().as(MusicSideAlignedItem);
this.contents = section_list?.contents?.array().as(Grid, MusicShelf);
this.#continuation = this.contents?.find((list: Grid | MusicShelf) => list.continuation)?.continuation;
}
async getContinuation() {
if (!this.#continuation) {
throw new InnertubeError('Continuation not found.');
/**
* Applies given sort filter to the library items.
*/
async applySortFilter(sort_by: string | MusicMultiSelectMenuItem) {
let target_item: MusicMultiSelectMenuItem | undefined;
if (typeof sort_by === 'string') {
const button = this.#page.contents_memo.getType(MusicSortFilterButton)?.[0];
const options = button.menu?.options
.filter(
(item: MusicMultiSelectMenuItem | MusicMenuItemDivider) => item instanceof MusicMultiSelectMenuItem
) as MusicMultiSelectMenuItem[];
target_item = options?.find((item) => item.title === sort_by);
if (!target_item)
throw new InnertubeError(`Sort filter "${sort_by}" not found`, { available_filters: options.map((item) => item.title) });
} else if (sort_by instanceof MusicMultiSelectMenuItem) {
target_item = sort_by;
}
let responsePromise;
const payload = this.#continuation.payload || {};
switch (this.#continuation.type) {
case 'next':
responsePromise = this.#actions.next({ ...payload, ctoken: this.#continuation.token, client: 'YTMUSIC' });
break;
default:
responsePromise = this.#actions.browse(this.#continuation.token, { ...payload, is_ctoken: true, client: 'YTMUSIC' });
}
const response = await responsePromise;
const page = Parser.parseResponse(response.data);
if (!target_item)
throw new InnertubeError('Invalid sort filter');
if (!page.continuation_contents) {
throw new InnertubeError('No continuation data found.');
}
if (target_item.selected)
return this;
return this.parseContinuationContents(page, this.#continuation);
const cmd = target_item.endpoint?.payload?.commands?.find((cmd: any) => cmd.browseSectionListReloadEndpoint)?.browseSectionListReloadEndpoint;
if (!cmd)
throw new InnertubeError('Failed to find sort filter command');
const response = await this.#actions.execute('/browse', {
client: 'YTMUSIC',
continuation: cmd.continuation.reloadContinuationData.continuation
});
return new Library(response, this.#actions);
}
get page() {
/**
* Applies given filter to the library.
*/
async applyFilter(filter: string | ChipCloudChip): Promise<Library> {
let target_chip: ChipCloudChip | undefined;
const chip_cloud = this.#page.contents_memo.getType(ChipCloud)?.[0];
if (typeof filter === 'string') {
target_chip = chip_cloud.chips.get({ text: filter });
if (!target_chip)
throw new InnertubeError(`Filter "${filter}" not found`, { available_filters: this.filters });
} else if (filter instanceof ChipCloudChip) {
target_chip = filter;
}
if (!target_chip)
throw new InnertubeError('Invalid filter', filter);
const target_cmd = new NavigationEndpoint(target_chip.endpoint?.payload?.commands?.[0]);
const response = await target_cmd.call(this.#actions, { client: 'YTMUSIC' });
return new Library(response, this.#actions);
}
/**
* Retrieves continuation of the library items.
*/
async getContinuation(): Promise<LibraryContinuation> {
if (!this.#continuation)
throw new InnertubeError('No continuation available');
const page = await this.#actions.execute('/browse', {
client: 'YTMUSIC',
continuation: this.#continuation
});
return new LibraryContinuation(page, this.#actions);
}
get has_continuation(): boolean {
return !!this.#continuation;
}
get sort_filters(): string[] {
const button = this.#page.contents_memo.getType(MusicSortFilterButton)?.[0];
const options = button.menu?.options.filter((item: MusicMultiSelectMenuItem | MusicMenuItemDivider) => item instanceof MusicMultiSelectMenuItem) as MusicMultiSelectMenuItem[];
return options.map((item) => item.title);
}
get filters(): string[] {
return this.#page.contents_memo.getType(ChipCloud)?.[0].chips.map((chip: ChipCloudChip) => chip.text);
}
get page(): ParsedResponse {
return this.#page;
}
abstract parseContinuationContents(page: ParsedResponse, from_continuation: Continuation): Promise<LibraryResultsBase>;
}
class LibraryItemList extends LibraryResultsBase {
#filter;
class LibraryContinuation {
#page;
#actions;
#all_items; // Unfiltered items
items; // Items after applying filter (if any)
sort_by: SortBy | 'random' | null;
#continuation;
constructor(items: Array<any>, filter: ItemFilter, continuation: Continuation | null, page: ParsedResponse, actions: Actions, overrides?: { sort_by: SortBy | 'random' | null }) {
super(continuation, page, actions);
this.#filter = filter;
contents;
constructor(response: ApiResponse, actions: Actions) {
this.#page = Parser.parseResponse(response.data);
this.#actions = actions;
this.#all_items = items;
this.items = filter ? items.filter(filter) : items;
this.sort_by = (overrides?.sort_by !== undefined) ? overrides.sort_by : this.#getSortBy();
this.contents = this.#page.continuation_contents?.hasKey('contents')
? this.#page.continuation_contents?.key('contents').array() :
this.#page.continuation_contents?.key('items').array();
this.#continuation = this.#page.continuation_contents?.key('continuation').isNull()
? null : this.#page.continuation_contents?.key('continuation').string();
}
async parseContinuationContents(page: ParsedResponse, from_continuation: Continuation) {
const data = page.continuation_contents?.as(MusicShelfContinuation, GridContinuation, PlaylistPanelContinuation);
const continuation = data?.continuation ? { ...from_continuation, token: data?.continuation } : null;
return new LibraryItemList(data?.contents || [], this.#filter, continuation, page, this.#actions, { sort_by: this.sort_by });
async getContinuation(): Promise<LibraryContinuation> {
if (!this.#continuation)
throw new InnertubeError('No continuation available');
const page = await this.#actions.execute('/browse', {
client: 'YTMUSIC',
continuation: this.#continuation
});
return new LibraryContinuation(page, this.#actions);
}
#getSortBy() {
const selected = this.page?.contents_memo.get('DropdownItem')?.filter((item) => item.as(DropdownItem).selected) as DropdownItem[] || [];
for (const s of selected) {
const v = SORT_BY_TEXTS_R[s.label];
if (v) {
return v as SortBy;
}
}
return null;
get has_continuation(): boolean {
return !!this.#continuation;
}
get all_items() {
return this.#all_items;
}
get filter() {
return this.#filter;
}
}
class LibrarySectionList extends LibraryResultsBase {
#actions;
sections;
constructor(sections: Array<any>, continuation: Continuation | null, page: ParsedResponse, actions: Actions) {
super(continuation, page, actions);
this.#actions = actions;
this.sections = sections;
}
async parseContinuationContents(page: ParsedResponse, from_continuation: Continuation) {
const data = page.continuation_contents?.as(SectionListContinuation);
const continuation = data?.continuation ? { ...from_continuation, token: data?.continuation } : null;
return new LibrarySectionList(data?.contents || [], continuation, page, this.#actions);
get page(): ParsedResponse {
return this.#page;
}
}
export { LibraryContinuation };
export default Library;

View File

@@ -1,5 +1,5 @@
import Parser, { MusicPlaylistShelfContinuation, MusicShelfContinuation, ParsedResponse, SectionListContinuation } from '../index';
import Actions, { AxioslikeResponse } from '../../core/Actions';
import Parser, { MusicPlaylistShelfContinuation, ParsedResponse, SectionListContinuation } from '../index';
import Actions, { ApiResponse } from '../../core/Actions';
import MusicCarouselShelf from '../classes/MusicCarouselShelf';
import MusicPlaylistShelf from '../classes/MusicPlaylistShelf';
@@ -15,19 +15,18 @@ class Playlist {
#page;
#actions;
#continuation;
#suggestions_continuation;
#last_fetched_suggestions: any;
#suggestions_continuation: any;
header;
items;
constructor(response: AxioslikeResponse, actions: Actions) {
constructor(response: ApiResponse, actions: Actions) {
this.#actions = actions;
this.#page = Parser.parseResponse(response.data);
this.#suggestions_continuation = this.#page.contents_memo.getType(MusicShelf)?.find(
(shelf) => shelf.title.toString() === 'Suggestions')?.continuation || null;
this.#last_fetched_suggestions = null;
this.#suggestions_continuation = null;
if (this.#page.continuation_contents) {
const data = this.#page.continuation_contents?.as(MusicPlaylistShelfContinuation);
@@ -44,14 +43,6 @@ class Playlist {
}
}
get page(): ParsedResponse {
return this.#page;
}
get has_continuation() {
return !!this.#continuation;
}
/**
* Retrieves playlist items continuation.
*/
@@ -59,7 +50,11 @@ class Playlist {
if (!this.#continuation)
throw new InnertubeError('Continuation not found.');
const response = await this.#actions.browse(this.#continuation, { is_ctoken: true, client: 'YTMUSIC' });
const response = await this.#actions.execute('/browse', {
client: 'YTMUSIC',
continuation: this.#continuation
});
return new Playlist(response, this.#actions);
}
@@ -70,17 +65,25 @@ class Playlist {
let section_continuation = this.#page.contents_memo.get('SectionList')?.[0].as(SectionList).continuation;
while (section_continuation) {
const response = await this.#actions.browse(section_continuation, { is_ctoken: true, client: 'YTMUSIC' });
const data = Parser.parseResponse(response.data);
const data = await this.#actions.execute('/browse', {
client: 'YTMUSIC',
continuation: section_continuation,
parse: true
});
const section_list = data.continuation_contents?.as(SectionListContinuation);
const sections = section_list?.contents?.as(MusicCarouselShelf);
const related = sections?.filter((section) => section.header?.title.toString() === 'Related playlists')[0];
if (related) {
const sections = section_list?.contents?.as(MusicCarouselShelf, MusicShelf);
const related = sections?.filter(
(section) =>
section.is(MusicCarouselShelf) ? section.header?.title.toString() === 'Related playlists' :
section.title.toString() === 'Related playlists'
)[0];
if (related)
return related.contents || [];
}
section_continuation = section_list?.continuation;
}
return [];
@@ -88,7 +91,7 @@ class Playlist {
async getSuggestions(refresh = true) {
const require_fetch = refresh || !this.#last_fetched_suggestions;
const fetch_promise = require_fetch ? this.#fetchSuggestions(this.#suggestions_continuation) : Promise.resolve(null);
const fetch_promise = require_fetch ? this.#fetchSuggestions() : Promise.resolve(null);
const fetch_result = await fetch_promise;
if (fetch_result) {
@@ -99,14 +102,26 @@ class Playlist {
return fetch_result?.items || this.#last_fetched_suggestions;
}
async #fetchSuggestions(continuation: string | null) {
async #fetchSuggestions() {
const continuation = this.#suggestions_continuation || this.#page.contents_memo.get('SectionList')?.[0].as(SectionList).continuation;
if (continuation) {
const response = await this.#actions.browse(continuation, { is_ctoken: true, client: 'YTMUSIC' });
const page = Parser.parseResponse(response.data);
const data = page.continuation_contents?.as(MusicShelfContinuation);
const page = await this.#actions.execute('/browse', {
client: 'YTMUSIC',
continuation: continuation,
parse: true
});
const section_list = page.continuation_contents?.as(SectionListContinuation);
const sections = section_list?.contents?.as(MusicCarouselShelf, MusicShelf);
const suggestions = sections?.filter(
(section) => section.is(MusicShelf) && section.title.toString() === 'Suggestions'
)[0] as MusicShelf | undefined;
return {
items: data?.contents || [],
continuation: data?.continuation || null
items: suggestions?.contents || [],
continuation: suggestions?.continuation || null
};
}
@@ -115,6 +130,14 @@ class Playlist {
continuation: null
};
}
get page(): ParsedResponse {
return this.#page;
}
get has_continuation() {
return !!this.#continuation;
}
}
export default Playlist;

View File

@@ -1,5 +1,5 @@
import Parser, { ParsedResponse } from '../index';
import Actions, { AxioslikeResponse } from '../../core/Actions';
import Actions, { ApiResponse } from '../../core/Actions';
import Playlist from './Playlist';
import MusicHeader from '../classes/MusicHeader';
@@ -22,15 +22,15 @@ class Recap {
header;
sections;
constructor(response: AxioslikeResponse, actions: Actions) {
constructor(response: ApiResponse, actions: Actions) {
this.#page = Parser.parseResponse(response.data);
this.#actions = actions;
const header = this.#page.header.item();
const header = this.#page.header?.item();
this.header = header.is(MusicElementHeader) ?
this.#page.header.item().as(MusicElementHeader).element?.model?.item().as(HighlightsCarousel) :
this.#page.header.item().as(MusicHeader);
this.header = header?.is(MusicElementHeader) ?
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);
@@ -51,7 +51,7 @@ class Recap {
throw new InnertubeError('Recap playlist not available, check back later.');
const endpoint = this.header.panels[0].text_on_tap_endpoint;
const response = await endpoint.callTest(this.#actions, { client: 'YTMUSIC' });
const response = await endpoint.call(this.#actions, { client: 'YTMUSIC' });
return new Playlist(response, this.#actions);
}

View File

@@ -1,5 +1,5 @@
import Parser, { ParsedResponse } from '../index';
import Actions, { AxioslikeResponse } from '../../core/Actions';
import Actions, { ApiResponse } from '../../core/Actions';
import { InnertubeError } from '../../utils/Utils';
@@ -30,12 +30,12 @@ class Search {
results;
sections;
constructor(response: AxioslikeResponse | ParsedResponse, actions: Actions, args: { is_continuation?: boolean, is_filtered?: boolean } = {}) {
constructor(response: ApiResponse | ParsedResponse, actions: Actions, args: { is_continuation?: boolean, is_filtered?: boolean } = {}) {
this.#actions = actions;
this.#page = args.is_continuation ?
response as ParsedResponse :
Parser.parseResponse((response as AxioslikeResponse).data);
Parser.parseResponse((response as ApiResponse).data);
const tab = this.#page.contents.item().as(TabbedSearchResults).tabs.get({ selected: true });
@@ -71,7 +71,7 @@ class 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, 'YTMUSIC', true);
const response = await shelf.endpoint.call(this.#actions, { parse: true, client: 'YTMUSIC' });
if (!response)
throw new InnertubeError('Endpoint did not return any data');
@@ -86,7 +86,11 @@ class Search {
if (!this.#continuation)
throw new InnertubeError('Continuation not found.');
const response = await this.#actions.search({ ctoken: this.#continuation, client: 'YTMUSIC' });
const response = await this.#actions.execute('/search', {
continuation: this.#continuation,
client: 'YTMUSIC'
});
const data = response.data.continuationContents.musicShelfContinuation;
this.results = Parser.parse(data.contents).array().as(MusicResponsiveListItem);
@@ -106,7 +110,7 @@ class Search {
if (filter?.is_selected) return this;
const response = await filter?.endpoint?.call(this.#actions, 'YTMUSIC', true);
const response = await filter?.endpoint?.call(this.#actions, { parse: true, client: 'YTMUSIC' });
if (!response)
throw new InnertubeError('Endpoint did not return any data');

View File

@@ -1,5 +1,5 @@
import Parser, { ParsedResponse } from '..';
import Actions, { AxioslikeResponse } from '../../core/Actions';
import Actions, { ApiResponse } from '../../core/Actions';
import Constants from '../../utils/Constants';
import { InnertubeError } from '../../utils/Utils';
@@ -36,7 +36,7 @@ class TrackInfo {
current_video_endpoint;
player_overlays;
constructor(data: [AxioslikeResponse, AxioslikeResponse?], actions: Actions, cpn: string) {
constructor(data: [ApiResponse, ApiResponse?], actions: Actions, cpn: string) {
this.#actions = actions;
const info = Parser.parseResponse(data[0].data);
@@ -96,7 +96,7 @@ class TrackInfo {
if (target_tab.content)
return target_tab.content;
const page = await target_tab.endpoint.callTest(this.#actions, { client: 'YTMUSIC', parse: true });
const page = await target_tab.endpoint.call(this.#actions, { client: 'YTMUSIC', parse: true });
if (page.contents.item().key('type').string() === 'Message')
return page.contents.item().as(Message);
@@ -121,7 +121,7 @@ class TrackInfo {
if (!automix_preview_video)
throw new InnertubeError('Automix item not found');
const page = await automix_preview_video.playlist_video?.endpoint.callTest(this.#actions, {
const page = await automix_preview_video.playlist_video?.endpoint.call(this.#actions, {
videoId: this.basic_info.id,
client: 'YTMUSIC',
parse: true

View File

@@ -1,7 +1,8 @@
import { CLIENTS } from '../utils/Constants';
import { u8ToBase64 } from '../utils/Utils';
import { VideoMetadata } from '../core/Studio';
import { ChannelAnalytics, CreateCommentParams, CreateCommentReplyParams, GetCommentsSectionParams, InnertubePayload, LiveMessageParams, MusicSearchFilter, NotificationPreferences, PeformCommentActionParams, SearchFilter, SoundInfoParams } from './youtube';
import { ChannelAnalytics, CreateCommentParams, GetCommentsSectionParams, InnertubePayload, LiveMessageParams, MusicSearchFilter, NotificationPreferences, PeformCommentActionParams, SearchFilter } from './youtube';
class Proto {
static encodeChannelAnalyticsParams(channel_id: string) {
@@ -139,28 +140,6 @@ class Proto {
return encodeURIComponent(u8ToBase64(buf));
}
static encodeCommentRepliesParams(video_id: string, comment_id: string) {
const buf = GetCommentsSectionParams.toBinary({
ctx: {
videoId: video_id
},
unkParam: 6,
params: {
repliesOpts: {
videoId: video_id, commentId: comment_id,
unkopts: {
unkParam: 0
},
unkParam1: 1, unkParam2: 10,
channelId: ' ' // XXX: Seems like this can be omitted
},
target: `comment-replies-item-${comment_id}`
}
});
return encodeURIComponent(u8ToBase64(buf));
}
static encodeCommentParams(video_id: string) {
const buf = CreateCommentParams.toBinary({
videoId: video_id,
@@ -172,18 +151,6 @@ class Proto {
return encodeURIComponent(u8ToBase64(buf));
}
static encodeCommentReplyParams(comment_id: string, video_id: string) {
const buf = CreateCommentReplyParams.toBinary({
videoId: video_id,
commentId: comment_id,
params: {
unkNum: 0
},
unkNum: 7
});
return encodeURIComponent(u8ToBase64(buf));
}
static encodeCommentActionParams(type: number, args: {
comment_id?: string,
video_id?: string,
@@ -312,23 +279,6 @@ class Proto {
return buf;
}
static encodeSoundInfoParams(id: string) {
const data: SoundInfoParams = {
sound: {
params: {
ids: {
id1: id,
id2: id,
id3: id
}
}
}
};
const buf = SoundInfoParams.toBinary(data);
return encodeURIComponent(u8ToBase64(buf));
}
}
export default Proto;

View File

@@ -186,18 +186,6 @@ message CreateCommentParams {
required int32 number = 10;
}
message CreateCommentReplyParams {
required string video_id = 2;
required string comment_id = 4;
message UnknownParams {
required int32 unk_num = 1;
}
required UnknownParams params = 5;
optional int32 unk_num = 10;
}
message PeformCommentActionParams {
required int32 type = 1;
required string comment_id = 3;

View File

@@ -1,4 +1,5 @@
import package_json from '../../package.json';
import { Memo } from '../parser/helpers';
import { FetchFunction } from './HTTPClient';
import userAgents from './user-agents.json';
@@ -151,9 +152,18 @@ export function timeToSeconds(time: string) {
}
}
/**
* Throws an error if given parameters are undefined.
*/
export function concatMemos(...iterables: Memo[]) {
const memo = new Memo();
for (const iterable of iterables) {
for (const item of iterable) {
memo.set(...item);
}
}
return memo;
}
export function throwIfMissing(params: object) {
for (const [ key, value ] of Object.entries(params)) {
if (!value)