chore: clean up build steps

This commit is contained in:
LuanRT
2022-07-20 16:28:51 -03:00
parent fb68e6bcfe
commit 6a5ebeb8ee
287 changed files with 40 additions and 40 deletions

151
src/core/AccountManager.ts Normal file
View 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
View 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
View 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;

View 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;

View 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
View 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
View 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
View 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
View 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
View 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
View 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;