mirror of
https://github.com/LuanRT/YouTube.js.git
synced 2026-06-28 09:06:51 +00:00
chore: clean up build steps
This commit is contained in:
151
src/core/AccountManager.ts
Normal file
151
src/core/AccountManager.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { throwIfMissing, findNode } from '../utils/Utils';
|
||||
import Constants from '../utils/Constants';
|
||||
import Analytics from '../parser/youtube/Analytics';
|
||||
import Proto from '../proto/index';
|
||||
import Actions from './Actions';
|
||||
|
||||
class AccountManager {
|
||||
#actions;
|
||||
channel;
|
||||
settings;
|
||||
|
||||
constructor(actions: Actions) {
|
||||
this.#actions = actions;
|
||||
this.channel = {
|
||||
/**
|
||||
* Edits channel name.
|
||||
*/
|
||||
editName: (new_name: string) => this.#actions.channel('channel/edit_name', { new_name }),
|
||||
/**
|
||||
* Edits channel description.
|
||||
*
|
||||
*/
|
||||
editDescription: (new_description: string) => this.#actions.channel('channel/edit_description', { new_description }),
|
||||
/**
|
||||
* Retrieves basic channel analytics.
|
||||
*/
|
||||
getBasicAnalytics: () => this.getAnalytics()
|
||||
};
|
||||
this.settings = {
|
||||
notifications: {
|
||||
/**
|
||||
* Notify about activity from the channels you're subscribed to.
|
||||
*
|
||||
* @param option - ON | OFF
|
||||
*/
|
||||
setSubscriptions: (option: boolean) => this.#setSetting(Constants.ACCOUNT_SETTINGS.SUBSCRIPTIONS, 'SPaccount_notifications', option),
|
||||
/**
|
||||
* Recommended content notifications.
|
||||
*
|
||||
* @param option - ON | OFF
|
||||
*/
|
||||
setRecommendedVideos: (option: boolean) => this.#setSetting(Constants.ACCOUNT_SETTINGS.RECOMMENDED_VIDEOS, 'SPaccount_notifications', option),
|
||||
/**
|
||||
* Notify about activity on your channel.
|
||||
*
|
||||
* @param option - ON | OFF
|
||||
*/
|
||||
setChannelActivity: (option: boolean) => this.#setSetting(Constants.ACCOUNT_SETTINGS.CHANNEL_ACTIVITY, 'SPaccount_notifications', option),
|
||||
/**
|
||||
* Notify about replies to your comments.
|
||||
*
|
||||
* @param option - ON | OFF
|
||||
*/
|
||||
setCommentReplies: (option: boolean) => this.#setSetting(Constants.ACCOUNT_SETTINGS.COMMENT_REPLIES, 'SPaccount_notifications', option),
|
||||
/**
|
||||
* Notify when others mention your channel.
|
||||
*
|
||||
* @param option - ON | OFF
|
||||
*/
|
||||
setMentions: (option: boolean) => this.#setSetting(Constants.ACCOUNT_SETTINGS.USER_MENTION, 'SPaccount_notifications', option),
|
||||
/**
|
||||
* Notify when others share your content on their channels.
|
||||
*
|
||||
* @param option - ON | OFF
|
||||
*/
|
||||
setSharedContent: (option: boolean) => this.#setSetting(Constants.ACCOUNT_SETTINGS.SHARED_CONTENT, 'SPaccount_notifications', option)
|
||||
},
|
||||
privacy: {
|
||||
/**
|
||||
* If set to true, your subscriptions won't be visible to others.
|
||||
*
|
||||
* @param option - ON | OFF
|
||||
*/
|
||||
setSubscriptionsPrivate: (option: boolean) => this.#setSetting(Constants.ACCOUNT_SETTINGS.SUBSCRIPTIONS_PRIVACY, 'SPaccount_privacy', option),
|
||||
/**
|
||||
* If set to true, saved playlists won't appear on your channel.
|
||||
*
|
||||
* @param option - ON | OFF
|
||||
*/
|
||||
setSavedPlaylistsPrivate: (option: boolean) => this.#setSetting(Constants.ACCOUNT_SETTINGS.PLAYLISTS_PRIVACY, 'SPaccount_privacy', option)
|
||||
}
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Internal method to perform changes on an account's settings.
|
||||
*/
|
||||
async #setSetting(setting_id: string, type: string, new_value: boolean) {
|
||||
throwIfMissing({ setting_id, type, new_value });
|
||||
const response = await this.#actions.browse(type);
|
||||
const contents = (() => {
|
||||
switch (type.trim()) {
|
||||
case 'SPaccount_notifications':
|
||||
return findNode(response.data, 'contents', 'Your preferences', 13, false).options;
|
||||
case 'SPaccount_privacy':
|
||||
return findNode(response.data, 'contents', 'settingsSwitchRenderer', 13, false).options;
|
||||
default:
|
||||
// This is just for maximum compatibility, this is most definitely a bad way to handle this
|
||||
throw new TypeError('undefined is not a function');
|
||||
}
|
||||
})();
|
||||
const option = contents.find((option: any) => option.settingsSwitchRenderer.enableServiceEndpoint.setSettingEndpoint.settingItemIdForClient == setting_id);
|
||||
const setting_item_id = option.settingsSwitchRenderer.enableServiceEndpoint.setSettingEndpoint.settingItemId;
|
||||
const set_setting = await this.#actions.account('account/set_setting', {
|
||||
new_value: type == 'SPaccount_privacy' ? !new_value : new_value,
|
||||
setting_item_id
|
||||
});
|
||||
return set_setting;
|
||||
}
|
||||
/**
|
||||
* Retrieves channel info.
|
||||
*/
|
||||
async getInfo() {
|
||||
const response = await this.#actions.account('account/accounts_list', { client: 'ANDROID' });
|
||||
const account_item_section_renderer = findNode(response.data, 'contents', 'accountItem', 8, false);
|
||||
const profile = account_item_section_renderer.accountItem.serviceEndpoint.signInEndpoint.directSigninUserProfile;
|
||||
const name = profile.accountName;
|
||||
const email = profile.email;
|
||||
const photo = profile.accountPhoto.thumbnails;
|
||||
const subscriber_count = account_item_section_renderer.accountItem.accountByline.runs.map((run: any) => run.text).join('');
|
||||
const channel_id = response.data.contents[0].accountSectionListRenderer.footers[0].accountChannelRenderer.navigationEndpoint.browseEndpoint.browseId;
|
||||
return { name, email, channel_id, subscriber_count, photo };
|
||||
}
|
||||
/**
|
||||
* Retrieves time watched statistics.
|
||||
*/
|
||||
async getTimeWatched() {
|
||||
const response = await this.#actions.browse('SPtime_watched', { client: 'ANDROID' });
|
||||
const rows: any[] = findNode(response.data, 'contents', 'statRowRenderer', 11, false);
|
||||
const stats = rows.map((row: any) => {
|
||||
const renderer = row.statRowRenderer;
|
||||
if (renderer) {
|
||||
return {
|
||||
title: renderer.title.runs.map((run: any) => run.text).join(''),
|
||||
time: renderer.contents.runs.map((run: any) => run.text).join('')
|
||||
};
|
||||
}
|
||||
}).filter((stat: any) => stat);
|
||||
return stats;
|
||||
}
|
||||
/**
|
||||
* Retrieves basic channel analytics.
|
||||
*
|
||||
*/
|
||||
async getAnalytics() {
|
||||
const info = await this.getInfo();
|
||||
const params = Proto.encodeChannelAnalyticsParams(info.channel_id);
|
||||
const response = await this.#actions.browse('FEanalytics_screen', { params, client: 'ANDROID' });
|
||||
return new Analytics(response.data);
|
||||
}
|
||||
}
|
||||
export default AccountManager;
|
||||
699
src/core/Actions.ts
Normal file
699
src/core/Actions.ts
Normal file
@@ -0,0 +1,699 @@
|
||||
import Proto from '../proto/index';
|
||||
import { hasKeys, InnertubeError, MissingParamError, uuidv4 } from '../utils/Utils';
|
||||
import Constants from '../utils/Constants';
|
||||
import Parser, { ParsedResponse } from '../parser/index';
|
||||
import Session from './Session';
|
||||
|
||||
|
||||
export interface BrowseArgs {
|
||||
params?: string;
|
||||
is_ytm?: boolean;
|
||||
is_ctoken?: boolean;
|
||||
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 {
|
||||
success: boolean;
|
||||
status_code: number;
|
||||
data: any;
|
||||
}
|
||||
|
||||
export type ActionsResponse = Promise<AxioslikeResponse>;
|
||||
|
||||
class Actions {
|
||||
#session;
|
||||
constructor(session: Session) {
|
||||
this.#session = session;
|
||||
}
|
||||
get session() {
|
||||
return this.#session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mimmics the Axios API using Fetch's Response object.
|
||||
*/
|
||||
async #wrap(response: Response) {
|
||||
return {
|
||||
success: response.ok,
|
||||
status_code: response.status,
|
||||
data: await response.json()
|
||||
};
|
||||
}
|
||||
/**
|
||||
* 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.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') {
|
||||
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[]; // TODO: this was a string before, but I made it an array, is this correct?
|
||||
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':
|
||||
// Note: this is currently broken due to a recent refactor
|
||||
// TODO: this should be implemented
|
||||
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;
|
||||
} = {}) {
|
||||
const data: Record<string, any> = { client: args.client };
|
||||
if (args.ctoken) {
|
||||
data.continuation = args.ctoken;
|
||||
}
|
||||
if (args.video_id) {
|
||||
data.videoId = args.video_id;
|
||||
}
|
||||
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.
|
||||
*/
|
||||
async getVideoInfo(id: string, cpn?: string, client?: string) {
|
||||
const data: Record<string, any> = {
|
||||
playbackContext: {
|
||||
contentPlaybackContext: {
|
||||
vis: 0,
|
||||
splay: false,
|
||||
referer: 'https://www.youtube.com',
|
||||
currentUrl: `/watch?v=${id}`,
|
||||
autonavState: 'STATE_OFF',
|
||||
signatureTimestamp: this.#session.player.sts,
|
||||
autoCaptionsDefaultOn: false,
|
||||
html5Preference: 'HTML5_PREF_WANTS',
|
||||
lactMilliseconds: '-1'
|
||||
}
|
||||
},
|
||||
attestationRequest: {
|
||||
omitBotguardData: true
|
||||
},
|
||||
videoId: id
|
||||
};
|
||||
if (client) {
|
||||
data.client = client;
|
||||
}
|
||||
if (cpn) {
|
||||
data.cpn = cpn;
|
||||
}
|
||||
const response = await this.#session.http.fetch('/player', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
return this.#wrap(response);
|
||||
}
|
||||
/**
|
||||
* Covers search suggestion endpoints.
|
||||
*/
|
||||
async getSearchSuggestions(client: 'YOUTUBE' | 'YTMUSIC', query: string) {
|
||||
if (![ 'YOUTUBE', 'YTMUSIC' ].includes(client))
|
||||
throw new InnertubeError('Invalid client', client);
|
||||
const response = await ({
|
||||
YOUTUBE: async () => {
|
||||
const params = new URLSearchParams({
|
||||
q: query,
|
||||
ds: 'yt',
|
||||
client: 'youtube',
|
||||
xssi: 't',
|
||||
oe: 'UTF',
|
||||
gl: this.#session.context.client.gl,
|
||||
hl: this.#session.context.client.hl
|
||||
});
|
||||
const response = await this.#session.http.fetch(`search?${params.toString()}`, {
|
||||
baseURL: Constants.URLS.YT_SUGGESTIONS,
|
||||
method: 'GET'
|
||||
});
|
||||
return this.#wrap(response);
|
||||
},
|
||||
YTMUSIC: () => this.music('get_search_suggestions', {
|
||||
input: query
|
||||
})
|
||||
}[client])();
|
||||
return 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);
|
||||
}
|
||||
/**
|
||||
* Executes an API call.
|
||||
* @param action - endpoint
|
||||
* @param args - call arguments
|
||||
*/
|
||||
async execute(action: string, args: {
|
||||
[key: string]: any;
|
||||
parse: true;
|
||||
}) : Promise<ParsedResponse>;
|
||||
async execute(action: string, args: {
|
||||
[key: string]: any;
|
||||
parse?: false;
|
||||
}) : Promise<ActionsResponse>;
|
||||
async execute(action: string, args: {
|
||||
[key: string]: any;
|
||||
parse?: boolean;
|
||||
}): Promise<ParsedResponse | ActionsResponse> {
|
||||
const data = { ...args };
|
||||
if (Reflect.has(data, 'parse'))
|
||||
delete data.parse;
|
||||
if (Reflect.has(data, 'request'))
|
||||
delete data.request;
|
||||
if (Reflect.has(data, 'clientActions'))
|
||||
delete data.clientActions;
|
||||
if (Reflect.has(data, 'action')) {
|
||||
data.actions = [ data.action ];
|
||||
delete data.action;
|
||||
}
|
||||
if (Reflect.has(data, 'token')) {
|
||||
data.continuation = data.token;
|
||||
delete data.token;
|
||||
}
|
||||
const response = await this.#session.http.fetch(action, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
if (args.parse) {
|
||||
return Parser.parseResponse(await response.json());
|
||||
}
|
||||
return this.#wrap(response);
|
||||
}
|
||||
#needsLogin(id: string) {
|
||||
return [
|
||||
'FElibrary',
|
||||
'FEhistory',
|
||||
'FEsubscriptions',
|
||||
'SPaccount_notifications',
|
||||
'SPaccount_privacy',
|
||||
'SPtime_watched'
|
||||
].includes(id);
|
||||
}
|
||||
}
|
||||
// TODO: maybe do this inferrance in a more elegant way
|
||||
export default Actions;
|
||||
173
src/core/Feed.ts
Normal file
173
src/core/Feed.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import AppendContinuationItemsAction from '../parser/classes/actions/AppendContinuationItemsAction';
|
||||
import BackstagePost from '../parser/classes/BackstagePost';
|
||||
import Channel from '../parser/classes/Channel';
|
||||
import CompactVideo from '../parser/classes/CompactVideo';
|
||||
import ContinuationItem from '../parser/classes/ContinuationItem';
|
||||
import GridChannel from '../parser/classes/GridChannel';
|
||||
import GridPlaylist from '../parser/classes/GridPlaylist';
|
||||
import GridVideo from '../parser/classes/GridVideo';
|
||||
import Playlist from '../parser/classes/Playlist';
|
||||
import PlaylistPanelVideo from '../parser/classes/PlaylistPanelVideo';
|
||||
import PlaylistVideo from '../parser/classes/PlaylistVideo';
|
||||
import Post from '../parser/classes/Post';
|
||||
import ReelShelf from '../parser/classes/ReelShelf';
|
||||
import RichShelf from '../parser/classes/RichShelf';
|
||||
import Shelf from '../parser/classes/Shelf';
|
||||
import Tab from '../parser/classes/Tab';
|
||||
import TwoColumnBrowseResults from '../parser/classes/TwoColumnBrowseResults';
|
||||
import TwoColumnSearchResults from '../parser/classes/TwoColumnSearchResults';
|
||||
import Video from '../parser/classes/Video';
|
||||
import WatchCardCompactVideo from '../parser/classes/WatchCardCompactVideo';
|
||||
import { Memo, ObservedArray } from '../parser/helpers';
|
||||
import Parser, { ParsedResponse, ReloadContinuationItemsCommand } from '../parser/index';
|
||||
import { InnertubeError } from '../utils/Utils';
|
||||
import Actions from './Actions';
|
||||
|
||||
// TODO: add a way subdivide into sections and return subfeeds?
|
||||
class Feed {
|
||||
#page: ParsedResponse;
|
||||
#continuation?: ObservedArray<ContinuationItem>;
|
||||
#actions;
|
||||
#memo;
|
||||
constructor(actions: Actions, data: any, already_parsed = false) {
|
||||
if (data.on_response_received_actions || data.on_response_received_endpoints || already_parsed) {
|
||||
this.#page = data;
|
||||
} else {
|
||||
this.#page = Parser.parseResponse(data);
|
||||
}
|
||||
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;
|
||||
if (!memo)
|
||||
throw new InnertubeError('No memo found in feed');
|
||||
this.#memo = memo;
|
||||
this.#actions = actions;
|
||||
}
|
||||
/**
|
||||
* Get all videos on a given page via memo
|
||||
*/
|
||||
static getVideosFromMemo(memo: Memo) {
|
||||
return memo.getType<Video | GridVideo | CompactVideo | PlaylistVideo | PlaylistPanelVideo | WatchCardCompactVideo>([
|
||||
Video,
|
||||
GridVideo,
|
||||
CompactVideo,
|
||||
PlaylistVideo,
|
||||
PlaylistPanelVideo,
|
||||
WatchCardCompactVideo
|
||||
]);
|
||||
}
|
||||
/**
|
||||
* Get all playlists on a given page via memo
|
||||
*/
|
||||
static getPlaylistsFromMemo(memo: Memo) {
|
||||
return memo.getType<Playlist | GridPlaylist>([ Playlist, GridPlaylist ]);
|
||||
}
|
||||
/**
|
||||
* Get all the videos in the feed
|
||||
*/
|
||||
get videos() {
|
||||
return Feed.getVideosFromMemo(this.#memo);
|
||||
}
|
||||
/**
|
||||
* Get all the community posts in the feed
|
||||
*/
|
||||
get posts() {
|
||||
return this.#memo.getType<Post | BackstagePost>([ BackstagePost, Post ]);
|
||||
}
|
||||
/**
|
||||
* Get all the channels in the feed
|
||||
*/
|
||||
get channels() {
|
||||
return this.#memo.getType<Channel | GridChannel>([ Channel, GridChannel ]);
|
||||
}
|
||||
/**
|
||||
* Get all playlists in the feed
|
||||
*/
|
||||
get playlists() {
|
||||
return Feed.getPlaylistsFromMemo(this.#memo);
|
||||
}
|
||||
get memo() {
|
||||
return this.#memo;
|
||||
}
|
||||
/**
|
||||
* Returns contents from the page.
|
||||
*/
|
||||
get contents() {
|
||||
const tab_content = this.#memo.getType(Tab)?.[0]?.content.item();
|
||||
const reload_continuation_items = this.#memo.getType(ReloadContinuationItemsCommand)?.[0];
|
||||
const append_continuation_items = this.#memo.getType(AppendContinuationItemsAction)?.[0];
|
||||
return tab_content || reload_continuation_items || append_continuation_items;
|
||||
}
|
||||
/**
|
||||
* Returns all segments/sections from the page.
|
||||
*/
|
||||
get shelves() {
|
||||
return this.#memo.getType<Shelf | RichShelf | ReelShelf>([ Shelf, RichShelf, ReelShelf ]);
|
||||
}
|
||||
/**
|
||||
* Finds shelf by title.
|
||||
*
|
||||
*/
|
||||
getShelf(title: string) {
|
||||
return this.shelves.find((shelf) => shelf.title.toString() === title);
|
||||
}
|
||||
/**
|
||||
* Returns secondary contents from the page.
|
||||
*/
|
||||
get secondary_contents() {
|
||||
if (!this.#page.contents.is_node)
|
||||
return undefined;
|
||||
const node = this.#page.contents.item();
|
||||
if (!node.is(TwoColumnBrowseResults, TwoColumnSearchResults))
|
||||
return undefined;
|
||||
return node.secondary_contents;
|
||||
}
|
||||
get actions() {
|
||||
return this.#actions;
|
||||
}
|
||||
/**
|
||||
* Get the original page data
|
||||
*/
|
||||
get page() {
|
||||
return this.#page;
|
||||
}
|
||||
/**
|
||||
* Checks if the feed has continuation.
|
||||
*
|
||||
*/
|
||||
get has_continuation() {
|
||||
return (this.#memo.get('ContinuationItem') || []).length > 0;
|
||||
}
|
||||
/**
|
||||
* Retrieves continuation data as it is.
|
||||
*/
|
||||
async getContinuationData(): Promise<ParsedResponse | undefined> {
|
||||
if (this.#continuation) {
|
||||
if (this.#continuation.length > 1)
|
||||
throw new InnertubeError('There are too many continuations, you\'ll need to find the correct one yourself in this.page');
|
||||
if (this.#continuation.length === 0)
|
||||
throw new InnertubeError('There are no continuations');
|
||||
const response = await this.#continuation[0].endpoint.call(this.#actions, undefined, true);
|
||||
return response;
|
||||
}
|
||||
this.#continuation = this.#memo.getType(ContinuationItem);
|
||||
if (this.#continuation)
|
||||
return this.getContinuationData();
|
||||
|
||||
}
|
||||
/**
|
||||
* Retrieves next batch of contents and returns a new {@link Feed} object.
|
||||
*
|
||||
*/
|
||||
async getContinuation() {
|
||||
const continuation_data = await this.getContinuationData();
|
||||
return new Feed(this.actions, continuation_data, true);
|
||||
}
|
||||
}
|
||||
export default Feed;
|
||||
54
src/core/FilterableFeed.ts
Normal file
54
src/core/FilterableFeed.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import ChipCloudChip from '../parser/classes/ChipCloudChip';
|
||||
import FeedFilterChipBar from '../parser/classes/FeedFilterChipBar';
|
||||
import { ObservedArray } from '../parser/helpers';
|
||||
import { InnertubeError } from '../utils/Utils';
|
||||
import Actions from './Actions';
|
||||
import Feed from './Feed';
|
||||
|
||||
class FilterableFeed extends Feed {
|
||||
#chips?: ObservedArray<ChipCloudChip>;
|
||||
constructor(actions: Actions, data: any, already_parsed = false) {
|
||||
super(actions, data, already_parsed);
|
||||
}
|
||||
/**
|
||||
* Get filters for the feed
|
||||
*
|
||||
*/
|
||||
get filter_chips() {
|
||||
if (this.#chips)
|
||||
return this.#chips || [];
|
||||
if (this.memo.getType(FeedFilterChipBar)?.length > 1)
|
||||
throw new InnertubeError('There are too many feed filter chipbars, you\'ll need to find the correct one yourself in this.page');
|
||||
if (this.memo.getType(FeedFilterChipBar)?.length === 0)
|
||||
throw new InnertubeError('There are no feed filter chipbars');
|
||||
this.#chips = this.memo.getType(ChipCloudChip);
|
||||
return this.#chips || [];
|
||||
}
|
||||
get filters() {
|
||||
return this.filter_chips.map((chip) => chip.text.toString()) || [];
|
||||
}
|
||||
/**
|
||||
* Applies given filter and returns a new {@link Feed} object.
|
||||
*/
|
||||
async getFilteredFeed(filter: string | ChipCloudChip) {
|
||||
let target_filter: ChipCloudChip | undefined;
|
||||
if (typeof filter === 'string') {
|
||||
if (!this.filters.includes(filter))
|
||||
throw new InnertubeError('Filter not found', {
|
||||
available_filters: this.filters
|
||||
});
|
||||
target_filter = this.filter_chips.find((chip) => chip.text.toString() === filter);
|
||||
} else if (filter.type === 'ChipCloudChip') {
|
||||
target_filter = filter;
|
||||
} else {
|
||||
throw new InnertubeError('Invalid filter');
|
||||
}
|
||||
if (!target_filter)
|
||||
throw new InnertubeError('Filter not found');
|
||||
if (target_filter.is_selected)
|
||||
return this;
|
||||
const response = await target_filter.endpoint?.call(this.actions, undefined, true);
|
||||
return new Feed(this.actions, response, true);
|
||||
}
|
||||
}
|
||||
export default FilterableFeed;
|
||||
93
src/core/InteractionManager.ts
Normal file
93
src/core/InteractionManager.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { throwIfMissing, findNode } from '../utils/Utils';
|
||||
import Actions from './Actions';
|
||||
|
||||
class InteractionManager {
|
||||
#actions;
|
||||
constructor(actions: Actions) {
|
||||
this.#actions = actions;
|
||||
}
|
||||
/**
|
||||
* Likes a given video.
|
||||
*/
|
||||
async like(video_id: string) {
|
||||
throwIfMissing({ video_id });
|
||||
const action = await this.#actions.engage('like/like', { video_id });
|
||||
return action;
|
||||
}
|
||||
/**
|
||||
* Dislikes a given video.
|
||||
*/
|
||||
async dislike(video_id: string) {
|
||||
throwIfMissing({ video_id });
|
||||
const action = await this.#actions.engage('like/dislike', { video_id });
|
||||
return action;
|
||||
}
|
||||
/**
|
||||
* Removes a like/dislike.
|
||||
*/
|
||||
async removeLike(video_id: string) {
|
||||
throwIfMissing({ video_id });
|
||||
const action = await this.#actions.engage('like/removelike', { video_id });
|
||||
return action;
|
||||
}
|
||||
/**
|
||||
* Subscribes to a given channel.
|
||||
*/
|
||||
async subscribe(channel_id: string) {
|
||||
throwIfMissing({ channel_id });
|
||||
const action = await this.#actions.engage('subscription/subscribe', { channel_id });
|
||||
return action;
|
||||
}
|
||||
/**
|
||||
* Unsubscribes from a given channel.
|
||||
*/
|
||||
async unsubscribe(channel_id: string) {
|
||||
throwIfMissing({ channel_id });
|
||||
const action = await this.#actions.engage('subscription/unsubscribe', { channel_id });
|
||||
return action;
|
||||
}
|
||||
/**
|
||||
* Posts a comment on a given video.
|
||||
*/
|
||||
async comment(video_id: string, text: string) {
|
||||
throwIfMissing({ video_id, text });
|
||||
const action = await this.#actions.engage('comment/create_comment', { video_id, text });
|
||||
return action;
|
||||
}
|
||||
/**
|
||||
* Translates a given text using YouTube's comment translate feature.
|
||||
*
|
||||
* @param target_language - an ISO language code
|
||||
* @param args - optional arguments
|
||||
*/
|
||||
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 translated_content = findNode(response.data, 'frameworkUpdates', 'content', 7, false);
|
||||
return {
|
||||
success: response.success,
|
||||
status_code: response.status_code,
|
||||
translated_content: translated_content.content,
|
||||
data: response.data
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Changes notification preferences for a given channel.
|
||||
* Only works with channels you are subscribed to.
|
||||
*/
|
||||
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' });
|
||||
return action;
|
||||
}
|
||||
}
|
||||
export default InteractionManager;
|
||||
168
src/core/Music.ts
Normal file
168
src/core/Music.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import Parser from '../parser/index';
|
||||
import { observe, YTNode } from '../parser/helpers';
|
||||
import Search from '../parser/ytmusic/Search';
|
||||
import HomeFeed from '../parser/ytmusic/HomeFeed';
|
||||
import Explore from '../parser/ytmusic/Explore';
|
||||
import Library from '../parser/ytmusic/Library';
|
||||
import Artist from '../parser/ytmusic/Artist';
|
||||
import Album from '../parser/ytmusic/Album';
|
||||
import { InnertubeError, throwIfMissing } from '../utils/Utils';
|
||||
import Session from './Session';
|
||||
import SingleColumnBrowseResults from '../parser/classes/SingleColumnBrowseResults';
|
||||
import TabbedSearchResults from '../parser/classes/TabbedSearchResults';
|
||||
import TwoColumnBrowseResults from '../parser/classes/TwoColumnBrowseResults';
|
||||
import Tab from '../parser/classes/Tab';
|
||||
import SearchSuggestionsSection from '../parser/classes/SearchSuggestionsSection';
|
||||
import NavigationEndpoint from '../parser/classes/NavigationEndpoint';
|
||||
import MusicDescriptionShelf from '../parser/classes/MusicDescriptionShelf';
|
||||
import MusicCarouselShelf from '../parser/classes/MusicCarouselShelf';
|
||||
|
||||
class Music {
|
||||
#actions;
|
||||
constructor(session: Session) {
|
||||
this.#actions = session.actions;
|
||||
}
|
||||
/**
|
||||
* Searches on YouTube Music.
|
||||
*/
|
||||
async search(query: string, filters: {
|
||||
type?: 'all' | 'song' | 'video' | 'album' | 'playlist' | 'artist';
|
||||
} = {}) {
|
||||
throwIfMissing({ query });
|
||||
const response = await this.#actions.search({ query, filters, client: 'YTMUSIC' });
|
||||
return new Search(response, this.#actions, {
|
||||
is_filtered: Reflect.has(filters, 'type') && filters.type !== 'all'
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Retrieves the home feed.
|
||||
*
|
||||
*/
|
||||
async getHomeFeed() {
|
||||
const response = await this.#actions.browse('FEmusic_home', { client: 'YTMUSIC' });
|
||||
return new HomeFeed(response, this.#actions);
|
||||
}
|
||||
/**
|
||||
* Retrieves the Explore feed.
|
||||
*
|
||||
*/
|
||||
async getExplore() {
|
||||
const response = await this.#actions.browse('FEmusic_explore', { client: 'YTMUSIC' });
|
||||
return new Explore(response);
|
||||
// TODO: return new Explore(response, this.#actions);
|
||||
}
|
||||
/**
|
||||
* Retrieves the Library.
|
||||
*
|
||||
*/
|
||||
async getLibrary() {
|
||||
const response = await this.#actions.browse('FEmusic_liked_albums', { client: 'YTMUSIC' });
|
||||
return new Library(response);
|
||||
// TODO: return new Library(response, this.#actions);
|
||||
}
|
||||
/**
|
||||
* Retrieves artist's info & content.
|
||||
*
|
||||
*/
|
||||
async getArtist(artist_id: string) {
|
||||
throwIfMissing({ artist_id });
|
||||
if (!artist_id.startsWith('UC'))
|
||||
throw new InnertubeError('Invalid artist id', artist_id);
|
||||
const response = await this.#actions.browse(artist_id, { client: 'YTMUSIC' });
|
||||
return new Artist(response, this.#actions);
|
||||
}
|
||||
/**
|
||||
* Retrieves album.
|
||||
*
|
||||
*/
|
||||
async getAlbum(album_id: string) {
|
||||
throwIfMissing({ album_id });
|
||||
if (!album_id.startsWith('MPR'))
|
||||
throw new InnertubeError('Invalid album id', album_id);
|
||||
const response = await this.#actions.browse(album_id, { client: 'YTMUSIC' });
|
||||
return new Album(response, this.#actions);
|
||||
}
|
||||
/**
|
||||
* Retrieves song lyrics.
|
||||
*
|
||||
*/
|
||||
async getLyrics(video_id: string) {
|
||||
throwIfMissing({ video_id });
|
||||
const response = await this.#actions.next({ video_id, client: 'YTMUSIC' });
|
||||
const data = Parser.parseResponse(response.data);
|
||||
const node = data.contents.item();
|
||||
if (!node.is(SingleColumnBrowseResults, TabbedSearchResults, TwoColumnBrowseResults))
|
||||
throw new InnertubeError('Invalid id', video_id);
|
||||
const tab = node.tabs.array().get({ title: 'Lyrics' });
|
||||
const page = await tab?.key('endpoint').nodeOfType(NavigationEndpoint).call(this.#actions, 'YTMUSIC', true);
|
||||
if (!page)
|
||||
throw new InnertubeError('Invalid video id');
|
||||
if (page.contents.constructor.name === 'Message')
|
||||
throw new InnertubeError(page.contents.item().key('text').any(), video_id);
|
||||
const description_shelf = page.contents.item().key('contents').parsed().array().get({ type: 'MusicDescriptionShelf' })?.as(MusicDescriptionShelf);
|
||||
return {
|
||||
text: description_shelf?.description.toString(),
|
||||
footer: description_shelf?.footer
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Retrieves up next.
|
||||
*
|
||||
*/
|
||||
async getUpNext(video_id: string) {
|
||||
throwIfMissing({ video_id });
|
||||
const response = await this.#actions.next({ video_id, client: 'YTMUSIC' });
|
||||
const data = Parser.parseResponse(response.data);
|
||||
const node = data.contents.item();
|
||||
if (!node.is(SingleColumnBrowseResults, TabbedSearchResults, TwoColumnBrowseResults))
|
||||
throw new InnertubeError('Invalid id', video_id);
|
||||
const tab = node.tabs.array().get({ title: 'Up next' });
|
||||
// TODO: verify this is a Tab
|
||||
const upnext_content = tab?.as(Tab).content.item().key('content').any();
|
||||
if (!upnext_content)
|
||||
throw new InnertubeError('Invalid id', video_id);
|
||||
return {
|
||||
id: upnext_content.playlist_id,
|
||||
title: upnext_content.title,
|
||||
is_editable: upnext_content.is_editable,
|
||||
contents: observe(upnext_content.contents)
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Retrieves related content.
|
||||
*
|
||||
*/
|
||||
async getRelated(video_id: string) {
|
||||
throwIfMissing({ video_id });
|
||||
const response = await this.#actions.next({ video_id, client: 'YTMUSIC' });
|
||||
const data = Parser.parseResponse(response.data);
|
||||
const node = data.contents.item();
|
||||
if (!node.is(SingleColumnBrowseResults, TabbedSearchResults, TwoColumnBrowseResults))
|
||||
throw new InnertubeError('Invalid id', video_id);
|
||||
const tab = node.tabs.array().get({ title: 'Related' });
|
||||
const page = await tab?.key('endpoint').nodeOfType(NavigationEndpoint).call(this.#actions, 'YTMUSIC', true);
|
||||
if (!page)
|
||||
throw new InnertubeError('Invalid video id');
|
||||
const shelves = page.contents.item().key('contents').parsed().array().filterType(MusicCarouselShelf);
|
||||
const info = page.contents.item().key('contents').parsed().array().get({ type: 'MusicDescriptionShelf' })?.as(MusicDescriptionShelf);
|
||||
return {
|
||||
sections: shelves,
|
||||
info: info?.description.toString() || ''
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Retrieves search suggestions for the given query.
|
||||
*/
|
||||
async getSearchSuggestions(query: string) {
|
||||
const response = await this.#actions.execute('/music/get_search_suggestions', {
|
||||
parse: true,
|
||||
input: query,
|
||||
client: 'YTMUSIC'
|
||||
});
|
||||
const search_suggestions_section = response.contents_memo.getType(SearchSuggestionsSection)?.[0];
|
||||
if (!search_suggestions_section.contents.is_array)
|
||||
return observe([] as YTNode[]);
|
||||
return search_suggestions_section?.contents.array();
|
||||
}
|
||||
}
|
||||
export default Music;
|
||||
207
src/core/OAuth.ts
Normal file
207
src/core/OAuth.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import Constants from '../utils/Constants';
|
||||
import { OAuthError, uuidv4 } from '../utils/Utils';
|
||||
import Session from './Session';
|
||||
|
||||
export interface Credentials {
|
||||
/**
|
||||
* Token used to sign in.
|
||||
*/
|
||||
access_token: string;
|
||||
/**
|
||||
* Token used to get a new access token.
|
||||
*/
|
||||
refresh_token: string;
|
||||
/**
|
||||
* Access token's expiration date, which is usually 24hrs-ish.
|
||||
*/
|
||||
expires: Date;
|
||||
}
|
||||
|
||||
// TODO: actual type info for this.
|
||||
export type OAuthAuthPendingData = any;
|
||||
|
||||
export type OAuthAuthEventHandler = (data: {
|
||||
credentials: Credentials;
|
||||
status: 'SUCCESS';
|
||||
}) => any;
|
||||
export type OAuthAuthPendingEventHandler = (data: OAuthAuthPendingData) => any;
|
||||
export type OAuthAuthErrorEventHandler = (err: OAuthError) => any;
|
||||
|
||||
class OAuth {
|
||||
#identity?: Record<string, string>;
|
||||
#session: Session;
|
||||
#credentials?: Credentials;
|
||||
#polling_interval = 5;
|
||||
constructor(session: Session) {
|
||||
this.#session = session;
|
||||
}
|
||||
/**
|
||||
* Starts the auth flow in case no valid credentials are available.
|
||||
*/
|
||||
async init(credentials?: Credentials) {
|
||||
this.#credentials = credentials;
|
||||
if (!credentials) {
|
||||
await this.#getUserCode();
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Asks the server for a user code and verification URL.
|
||||
*/
|
||||
async #getUserCode() {
|
||||
this.#identity = await this.#getClientIdentity();
|
||||
const data = {
|
||||
client_id: this.#identity.client_id,
|
||||
scope: Constants.OAUTH.SCOPE,
|
||||
device_id: uuidv4(),
|
||||
model_name: Constants.OAUTH.MODEL_NAME
|
||||
};
|
||||
const response = await this.#session.http.fetch_function(new URL('/o/oauth2/device/code', Constants.URLS.YT_BASE), {
|
||||
body: JSON.stringify(data),
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
const response_data = await response.json();
|
||||
this.#session.emit('auth-pending', response_data);
|
||||
this.#polling_interval = response_data.interval;
|
||||
this.#startPolling(response_data.device_code);
|
||||
}
|
||||
/**
|
||||
* Polls the authorization server until access is granted by the user.
|
||||
*/
|
||||
#startPolling(device_code: string) {
|
||||
const poller = setInterval(async () => {
|
||||
const data = {
|
||||
...this.#identity,
|
||||
code: device_code,
|
||||
grant_type: Constants.OAUTH.GRANT_TYPE
|
||||
};
|
||||
try {
|
||||
const response = await this.#session.http.fetch_function(new URL('/o/oauth2/token', Constants.URLS.YT_BASE), {
|
||||
body: JSON.stringify(data),
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
const response_data = await response.json();
|
||||
if (response_data.error) {
|
||||
switch (response_data.error) {
|
||||
case 'access_denied':
|
||||
this.#session.emit('auth-error', new OAuthError('Access was denied.', { status: 'ACCESS_DENIED' }));
|
||||
break;
|
||||
case 'expired_token':
|
||||
this.#session.emit('auth-error', new OAuthError('The device code has expired, restarting auth flow.', { status: 'DEVICE_CODE_EXPIRED' }));
|
||||
clearInterval(poller);
|
||||
this.#getUserCode();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const expiration_date = new Date(new Date().getTime() + response_data.expires_in * 1000);
|
||||
this.#credentials = {
|
||||
access_token: response_data.access_token,
|
||||
refresh_token: response_data.refresh_token,
|
||||
expires: expiration_date
|
||||
};
|
||||
this.#session.emit('auth', {
|
||||
credentials: this.#credentials,
|
||||
status: 'SUCCESS'
|
||||
});
|
||||
clearInterval(poller);
|
||||
} catch (err) {
|
||||
clearInterval(poller);
|
||||
return this.#session.emit('auth-error', new OAuthError('Could not obtain user code.', { status: 'FAILED', error: err }));
|
||||
}
|
||||
}, this.#polling_interval * 1000);
|
||||
}
|
||||
/**
|
||||
* Refreshes the access token if necessary.
|
||||
*/
|
||||
async checkAccessTokenValidity() {
|
||||
const timestamp = this.#credentials ? new Date(this.#credentials.expires).getTime() : -Infinity;
|
||||
if (new Date().getTime() > timestamp) {
|
||||
await this.#refreshAccessToken();
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Retrieves a new access token using the refresh token.
|
||||
*/
|
||||
async #refreshAccessToken() {
|
||||
if (!this.#credentials) return;
|
||||
this.#identity = await this.#getClientIdentity();
|
||||
const data = {
|
||||
...this.#identity,
|
||||
refresh_token: this.#credentials.refresh_token,
|
||||
grant_type: 'refresh_token'
|
||||
};
|
||||
const response = await this.#session.http.fetch_function(new URL('/o/oauth2/token', Constants.URLS.YT_BASE), {
|
||||
body: JSON.stringify(data),
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
if (response instanceof Error) {
|
||||
const error = new OAuthError('Could not refresh access token.', { status: 'FAILED' });
|
||||
this.#session.emit('update-credentials', error);
|
||||
throw error;
|
||||
}
|
||||
const response_data = await response.json();
|
||||
const expiration_date = new Date(new Date().getTime() + response_data.expires_in * 1000);
|
||||
this.#credentials = {
|
||||
access_token: response_data.access_token,
|
||||
refresh_token: response_data.refresh_token || this.#credentials.refresh_token,
|
||||
expires: expiration_date
|
||||
};
|
||||
this.#session.emit('update-credentials', {
|
||||
credentials: this.#credentials,
|
||||
status: 'SUCCESS'
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Revokes credentials.
|
||||
*/
|
||||
revokeCredentials() {
|
||||
if (!this.#credentials) return;
|
||||
return this.#session.http.fetch_function(new URL(`/o/oauth2/revoke?token=${encodeURIComponent(this.#credentials.access_token)}`, Constants.URLS.YT_BASE), {
|
||||
method: 'post'
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Retrieves client identity from YouTube TV.
|
||||
*/
|
||||
async #getClientIdentity() {
|
||||
const response = await this.#session.http.fetch_function(new URL('/tv', Constants.URLS.YT_BASE), {
|
||||
headers: Constants.OAUTH.HEADERS
|
||||
});
|
||||
const response_data = await response.text();
|
||||
const url_body = Constants.OAUTH.REGEX.AUTH_SCRIPT.exec(response_data)?.[1];
|
||||
if (!url_body)
|
||||
throw new OAuthError('Could not obtain script url.', { status: 'FAILED' });
|
||||
const script = await this.#session.http.fetch(url_body, {
|
||||
baseURL: Constants.URLS.YT_BASE
|
||||
});
|
||||
const client_identity = (await script.text())
|
||||
.replace(/\n/g, '')
|
||||
.match(Constants.OAUTH.REGEX.CLIENT_IDENTITY);
|
||||
// TODO: check this.
|
||||
const groups = client_identity?.groups;
|
||||
if (!groups)
|
||||
throw new OAuthError('Could not obtain client identity.', { status: 'FAILED' });
|
||||
return groups;
|
||||
}
|
||||
get credentials() {
|
||||
return this.#credentials;
|
||||
}
|
||||
validateCredentials(): this is this & { credentials: Credentials } {
|
||||
return this.#credentials &&
|
||||
Reflect.has(this.#credentials, 'access_token') &&
|
||||
Reflect.has(this.#credentials, 'refresh_token') &&
|
||||
Reflect.has(this.#credentials, 'expires') || false;
|
||||
}
|
||||
}
|
||||
export default OAuth;
|
||||
149
src/core/Player.ts
Normal file
149
src/core/Player.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { getRandomUserAgent, getStringBetweenStrings, PlayerError } from '../utils/Utils';
|
||||
import Constants from '../utils/Constants';
|
||||
import Signature from '../deciphers/Signature';
|
||||
import NToken from '../deciphers/NToken';
|
||||
import UniversalCache from '../utils/Cache';
|
||||
import { FetchFunction } from '../utils/HTTPClient';
|
||||
|
||||
export default class Player {
|
||||
#ntoken;
|
||||
#signature;
|
||||
#signature_timestamp;
|
||||
#player_id;
|
||||
constructor(signature: Signature, ntoken: NToken, signature_timestamp: number, player_id: string) {
|
||||
this.#ntoken = ntoken;
|
||||
this.#signature = signature;
|
||||
this.#signature_timestamp = signature_timestamp;
|
||||
this.#player_id = player_id;
|
||||
}
|
||||
static async fromCache(cache: UniversalCache, player_id: string) {
|
||||
const buffer = await cache.get(player_id);
|
||||
if (!buffer)
|
||||
return null;
|
||||
const view = new DataView(buffer);
|
||||
const version = view.getUint32(0, true);
|
||||
if (version !== Player.LIBRARY_VERSION)
|
||||
return null;
|
||||
const sig_timestamp = view.getUint32(4, true);
|
||||
const sig_decipher_len = view.getUint32(8, true);
|
||||
const sig_decipher_buf = buffer.slice(12, 12 + sig_decipher_len);
|
||||
const ntoken_transform_buf = buffer.slice(12 + sig_decipher_len);
|
||||
|
||||
return new Player(Signature.fromArrayBuffer(sig_decipher_buf), NToken.fromArrayBuffer(ntoken_transform_buf), sig_timestamp, player_id);
|
||||
}
|
||||
static async fromSource(cache: UniversalCache | undefined, sig_timestamp: number, sig_decipher_sc: string, ntoken_sc: string, player_id: string) {
|
||||
const player = new Player(Signature.fromSourceCode(sig_decipher_sc), NToken.fromSourceCode(ntoken_sc), sig_timestamp, player_id);
|
||||
await player.cache(cache);
|
||||
return player;
|
||||
}
|
||||
async cache(cache?: UniversalCache) {
|
||||
if (!cache)
|
||||
return;
|
||||
const ntokenBuf = this.#ntoken.toArrayBuffer();
|
||||
const sigDecipherBuf = this.#signature.toArrayBuffer();
|
||||
const buffer = new ArrayBuffer(12 + sigDecipherBuf.byteLength + ntokenBuf.byteLength);
|
||||
const view = new DataView(buffer);
|
||||
view.setUint32(0, Player.LIBRARY_VERSION, true);
|
||||
view.setUint32(4, this.#signature_timestamp, true);
|
||||
view.setUint32(8, sigDecipherBuf.byteLength, true);
|
||||
new Uint8Array(buffer).set(new Uint8Array(sigDecipherBuf), 12);
|
||||
new Uint8Array(buffer).set(new Uint8Array(ntokenBuf), 12 + sigDecipherBuf.byteLength);
|
||||
await cache.set(this.#player_id, new Uint8Array(buffer));
|
||||
}
|
||||
decipher(url?: string, signature_cipher?: string, cipher?: string) {
|
||||
url = url || signature_cipher || cipher;
|
||||
if (!url)
|
||||
throw new PlayerError('No valid URL to decipher');
|
||||
const args = new URLSearchParams(url);
|
||||
const url_components = new URL(args.get('url') || url);
|
||||
url_components.searchParams.set('ratebypass', 'yes');
|
||||
if (signature_cipher || cipher) {
|
||||
const signature = this.#signature.decipher(url);
|
||||
const sp = args.get('sp');
|
||||
sp ?
|
||||
url_components.searchParams.set(sp, signature) :
|
||||
url_components.searchParams.set('signature', signature);
|
||||
}
|
||||
const n = url_components.searchParams.get('n');
|
||||
if (n) {
|
||||
const ntoken = this.#ntoken.transform(n);
|
||||
url_components.searchParams.set('n', ntoken);
|
||||
}
|
||||
return url_components.toString();
|
||||
}
|
||||
get url() {
|
||||
return new URL(`/s/player/${this.#player_id}/player_ias.vflset/en_US/base.js`, Constants.URLS.YT_BASE).toString();
|
||||
}
|
||||
get sts() {
|
||||
return this.#signature_timestamp;
|
||||
}
|
||||
static async create(cache: UniversalCache | undefined, fetch: FetchFunction = globalThis.fetch) {
|
||||
const url = new URL('/iframe_api', Constants.URLS.YT_BASE);
|
||||
const res = await fetch(url);
|
||||
|
||||
if (res.status !== 200)
|
||||
throw new PlayerError('Failed to request player id');
|
||||
|
||||
const js = await res.text();
|
||||
|
||||
const player_id = getStringBetweenStrings(js, 'player\\/', '\\/');
|
||||
|
||||
if (!player_id)
|
||||
throw new PlayerError('Failed to get player id');
|
||||
|
||||
// We have the playerID now we can check if we have a cached player
|
||||
if (cache) {
|
||||
const cachedPlayer = await Player.fromCache(cache, player_id);
|
||||
if (cachedPlayer)
|
||||
return cachedPlayer;
|
||||
}
|
||||
|
||||
const player_url = new URL(`/s/player/${player_id}/player_ias.vflset/en_US/base.js`, Constants.URLS.YT_BASE);
|
||||
|
||||
const player_res = await fetch(player_url, {
|
||||
headers: {
|
||||
'user-agent': getRandomUserAgent('desktop').userAgent
|
||||
}
|
||||
});
|
||||
|
||||
if (!player_res.ok) {
|
||||
throw new PlayerError(`Failed to get player data: ${player_res.status}`);
|
||||
}
|
||||
|
||||
const player_js = await player_res.text();
|
||||
|
||||
const sig_timestamp = this.extractSigTimestamp(player_js);
|
||||
const sig_decipher_sc = this.extractSigDecipherSc(player_js);
|
||||
const ntoken_sc = this.extractNTokenSc(player_js);
|
||||
|
||||
return await Player.fromSource(cache, sig_timestamp, sig_decipher_sc, ntoken_sc, player_id);
|
||||
}
|
||||
/**
|
||||
* Extracts the signature timestamp from the player source code.
|
||||
*/
|
||||
static extractSigTimestamp(data: string) {
|
||||
return parseInt(getStringBetweenStrings(data, 'signatureTimestamp:', ',') || '0');
|
||||
}
|
||||
/**
|
||||
* Extracts the signature decipher algorithm.
|
||||
*/
|
||||
static extractSigDecipherSc(data: string) {
|
||||
const sig_alg_sc = getStringBetweenStrings(data, 'this.audioTracks};var', '};');
|
||||
const sig_data = getStringBetweenStrings(data, 'function(a){a=a.split("")', 'return a.join("")}');
|
||||
if (!sig_alg_sc || !sig_data)
|
||||
throw new PlayerError('Failed to extract signature decipher algorithm');
|
||||
return sig_alg_sc + sig_data;
|
||||
}
|
||||
/**
|
||||
* Extracts the n-token decipher algorithm.
|
||||
*/
|
||||
static extractNTokenSc(data: string) {
|
||||
const sc = `var b=a.split("")${getStringBetweenStrings(data, 'b=a.split("")', '}return b.join("")}')}} return b.join("");`;
|
||||
if (!sc)
|
||||
throw new PlayerError('Failed to extract n-token decipher algorithm');
|
||||
return sc;
|
||||
}
|
||||
static get LIBRARY_VERSION() {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
157
src/core/PlaylistManager.ts
Normal file
157
src/core/PlaylistManager.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import Playlist from '../parser/youtube/Playlist';
|
||||
import { InnertubeError, throwIfMissing } from '../utils/Utils';
|
||||
import Actions from './Actions';
|
||||
import Feed from './Feed';
|
||||
|
||||
class PlaylistManager {
|
||||
#actions;
|
||||
constructor(actions: Actions) {
|
||||
this.#actions = actions;
|
||||
}
|
||||
/**
|
||||
* Creates a 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 });
|
||||
return {
|
||||
success: response.success,
|
||||
status_code: response.status_code,
|
||||
playlist_id: response.data.playlistId,
|
||||
data: response.data
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Deletes a given playlist.
|
||||
*/
|
||||
async delete(playlist_id: string) {
|
||||
throwIfMissing({ playlist_id });
|
||||
const response = await this.#actions.execute('playlist/delete', { playlistId: playlist_id });
|
||||
return {
|
||||
playlist_id,
|
||||
success: response.success,
|
||||
status_code: response.status_code,
|
||||
data: response.data
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Adds videos to a given playlist.
|
||||
*/
|
||||
async addVideos(playlist_id: string, video_ids: string[]) {
|
||||
throwIfMissing({ playlist_id, video_ids });
|
||||
const response = await this.#actions.execute('/browse/edit_playlist', {
|
||||
playlistId: playlist_id,
|
||||
actions: video_ids.map((id) => ({
|
||||
action: 'ACTION_ADD_VIDEO',
|
||||
addedVideoId: id
|
||||
})),
|
||||
parse: false
|
||||
});
|
||||
return {
|
||||
playlist_id,
|
||||
action_result: response.data.actions // TODO: implement actions in the parser
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Removes videos from a given 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 });
|
||||
const playlist = new Playlist(this.#actions, info, true);
|
||||
|
||||
if (!playlist.info.is_editable)
|
||||
throw new InnertubeError('This playlist cannot be edited.', playlist_id);
|
||||
|
||||
const payload = {
|
||||
playlistId: playlist_id,
|
||||
actions: [] as {
|
||||
action: string;
|
||||
setVideoId: string;
|
||||
}[]
|
||||
};
|
||||
|
||||
const getSetVideoIds = async (pl: Feed): Promise<void> => {
|
||||
const videos = pl.videos.filter((video) => video_ids.includes(video.key('id').string()));
|
||||
|
||||
videos.forEach((video) =>
|
||||
payload.actions.push({
|
||||
action: 'ACTION_REMOVE_VIDEO',
|
||||
setVideoId: video.key('set_video_id').string()
|
||||
})
|
||||
);
|
||||
|
||||
if (payload.actions.length < video_ids.length) {
|
||||
const next = await pl.getContinuation();
|
||||
return getSetVideoIds(next);
|
||||
}
|
||||
};
|
||||
|
||||
await getSetVideoIds(playlist);
|
||||
|
||||
if (!payload.actions.length)
|
||||
throw new InnertubeError('Given video ids were not found in this playlist.', video_ids);
|
||||
|
||||
const response = await this.#actions.execute('/browse/edit_playlist', { ...payload, parse: false });
|
||||
|
||||
return {
|
||||
playlist_id,
|
||||
action_result: response.data.actions // TODO: implement actions in the parser
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a video to a new position within a given playlist.
|
||||
*/
|
||||
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 });
|
||||
const playlist = new Playlist(this.#actions, info, true);
|
||||
|
||||
if (!playlist.info.is_editable)
|
||||
throw new InnertubeError('This playlist cannot be edited.', playlist_id);
|
||||
|
||||
const payload = {
|
||||
playlistId: playlist_id,
|
||||
actions: [] as {
|
||||
action: string,
|
||||
setVideoId?: string,
|
||||
movedSetVideoIdPredecessor?: string
|
||||
}[]
|
||||
};
|
||||
|
||||
let set_video_id_0: string | undefined,
|
||||
set_video_id_1: string | undefined;
|
||||
|
||||
const getSetVideoIds = async (pl: Feed): Promise<void> => {
|
||||
const video_0 = pl.videos.find((video) => moved_video_id === video.key('id').string());
|
||||
const video_1 = pl.videos.find((video) => predecessor_video_id === video.key('id').string());
|
||||
|
||||
set_video_id_0 = set_video_id_0 || video_0?.key('set_video_id').string();
|
||||
set_video_id_1 = set_video_id_1 || video_1?.key('set_video_id').string();
|
||||
|
||||
if (!set_video_id_0 || !set_video_id_1) {
|
||||
const next = await pl.getContinuation();
|
||||
return getSetVideoIds(next);
|
||||
}
|
||||
};
|
||||
|
||||
await getSetVideoIds(playlist);
|
||||
|
||||
payload.actions.push({
|
||||
action: 'ACTION_MOVE_VIDEO_AFTER',
|
||||
setVideoId: set_video_id_0,
|
||||
movedSetVideoIdPredecessor: set_video_id_1
|
||||
});
|
||||
|
||||
const response = await this.#actions.execute('/browse/edit_playlist', { ...payload, parse: false });
|
||||
|
||||
return {
|
||||
playlist_id,
|
||||
action_result: response.data.actions // TODO: implement actions in the parser
|
||||
};
|
||||
}
|
||||
}
|
||||
export default PlaylistManager;
|
||||
220
src/core/Session.ts
Normal file
220
src/core/Session.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import Player from './Player';
|
||||
import Proto from '../proto/index';
|
||||
import { DeviceCategory, generateRandomString, getRandomUserAgent, InnertubeError, SessionError } from '../utils/Utils';
|
||||
import Constants from '../utils/Constants';
|
||||
import UniversalCache from '../utils/Cache';
|
||||
import OAuth, { Credentials, OAuthAuthErrorEventHandler, OAuthAuthEventHandler, OAuthAuthPendingEventHandler } from './OAuth';
|
||||
import EventEmitterLike from '../utils/EventEmitterLike';
|
||||
import HTTPClient, { FetchFunction } from '../utils/HTTPClient';
|
||||
import Actions from './Actions';
|
||||
|
||||
export interface Context {
|
||||
client: {
|
||||
hl: string;
|
||||
gl: string;
|
||||
remoteHost: string;
|
||||
visitorData: string;
|
||||
userAgent: string;
|
||||
clientName: string;
|
||||
clientVersion: string;
|
||||
osName: string;
|
||||
osVersion: string;
|
||||
platform: string;
|
||||
clientFormFactor: string;
|
||||
userInterfaceTheme: string;
|
||||
timeZone: string;
|
||||
browserName: string;
|
||||
browserVersion: string;
|
||||
originalUrl: string;
|
||||
deviceMake: string;
|
||||
deviceModel: string;
|
||||
utcOffsetMinutes: number;
|
||||
};
|
||||
user: {
|
||||
lockedSafetyMode: false;
|
||||
};
|
||||
request: {
|
||||
useSsl: true;
|
||||
};
|
||||
}
|
||||
|
||||
export enum ClientType {
|
||||
WEB = 'WEB',
|
||||
MUSIC = 'WEB_REMIX',
|
||||
ANDROID = 'ANDROID',
|
||||
}
|
||||
|
||||
export interface SessionOptions {
|
||||
lang?: string;
|
||||
device_category?: DeviceCategory;
|
||||
client_type?: ClientType;
|
||||
timezone?: string;
|
||||
cache?: UniversalCache;
|
||||
cookie?: string;
|
||||
fetch?: FetchFunction;
|
||||
}
|
||||
|
||||
export default class Session extends EventEmitterLike {
|
||||
#api_version;
|
||||
#key;
|
||||
#context;
|
||||
#player;
|
||||
oauth;
|
||||
http;
|
||||
logged_in;
|
||||
actions;
|
||||
constructor(context: Context, api_key: string, api_version: string, player: Player, cookie?: string, fetch?: FetchFunction) {
|
||||
super();
|
||||
this.#context = context;
|
||||
this.#key = api_key;
|
||||
this.#api_version = api_version;
|
||||
this.#player = player;
|
||||
this.http = new HTTPClient(this, cookie, fetch);
|
||||
this.actions = new Actions(this);
|
||||
this.oauth = new OAuth(this);
|
||||
this.logged_in = !!cookie;
|
||||
}
|
||||
on(type: 'auth', listener: OAuthAuthEventHandler): void;
|
||||
on(type: 'auth-pending', listener: OAuthAuthPendingEventHandler): void;
|
||||
on(type: 'auth-error', listener: OAuthAuthErrorEventHandler): void;
|
||||
on(type: string, listener: (...args: any[]) => void): void {
|
||||
super.on(type, listener);
|
||||
}
|
||||
once(type: 'auth', listener: OAuthAuthEventHandler): void;
|
||||
once(type: 'auth-pending', listener: OAuthAuthPendingEventHandler): void;
|
||||
once(type: 'auth-error', listener: OAuthAuthErrorEventHandler): void;
|
||||
once(type: string, listener: (...args: any[]) => void): void {
|
||||
super.once(type, listener);
|
||||
}
|
||||
async signIn(credentials?: Credentials): Promise<void> {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const error_handler: OAuthAuthErrorEventHandler = (err) => {
|
||||
reject(err);
|
||||
};
|
||||
this.once('auth', (data) => {
|
||||
this.off('auth-error', error_handler);
|
||||
if (data.status === 'SUCCESS') {
|
||||
this.logged_in = true;
|
||||
resolve();
|
||||
} else
|
||||
reject(data);
|
||||
});
|
||||
this.once('auth-error', error_handler);
|
||||
try {
|
||||
await this.oauth.init(credentials);
|
||||
if (this.oauth.validateCredentials()) {
|
||||
await this.oauth.checkAccessTokenValidity();
|
||||
this.logged_in = true;
|
||||
resolve();
|
||||
}
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
async signOut() {
|
||||
if (!this.logged_in)
|
||||
throw new InnertubeError('You are not signed in');
|
||||
const response = await this.oauth.revokeCredentials();
|
||||
this.logged_in = false;
|
||||
return response;
|
||||
}
|
||||
static async create(options: SessionOptions = {}) {
|
||||
const { context, api_key, api_version } = await Session.getSessionData(options.lang, options.device_category, options.client_type, options.timezone, options.fetch);
|
||||
return new Session(context, api_key, api_version, await Player.create(options.cache, options.fetch), options.cookie, options.fetch);
|
||||
}
|
||||
static async getSessionData(
|
||||
lang = 'en-US',
|
||||
deviceCategory: DeviceCategory = 'desktop',
|
||||
clientName: ClientType = ClientType.WEB,
|
||||
tz: string = Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
fetch: FetchFunction = globalThis.fetch
|
||||
) {
|
||||
const url = new URL('/sw.js_data', Constants.URLS.YT_BASE);
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
'accept-language': lang,
|
||||
'user-agent': getRandomUserAgent('desktop').userAgent,
|
||||
'accept': '*/*',
|
||||
'referer': 'https://www.youtube.com/sw.js',
|
||||
'cookie': `PREF=tz=${tz.replace('/', '.')}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new SessionError(`Failed to get session data: ${res.status}`);
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
|
||||
const data = JSON.parse(text.replace(/^\)\]\}'/, ''));
|
||||
|
||||
const ytcfg = data[0][2];
|
||||
|
||||
const api_version = `v${ytcfg[0][0][6]}`;
|
||||
|
||||
const [ [ device_info ], api_key ] = ytcfg;
|
||||
|
||||
const id = generateRandomString(11);
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
const visitor_data = Proto.encodeVisitorData(id, timestamp);
|
||||
|
||||
const context: Context = {
|
||||
client: {
|
||||
hl: device_info[0],
|
||||
gl: device_info[2],
|
||||
remoteHost: device_info[3],
|
||||
visitorData: visitor_data,
|
||||
userAgent: device_info[14],
|
||||
clientName,
|
||||
clientVersion: device_info[16],
|
||||
osName: device_info[17],
|
||||
osVersion: device_info[18],
|
||||
platform: deviceCategory.toUpperCase(),
|
||||
clientFormFactor: 'UNKNOWN_FORM_FACTOR',
|
||||
userInterfaceTheme: 'USER_INTERFACE_THEME_LIGHT',
|
||||
timeZone: device_info[79],
|
||||
browserName: device_info[86],
|
||||
browserVersion: device_info[87],
|
||||
originalUrl: Constants.URLS.API.BASE,
|
||||
deviceMake: device_info[11],
|
||||
deviceModel: device_info[12],
|
||||
utcOffsetMinutes: new Date().getTimezoneOffset()
|
||||
},
|
||||
user: {
|
||||
lockedSafetyMode: false
|
||||
},
|
||||
request: {
|
||||
useSsl: true
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
context,
|
||||
api_key,
|
||||
api_version
|
||||
};
|
||||
}
|
||||
get key() {
|
||||
return this.#key;
|
||||
}
|
||||
get api_version() {
|
||||
return this.#api_version;
|
||||
}
|
||||
get client_version() {
|
||||
return this.#context.client.clientVersion;
|
||||
}
|
||||
get client_name() {
|
||||
return this.#context.client.clientName;
|
||||
}
|
||||
get context() {
|
||||
return this.#context;
|
||||
}
|
||||
get player() {
|
||||
return this.#player;
|
||||
}
|
||||
get lang() {
|
||||
return this.#context.client.hl;
|
||||
}
|
||||
}
|
||||
30
src/core/TabbedFeed.ts
Normal file
30
src/core/TabbedFeed.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import Tab from '../parser/classes/Tab';
|
||||
import { InnertubeError } from '../utils/Utils';
|
||||
import Actions from './Actions';
|
||||
import Feed from './Feed';
|
||||
|
||||
class TabbedFeed extends Feed {
|
||||
#tabs;
|
||||
#actions;
|
||||
constructor(actions: Actions, data: any, already_parsed = false) {
|
||||
super(actions, data, already_parsed);
|
||||
this.#actions = actions;
|
||||
this.#tabs = this.page.contents_memo.getType(Tab);
|
||||
}
|
||||
get tabs() {
|
||||
return this.#tabs.map((tab) => tab.title.toString());
|
||||
}
|
||||
async getTab(title: string) {
|
||||
const tab = this.#tabs.find((tab) => tab.title.toLowerCase() === title.toLowerCase());
|
||||
if (!tab)
|
||||
throw new InnertubeError(`Tab "${title}" not found`);
|
||||
if (tab.selected)
|
||||
return this;
|
||||
const response = await tab.endpoint.call(this.#actions);
|
||||
return new TabbedFeed(this.#actions, response, true);
|
||||
}
|
||||
get title() {
|
||||
return this.page.contents_memo.getType(Tab)?.find((tab) => tab.selected)?.title.toString();
|
||||
}
|
||||
}
|
||||
export default TabbedFeed;
|
||||
Reference in New Issue
Block a user