refactor!: welp, a lot of stuff

- Use the OS temp folder to cache the player, closes #57.
- Added support for editing channel name, closes #40.
- Added support for editing channel description.
- Added support for retrieving basic channel analytics, closes #54.
- Moved `Innertube#getAccountInfo()` to `Innertube#account`, and renamed it to `getInfo()`.
- `getInfo()` is now able to return email, channel id, etc.
- Improved jsdoc.
This commit is contained in:
LuanRT
2022-05-27 08:17:16 -03:00
parent 865b6870a1
commit a85e9ef667
25 changed files with 1275 additions and 754 deletions

251
lib/core/AccountManager.js Normal file
View File

@@ -0,0 +1,251 @@
'use strict';
const Utils = require('../utils/Utils');
const Constants = require('../utils/Constants');
const Proto = require('../proto');
/** @namespace */
class AccountManager {
#actions;
/**
* @param {Actions} actions
* @constructor
*/
constructor (actions) {
this.#actions = actions;
/** @namespace */
this.channel = {
/**
* Edits channel name.
*
* @param {string} new_name
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
editName: (new_name) => this.#actions.channel('channel/edit_name', { new_name }),
/**
* Edits channel description.
*
* @param {string} new_description
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
editDescription: (new_description) => this.#actions.channel('channel/edit_description', { new_description }),
/**
* Retrieves basic channel analytics.
* @borrows AccountManager#getAnalytics as getBasicAnalytics
*/
getBasicAnalytics: () => this.getAnalytics()
}
/** @namespace */
this.settings = {
notifications: {
/**
* Notify about activity from the channels you're subscribed to.
*
* @param {boolean} option - ON | OFF
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
setSubscriptions: (option) => this.#setSetting(Constants.ACCOUNT_SETTINGS.SUBSCRIPTIONS, 'SPaccount_notifications', option),
/**
* Recommended content notifications.
*
* @param {boolean} option - ON | OFF
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
setRecommendedVideos: (option) => this.#setSetting(Constants.ACCOUNT_SETTINGS.RECOMMENDED_VIDEOS, 'SPaccount_notifications', option),
/**
* Notify about activity on your channel.
*
* @param {boolean} option - ON | OFF
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
setChannelActivity: (option) => this.#setSetting(Constants.ACCOUNT_SETTINGS.CHANNEL_ACTIVITY, 'SPaccount_notifications', option),
/**
* Notify about replies to your comments.
*
* @param {boolean} option - ON | OFF
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
setCommentReplies: (option) => this.#setSetting(Constants.ACCOUNT_SETTINGS.COMMENT_REPLIES, 'SPaccount_notifications', option),
/**
* Notify when others mention your channel.
*
* @param {boolean} option - ON | OFF
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
setMentions: (option) => this.#setSetting(Constants.ACCOUNT_SETTINGS.USER_MENTION, 'SPaccount_notifications', option),
/**
* Notify when others share your content on their channels.
*
* @param {boolean} option - ON | OFF
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
setSharedContent: (option) => this.#setSetting(Constants.ACCOUNT_SETTINGS.SHARED_CONTENT, 'SPaccount_notifications', option)
},
privacy: {
/**
* If set to true, your subscriptions won't be visible to others.
*
* @param {boolean} option - ON | OFF
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
setSubscriptionsPrivate: (option) => this.#setSetting(Constants.ACCOUNT_SETTINGS.SUBSCRIPTIONS_PRIVACY, 'SPaccount_privacy', option),
/**
* If set to true, saved playlists won't appear on your channel.
*
* @param {boolean} option - ON | OFF
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
setSavedPlaylistsPrivate: (option) => this.#setSetting(Constants.ACCOUNT_SETTINGS.PLAYLISTS_PRIVACY, 'SPaccount_privacy', option)
}
}
}
/**
* Internal method to perform changes on an account's settings.
*
* @param {string} setting_id
* @param {string} type
* @param {string} new_value
* @private
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
async #setSetting(setting_id, type, new_value) {
Utils.throwIfMissing({ setting_id, type, new_value });
const values = { ON: true, OFF: false };
if (!values.hasOwnProperty(new_value))
throw new Utils.InnertubeError('Invalid option', { option: new_value, available_options: Object.keys(values) });
const response = await this.#actions.browse(type);
const contents = ({
SPaccount_notifications: () => Utils.findNode(response.data, 'contents', 'Your preferences', 13, false).options,
SPaccount_privacy: () => Utils.findNode(response.data, 'contents', 'settingsSwitchRenderer', 13, false).options
})[type.trim()]();
const option = contents.find((option) => 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' ? !values[new_value] : values[new_value],
setting_item_id
});
return set_setting;
}
/**
* Retrieves channel info.
* @returns {Promise.<{ name: string; email: string; channel_id: string; subscriber_count: string; photo: object[]; }>}
*/
async getInfo() {
const response = await this.#actions.account('account/accounts_list', { client: 'ANDROID' });
const account_item_section_renderer = Utils.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) => 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.
* @returns {Promise.<[{ title: string; time: string; }]>}
*/
async getTimeWatched() {
const response = await this.#actions.browse('SPtime_watched', { client: 'ANDROID' });
const rows = Utils.findNode(response.data, 'contents', 'statRowRenderer', 11, false);
const stats = rows.map((row) => {
const renderer = row.statRowRenderer;
if (renderer) {
return {
title: renderer.title.runs.map((run) => run.text).join(''),
time: renderer.contents.runs.map((run) => run.text).join('')
}
}
}).filter((stat) => stat);
return stats;
}
/**
* Retrieves basic channel analytics.
*
* @returns {Promise.<{ metrics: { title: string; subtitle: string; metric_value: string;
* comparison_indicator: object; series_configuration: object; }[]; top_content: { views: string;
* published: string; thumbnails: object[]; duration: string; is_short: boolean }[]; }>}
*/
async getAnalytics() {
const info = await this.getInfo();
const params = Proto.encodeChannelAnalyticsParams(info.channel_id);
const action = await this.#actions.browse('FEanalytics_screen', { params, client: 'ANDROID' });
const contents = Utils.findNode(action.data, 'contents', 'elementRenderer', 11, false);
const analytics = {
metrics: {},
top_content: {}
}
contents.forEach((el) => {
const element = el.elementRenderer.newElement;
const model = element.type.componentType.model;
const key = Object.keys(model)[0];
switch (key) {
case 'analyticsRootModel':
const sections = model.analyticsRootModel.analyticsKeyMetricsData.dataModel.sections;
analytics.metrics = sections.map((section) => ({
title: section.title,
subtitle: section.subtitle,
metric_value: section.metricValue,
comparison_indicator: section.comparisonIndicator,
series_configuration: section.seriesConfiguration
}));
break;
case 'analyticsVodCarouselCardModel':
const video_carousel = model.analyticsVodCarouselCardModel.videoCarouselData;
analytics.top_content = video_carousel?.videos.map((video) => ({
title: video.videoTitle,
metadata: {
views: video.videoDescription.split('·')[0].trim(),
published: video.videoDescription.split('·')[1].trim(),
thumbnails: video.thumbnailDetails.thumbnails,
duration: video.formattedLength,
is_short: video.isShort
}
})) || [];
break;
default:
break;
}
});
return analytics;
}
}
module.exports = AccountManager;

View File

@@ -0,0 +1,134 @@
'use strict';
const Utils = require('../utils/Utils');
/** @namespace */
class InteractionManager {
#actions;
/**
* @param {Actions} actions
* @constructor
*/
constructor(actions) {
this.#actions = actions;
}
/**
* Likes a given video.
* @param {string} video_id
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
async like(video_id) {
Utils.throwIfMissing({ video_id });
const action = await this.#actions.engage('like/like', { video_id });
return action;
}
/**
* Dislikes a given video.
* @param {string} video_id
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
async dislike(video_id) {
Utils.throwIfMissing({ video_id });
const action = await this.#actions.engage('like/dislike', { video_id });
return action;
}
/**
* Removes a like/dislike.
* @param {string} video_id
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
async removeLike(video_id) {
Utils.throwIfMissing({ video_id });
const action = await this.actions.engage('like/removelike', { video_id });
return action;
}
/**
* Subscribes to a given channel.
* @param {string} channel_id
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
async subscribe(channel_id) {
Utils.throwIfMissing({ channel_id });
const action = await this.#actions.engage('subscription/subscribe', { channel_id });
return action;
}
/**
* Unsubscribes from a given channel.
* @param {string} channel_id
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
async unsubscribe(channel_id) {
Utils.throwIfMissing({ channel_id });
const action = await this.#actions.engage('subscription/unsubscribe', { channel_id });
return action;
}
/**
* Posts a comment on a given video.
*
* @param {string} video_id
* @param {string} text
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
async comment(video_id, text) {
Utils.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 {string} text
* @param {string} target_language - an ISO language code
* @param {object} [args] - optional arguments
* @param {string} [args.video_id]
* @param {string} [args.comment_id]
*
* @returns {Promise.<{ success: boolean; status_code: number; translated_content: string; data: object; }>}
*/
async translate(text, target_language, args = {}) {
Utils.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 = Utils.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.
*
* @param {string} channel_id
* @param {string} type - `PERSONALIZED` | `ALL` | `NONE`
*
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>}
*/
async setNotificationPreferences(channel_id, type) {
Utils.throwIfMissing({ channel_id, type });
const action = await this.#actions.notifications('modify_channel_preference', { channel_id, pref: type || 'NONE' });
return action;
}
}
module.exports = InteractionManager;

View File

@@ -4,11 +4,8 @@ const Axios = require('axios');
const Constants = require('../utils/Constants');
const Uuid = require('uuid');
/** @namespace */
class OAuth {
#scope = Constants.OAUTH.SCOPE;
#model_name = Constants.OAUTH.MODEL_NAME;
#grant_type = Constants.OAUTH.GRANT_TYPE;
#oauth_code_url = `${Constants.URLS.YT_BASE}/o/oauth2/device/code`;
#oauth_token_url = `${Constants.URLS.YT_BASE}/o/oauth2/token`;
#oauth_revoke_url = `${Constants.URLS.YT_BASE}/o/oauth2/revoke`;
@@ -17,6 +14,10 @@ class OAuth {
#polling_interval = 5;
#ev = null;
/**
* @param {EventEmitter} ev
* @constructor
*/
constructor(ev) {
this.#ev = ev;
}
@@ -46,9 +47,9 @@ class OAuth {
const data = {
client_id: this.client_id,
scope: this.#scope,
scope: Constants.OAUTH.SCOPE,
device_id: Uuid.v4(),
model_name: this.#model_name
model_name: Constants.OAUTH.MODEL_NAME
};
const response = await Axios.post(this.#oauth_code_url, JSON.stringify(data), Constants.OAUTH.HEADERS).catch((error) => error);
@@ -77,7 +78,7 @@ class OAuth {
client_id: this.client_id,
client_secret: this.client_secret,
code: device_code,
grant_type: this.#grant_type
grant_type: Constants.OAUTH.GRANT_TYPE
};
setTimeout(async () => {
@@ -204,16 +205,24 @@ class OAuth {
return client_identity.groups;
}
/**
* Returns the access token.
* @returns {string}
*/
getAccessToken() {
return this.#auth_info.access_token;
}
/**
* Returns the refresh token.
* @returns {string}
*/
getRefreshToken() {
return this.#auth_info.refresh_token;
}
/**
* Checks if the auth info is valid.
* Checks if the auth info format is valid.
* @returns {boolean} true | false
*/
isValidAuthInfo() {

View File

@@ -1,10 +1,12 @@
'use strict';
const os = require('os');
const Fs = require('fs');
const Axios = require('axios');
const Utils = require('../utils/Utils');
const Constants = require('../utils/Constants');
/** @namespace */
class Player {
#player_id;
#player_url;
@@ -13,12 +15,16 @@ class Player {
#ntoken_decipher_sc;
#signature_decipher_sc;
#signature_timestamp;
#cache_dir;
/**
* Represents the YouTube Web player script.
* @param {string} id - the id of the player.
* @constructor
*/
constructor(id) {
this.#player_id = id;
this.#cache_dir = __dirname.slice(0, -8) + 'cache';
this.#cache_dir = `${os.tmpdir()}/cache`;
this.#player_url = Constants.URLS.YT_BASE + '/s/player/' + this.#player_id + '/player_ias.vflset/en_US/base.js';
this.#player_path = `${this.#cache_dir}/${this.#player_id}.js`;
}
@@ -26,7 +32,6 @@ class Player {
async init() {
if (this.isCached()) {
const player_data = Fs.readFileSync(this.#player_path).toString();
this.#signature_timestamp = this.#extractSigTimestamp(player_data);
this.#signature_decipher_sc = this.#extractSigDecipherSc(player_data);
this.#ntoken_decipher_sc = this.#extractNTokenSc(player_data);
@@ -52,32 +57,64 @@ class Player {
return this;
}
/**
* Returns the current player's url.
* @readonly
* @returns {string}
*/
get url() {
return this.#player_url;
}
/**
* Returns the signature timestamp.
* @readonly
* @returns {string}
*/
get sts() {
return this.#signature_timestamp;
}
/**
* Returns the n-token decipher algorithm.
* @readonly
* @returns {string}
*/
get ntoken_decipher() {
return this.#ntoken_decipher_sc;
}
/**
* Returns the signature decipher algorithm.
* @readonly
* @returns {string}
*/
get signature_decipher() {
return this.#signature_decipher_sc;
}
/**
* Extracts the signature timestamp from the player source code.
* @returns {number}
*/
#extractSigTimestamp(data) {
return parseInt(Utils.getStringBetweenStrings(data, 'signatureTimestamp:', ','));
}
/**
* Extracts the signature decipher algorithm.
* @returns {string}
*/
#extractSigDecipherSc(data) {
const sig_alg_sc = Utils.getStringBetweenStrings(data, 'this.audioTracks};var', '};');
const sig_data = Utils.getStringBetweenStrings(data, 'function(a){a=a.split("")', 'return a.join("")}');
return sig_alg_sc + sig_data;
}
/**
* Extracts the n-token decipher algorithm.
* @returns {string}
*/
#extractNTokenSc(data) {
return `var b=a.split("")${Utils.getStringBetweenStrings(data, 'b=a.split("")', '}return b.join("")}')}} return b.join("");`;
}

115
lib/core/PlaylistManager.js Normal file
View File

@@ -0,0 +1,115 @@
'use strict';
const Utils = require('../utils/Utils');
/** @namespace */
class PlaylistManager {
#actions;
/**
* @param {Actions} actions
* @constructor
*/
constructor (actions) {
this.#actions = actions;
}
/**
* Creates a playlist.
*
* @param {string} title
* @param {Array.<string>} video_ids
*
* @returns {Promise.<{ success: boolean; status_code: number; playlist_id: string; data: object; }>}
*/
async create(title, video_ids) {
Utils.throwIfMissing({ title, video_ids });
const response = await this.#actions.playlist('playlist/create', { title, ids: video_ids });
return {
success: response.success,
status_code: response.status_code,
playlist_id: response.data.playlistId,
data: response.data
}
}
/**
* Deletes a given playlist.
* @param {string} playlist_id
* @returns {Promise.<{ success: boolean; status_code: number; playlist_id: string; data: object; }>}
*/
async delete(playlist_id) {
Utils.throwIfMissing({ playlist_id });
const response = await this.#actions.playlist('playlist/delete', { playlist_id });
return {
success: response.success,
status_code: response.status_code,
playlist_id,
data: response.data
}
}
/**
* Adds videos to a given playlist.
*
* @param {string} playlist_id
* @param {Array.<string>} video_ids
*
* @returns {Promise.<{ success: boolean; status_code: number; playlist_id: string; data: object; }>}
*/
async addVideos(playlist_id, video_ids) {
Utils.throwIfMissing({ playlist_id, video_ids });
const response = await this.#actions.playlist('browse/edit_playlist', {
ids: video_ids,
action: 'ACTION_ADD_VIDEO',
playlist_id
});
return {
success: response.success,
status_code: response.status_code,
playlist_id,
data: response.data
}
}
/**
* Removes videos from a given playlist.
*
* @param {string} playlist_id
* @param {Array.<string>} video_ids
*
* @returns {Promise.<{ success: boolean; status_code: number; playlist_id: string; data: object; }>}
*/
async removeVideos(playlist_id, video_ids) {
Utils.throwIfMissing({ playlist_id, video_ids });
const plinfo = await this.#actions.browse(`VL${playlist_id}`);
const list = Utils.findNode(plinfo.data, 'contents', 'contents', 13, false);
if (!list.isEditable) throw new Utils.InnertubeError('This playlist cannot be edited.', playlist_id);
const videos = list.contents.filter((item) => video_ids.includes(item.playlistVideoRenderer.videoId));
const set_video_ids = videos.map((video) => video.playlistVideoRenderer.setVideoId);
const response = await this.#actions.playlist('browse/edit_playlist', {
ids: set_video_ids,
action: 'ACTION_REMOVE_VIDEO',
playlist_id
});
return {
success: response.success,
status_code: response.status_code,
playlist_id,
data: response.data
}
}
}
module.exports = PlaylistManager;

View File

@@ -7,6 +7,7 @@ const Utils = require('../utils/Utils');
const Constants = require('../utils/Constants');
const UserAgent = require('user-agents');
/** @namespace */
class SessionBuilder {
#config;
@@ -18,6 +19,10 @@ class SessionBuilder {
#context;
#player;
/**
* @param {string} config
* @constructor
*/
constructor(config) {
this.#config = config;
}
@@ -42,6 +47,10 @@ class SessionBuilder {
return this;
}
/**
* Builds a valid context object.
* @returns
*/
#buildContext() {
const user_agent = new UserAgent({ deviceCategory: 'desktop' });
@@ -70,6 +79,11 @@ class SessionBuilder {
return context;
}
/**
* Retrieves initial configuration such as keys,
* client data, etc.
* @returns Promise.<object>
*/
async #getYtConfig() {
const response = await Axios.get(`${Constants.URLS.YT_BASE}/sw.js_data`).catch((err) => err);
@@ -82,6 +96,10 @@ class SessionBuilder {
return JSON.parse(response.data.replace(')]}\'', ''));
}
/**
* Retrives the YouTube player id.
* @returns {Promise.<string>
*/
async #getPlayerId() {
const response = await Axios.get(`${Constants.URLS.YT_BASE}/iframe_api`).catch((err) => err);
@@ -94,26 +112,32 @@ class SessionBuilder {
return Utils.getStringBetweenStrings(response.data, 'player\\/', '\\/');
}
/** @readonly */
get key() {
return this.#key;
}
/** @readonly */
get context() {
return this.#context;
}
/** @readonly */
get api_version() {
return this.#api_version;
}
/** @readonly */
get client_version() {
return this.#client_version;
}
/** @readonly */
get client_name() {
return this.#client_name;
}
/** @readonly */
get player() {
return this.#player;
}