mirror of
https://github.com/LuanRT/YouTube.js.git
synced 2026-06-13 09:32:12 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea1d206b26 | ||
|
|
aa334aacbd | ||
|
|
1eda93ee08 | ||
|
|
fe0ac0a961 | ||
|
|
8740deb1f2 | ||
|
|
d71b762df5 |
@@ -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);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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
2150
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user