youtubei.js
Advanced tools
Comparing version 1.4.1 to 1.4.2-d.1
@@ -9,430 +9,487 @@ 'use strict'; | ||
/** | ||
* Performs direct interactions on YouTube. | ||
* | ||
* @param {Innertube} session - A valid Innertube session. | ||
* @param {string} engagement_type - Type of engagement. | ||
* @param {object} args - Engagement arguments. | ||
* @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>} | ||
*/ | ||
async function engage(session, engagement_type, args = {}) { | ||
if (!session.logged_in) throw new Utils.InnertubeError('You are not signed in'); | ||
const data = { context: session.context }; | ||
switch (engagement_type) { | ||
case 'like/like': | ||
case 'like/dislike': | ||
case 'like/removelike': | ||
data.target = { | ||
videoId: args.video_id | ||
} | ||
break; | ||
case 'subscription/subscribe': | ||
case 'subscription/unsubscribe': | ||
data.channelIds = [args.channel_id]; | ||
data.params = engagement_type == 'subscription/subscribe' ? 'EgIIAhgA' : 'CgIIAhgA'; | ||
break; | ||
case 'comment/create_comment': | ||
data.commentText = args.text; | ||
data.createCommentParams = Proto.encodeCommentParams(args.video_id); | ||
break; | ||
case 'comment/create_comment_reply': | ||
data.createReplyParams = Proto.encodeCommentReplyParams(args.comment_id, args.video_id); | ||
data.commentText = args.text; | ||
break; | ||
case 'comment/perform_comment_action': | ||
const action = ({ | ||
like: () => Proto.encodeCommentActionParams(5, args.comment_id, args.video_id, args.channel_id), | ||
dislike: () => Proto.encodeCommentActionParams(4, args.comment_id, args.video_id, args.channel_id), | ||
})[args.comment_action](); | ||
data.actions = [ action ]; | ||
break; | ||
case 'playlist/create': | ||
data.title = args.title; | ||
data.videoIds = [args.video_id]; | ||
break; | ||
case 'playlist/delete': | ||
data.playlistId = args.playlist_id; | ||
break; | ||
case 'browse/edit_playlist': | ||
data.playlistId = args.playlist_id; | ||
data.actions = args.video_ids.map((id) => ({ | ||
action: args.action, | ||
addedVideoId: id | ||
})); | ||
break; | ||
default: | ||
throw new Utils.InnertubeError('Invalid action', engagement_type); | ||
class Actions { | ||
#session; | ||
#request; | ||
constructor(session) { | ||
this.#session = session; | ||
this.#request = session.request; | ||
} | ||
const response = await session.YTRequester.post(`/${engagement_type}`, JSON.stringify(data)).catch((error) => error); | ||
if (response instanceof Error) return { success: false, status_code: response.response?.status || 0, message: response.message }; | ||
return { | ||
success: true, | ||
status_code: response.status | ||
}; | ||
} | ||
/** | ||
* Accesses YouTube's various sections. | ||
* | ||
* @param {Innertube} session - A valid Innertube session. | ||
* @param {string} action - Type of action. | ||
* @param {object} args - Action argumenets. | ||
* @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>} | ||
*/ | ||
async function browse(session, action, args = {}) { | ||
if (!session.logged_in && ![ 'home_feed', 'lyrics', | ||
'music_playlist', 'playlist', 'trending' ].includes(action)) | ||
/** | ||
* Covers Innertube's browse endpoint, mostly used to | ||
* access YouTube's sections such as the home page | ||
* and sometimes to retrieve continuations. | ||
* | ||
* @param {string} id - browseId or a continuation token | ||
* @param {object} args - additional arguments | ||
* @param {string} [args.params] | ||
* @param {boolean} [args.is_ytm] | ||
* @param {boolean} [args.is_ctoken] | ||
* | ||
* @returns {Promise.<{ success: boolean; status_code: number; data: object }>} | ||
*/ | ||
async browse(id, args = {}) { | ||
if (this.#needsLogin(id) && !this.#session.logged_in) | ||
throw new Utils.InnertubeError('You are not signed in'); | ||
const data = { context: this.#session.context }; | ||
args.params && | ||
(data.params = args.params); | ||
args.is_ctoken && | ||
(data.continuation = id) || | ||
(data.browseId = id); | ||
if (args.is_ytm) { | ||
const context = JSON.parse(JSON.stringify(this.#session.context)); // deep copy the context obj so we don't accidentally change it | ||
const data = { context: session.context }; | ||
switch (action) { | ||
case 'account_notifications': | ||
data.browseId = 'SPaccount_notifications'; | ||
break; | ||
case 'account_privacy': | ||
data.browseId = 'SPaccount_privacy'; | ||
break; | ||
case 'history': | ||
data.browseId = 'FEhistory'; | ||
break; | ||
case 'home_feed': | ||
data.browseId = 'FEwhat_to_watch'; | ||
break; | ||
case 'trending': | ||
data.browseId = 'FEtrending'; | ||
args.params && (data.params = args.params); | ||
break; | ||
case 'subscriptions_feed': | ||
data.browseId = 'FEsubscriptions'; | ||
break; | ||
case 'lyrics': | ||
case 'music_playlist': | ||
const context = JSON.parse(JSON.stringify(session.context)); // deep copy the context obj so we don't accidentally change it | ||
context.client.originalUrl = Constants.URLS.YT_MUSIC; | ||
context.client.clientVersion = Constants.YTMUSIC_VERSION; | ||
context.client.clientName = 'WEB_REMIX'; | ||
context.client.clientVersion = Constants.CLIENTS.YTMUSIC.VERSION; | ||
context.client.clientName = Constants.CLIENTS.YTMUSIC.NAME; | ||
data.context = context; | ||
data.browseId = args.browse_id; | ||
break; | ||
case 'channel': | ||
case 'playlist': | ||
data.browseId = args.browse_id; | ||
break; | ||
case 'continuation': | ||
data.continuation = args.ctoken; | ||
break; | ||
default: | ||
throw new Utils.InnertubeError('Invalid action', action); | ||
} | ||
const response = await this.#request.post('/browse', JSON.stringify(data)); | ||
return response; | ||
} | ||
const requester = args.ytmusic && session.YTMRequester || session.YTRequester; | ||
const response = await requester.post('/browse', JSON.stringify(data)).catch((error) => error); | ||
if (response instanceof Error) return { success: false, status_code: response.response?.status || 0, message: response.message }; | ||
/** | ||
* Covers endpoints used to perform direct interactions | ||
* on YouTube. | ||
* | ||
* @param {string} action | ||
* @param {object} args | ||
* @param {string} [args.video_id] | ||
* @param {string} [args.channel_id] | ||
* @param {string} [args.comment_id] | ||
* @param {string} [args.comment_action] | ||
* | ||
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>} | ||
*/ | ||
async engage(action, args = {}) { | ||
if (!this.#session.logged_in && !args.hasOwnProperty('text')) | ||
throw new Utils.InnertubeError('You are not signed in'); | ||
const data = { context: this.#session.context }; | ||
switch (action) { | ||
case 'like/like': | ||
case 'like/dislike': | ||
case 'like/removelike': | ||
data.target = { | ||
videoId: args.video_id | ||
} | ||
break; | ||
case 'subscription/subscribe': | ||
case 'subscription/unsubscribe': | ||
data.channelIds = [args.channel_id]; | ||
data.params = action === 'subscription/subscribe' ? 'EgIIAhgA' : 'CgIIAhgA'; | ||
break; | ||
case 'comment/create_comment': | ||
data.commentText = args.text; | ||
data.createCommentParams = Proto.encodeCommentParams(args.video_id); | ||
break; | ||
case 'comment/create_comment_reply': | ||
data.createReplyParams = Proto.encodeCommentReplyParams(args.comment_id, args.video_id); | ||
data.commentText = args.text; | ||
break; | ||
case 'comment/perform_comment_action': | ||
const params = { | ||
video_id: args.video_id, | ||
comment_id: args.comment_id | ||
}; | ||
const target_action = ({ | ||
like: () => Proto.encodeCommentActionParams(5, params), | ||
dislike: () => Proto.encodeCommentActionParams(4, params), | ||
translate: () => { | ||
params.text = args.text; | ||
params.target_language = args.target_language; | ||
return Proto.encodeCommentActionParams(22, params); | ||
} | ||
})[args.comment_action](); | ||
data.actions = [ target_action ]; | ||
break; | ||
default: | ||
throw new Utils.InnertubeError('Invalid action', action); | ||
} | ||
const response = await this.#request.post(`/${action}`, JSON.stringify(data)); | ||
return response; | ||
} | ||
/** | ||
* Covers endpoints related to account management. | ||
* | ||
* @param {string} action | ||
* @param {object} args | ||
* @param {string} args.new_value | ||
* @param {string} args.setting_item_id | ||
* | ||
* @returns {Promise.<{ success: boolean; status_code: number; data: object }>} | ||
*/ | ||
async account(action, args = {}) { | ||
if (!this.#session.logged_in) | ||
throw new Utils.InnertubeError('You are not signed in'); | ||
return { | ||
success: true, | ||
status_code: response.status, | ||
data: response.data | ||
}; | ||
} | ||
/** | ||
* Account settings endpoints. | ||
* | ||
* @param {Innertube} session - A valid Innertube session. | ||
* @param {string} action - Type of action. | ||
* @param {object} args - Action argumenets. | ||
* @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>} | ||
*/ | ||
async function account(session, action, args = {}) { | ||
if (!session.logged_in) throw new Utils.InnertubeError('You are not signed in'); | ||
const data = {}; | ||
switch (action) { | ||
case 'account/account_menu': | ||
data.context = session.context; | ||
break; | ||
case 'account/set_setting': | ||
data.context = session.context; | ||
const data = { context: this.#session.context }; | ||
if (action === 'account/set_setting') { | ||
data.newValue = { boolValue: args.new_value }; | ||
data.settingItemId = args.setting_item_id; | ||
break; | ||
default: | ||
throw new Utils.InnertubeError('Invalid action', action); | ||
} | ||
const response = await this.#request.post(`/${action}`, JSON.stringify(data)); | ||
return response; | ||
} | ||
const response = await session.YTRequester.post(`/${action}`, JSON.stringify(data)).catch((error) => error); | ||
if (response instanceof Error) return { success: false, status_code: response.response?.status || 0, message: response.message }; | ||
return { | ||
success: true, | ||
status_code: response.status, | ||
data: response.data | ||
}; | ||
} | ||
/** | ||
* Accesses YouTube Music endpoints (/youtubei/v1/music/). | ||
* | ||
* @param {Innertube} session - A valid Innertube session. | ||
* @param {string} action - Type of action. | ||
* @param {object} args - Action arguments. | ||
* @todo Implement more actions. | ||
* @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>} | ||
*/ | ||
async function music(session, action, args) { | ||
const context = JSON.parse(JSON.stringify(session.context)); // deep copy the context obj so we don't accidentally change it | ||
context.client.originalUrl = Constants.URLS.YT_MUSIC; | ||
context.client.clientVersion = Constants.YTMUSIC_VERSION; | ||
context.client.clientName = 'WEB_REMIX'; | ||
let data = {}; | ||
switch (action) { | ||
case 'get_search_suggestions': | ||
data.context = context; | ||
data.input = args.input || ''; | ||
break; | ||
default: | ||
throw new Utils.InnertubeError('Invalid action', action); | ||
} | ||
const response = await session.YTMRequester.post(`/music/${action}`, JSON.stringify(data)).catch((error) => error); | ||
if (response instanceof Error) return { success: false, status_code: response.response?.status || 0, message: response.message }; | ||
return { | ||
success: true, | ||
status_code: response.status, | ||
data: response.data | ||
}; | ||
} | ||
/** | ||
* Searches a given query on YouTube/YTMusic. | ||
* | ||
* @param {Innertube} session - A valid Innertube session. | ||
* @param {string} client - YouTube client: YOUTUBE | YTMUSIC | ||
* @param {object} args - Search arguments. | ||
* @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>} | ||
*/ | ||
async function search(session, client, args = {}) { | ||
const data = { context: session.context }; | ||
switch (client) { | ||
case 'YOUTUBE': | ||
if (args.query) { | ||
/** | ||
* Covers endpoint used for searches. | ||
* | ||
* @param {object} args | ||
* @param {string} args.query | ||
* @param {object} args.options | ||
* @param {string} args.options.period | ||
* @param {string} args.options.duration | ||
* @param {string} args.options.order | ||
* | ||
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>} | ||
*/ | ||
async search(args = {}) { | ||
const data = { context: this.#session.context }; | ||
if (args.hasOwnProperty('query')) { | ||
data.query = args.query; | ||
if (!args.is_ytm) { | ||
data.params = Proto.encodeSearchFilter(args.options.period, args.options.duration, args.options.order); | ||
data.query = args.query; | ||
} else { | ||
data.continuation = args.ctoken; | ||
} | ||
break; | ||
case 'YTMUSIC': | ||
const context = JSON.parse(JSON.stringify(session.context)); // deep copy the context obj so we don't accidentally change it | ||
} else { | ||
data.continuation = args.ctoken; | ||
} | ||
if (args.is_ytm) { | ||
const context = JSON.parse(JSON.stringify(this.#session.context)); | ||
context.client.originalUrl = Constants.URLS.YT_MUSIC; | ||
context.client.clientVersion = Constants.YTMUSIC_VERSION; | ||
context.client.clientName = 'WEB_REMIX'; | ||
context.client.clientVersion = Constants.CLIENTS.YTMUSIC.VERSION; | ||
context.client.clientName = Constants.CLIENTS.YTMUSIC.NAME; | ||
data.context = context; | ||
data.query = args.query; | ||
break; | ||
default: | ||
throw new Utils.InnertubeError('Invalid client', client); | ||
} | ||
const response = await this.#request.post('/search', JSON.stringify(data)); | ||
return response; | ||
} | ||
const requester = client == 'YOUTUBE' && session.YTRequester || session.YTMRequester; | ||
const response = await requester.post('/search', JSON.stringify(data)).catch((error) => error); | ||
if (response instanceof Error) return { success: false, status_code: response.response?.status || 0, message: response.message }; | ||
/** | ||
* Endpoint used fo Shorts' sound search. | ||
* | ||
* @param {object} args | ||
* @param {string} args.query | ||
* | ||
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>} | ||
*/ | ||
async searchSound(args = {}) { | ||
const context = JSON.parse(JSON.stringify(this.#session.context)); // deep copy the context obj so we don't accidentally change it | ||
return { | ||
success: true, | ||
status_code: response.status, | ||
data: response.data | ||
}; | ||
} | ||
/** | ||
* Interacts with YouTube's notification system. | ||
* | ||
* @param {Innertube} session - A valid Innertube session. | ||
* @param {string} action - Type of action. | ||
* @param {object} args - Action arguments. | ||
* @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>} | ||
*/ | ||
async function notifications(session, action, args = {}) { | ||
if (!session.logged_in) throw new Utils.InnertubeError('You are not signed in'); | ||
const data = {}; | ||
switch (action) { | ||
case 'modify_channel_preference': | ||
const pref_types = { PERSONALIZED: 1, ALL: 2, NONE: 3 }; | ||
data.context = session.context; | ||
data.params = Proto.encodeNotificationPref(args.channel_id, pref_types[args.pref.toUpperCase()]); | ||
break; | ||
case 'get_notification_menu': | ||
data.context = session.context; | ||
data.notificationsMenuRequestType = 'NOTIFICATIONS_MENU_REQUEST_TYPE_INBOX'; | ||
args.ctoken && (data.ctoken = args.ctoken); | ||
break; | ||
case 'get_unseen_count': | ||
data.context = session.context; | ||
break; | ||
default: | ||
throw new Utils.InnertubeError('Invalid action', action); | ||
context.client.clientVersion = Constants.CLIENTS.ANDROID.VERSION; | ||
context.client.clientName = Constants.CLIENTS.ANDROID.NAME; | ||
const data = { context }; | ||
data.query = args.query; | ||
const response = await this.#request.post('/sfv/search', JSON.stringify(data)); | ||
return response; | ||
} | ||
const response = await session.YTRequester.post(`/notification/${action}`, JSON.stringify(data)).catch((err) => err); | ||
if (response instanceof Error) return { success: false, status_code: response.response?.status || 0, message: response.message }; | ||
if (action === 'modify_channel_preference') return { success: true, status_code: response.status }; | ||
return { | ||
success: true, | ||
status_code: response.status, | ||
data: response.data | ||
}; | ||
} | ||
/** | ||
* Interacts with YouTube's livechat system. | ||
* | ||
* @param {Innertube} session - A valid Innertube session. | ||
* @param {string} action - Type of action. | ||
* @param {object} args - Action arguments. | ||
* @returns {Promise.<{ success: boolean; data: object; message?: string }>} | ||
*/ | ||
async function livechat(session, action, args = {}) { | ||
const data = {}; | ||
switch (action) { | ||
case 'live_chat/get_live_chat': | ||
data.context = session.context; | ||
data.continuation = args.ctoken; | ||
break; | ||
case 'live_chat/send_message': | ||
data.context = session.context; | ||
data.params = Proto.encodeMessageParams(args.channel_id, args.video_id); | ||
data.clientMessageId = `ytjs-${Uuid.v4()}`; | ||
data.richMessage = { | ||
textSegments: [{ text: args.text }] | ||
} | ||
break; | ||
case 'live_chat/get_item_context_menu': | ||
data.context = session.context; | ||
break; | ||
case 'live_chat/moderate': | ||
data.context = session.context; | ||
data.params = args.cmd_params; | ||
break; | ||
case 'updated_metadata': | ||
data.context = session.context; | ||
data.videoId = args.video_id; | ||
args.continuation && (data.continuation = args.continuation); | ||
break; | ||
default: | ||
throw new Utils.InnertubeError('Invalid action', action); | ||
/** | ||
* Covers endpoints used for playlist management. | ||
* | ||
* @param {string} action | ||
* @param {object} args | ||
* @param {string} [args.title] | ||
* @param {string} [args.ids] | ||
* @param {string} [args.playlist_id] | ||
* @param {string} [args.action] | ||
* | ||
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>} | ||
*/ | ||
async playlist(action, args = {}) { | ||
if (!this.#session.logged_in) | ||
throw new Utils.InnertubeError('You are not signed in'); | ||
const data = { context: this.#session.context }; | ||
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': | ||
data.playlistId = args.playlist_id; | ||
data.actions = args.ids.map((id) => ({ | ||
'ACTION_ADD_VIDEO': { | ||
action: args.action, | ||
addedVideoId: id | ||
}, | ||
'ACTION_REMOVE_VIDEO': { | ||
action: args.action, | ||
setVideoId: id | ||
} | ||
})[args.action]); | ||
break; | ||
default: | ||
throw new Utils.InnertubeError('Invalid action', action); | ||
} | ||
const response = await this.#request.post(`/${action}`, JSON.stringify(data)); | ||
return response; | ||
} | ||
const response = await session.YTRequester.post(`/${action}`, JSON.stringify(data)).catch((err) => err); | ||
if (response instanceof Error) return { success: false, message: response.message }; | ||
/** | ||
* Covers endpoints used for notifications management. | ||
* | ||
* @param {string} action | ||
* @param {object} args | ||
* @param {string} [args.pref] | ||
* @param {string} [args.channel_id] | ||
* @param {string} [args.ctoken] | ||
* | ||
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>} | ||
*/ | ||
async notifications(action, args) { | ||
if (!this.#session.logged_in) | ||
throw new Utils.InnertubeError('You are not signed in'); | ||
const data = { context: this.#session.context }; | ||
switch (action) { | ||
case 'modify_channel_preference': | ||
const pref_types = { PERSONALIZED: 1, ALL: 2, NONE: 3 }; | ||
data.params = Proto.encodeNotificationPref(args.channel_id, pref_types[args.pref.toUpperCase()]); | ||
break; | ||
case 'get_notification_menu': | ||
data.notificationsMenuRequestType = 'NOTIFICATIONS_MENU_REQUEST_TYPE_INBOX'; | ||
args.ctoken && (data.ctoken = args.ctoken); | ||
break; | ||
case 'get_unseen_count': | ||
// doesn't require any parameter | ||
break; | ||
default: | ||
throw new Utils.InnertubeError('Invalid action', action); | ||
} | ||
return { success: true, data: response.data }; | ||
} | ||
const response = await this.#request.post(`/notification/${action}`, JSON.stringify(data)); | ||
if (response instanceof Error) return { success: false, status_code: response.status_code || 0, message: response.message }; | ||
return response; | ||
} | ||
/** | ||
* Covers livechat endpoints. | ||
* | ||
* @param {string} action | ||
* @param {object} args | ||
* @param {string} [args.text] | ||
* @param {string} [args.video_id] | ||
* @param {string} [args.channel_id] | ||
* @param {string} [args.ctoken] | ||
* @param {string} [args.params] | ||
* | ||
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>} | ||
*/ | ||
async livechat(action, args = {}) { | ||
const data = { context: this.#session.context }; | ||
switch (action) { | ||
case 'live_chat/get_live_chat': | ||
data.continuation = args.ctoken; | ||
break; | ||
case 'live_chat/send_message': | ||
data.params = Proto.encodeMessageParams(args.channel_id, args.video_id); | ||
data.clientMessageId = Uuid.v4(); | ||
data.richMessage = { | ||
textSegments: [{ text: args.text }] | ||
} | ||
break; | ||
case 'live_chat/get_item_context_menu': | ||
// note: this is currently broken due to a recent refactor | ||
break; | ||
case 'live_chat/moderate': | ||
data.params = args.params; | ||
break; | ||
case 'updated_metadata': | ||
data.videoId = args.video_id; | ||
args.ctoken && (data.continuation = args.ctoken); | ||
break; | ||
default: | ||
throw new Utils.InnertubeError('Invalid action', action); | ||
} | ||
const response = await this.#request.post(`/${action}`, JSON.stringify(data)); | ||
return response; | ||
} | ||
/** | ||
* Covers endpoints used to report content. | ||
* | ||
* @param {string} action | ||
* @param {object} args | ||
* @param {object} [args.action] | ||
* @param {string} [args.params] | ||
* | ||
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>} | ||
*/ | ||
async flag(action, args) { | ||
if (!this.#session.logged_in) | ||
throw new Utils.InnertubeError('You are not signed in'); | ||
const data = { context: this.#session.context }; | ||
switch (action) { | ||
case 'flag/flag': | ||
data.action = args.action; | ||
break; | ||
case 'flag/get_form': | ||
data.params = args.params; | ||
break; | ||
default: | ||
throw new Utils.InnertubeError('Invalid action', action); | ||
} | ||
const response = await this.#request.post(`/${action}`, JSON.stringify(data)); | ||
return response; | ||
} | ||
/** | ||
* Covers specific YouTube Music endpoints. | ||
* | ||
* @param {string} action | ||
* @param {string} args.input | ||
* | ||
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>} | ||
*/ | ||
async music(action, args) { | ||
const context = JSON.parse(JSON.stringify(this.#session.context)); // deep copy the context obj so we don't accidentally change it | ||
/** | ||
* Requests continuation for previously performed actions. | ||
* | ||
* @param {Innertube} session - A valid Innertube session. | ||
* @param {object} args - Continuation arguments. | ||
* @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>} | ||
*/ | ||
async function next(session, args = {}) { | ||
let data = { context: session.context }; | ||
args.continuation_token && (data.continuation = args.continuation_token); | ||
context.client.originalUrl = Constants.URLS.YT_MUSIC; | ||
context.client.clientVersion = Constants.CLIENTS.YTMUSIC.VERSION; | ||
context.client.clientName = Constants.CLIENTS.YTMUSIC.NAME; | ||
const data = { context }; | ||
data.input = args.input || ''; | ||
const response = await this.#request.post(`/music/${action}`, JSON.stringify(data)); | ||
return response; | ||
} | ||
/** | ||
* Mostly used to retrieve data continuation for | ||
* previously executed actions. | ||
* | ||
* @param {string} action | ||
* @param {object} args | ||
* @param {string} args.video_id | ||
* @param {string} args.channel_id | ||
* @param {string} args.ctoken | ||
* @param {boolean} is_ytm | ||
* | ||
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>} | ||
*/ | ||
async next(args = {}) { | ||
const data = { context: this.#session.context }; | ||
args.ctoken && | ||
(data.continuation = args.ctoken); | ||
args.video_id && | ||
(data.videoId = args.video_id); | ||
if (args.is_ytm) { | ||
const context = JSON.parse(JSON.stringify(this.#session.context)); // deep copy the context obj so we don't accidentally change it | ||
if (args.video_id) { | ||
data.videoId = args.video_id; | ||
if (args.ytmusic) { | ||
const context = JSON.parse(JSON.stringify(session.context)); // deep copy the context obj so we don't accidentally change it | ||
context.client.originalUrl = Constants.URLS.YT_MUSIC; | ||
context.client.clientVersion = Constants.YTMUSIC_VERSION; | ||
context.client.clientName = 'WEB_REMIX'; | ||
context.client.clientVersion = Constants.CLIENTS.YTMUSIC.VERSION; | ||
context.client.clientName = Constants.CLIENTS.YTMUSIC.NAME; | ||
data.context = context; | ||
data.isAudioOnly = true; | ||
data.tunerSettingValue = 'AUTOMIX_SETTING_NORMAL'; | ||
} else { | ||
data.racyCheckOk = true; | ||
data.contentCheckOk = false; | ||
data.autonavState = 'STATE_NONE'; | ||
data.playbackContext = { vis: 0, lactMilliseconds: '-1' }; | ||
data.captionsRequested = false; | ||
} | ||
const response = await this.#request.post('/next', JSON.stringify(data)); | ||
return response; | ||
} | ||
const requester = args.ytmusic && session.YTMRequester || session.YTRequester; | ||
const response = await requester.post('/next', JSON.stringify(data)).catch((error) => error); | ||
if (response instanceof Error) return { | ||
success: false, | ||
status_code: response.response?.status || 0, | ||
message: response.message | ||
}; | ||
return { | ||
success: true, | ||
status_code: response.status, | ||
data: response.data | ||
}; | ||
} | ||
/** | ||
* Retrieves video data. | ||
* | ||
* @param {Innertube} session - A valid Innertube session. | ||
* @param {object} args - Request arguments. | ||
* @returns {Promise.<object>} - Video data. | ||
*/ | ||
async function getVideoInfo(session, args = {}) { | ||
const response = await session.YTRequester.post(`/player`, JSON.stringify(Constants.VIDEO_INFO_REQBODY(args.id, session.sts, session.context))).catch((err) => err); | ||
if (response instanceof Error) throw new Utils.InnertubeError(`Could not get video info: ${response.message}`); | ||
return response.data; | ||
} | ||
/** | ||
* Gets search suggestions. | ||
* | ||
* @param {Innertube} session - A valid innertube session | ||
* @param {string} query - Search query | ||
* @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>} | ||
*/ | ||
async function getSearchSuggestions(session, client, input) { | ||
if (!['YOUTUBE', 'YTMUSIC'].includes(client)) | ||
throw new Utils.InnertubeError('Invalid client', client); | ||
/** | ||
* Used to retrieve video info. | ||
* | ||
* @param {string} id | ||
* @param {string} [cpn] | ||
* | ||
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>} | ||
*/ | ||
async getVideoInfo(id, cpn) { | ||
const data = { | ||
playbackContext: { | ||
contentPlaybackContext: { | ||
vis: 0, | ||
splay: false, | ||
referer: 'https://www.youtube.com', | ||
currentUrl: '/watch?v=' + id, | ||
autonavState: 'STATE_OFF', | ||
signatureTimestamp: this.#session.sts, | ||
autoCaptionsDefaultOn: false, | ||
html5Preference: 'HTML5_PREF_WANTS', | ||
lactMilliseconds: '-1' | ||
} | ||
}, | ||
context: this.#session.context, | ||
videoId: id | ||
}; | ||
cpn && (data.cpn = cpn); | ||
const response = await this.#request.post('/player', JSON.stringify(data)); | ||
return response.data; | ||
} | ||
const response = await ({ | ||
'YOUTUBE': async () => { | ||
const response = await Axios.get(`${Constants.URLS.YT_SUGGESTIONS}search?client=firefox&ds=yt&q=${encodeURIComponent(input)}`, | ||
Constants.DEFAULT_HEADERS(session)).catch((error) => error); | ||
return { | ||
success: !(response instanceof Error), | ||
status_code: response.status, | ||
data: response?.data | ||
}; | ||
}, | ||
'YTMUSIC': async () => { | ||
const response = await music(session, 'get_search_suggestions', { input }); | ||
return response; | ||
} | ||
}[client])(); | ||
/** | ||
* Covers search suggestion endpoints. | ||
* | ||
* @param {string} client | ||
* @param {string} input | ||
* | ||
* @returns {Promise.<{ success: boolean; status_code: number; data: object; }>} | ||
*/ | ||
async getSearchSuggestions(client, input) { | ||
if (!['YOUTUBE', 'YTMUSIC'].includes(client)) | ||
throw new Utils.InnertubeError('Invalid client', client); | ||
const response = await ({ | ||
YOUTUBE: () => this.#request({ | ||
baseURL: Constants.URLS.YT_SUGGESTIONS + `search?client=firefox&ds=yt&q=${encodeURIComponent(input)}`, | ||
}), | ||
YTMUSIC: () => this.music('get_search_suggestions', { | ||
input | ||
}) | ||
}[client])(); | ||
return response; | ||
} | ||
return response; | ||
#needsLogin(id) { | ||
return [ | ||
'FElibrary', 'FEhistory', 'FEsubscriptions', | ||
'SPaccount_notifications', 'SPaccount_privacy' | ||
].includes(id); | ||
} | ||
} | ||
module.exports = { engage, browse, account, music, search, notifications, livechat, getVideoInfo, next, getSearchSuggestions }; | ||
module.exports = Actions; |
'use strict'; | ||
const Actions = require('./Actions'); | ||
const EventEmitter = require('events'); | ||
@@ -30,3 +29,3 @@ | ||
const livechat = await Actions.livechat(this.session, 'live_chat/get_live_chat', { ctoken: this.ctoken }); | ||
const livechat = await this.session.actions.livechat('live_chat/get_live_chat', { ctoken: this.ctoken }); | ||
if (!livechat.success) { | ||
@@ -50,5 +49,5 @@ this.emit('error', { message: `Failed polling livechat: ${livechat.message}. Retrying...` }); | ||
const data = { video_id: this.video_id }; | ||
if (this.metadata_ctoken) data.continuation = this.metadata_ctoken; | ||
if (this.metadata_ctoken) data.ctoken = this.metadata_ctoken; | ||
const updated_metadata = await Actions.livechat(this.session, 'updated_metadata', data); | ||
const updated_metadata = await this.session.actions.livechat('updated_metadata', data); | ||
if (!updated_metadata.success) { | ||
@@ -94,7 +93,7 @@ this.emit('error', { message: `Failed polling livechat metadata: ${livechat.message}.` }); | ||
async sendMessage(text) { | ||
const message = await Actions.livechat(this.session, 'live_chat/send_message', { text, channel_id: this.channel_id, video_id: this.video_id }); | ||
const message = await this.session.actions.livechat('live_chat/send_message', { text, channel_id: this.channel_id, video_id: this.video_id }); | ||
if (!message.success) return message; | ||
const deleteMessage = async () => { | ||
const menu = await Actions.livechat(this.session, 'live_chat/get_item_context_menu', { params: { params: message.data.actions[0].addChatItemAction.item.liveChatTextMessageRenderer.contextMenuEndpoint.liveChatItemContextMenuEndpoint.params, pbj: 1 } }); | ||
const menu = await this.session.actions.livechat('live_chat/get_item_context_menu', { params: { params: message.data.actions[0].addChatItemAction.item.liveChatTextMessageRenderer.contextMenuEndpoint.liveChatItemContextMenuEndpoint.params, pbj: 1 } }); | ||
if (!menu.success) return menu; | ||
@@ -104,3 +103,3 @@ | ||
const cmd = await Actions.livechat(this.session, 'live_chat/moderate', { cmd_params: chat_item_menu.menuServiceItemRenderer.serviceEndpoint.moderateLiveChatEndpoint.params }); | ||
const cmd = await this.session.actions.livechat('live_chat/moderate', { params: chat_item_menu.menuServiceItemRenderer.serviceEndpoint.moderateLiveChatEndpoint.params }); | ||
if (!cmd.success) return cmd; | ||
@@ -107,0 +106,0 @@ |
@@ -200,3 +200,3 @@ 'use strict'; | ||
const response = await Axios.get(script_url, Constants.DEFAULT_HEADERS).catch((error) => error); | ||
const response = await Axios.get(script_url).catch((error) => error); | ||
if (response instanceof Error) throw new Error(`Could not extract client identity: ${response.message}`); | ||
@@ -203,0 +203,0 @@ |
@@ -9,32 +9,69 @@ 'use strict'; | ||
class Player { | ||
constructor(session) { | ||
this.session = session; | ||
this.player_name = Utils.getStringBetweenStrings(this.session.player_url, '/player/', '/'); | ||
this.tmp_cache_dir = __dirname.slice(0, -8) + 'cache'; | ||
#player_id; | ||
#player_url; | ||
#player_path; | ||
#ntoken_decipher_sc; | ||
#signature_decipher_sc; | ||
#signature_timestamp; | ||
#cache_dir; | ||
constructor(id) { | ||
this.#player_id = id; | ||
this.#cache_dir = __dirname.slice(0, -8) + '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`; | ||
} | ||
async init() { | ||
if (Fs.existsSync(`${this.tmp_cache_dir}/${this.player_name}.js`)) { | ||
const player_data = Fs.readFileSync(`${this.tmp_cache_dir}/${this.player_name}.js`).toString(); | ||
this.sig_decipher_sc = this.#getSigDecipherCode(player_data); | ||
this.ntoken_sc = this.#getNEncoder(player_data); | ||
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); | ||
} else { | ||
const response = await Axios.get(`${Constants.URLS.YT_BASE}${this.session.player_url}`, { path: this.session.playerUrl, headers: { 'content-type': 'text/javascript', 'user-agent': Utils.getRandomUserAgent('desktop').userAgent } }).catch((error) => error); | ||
if (response instanceof Error) throw new Error('Could not download player script: ' + response.message); | ||
const response = await Axios.get(this.#player_url, { headers: { 'content-type': 'text/javascript', 'user-agent': Utils.getRandomUserAgent('desktop').userAgent } }).catch((error) => error); | ||
if (response instanceof Error) throw new Utils.InnertubeError('Could not download js player', { player_id }); | ||
this.#signature_timestamp = this.#extractSigTimestamp(response.data); | ||
this.#signature_decipher_sc = this.#extractSigDecipherSc(response.data); | ||
this.#ntoken_decipher_sc = this.#extractNTokenSc(response.data); | ||
try { | ||
// Deletes old players | ||
Fs.existsSync(this.tmp_cache_dir) && Fs.rmSync(this.tmp_cache_dir, { recursive: true }); | ||
// Caches the current player so we don't have to download it all the time. | ||
Fs.mkdirSync(this.tmp_cache_dir, { recursive: true }); | ||
Fs.writeFileSync(`${this.tmp_cache_dir}/${this.player_name}.js`, response.data); | ||
// Delete the old player | ||
Fs.existsSync(this.#cache_dir) && | ||
Fs.rmSync(this.#cache_dir, { recursive: true }); | ||
// Cache the current player | ||
Fs.mkdirSync(this.#cache_dir, { recursive: true }); | ||
Fs.writeFileSync(this.#player_path, response.data); | ||
} catch (err) {} | ||
this.sig_decipher_sc = this.#getSigDecipherCode(response.data); | ||
this.ntoken_sc = this.#getNEncoder(response.data); | ||
} | ||
return this; | ||
} | ||
#getSigDecipherCode(data) { | ||
get url() { | ||
return this.#player_url; | ||
} | ||
get sts() { | ||
return this.#signature_timestamp; | ||
} | ||
get ntoken_decipher() { | ||
return this.#ntoken_decipher_sc; | ||
} | ||
get signature_decipher() { | ||
return this.#signature_decipher_sc; | ||
} | ||
#extractSigTimestamp(data) { | ||
return parseInt(Utils.getStringBetweenStrings(data, 'signatureTimestamp:', ',')); | ||
} | ||
#extractSigDecipherSc(data) { | ||
const sig_alg_sc = Utils.getStringBetweenStrings(data, 'this.audioTracks};var', '};'); | ||
@@ -45,7 +82,11 @@ const sig_data = Utils.getStringBetweenStrings(data, 'function(a){a=a.split("")', 'return a.join("")}'); | ||
#getNEncoder(data) { | ||
#extractNTokenSc(data) { | ||
return `var b=a.split("")${Utils.getStringBetweenStrings(data, 'b=a.split("")', '}return b.join("")}')}} return b.join("");`; | ||
} | ||
isCached() { | ||
return Fs.existsSync(this.#player_path); | ||
} | ||
} | ||
module.exports = Player; |
@@ -14,3 +14,3 @@ 'use strict'; | ||
* Solves throttling challange by transforming the n token. | ||
* @returns {string} transformed token. | ||
* @returns {string} | ||
*/ | ||
@@ -56,3 +56,6 @@ transform() { | ||
} catch (err) { | ||
console.error(`Could not transform n-token (${this.n}), download may be throttled:`, err.message); | ||
console.error(new Utils.ParsingError('Could not transform n-token, download may be throttled.', { | ||
original_token: this.n, | ||
stack: err.stack | ||
})); | ||
return this.n; | ||
@@ -64,3 +67,3 @@ } | ||
#getFunc(el) { | ||
return el.match(Constants.FUNCS_REGEX); | ||
return el.match(Constants.NTOKEN_REGEX.FUNCTIONS); | ||
} | ||
@@ -70,3 +73,3 @@ | ||
* Takes the n-transform data, refines it, and then returns a readable json array. | ||
* @returns {object} | ||
* @returns {Array} | ||
*/ | ||
@@ -73,0 +76,0 @@ #getTransformationData() { |
@@ -10,11 +10,13 @@ 'use strict'; | ||
const OAuth = require('./core/OAuth'); | ||
const Player = require('./core/Player'); | ||
const Actions = require('./core/Actions'); | ||
const Livechat = require('./core/Livechat'); | ||
const SessionBuilder = require('./core/SessionBuilder'); | ||
const Utils = require('./utils/Utils'); | ||
const Request = require('./utils/Request'); | ||
const Constants = require('./utils/Constants'); | ||
const Proto = require('./proto'); | ||
const NToken = require('./deciphers/NToken'); | ||
const SigDecipher = require('./deciphers/Sig'); | ||
const Signature = require('./deciphers/Signature'); | ||
@@ -24,2 +26,3 @@ class Innertube { | ||
#player; | ||
#actions; | ||
#retry_count; | ||
@@ -32,76 +35,55 @@ | ||
* ``` | ||
* @param {string} [cookie] | ||
* | ||
* @param {object} [config] | ||
* @param {string} [config.gl] | ||
* @param {string} [config.cookie] | ||
* @param {boolean} [config.debug] | ||
* | ||
* @returns {Innertube} | ||
* @constructor | ||
*/ | ||
constructor(cookie) { | ||
this.cookie = cookie || ''; | ||
constructor(config) { | ||
this.config = config || {}; | ||
this.#retry_count = 0; | ||
return this.#init(); | ||
} | ||
async #init() { | ||
const response = await Axios.get(Constants.URLS.YT_BASE, Constants.DEFAULT_HEADERS(this)).catch((error) => error); | ||
if (response instanceof Error) throw new Utils.InnertubeError('Could not retrieve Innertube session', { status_code: response.status || 0 }); | ||
const data = JSON.parse(`{${Utils.getStringBetweenStrings(response.data, 'ytcfg.set({', '});') || ''}}`); | ||
if (data.INNERTUBE_CONTEXT) { | ||
this.key = data.INNERTUBE_API_KEY; | ||
this.version = data.INNERTUBE_API_VERSION; | ||
this.context = data.INNERTUBE_CONTEXT; | ||
this.player_url = data.PLAYER_JS_URL; | ||
this.logged_in = data.LOGGED_IN; | ||
this.sts = data.STS; | ||
this.context.client.hl = 'en'; | ||
this.context.client.gl = 'US'; | ||
/** | ||
* @event Innertube#auth - Fired when signing in to an account. | ||
* @event Innertube#update-credentials - Fired when the access token is no longer valid. | ||
* @type {EventEmitter} | ||
*/ | ||
this.ev = new EventEmitter(); | ||
this.#oauth = new OAuth(this.ev); | ||
this.#player = new Player(this); | ||
await this.#player.init(); | ||
if (this.logged_in && this.cookie.length) { | ||
this.auth_apisid = Utils.getStringBetweenStrings(this.cookie, 'PAPISID=', ';'); | ||
this.auth_apisid = Utils.generateSidAuth(this.auth_apisid); | ||
} | ||
// Axios instances | ||
this.YTRequester = Axios.create({ | ||
baseURL: Constants.URLS.YT_BASE_API + this.version, | ||
timeout: 15000, | ||
headers: Constants.INNERTUBE_HEADERS({ session: this, ytmusic: false }), | ||
params: { key: this.key } | ||
}); | ||
this.YTMRequester = Axios.create({ | ||
baseURL: Constants.URLS.YT_MUSIC_BASE_API + this.version, | ||
timeout: 15000, | ||
headers: Constants.INNERTUBE_HEADERS({ session: this, ytmusic: true }), | ||
params: { key: this.key } | ||
}); | ||
this.#initMethods(); | ||
} else { | ||
this.#retry_count += 1; | ||
if (this.#retry_count >= 10) | ||
throw new Utils.ParsingError('No InnerTubeContext shell provided in ytconfig.', { | ||
data_snippet: response.data.slice(0, 300), | ||
status_code: response.status || 0 | ||
}); | ||
return this.#init(); | ||
const session = await new SessionBuilder(this.config).build(); | ||
this.key = session.key; | ||
this.version = session.api_version; | ||
this.context = session.context; | ||
this.logged_in = false; | ||
this.player_url = session.player.url; | ||
this.sts = session.player.sts; | ||
this.#player = session.player; | ||
/** | ||
* @fires Innertube#auth - fired when signing in to an account. | ||
* @fires Innertube#update-credentials - fired when the access token is no longer valid. | ||
* @type {EventEmitter} | ||
*/ | ||
this.ev = new EventEmitter(); | ||
this.#oauth = new OAuth(this.ev); | ||
if (this.config.cookie) { | ||
this.auth_apisid = Utils.getStringBetweenStrings(this.config.cookie, 'PAPISID=', ';'); | ||
this.auth_apisid = Utils.generateSidAuth(this.auth_apisid); | ||
} | ||
this.request = new Request(this); | ||
this.actions = new Actions(this); | ||
this.#initMethods(); | ||
return this; | ||
} | ||
#initMethods() { | ||
this.account = { | ||
info: () => this.getAccountInfo(), | ||
getTimeWatched: () => { /* TODO: Implement this */ }, | ||
settings: { | ||
@@ -113,5 +95,5 @@ notifications: { | ||
* @param {boolean} new_value | ||
* @returns {Promise<{ success: boolean; status_code: string; }>} | ||
* @returns {Promise.<{ success: boolean; status_code: string; }>} | ||
*/ | ||
setSubscriptions: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.SUBSCRIPTIONS, 'account_notifications', new_value), | ||
setSubscriptions: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.SUBSCRIPTIONS, 'SPaccount_notifications', new_value), | ||
@@ -122,5 +104,5 @@ /** | ||
* @param {boolean} new_value | ||
* @returns {Promise<{ success: boolean; status_code: string; }>} | ||
* @returns {Promise.<{ success: boolean; status_code: string; }>} | ||
*/ | ||
setRecommendedVideos: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.RECOMMENDED_VIDEOS, 'account_notifications', new_value), | ||
setRecommendedVideos: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.RECOMMENDED_VIDEOS, 'SPaccount_notifications', new_value), | ||
@@ -131,5 +113,5 @@ /** | ||
* @param {boolean} new_value | ||
* @returns {Promise<{ success: boolean; status_code: string; }>} | ||
* @returns {Promise.<{ success: boolean; status_code: string; }>} | ||
*/ | ||
setChannelActivity: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.CHANNEL_ACTIVITY, 'account_notifications', new_value), | ||
setChannelActivity: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.CHANNEL_ACTIVITY, 'SPaccount_notifications', new_value), | ||
@@ -140,5 +122,5 @@ /** | ||
* @param {boolean} new_value | ||
* @returns {Promise<{ success: boolean; status_code: string; }>} | ||
* @returns {Promise.<{ success: boolean; status_code: string; }>} | ||
*/ | ||
setCommentReplies: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.COMMENT_REPLIES, 'account_notifications', new_value), | ||
setCommentReplies: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.COMMENT_REPLIES, 'SPaccount_notifications', new_value), | ||
@@ -149,5 +131,5 @@ /** | ||
* @param {boolean} new_value | ||
* @returns {Promise<{ success: boolean; status_code: string; }>} | ||
* @returns {Promise.<{ success: boolean; status_code: string; }>} | ||
*/ | ||
setMentions: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.USER_MENTION, 'account_notifications', new_value), | ||
setMentions: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.USER_MENTION, 'SPaccount_notifications', new_value), | ||
@@ -158,5 +140,5 @@ /** | ||
* @param {boolean} new_value | ||
* @returns {Promise<{ success: boolean; status_code: string; }>} | ||
* @returns {Promise.<{ success: boolean; status_code: string; }>} | ||
*/ | ||
setSharedContent: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.SHARED_CONTENT, 'account_notifications', new_value) | ||
setSharedContent: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.SHARED_CONTENT, 'SPaccount_notifications', new_value) | ||
}, | ||
@@ -168,5 +150,5 @@ privacy: { | ||
* @param {boolean} new_value | ||
* @returns {Promise<{ success: boolean; status_code: string; }>} | ||
* @returns {Promise.<{ success: boolean; status_code: string; }>} | ||
*/ | ||
setSubscriptionsPrivate: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.SUBSCRIPTIONS_PRIVACY, 'account_privacy', new_value), | ||
setSubscriptionsPrivate: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.SUBSCRIPTIONS_PRIVACY, 'SPaccount_privacy', new_value), | ||
@@ -177,5 +159,5 @@ /** | ||
* @param {boolean} new_value | ||
* @returns {Promise<{ success: boolean; status_code: string; }>} | ||
* @returns {Promise.<{ success: boolean; status_code: string; }>} | ||
*/ | ||
setSavedPlaylistsPrivate: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.PLAYLISTS_PRIVACY, 'account_privacy', new_value) | ||
setSavedPlaylistsPrivate: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.PLAYLISTS_PRIVACY, 'SPaccount_privacy', new_value) | ||
} | ||
@@ -190,5 +172,5 @@ } | ||
* @param {string} video_id | ||
* @returns {Promise<{ success: boolean; status_code: string; }>} | ||
* @returns {Promise.<{ success: boolean; status_code: string; }>} | ||
*/ | ||
like: (video_id) => Actions.engage(this, 'like/like', { video_id }), | ||
like: (video_id) => this.actions.engage('like/like', { video_id }), | ||
@@ -199,5 +181,5 @@ /** | ||
* @param {string} video_id | ||
* @returns {Promise<{ success: boolean; status_code: string; }>} | ||
* @returns {Promise.<{ success: boolean; status_code: string; }>} | ||
*/ | ||
dislike: (video_id) => Actions.engage(this, 'like/dislike', { video_id }), | ||
dislike: (video_id) => this.actions.engage('like/dislike', { video_id }), | ||
@@ -208,5 +190,5 @@ /** | ||
* @param {string} video_id | ||
* @returns {Promise<{ success: boolean; status_code: string; }>} | ||
* @returns {Promise.<{ success: boolean; status_code: string; }>} | ||
*/ | ||
removeLike: (video_id) => Actions.engage(this, 'like/removelike', { video_id }), | ||
removeLike: (video_id) => this.actions.engage('like/removelike', { video_id }), | ||
@@ -218,13 +200,42 @@ /** | ||
* @param {string} text | ||
* @returns {Promise<{ success: boolean; status_code: string; }>} | ||
* @returns {Promise.<{ success: boolean; status_code: string; }>} | ||
*/ | ||
comment: (video_id, text) => Actions.engage(this, 'comment/create_comment', { video_id, text }), | ||
comment: (video_id, text) => this.actions.engage('comment/create_comment', { video_id, text }), | ||
/** | ||
* Translates a given text using YouTube's comment translate feature. | ||
* | ||
* @param {string} text | ||
* @param {string} target_language | ||
* @param {object} [args] - optional arguments | ||
* @param {string} [args.video_id] | ||
* @param {string} [args.comment_id] | ||
* | ||
* @returns {Promise.<{ success: boolean; status_code: string; }>} | ||
*/ | ||
translate: async (text, target_language, args = {}) => { | ||
const response = await this.actions.engage('comment/perform_comment_action', { | ||
text, | ||
video_id: args.video_id, | ||
comment_id: args.comment_id, | ||
target_language: target_language, | ||
comment_action: 'translate' | ||
}); | ||
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 | ||
} | ||
}, | ||
/** | ||
* Subscribes to a given channel. | ||
* | ||
* @param {string} channel_id | ||
* @returns {Promise<{ success: boolean; status_code: string; }>} | ||
* @returns {Promise.<{ success: boolean; status_code: string; }>} | ||
*/ | ||
subscribe: (channel_id) => Actions.engage(this, 'subscription/subscribe', { channel_id }), | ||
subscribe: (channel_id) => this.actions.engage('subscription/subscribe', { channel_id }), | ||
@@ -235,5 +246,5 @@ /** | ||
* @param {string} channel_id | ||
* @returns {Promise<{ success: boolean; status_code: string; }>} | ||
* @returns {Promise.<{ success: boolean; status_code: string; }>} | ||
*/ | ||
unsubscribe: (channel_id) => Actions.engage(this, 'subscription/unsubscribe', { channel_id }), | ||
unsubscribe: (channel_id) => this.actions.engage('subscription/unsubscribe', { channel_id }), | ||
@@ -246,5 +257,5 @@ /** | ||
* @param {string} type PERSONALIZED | ALL | NONE | ||
* @returns {Promise<{ success: boolean; status_code: string; }>} | ||
* @returns {Promise.<{ success: boolean; status_code: string; }>} | ||
*/ | ||
changeNotificationPreferences: (channel_id, type) => Actions.notifications(this, 'modify_channel_preference', { channel_id, pref: type || 'NONE' }), | ||
setNotificationPreferences: (channel_id, type) => this.actions.notifications('modify_channel_preference', { channel_id, pref: type || 'NONE' }), | ||
}; | ||
@@ -257,7 +268,17 @@ | ||
* @param {string} title | ||
* @param {string} video_id - Note that a video must be supplied, empty playlists cannot be created. | ||
* @returns {Promise<{ success: boolean; status_code: string; }>} | ||
* @param {string} video_ids | ||
* | ||
* @returns {Promise.<{ success: boolean; status_code: string; playlist_id: string; }>} | ||
*/ | ||
create: (title, video_id) => Actions.engage(this, 'playlist/create', { title, video_id }), | ||
create: async (title, video_ids) => { | ||
const response = await this.actions.playlist('playlist/create', { title, ids: video_ids }); | ||
if (!response.success) return response; | ||
return { | ||
success: true, | ||
status_code: response.status_code, | ||
playlist_id: response.data.playlistId | ||
} | ||
}, | ||
/** | ||
@@ -267,13 +288,68 @@ * Deletes a given playlist. | ||
* @param {string} playlist_id | ||
* @returns {Promise<{ success: boolean; status_code: string; }>} | ||
* @returns {Promise.<{ success: boolean; status_code: string; playlist_id: string; }>} | ||
*/ | ||
delete: (playlist_id) => Actions.engage(this, 'playlist/delete', { playlist_id }), | ||
delete: async (playlist_id) => { | ||
const response = await this.actions.playlist('playlist/delete', { playlist_id }); | ||
if (!response.success) return response; | ||
return { | ||
success: true, | ||
status_code: response.status_code, | ||
playlist_id | ||
} | ||
}, | ||
/** | ||
* Adds videos to a given playlist. | ||
* Adds an array of videos to a given playlist. | ||
* | ||
* @param {string} playlist_id | ||
* @param {Array.<string>} video_ids | ||
* @returns {Promise.<{ success: boolean; status_code: string; playlist_id: string; }>} | ||
*/ | ||
addVideos: (playlist_id, video_ids) => Actions.engage(this, 'browse/edit_playlist', { action: 'ACTION_ADD_VIDEO', playlist_id, video_ids }) | ||
addVideos: async (playlist_id, video_ids) => { | ||
const response = await this.actions.playlist('browse/edit_playlist', { | ||
action: 'ACTION_ADD_VIDEO', | ||
playlist_id, | ||
ids: video_ids | ||
}); | ||
if (!response.success) return response; | ||
return { | ||
success: true, | ||
status_code: response.status_code, | ||
playlist_id | ||
} | ||
}, | ||
/** | ||
* Removes videos from a given playlist. | ||
* | ||
* @param {string} playlist_id | ||
* @param {Array.<string>} video_ids | ||
* @returns {Promise.<{ success: boolean; status_code: string; playlist_id: string; }>} | ||
*/ | ||
removeVideos: async (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', { | ||
action: 'ACTION_REMOVE_VIDEO', | ||
playlist_id, | ||
ids: set_video_ids | ||
}); | ||
if (!response.success) return response; | ||
return { | ||
success: true, | ||
status_code: response.status_code, | ||
playlist_id | ||
} | ||
} | ||
}; | ||
@@ -288,8 +364,8 @@ } | ||
* @param {string} new_value | ||
* @returns {Promise<{ success: boolean; status_code: string; }>} | ||
* @returns {Promise.<{ success: boolean; status_code: string; }>} | ||
*/ | ||
async #setSetting(setting_id, type, new_value) { | ||
const response = await Actions.browse(this, type); | ||
const response = await this.actions.browse(type); | ||
if (!response.success) return response; | ||
const contents = ({ | ||
@@ -303,3 +379,3 @@ account_notifications: () => Utils.findNode(response.data, 'contents', 'Your preferences', 13, false).options, | ||
const setting_item_id = option.settingsSwitchRenderer.enableServiceEndpoint.setSettingEndpoint.settingItemId; | ||
const set_setting = await Actions.account(this, 'account/set_setting', { new_value: type == 'account_privacy' ? !new_value : new_value, setting_item_id }); | ||
const set_setting = await this.actions.account('account/set_setting', { new_value: type == 'account_privacy' ? !new_value : new_value, setting_item_id }); | ||
@@ -330,3 +406,3 @@ return { | ||
} | ||
this.ev.on('auth', (data) => { | ||
@@ -340,3 +416,3 @@ if (data.status === 'SUCCESS') { | ||
} | ||
#updateCredentials() { | ||
@@ -346,14 +422,6 @@ this.access_token = this.#oauth.getAccessToken(); | ||
this.logged_in = true; | ||
// API key is not needed if logged in via OAuth | ||
delete this.YTRequester.defaults.params.key; | ||
delete this.YTMRequester.defaults.params.key; | ||
} | ||
// Update default headers | ||
this.YTRequester.defaults.headers = Constants.INNERTUBE_HEADERS({ session: this, ytmusic: false }); | ||
this.YTMRequester.defaults.headers = Constants.INNERTUBE_HEADERS({ session: this, ytmusic: true }); | ||
} | ||
/** | ||
* Signs out of your account. | ||
* Signs out of an account. | ||
* @returns {Promise.<{ success: boolean; status_code: number }>} | ||
@@ -369,7 +437,7 @@ */ | ||
/** | ||
* Returns information about the account being used. | ||
* @returns {Promise<{ name: string; photo: Array<object>; country: string; language: string; }>} | ||
* Retrieves account details. | ||
* @returns {Promise.<{ name: string; photo: Array<object>; country: string; language: string; }>} | ||
*/ | ||
async getAccountInfo() { | ||
const response = await Actions.account(this, 'account/account_menu'); | ||
const response = await this.actions.account('account/account_menu'); | ||
if (!response.success) throw new Utils.InnertubeError('Could not get account info', response); | ||
@@ -390,8 +458,9 @@ | ||
* | ||
* @param {string} query - Search query. | ||
* @param {object} options - Search options. | ||
* @param {string} options.client - Client used to perform the search, can be: `YTMUSIC` or `YOUTUBE`. | ||
* @param {string} options.period - Filter videos uploaded within a period, can be: any | hour | day | week | month | year | ||
* @param {string} options.order - Filter results by order, can be: relevance | rating | age | views | ||
* @param {string} options.duration - Filter video results by duration, can be: any | short | long | ||
* @param {string} query - search query. | ||
* @param {object} options - search options. | ||
* @param {string} options.client - client used to perform the search, can be: `YTMUSIC` or `YOUTUBE`. | ||
* @param {string} options.period - filter videos uploaded within a period, can be: any | hour | day | week | month | year | ||
* @param {string} options.order - filter results by order, can be: relevance | rating | age | views | ||
* @param {string} options.duration - filter video results by duration, can be: any | short | long | ||
* | ||
* @returns {Promise.<{ query: string; corrected_query: string; estimated_results: number; videos: [] } | | ||
@@ -401,7 +470,7 @@ * { results: { songs: []; videos: []; albums: []; community_playlists: [] } }>} | ||
async search(query, options = { client: 'YOUTUBE', period: 'any', order: 'relevance', duration: 'any' }) { | ||
const response = await Actions.search(this, options.client, { query, options }); | ||
if (!response.success) throw new Utils.InnertubeError('Could not search on YouTube', response); | ||
const response = await this.actions.search({ query, options, is_ytm: options.client == 'YTMUSIC' }); | ||
const results = new Parser(this, response.data, { | ||
query, client: options.client, | ||
query, | ||
client: options.client, | ||
data_type: 'SEARCH' | ||
@@ -414,11 +483,12 @@ }).parse(); | ||
/** | ||
* Gets search suggestions. | ||
* Retrieves search suggestions. | ||
* | ||
* @param {string} input - The search query. | ||
* @param {object} [options] - Search options. | ||
* @param {string} [options.client='YOUTUBE'] - Client used to retrieve search suggestions, can be: `YOUTUBE` or `YTMUSIC`. | ||
* @param {string} input - the search query. | ||
* @param {object} [options] - search options. | ||
* @param {string} [options.client='YOUTUBE'] - client used to retrieve search suggestions, can be: `YOUTUBE` or `YTMUSIC`. | ||
* | ||
* @returns {Promise.<[{ text: string; bold_text: string }]>} | ||
*/ | ||
async getSearchSuggestions(input, options = { client: 'YOUTUBE' }) { | ||
const response = await Actions.getSearchSuggestions(this, options.client, input); | ||
const response = await this.actions.getSearchSuggestions(options.client, input); | ||
if (!response.success) throw new Utils.InnertubeError('Could not get search suggestions', response); | ||
@@ -428,6 +498,7 @@ if (options.client === 'YTMUSIC' && !response.data.contents) return []; | ||
const suggestions = new Parser(this, response.data, { | ||
input, client: options.client, | ||
input, | ||
client: options.client, | ||
data_type: 'SEARCH_SUGGESTIONS' | ||
}).parse(); | ||
return suggestions; | ||
@@ -437,5 +508,5 @@ } | ||
/** | ||
* Gets video info. | ||
* Retrieves video info. | ||
* | ||
* @param {string} video_id - The id of the video. | ||
* @param {string} video_id - video id | ||
* @return {Promise.<{ title: string; description: string; thumbnail: []; metadata: object }>} | ||
@@ -446,22 +517,25 @@ */ | ||
const response = await Actions.getVideoInfo(this, { id: video_id }); | ||
const continuation = await Actions.next(this, { video_id }); | ||
const response = await this.actions.getVideoInfo(video_id); | ||
const continuation = await this.actions.next({ video_id }); | ||
continuation.success && (response.continuation = continuation.data); | ||
const details = new Parser(this, response, { | ||
client: 'YOUTUBE', | ||
client: 'YOUTUBE', | ||
data_type: 'VIDEO_INFO' | ||
}).parse(); | ||
// Functions | ||
details.like = () => Actions.engage(this, 'like/like', { video_id }); | ||
details.dislike = () => Actions.engage(this, 'like/dislike', { video_id }); | ||
details.removeLike = () => Actions.engage(this, 'like/removelike', { video_id }); | ||
details.subscribe = () => Actions.engage(this, 'subscription/subscribe', { channel_id: details.metadata.channel_id }); | ||
details.unsubscribe = () => Actions.engage(this, 'subscription/unsubscribe', { channel_id: details.metadata.channel_id }); | ||
details.comment = (text) => Actions.engage(this, 'comment/create_comment', { video_id, text }); | ||
details.getComments = () => this.getComments(video_id, { channel_id: details.metadata.channel_id }); | ||
details.getLivechat = () => new Livechat(this, continuation.data.contents?.twoColumnWatchNextResults?.conversationBar?.liveChatRenderer?.continuations?.find((continuation) => continuation.reloadContinuationData).reloadContinuationData.continuation, details.metadata.channel_id, video_id); | ||
details.changeNotificationPreferences = (type) => Actions.notifications(this, 'modify_channel_preference', { channel_id: details.metadata.channel_id, pref: type || 'NONE' }); | ||
const livechat_ctoken = continuation.data.contents?.twoColumnWatchNextResults | ||
?.conversationBar?.liveChatRenderer?.continuations?.find((continuation) => continuation.reloadContinuationData) | ||
.reloadContinuationData.continuation; | ||
details.like = () => this.actions.engage('like/like', { video_id }); | ||
details.dislike = () => this.actions.engage('like/dislike', { video_id }); | ||
details.removeLike = () => this.actions.engage('like/removelike', { video_id }); | ||
details.subscribe = () => this.actions.engage('subscription/subscribe', { channel_id: details.metadata.channel_id }); | ||
details.unsubscribe = () => this.actions.engage('subscription/unsubscribe', { channel_id: details.metadata.channel_id }); | ||
details.comment = (text) => this.actions.engage('comment/create_comment', { video_id, text }); | ||
details.getComments = (sort_by) => this.getComments(video_id, sort_by); | ||
details.getLivechat = () => new Livechat(this, livechat_ctoken, details.metadata.channel_id, video_id); | ||
details.setNotificationPreferences = (type) => this.actions.notifications('modify_channel_preference', { channel_id: details.metadata.channel_id, pref: type || 'NONE' }); | ||
return details; | ||
@@ -471,165 +545,56 @@ } | ||
/** | ||
* Gets info about a given channel. (WIP) | ||
* | ||
* @param {string} id - The id of the channel. | ||
* @return {Promise.<{ title: string; description: string; metadata: object; content: object }>} | ||
* Retrieves comments for a video. | ||
* | ||
* @param {string} video_id - video id | ||
* @param {string} [sort_by] - can be: `TOP_COMMENTS` or `NEWEST_FIRST`. | ||
* @return {Promise.<{ page_count: number; comment_count: number; items: []; }>} | ||
*/ | ||
async getChannel(id) { | ||
const response = await Actions.browse(this, 'channel', { browse_id: id }); | ||
if (!response.success) throw new Utils.InnertubeError('Could not retrieve channel info.', response); | ||
const channel_info = new Parser(this, response.data, { | ||
client: 'YOUTUBE', | ||
data_type: 'CHANNEL' | ||
}).parse(); | ||
return channel_info; | ||
} | ||
async getComments(video_id, sort_by) { | ||
const payload = Proto.encodeCommentsSectionParams(video_id, { | ||
sort_by: sort_by || 'TOP_COMMENTS' | ||
}); | ||
/** | ||
* Retrieves the lyrics for a given song if available. | ||
* | ||
* @param {string} video_id | ||
* @returns {Promise.<string>} Song lyrics | ||
*/ | ||
async getLyrics(video_id) { | ||
const continuation = await Actions.next(this, { video_id: video_id, ytmusic: true }); | ||
if (!continuation.success) throw new Utils.InnertubeError('Could not retrieve lyrics', continuation); | ||
const response = await this.actions.next({ ctoken: payload }); | ||
if (!response.success) throw new Utils.InnertubeError('Could not retrieve comments', response); | ||
const lyrics_tab = continuation.data.contents.singleColumnMusicWatchNextResultsRenderer.tabbedRenderer | ||
.watchNextTabbedResultsRenderer.tabs.find((obj) => obj.tabRenderer.title == 'Lyrics'); | ||
const comments = new Parser(this, response.data, { | ||
video_id, | ||
client: 'YOUTUBE', | ||
data_type: 'COMMENTS' | ||
}).parse(); | ||
const response = await Actions.browse(this, 'lyrics', { ytmusic: true, browse_id: lyrics_tab.tabRenderer.endpoint.browseEndpoint.browseId }); | ||
if (!response.success || !response.data?.contents?.sectionListRenderer) throw new Utils.UnavailableContentError('Lyrics not available', { video_id }); | ||
const lyrics = response.data.contents.sectionListRenderer.contents[0].musicDescriptionShelfRenderer.description.runs[0].text; | ||
return lyrics; | ||
return comments; | ||
} | ||
/** | ||
* Parses a given playlist. | ||
* Retrieves contents for a given channel. (WIP) | ||
* | ||
* @param {string} playlist_id - The id of the playlist. | ||
* @param {object} options - { client: YOUTUBE | YTMUSIC } | ||
* @param {string} options.client - Client used to parse the playlist, can be: `YTMUSIC` | `YOUTUBE` | ||
* @returns {Promise.< | ||
* { title: string; description: string; total_items: string; last_updated: string; views: string; items: [] } | | ||
* { title: string; description: string; total_items: number; duration: string; year: string; items: [] }>} | ||
* @param {string} id - channel id | ||
* @return {Promise.<{ title: string; description: string; metadata: object; content: object }>} | ||
*/ | ||
async getPlaylist(playlist_id, options = { client: 'YOUTUBE' }) { | ||
const response = await Actions.browse(this, options.client == 'YTMUSIC' ? 'music_playlist' : 'playlist', { ytmusic: options.client == 'YTMUSIC', browse_id: `VL${playlist_id}` }); | ||
if (!response.success) throw new Utils.InnertubeError('Could not get playlist', response); | ||
const playlist = new Parser(this, response.data, { | ||
client: options.client, | ||
data_type: 'PLAYLIST' | ||
async getChannel(id) { | ||
const response = await this.actions.browse(id); | ||
if (!response.success) throw new Utils.InnertubeError('Could not retrieve channel info.', response); | ||
const channel_info = new Parser(this, response.data, { | ||
client: 'YOUTUBE', | ||
data_type: 'CHANNEL' | ||
}).parse(); | ||
return playlist; | ||
} | ||
/** | ||
* Gets the comments section of a video. | ||
* | ||
* @param {string} video_id - The id of the video. | ||
* @param {string} [data] - Video data and continuation token (optional). | ||
* @return {Promise.<[{ comments: []; comment_count?: string }]> | ||
*/ | ||
async getComments(video_id, data = {}) { | ||
let comment_section_token; | ||
//TODO: Refactor this and move it to the parser | ||
if (!data.token) { | ||
const continuation = await Actions.next(this, { video_id }); | ||
if (!continuation.success) throw new Utils.InnertubeError('Could not fetch comments section', continuation); | ||
const contents = Utils.findNode(continuation.data, 'contents', 'comments-section', 5); | ||
const item_section_renderer = contents.find((item) => item.itemSectionRenderer).itemSectionRenderer; | ||
comment_section_token = item_section_renderer?.contents[0]?.continuationItemRenderer?.continuationEndpoint.continuationCommand.token; | ||
const secondary_info_renderer = contents.find((item) => item.videoSecondaryInfoRenderer).videoSecondaryInfoRenderer; | ||
data.channel_id = secondary_info_renderer.owner.videoOwnerRenderer.navigationEndpoint.browseEndpoint.browseId; | ||
} | ||
const response = await Actions.next(this, { continuation_token: comment_section_token || data.token }); | ||
if (!response.success) throw new Utils.InnertubeError('Could not fetch comments section', response); | ||
const comments_section = { comments: [] }; | ||
!data.token && (comments_section.comment_count = response.data?.onResponseReceivedEndpoints[0]?.reloadContinuationItemsCommand?.continuationItems[0]?.commentsHeaderRenderer?.countText.runs[0]?.text || 'N/A'); | ||
let continuation_token; | ||
!data.token && | ||
(continuation_token = response.data?.onResponseReceivedEndpoints[1]?.reloadContinuationItemsCommand?.continuationItems | ||
?.find((item) => item.continuationItemRenderer)?.continuationItemRenderer?.continuationEndpoint?.continuationCommand.token) || | ||
((continuation_token = response.data?.onResponseReceivedEndpoints[0]?.appendContinuationItemsAction?.continuationItems | ||
?.find((item) => item.continuationItemRenderer)?.continuationItemRenderer?.continuationEndpoint?.continuationCommand.token) || | ||
(continuation_token = response.data?.onResponseReceivedEndpoints[0]?.appendContinuationItemsAction?.continuationItems | ||
?.find((item) => item.continuationItemRenderer)?.continuationItemRenderer?.button.buttonRenderer.command.continuationCommand.token)); | ||
continuation_token && (comments_section.getContinuation = | ||
() => this.getComments(video_id, { | ||
token: continuation_token, | ||
channel_id: data.channel_id | ||
})); | ||
let contents; | ||
!data.token && (contents = response.data.onResponseReceivedEndpoints[1].reloadContinuationItemsCommand.continuationItems) || | ||
(contents = response.data.onResponseReceivedEndpoints[0].appendContinuationItemsAction.continuationItems); | ||
contents.forEach((content) => { | ||
const thread = content?.commentThreadRenderer?.comment.commentRenderer || content?.commentRenderer; | ||
if (!thread) return; | ||
// TODO: Reverse engineer this token so we can generate it manually (it's just protobuf). | ||
const replies_token = content?.commentThreadRenderer?.replies?.commentRepliesRenderer?.contents | ||
?.find((content) => content.continuationItemRenderer.continuationEndpoint) | ||
?.continuationItemRenderer.continuationEndpoint.continuationCommand.token; | ||
const like_btn = thread?.actionButtons?.commentActionButtonsRenderer.likeButton; | ||
const dislike_btn = thread?.actionButtons?.commentActionButtonsRenderer.dislikeButton; | ||
const comment = { | ||
text: thread.contentText.runs.map((t) => t.text).join(' '), | ||
author: { | ||
name: thread.authorText.simpleText, | ||
thumbnail: thread.authorThumbnail.thumbnails, | ||
channel_id: thread.authorEndpoint.browseEndpoint.browseId | ||
}, | ||
metadata: { | ||
published: thread.publishedTimeText.runs[0].text, | ||
is_liked: like_btn?.toggleButtonRenderer.isToggled, | ||
is_disliked: dislike_btn?.toggleButtonRenderer.isToggled, | ||
is_pinned: thread.pinnedCommentBadge && true || false, | ||
is_channel_owner: thread.authorIsChannelOwner, | ||
like_count: thread?.voteCount?.simpleText || '0', | ||
reply_count: thread.replyCount || 0, | ||
id: thread.commentId, | ||
}, | ||
like: () => Actions.engage(this, 'comment/perform_comment_action', { comment_action: 'like', comment_id: thread.commentId, video_id, channel_id: data.channel_id }), | ||
dislike: () => Actions.engage(this, 'comment/perform_comment_action', { comment_action: 'dislike', comment_id: thread.commentId, video_id, channel_id: data.channel_id }), | ||
reply: (text) => Actions.engage(this, 'comment/create_comment_reply', { text, comment_id: thread.commentId, video_id }), | ||
getReplies: () => this.getComments(video_id, { token: replies_token, channel_id: data.channel_id }) | ||
}; | ||
!replies_token && (delete comment.getReplies); | ||
comments_section.comments.push(comment); | ||
}); | ||
return comments_section; | ||
return channel_info; | ||
} | ||
/** | ||
* Returns your watch history. | ||
* Retrieves watch history. | ||
* @returns {Promise.<{ items: [{ date: string; videos: [] }] }>} | ||
*/ | ||
async getHistory() { | ||
const response = await Actions.browse(this, 'history'); | ||
const response = await this.actions.browse('FEhistory'); | ||
if (!response.success) throw new Utils.InnertubeError('Could not retrieve watch history', response); | ||
const history = new Parser(this, response, { | ||
client: 'YOUTUBE', | ||
client: 'YOUTUBE', | ||
data_type: 'HISTORY' | ||
}).parse(); | ||
return history; | ||
@@ -639,21 +604,28 @@ } | ||
/** | ||
* Returns YouTube's home feed (aka recommendations). | ||
* Retrieves YouTube's home feed (aka recommendations). | ||
* @returns {Promise.<{ videos: [{ id: string; title: string; description: string; channel: string; metadata: object }] }>} | ||
*/ | ||
async getHomeFeed() { | ||
const response = await Actions.browse(this, 'home_feed'); | ||
const response = await this.actions.browse('FEwhat_to_watch'); | ||
if (!response.success) throw new Utils.InnertubeError('Could not retrieve home feed', response); | ||
const homefeed = new Parser(this, response, { | ||
client: 'YOUTUBE', | ||
client: 'YOUTUBE', | ||
data_type: 'HOMEFEED' | ||
}).parse(); | ||
return homefeed; | ||
} | ||
/** | ||
* Retrieves trending content. | ||
* | ||
* @returns {Promise.<{ now: { content: [{ title: string; videos: []; }] }; | ||
* music: { getVideos: Promise.<Array>; }; gaming: { getVideos: Promise.<Array>; }; | ||
* gaming: { getVideos: Promise.<Array>; }; }>} | ||
*/ | ||
async getTrending() { | ||
const response = await Actions.browse(this, 'trending'); | ||
const response = await this.actions.browse('FEtrending'); | ||
if (!response.success) throw new Utils.InnertubeError('Could not retrieve trending content', response); | ||
const trending = new Parser(this, response, { | ||
@@ -663,16 +635,31 @@ client: 'YOUTUBE', | ||
}).parse(); | ||
return trending; | ||
} | ||
/** | ||
* WIP | ||
*/ | ||
async getLibrary() { | ||
const response = await this.actions.browse('FElibrary'); | ||
if (!response.success) throw new Utils.InnertubeError('Could not retrieve library', response); | ||
const library = new Parser(this, response.data, { | ||
client: 'YOUTUBE', | ||
data_type: 'LIBRARY' | ||
}).parse(); | ||
return library; | ||
} | ||
/** | ||
* Returns your subscription feed. | ||
* Retrieves subscriptions feed. | ||
* @returns {Promise.<{ items: [{ date: string; videos: [] }] }>} | ||
*/ | ||
async getSubscriptionsFeed() { | ||
const response = await Actions.browse(this, 'subscriptions_feed'); | ||
const response = this.actions.browse('FEsubscriptions'); | ||
if (!response.success) throw new Utils.InnertubeError('Could not retrieve subscriptions feed', response); | ||
const subsfeed = new Parser(this, response, { | ||
client: 'YOUTUBE', | ||
client: 'YOUTUBE', | ||
data_type: 'SUBSFEED' | ||
@@ -689,10 +676,10 @@ }).parse(); | ||
async getNotifications() { | ||
const response = await Actions.notifications(this, 'get_notification_menu'); | ||
const response = await this.actions.notifications('get_notification_menu'); | ||
if (!response.success) throw new Utils.InnertubeError('Could not fetch notifications', response); | ||
const notifications = new Parser(this, response.data, { | ||
client: 'YOUTUBE', | ||
client: 'YOUTUBE', | ||
data_type: 'NOTIFICATIONS' | ||
}).parse(); | ||
return notifications; | ||
@@ -702,7 +689,7 @@ } | ||
/** | ||
* Returns unseen notifications count. | ||
* @returns {Promise.<number>} unseen notifications count. | ||
* Retrieves unseen notifications count. | ||
* @returns {Promise.<number>} | ||
*/ | ||
async getUnseenNotificationsCount() { | ||
const response = await Actions.notifications(this, 'get_unseen_count'); | ||
const response = await this.actions.notifications('get_unseen_count'); | ||
if (!response.success) throw new Utils.InnertubeError('Could not get unseen notifications count', response); | ||
@@ -713,2 +700,43 @@ return response.data.unseenCount; | ||
/** | ||
* Retrieves lyrics for a given song if available. | ||
* | ||
* @param {string} video_id | ||
* @returns {Promise.<string>} | ||
*/ | ||
async getLyrics(video_id) { | ||
const continuation = await this.actions.next({ video_id: video_id, is_ytm: true }); | ||
if (!continuation.success) throw new Utils.InnertubeError('Could not retrieve lyrics', continuation); | ||
const lyrics_tab = Utils.findNode(continuation, 'contents', 'Lyrics', 8, false); | ||
const response = await this.actions.browse(lyrics_tab.endpoint?.browseEndpoint.browseId, { is_ytm: true }); | ||
if (!response.success || !response.data?.contents?.sectionListRenderer) throw new Utils.UnavailableContentError('Lyrics not available', { video_id }); | ||
const lyrics = Utils.findNode(response.data, 'contents', 'runs', 6, false); | ||
return lyrics.runs[0].text; | ||
} | ||
/** | ||
* Retrieves a given playlist. | ||
* | ||
* @param {string} playlist_id - playlist id. | ||
* @param {object} options - { client: YOUTUBE | YTMUSIC } | ||
* @param {string} options.client - client used to parse the playlist, can be: `YTMUSIC` | `YOUTUBE` | ||
* @returns {Promise.< | ||
* { title: string; description: string; total_items: string; last_updated: string; views: string; items: [] } | | ||
* { title: string; description: string; total_items: number; duration: string; year: string; items: [] }>} | ||
*/ | ||
async getPlaylist(playlist_id, options = { client: 'YOUTUBE' }) { | ||
const response = await this.actions.browse(`VL${playlist_id}`, { is_ytm: options.client == 'YTMUSIC' }); | ||
if (!response.success) throw new Utils.InnertubeError('Could not get playlist', response); | ||
const playlist = new Parser(this, response.data, { | ||
client: options.client, | ||
data_type: 'PLAYLIST' | ||
}).parse(); | ||
return playlist; | ||
} | ||
/** | ||
* Internal method to process and filter formats. | ||
@@ -731,3 +759,3 @@ * | ||
if (format.signatureCipher || format.cipher) { | ||
format.url = new SigDecipher(format.url, this.#player).decipher(); | ||
format.url = new Signature(format.url, this.#player.signature_decipher).decipher(); | ||
} | ||
@@ -740,3 +768,3 @@ | ||
if (url_components.searchParams.get('n')) { | ||
url_components.searchParams.set('n', new NToken(this.#player.ntoken_sc, url_components.searchParams.get('n')).transform()); | ||
url_components.searchParams.set('n', new NToken(this.#player.ntoken_decipher, url_components.searchParams.get('n')).transform()); | ||
} | ||
@@ -752,5 +780,2 @@ | ||
formats.hls_manifest_url = video_data.streamingData.hlsManifestUrl || undefined; | ||
formats.dash_manifest_url = video_data.streamingData.dashManifestUrl || undefined; | ||
let format; | ||
@@ -766,17 +791,13 @@ let bitrates; | ||
if (options.type != 'videoandaudio') { | ||
let streams; | ||
let streams; | ||
options.type != 'audio' && | ||
(streams = filtered_formats.filter((format) => format.mimeType.includes(options.format || 'mp4') && format.qualityLabel == options.quality)) || | ||
(streams = filtered_formats.filter((format) => format.mimeType.includes(options.format || 'mp4'))); | ||
options.type != 'audio' && | ||
(streams = filtered_formats.filter((format) => format.mimeType.includes(options.format || 'mp4') && format.qualityLabel == options.quality)) || | ||
(streams = filtered_formats.filter((format) => format.mimeType.includes(options.format || 'mp4'))); | ||
streams == undefined || streams.length == 0 && | ||
(streams = filtered_formats.filter((format) => format.quality == 'medium')); | ||
!streams || !streams.length && | ||
(streams = filtered_formats.filter((format) => format.quality == 'medium')); | ||
bitrates = streams.map((format) => format.bitrate); | ||
format = streams.filter((format) => format.bitrate === Math.max(...bitrates))[0]; | ||
} else { | ||
format = filtered_formats[0]; | ||
} | ||
bitrates = streams.map((format) => format.bitrate); | ||
format = streams.filter((format) => format.bitrate === Math.max(...bitrates))[0]; | ||
@@ -790,7 +811,8 @@ return { selected_format: format, formats }; | ||
* | ||
* @param {string} id - The id of the video. | ||
* @param {object} options - Download options. | ||
* @param {string} options.quality - Video quality; 360p, 720p, 1080p, etc.... | ||
* @param {string} options.type - Download type, can be: video, audio or videoandaudio | ||
* @param {string} options.format - File format | ||
* @param {string} id - video id | ||
* @param {object} options - download options. | ||
* @param {string} options.quality - video quality; 360p, 720p, 1080p, etc... | ||
* @param {string} options.type - download type, can be: video, audio or videoandaudio | ||
* @param {string} options.format - file format | ||
* | ||
* @returns {Promise.<{ selected_format: {}; formats: [] }>} | ||
@@ -803,6 +825,8 @@ */ | ||
const data = await Actions.getVideoInfo(this, { id }); | ||
const data = await this.actions.getVideoInfo(id); | ||
const streaming_data = this.#chooseFormat(options, data); | ||
if (!streaming_data.selected_format) throw new Utils.NoStreamingDataError('Could not find any suitable format.', { id, options }); | ||
if (!streaming_data.selected_format) | ||
throw new Utils.NoStreamingDataError('Could not find any suitable format.', { id, options }); | ||
return streaming_data; | ||
@@ -814,8 +838,9 @@ } | ||
* | ||
* @param {string} id - The id of the video. | ||
* @param {object} options - Download options. | ||
* @param {string} options.quality - Video quality; 360p, 720p, 1080p, etc.... | ||
* @param {string} options.type - Download type, can be: video, audio or videoandaudio | ||
* @param {string} options.format - File format | ||
* @return {ReadableStream} | ||
* @param {string} id - video id | ||
* @param {object} options - download options. | ||
* @param {string} [options.quality] - video quality; 360p, 720p, 1080p, etc... | ||
* @param {string} [options.type] - download type, can be: video, audio or videoandaudio | ||
* @param {string} [options.format] - file format | ||
* | ||
* @return {Stream.PassThrough} | ||
*/ | ||
@@ -831,5 +856,7 @@ download(id, options = {}) { | ||
let cancelled = false; | ||
const cpn = Utils.generateRandomString(16); | ||
const stream = new Stream.PassThrough(); | ||
Actions.getVideoInfo(this, { id }).then(async (video_data) => { | ||
this.actions.getVideoInfo(id, cpn).then(async (video_data) => { | ||
if (video_data.playabilityStatus.status === 'LOGIN_REQUIRED') | ||
@@ -849,3 +876,3 @@ return stream.emit('error', { message: 'You must login to download age-restricted videos.', error_type: 'LOGIN_REQUIRED', playability_status: video_data.playabilityStatus.status }); | ||
if (options.type == 'videoandaudio' && !options.range) { | ||
const response = await Axios.get(format.url, { | ||
const response = await Axios.get(`${format.url}&cpn=${cpn}`, { | ||
responseType: 'stream', | ||
@@ -903,4 +930,4 @@ cancelToken: new CancelToken(function executor(c) { cancel = c; }), | ||
options.range && (format.contentLength = options.range.end); | ||
const response = await Axios.get(`${format.url}&range=${chunk_start}-${chunk_end || ''}`, { | ||
const response = await Axios.get(`${format.url}&cpn=${cpn}&range=${chunk_start}-${chunk_end || ''}`, { | ||
responseType: 'stream', | ||
@@ -965,2 +992,2 @@ cancelToken: new CancelToken(function executor(c) { cancel = c; }), | ||
module.exports = Innertube; | ||
module.exports = Innertube; |
'use strict'; | ||
const Utils = require('../utils/Utils'); | ||
const Actions = require('../core/Actions'); | ||
const Constants = require('../utils/Constants'); | ||
const YTDataItems = require('./youtube'); | ||
const YTMusicDataItems = require('./ytmusic'); | ||
const Proto = require('../proto'); | ||
@@ -30,4 +30,6 @@ class Parser { | ||
HOMEFEED: () => this.#processHomeFeed(), | ||
LIBRARY: () => this.#processLibrary(), //WIP | ||
TRENDING: () => this.#processTrending(), | ||
HISTORY: () => this.#processHistory(), | ||
COMMENTS: () => this.#processComments(), | ||
VIDEO_INFO: () => this.#processVideoInfo(), | ||
@@ -70,3 +72,3 @@ NOTIFICATIONS: () => this.#processNotifications(), | ||
const response = await Actions.search(this.session, 'YOUTUBE', { ctoken }); | ||
const response = await this.session.actions.search({ ctoken, is_ytm: false }); | ||
if (!response.success) throw new Utils.InnertubeError('Could not get continuation', response); | ||
@@ -87,3 +89,3 @@ | ||
const contents = Utils.findNode(tabs, '0', 'contents', 5); | ||
const did_you_mean_item = contents.find((content) => content.itemSectionRenderer); | ||
@@ -107,3 +109,3 @@ const did_you_mean_renderer = did_you_mean_item?.itemSectionRenderer.contents[0].didYouMeanRenderer; | ||
const section_items = ({ | ||
['Top result']: () => YTMusicDataItems.TopResultItem.parse(section.contents), // console.log(JSON.stringify(section.contents, null, 4)), | ||
['Top result']: () => YTMusicDataItems.TopResultItem.parse(section.contents), | ||
['Songs']: () => YTMusicDataItems.SongResultItem.parse(section.contents), | ||
@@ -135,8 +137,8 @@ ['Videos']: () => YTMusicDataItems.VideoResultItem.parse(section.contents), | ||
const details = this.data.sidebar.playlistSidebarRenderer.items[0]; | ||
const metadata = { | ||
title: this.data.metadata.playlistMetadataRenderer.title, | ||
description: details.playlistSidebarPrimaryInfoRenderer.description.simpleText || 'N/A', | ||
total_items: details.playlistSidebarPrimaryInfoRenderer.stats[0].runs[0].text, | ||
last_updated: details.playlistSidebarPrimaryInfoRenderer.stats[2].runs[1].text, | ||
description: details.playlistSidebarPrimaryInfoRenderer?.description?.simpleText || 'N/A', | ||
total_items: details.playlistSidebarPrimaryInfoRenderer.stats[0].runs[0]?.text || 'N/A', | ||
last_updated: details.playlistSidebarPrimaryInfoRenderer.stats[2].runs[1]?.text || 'N/A', | ||
views: details.playlistSidebarPrimaryInfoRenderer.stats[1].simpleText | ||
@@ -276,2 +278,173 @@ } | ||
#processComments() { | ||
if (!this.data.onResponseReceivedEndpoints) | ||
throw new Utils.UnavailableContentError('Comments section not available', this.args); | ||
const header = Utils.findNode(this.data, 'onResponseReceivedEndpoints', 'commentsHeaderRenderer', 5, false); | ||
const comment_count = parseInt(header.commentsHeaderRenderer.countText.runs[0].text.replace(/,/g, '')); | ||
const page_count = parseInt(comment_count / 20); | ||
const parseComments = (data) => { | ||
const items = Utils.findNode(data, 'onResponseReceivedEndpoints', 'commentRenderer', 4, false); | ||
const response = { | ||
page_count, | ||
comment_count, | ||
items: [] | ||
}; | ||
response.items = items.map((item) => { | ||
const comment = YTDataItems.CommentThread.parseItem(item); | ||
if (comment) { | ||
comment.like = () => this.session.actions.engage('comment/perform_comment_action', { comment_action: 'like', comment_id: comment.metadata.id, video_id: this.args.video_id }), | ||
comment.dislike = () => this.session.actions.engage('comment/perform_comment_action', { comment_action: 'dislike', comment_id: comment.metadata.id, video_id: this.args.video_id }), | ||
comment.reply = (text) => this.session.actions.engage('comment/create_comment_reply', { text, comment_id: comment.metadata.id, video_id: this.args.video_id }); | ||
comment.report = async () => { | ||
const payload = Utils.findNode(item, 'commentThreadRenderer', 'params', 10, false); | ||
const form = await this.session.actions.flag('flag/get_form', { params: payload.params }); | ||
const action = Utils.findNode(form, 'actions', 'flagAction', 13, false); | ||
const flag = await this.session.actions.flag('flag/flag', { action: action.flagAction }); | ||
return flag; | ||
}; | ||
comment.getReplies = async () => { | ||
if (comment.metadata.reply_count === 0) throw new Utils.InnertubeError('This comment has no replies', comment); | ||
const payload = Proto.encodeCommentRepliesParams(this.args.video_id, comment.metadata.id); | ||
const next = await this.session.actions.next({ ctoken: payload }); | ||
return parseComments(next.data); | ||
}; | ||
comment.translate = async (target_language) => { | ||
const response = await this.session.actions.engage('comment/perform_comment_action', { | ||
text: comment.text, | ||
comment_action: 'translate', | ||
comment_id: comment.metadata.id, | ||
video_id: this.args.video_id, | ||
target_language | ||
}); | ||
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 | ||
} | ||
} | ||
return comment; | ||
} | ||
}).filter((c) => c); | ||
response.comment = (text) => this.session.actions.engage('comment/create_comment', { video_id: this.args.video_id, text }); | ||
response.getContinuation = async () => { | ||
const continuation_item = items.find((item) => item.continuationItemRenderer); | ||
if (!continuation_item) throw new Utils.InnertubeError('You\'ve reached the end'); | ||
const is_reply = !!continuation_item.continuationItemRenderer.button; | ||
const payload = Utils.findNode(continuation_item, 'continuationItemRenderer', 'token', is_reply && 5 || 3); | ||
const next = await this.session.actions.next({ ctoken: payload.token }); | ||
return parseComments(next.data); | ||
}; | ||
return response; | ||
}; | ||
return parseComments(this.data); | ||
} | ||
#processHomeFeed() { | ||
const contents = Utils.findNode(this.data, 'contents', 'videoRenderer', 9, false) | ||
const parseItems = (contents) => { | ||
const videos = YTDataItems.VideoItem.parse(contents); | ||
const getContinuation = async () => { | ||
const citem = contents.find((item) => item.continuationItemRenderer); | ||
const ctoken = citem.continuationItemRenderer.continuationEndpoint.continuationCommand.token; | ||
const response = await this.session.actions.browse(ctoken, { is_ctoken: true }); | ||
if (!response.success) throw new Utils.InnertubeError('Could not retrieve continuation', response); | ||
return parseItems(response.data.onResponseReceivedActions[0].appendContinuationItemsAction.continuationItems); | ||
} | ||
return { videos, getContinuation }; | ||
} | ||
return parseItems(contents); | ||
} | ||
#processLibrary() { // TODO: Finish this | ||
const profile_data = Utils.findNode(this.data, 'contents', 'profileColumnRenderer', 3); | ||
const stats_data = profile_data.profileColumnRenderer.items.find((item) => item.profileColumnStatsRenderer); | ||
const stats_items = stats_data.profileColumnStatsRenderer.items; | ||
const userinfo = profile_data.profileColumnRenderer.items.find((item) => item.profileColumnUserInfoRenderer); | ||
const stats = {}; | ||
stats_items.forEach((item) => { | ||
const label = item.profileColumnStatsEntryRenderer.label.runs.map((run) => run.text).join(''); | ||
stats[label.toLowerCase()] = parseInt(item.profileColumnStatsEntryRenderer.value.simpleText); | ||
}); | ||
const profile = { | ||
name: userinfo.profileColumnUserInfoRenderer?.title?.simpleText, | ||
thumbnails: userinfo.profileColumnUserInfoRenderer?.thumbnail.thumbnails, | ||
stats | ||
} | ||
const content = Utils.findNode(this.data, 'contents', 'content', 8, false); | ||
// console.info(content[0].itemSectionRenderer.contents[0].shelfRenderer); | ||
return { | ||
profile | ||
} | ||
} | ||
#processSubscriptionFeed() { | ||
const contents = Utils.findNode(this.data, 'contents', 'contents', 9, false); | ||
const subsfeed = { items: [] }; | ||
const parseItems = (contents) => { | ||
contents.forEach((section) => { | ||
if (!section.itemSectionRenderer) return; | ||
const section_contents = section.itemSectionRenderer.contents[0]; | ||
const section_title = section_contents.shelfRenderer.title.runs[0].text; | ||
const section_items = section_contents.shelfRenderer.content.gridRenderer.items; | ||
const items = YTDataItems.GridVideoItem.parse(section_items); | ||
subsfeed.items.push({ | ||
date: section_title, | ||
videos: items | ||
}); | ||
}); | ||
subsfeed.getContinuation = async () => { | ||
const citem = contents.find((item) => item.continuationItemRenderer); | ||
const ctoken = citem.continuationItemRenderer.continuationEndpoint.continuationCommand.token; | ||
const response = await this.session.actions.browse(ctoken, { is_ctoken: true }); | ||
if (!response.success) throw new Utils.InnertubeError('Could not retrieve continuation', response); | ||
const ccontents = Utils.findNode(response.data, 'onResponseReceivedActions', 'itemSectionRenderer', 4, false); | ||
subsfeed.items = []; | ||
return parseItems(ccontents); | ||
} | ||
return subsfeed; | ||
}; | ||
return parseItems(contents); | ||
} | ||
#processChannel() { | ||
@@ -337,3 +510,3 @@ const tabs = this.data.contents.twoColumnBrowseResultsRenderer.tabs; | ||
const response = await Actions.notifications(this.session, 'get_notification_menu', { ctoken }); | ||
const response = await this.session.actions.notifications('get_notification_menu', { ctoken }); | ||
if (!response.success) throw new Utils.InnertubeError('Could not retrieve continuation', response); | ||
@@ -352,3 +525,2 @@ | ||
const tabs = Utils.findNode(this.data, 'contents', 'tabRenderer', 4, false); | ||
const categories = {}; | ||
@@ -374,3 +546,3 @@ | ||
categories[category_title].getVideos = async () => { | ||
const response = await Actions.browse(this.session, 'trending', { params }); | ||
const response = await this.session.actions.browse('FEtrending', { params }); | ||
if (!response.success) throw new Utils.InnertubeError('Could not retrieve category videos', response); | ||
@@ -417,3 +589,3 @@ | ||
const response = await Actions.browse(this.session, 'continuation', { ctoken }); | ||
const response = await this.session.actions.browse(ctoken, { is_ctoken: true }); | ||
if (!response.success) throw new Utils.InnertubeError('Could not retrieve continuation', response); | ||
@@ -431,66 +603,4 @@ | ||
} | ||
#processHomeFeed() { | ||
const contents = Utils.findNode(this.data, 'contents', 'videoRenderer', 9, false) | ||
const parseItems = (contents) => { | ||
const videos = YTDataItems.VideoItem.parse(contents); | ||
const getContinuation = async () => { | ||
const citem = contents.find((item) => item.continuationItemRenderer); | ||
const ctoken = citem.continuationItemRenderer.continuationEndpoint.continuationCommand.token; | ||
const response = await Actions.browse(this.session, 'continuation', { ctoken }); | ||
if (!response.success) throw new Utils.InnertubeError('Could not retrieve continuation', response); | ||
return parseItems(response.data.onResponseReceivedActions[0].appendContinuationItemsAction.continuationItems); | ||
} | ||
return { videos, getContinuation }; | ||
} | ||
return parseItems(contents); | ||
} | ||
#processSubscriptionFeed() { | ||
const contents = Utils.findNode(this.data, 'contents', 'contents', 9, false); | ||
const subsfeed = { items: [] }; | ||
const parseItems = (contents) => { | ||
contents.forEach((section) => { | ||
if (!section.itemSectionRenderer) return; | ||
const section_contents = section.itemSectionRenderer.contents[0]; | ||
const section_title = section_contents.shelfRenderer.title.runs[0].text; | ||
const section_items = section_contents.shelfRenderer.content.gridRenderer.items; | ||
const items = YTDataItems.GridVideoItem.parse(section_items); | ||
subsfeed.items.push({ | ||
date: section_title, | ||
videos: items | ||
}); | ||
}); | ||
subsfeed.getContinuation = async () => { | ||
const citem = contents.find((item) => item.continuationItemRenderer); | ||
const ctoken = citem.continuationItemRenderer.continuationEndpoint.continuationCommand.token; | ||
const response = await Actions.browse(this.session, 'continuation', { ctoken }); | ||
if (!response.success) throw new Utils.InnertubeError('Could not retrieve continuation', response); | ||
const ccontents = Utils.findNode(response.data, 'onResponseReceivedActions', 'itemSectionRenderer', 4, false); | ||
subsfeed.items = []; | ||
return parseItems(ccontents); | ||
} | ||
return subsfeed; | ||
}; | ||
return parseItems(contents); | ||
} | ||
} | ||
module.exports = Parser; | ||
module.exports = Parser; |
@@ -12,3 +12,4 @@ 'use strict'; | ||
const ShelfRenderer = require('./others/ShelfRenderer'); | ||
const CommentThread = require('./others/CommentThread'); | ||
module.exports = { VideoResultItem, SearchSuggestionItem, PlaylistItem, NotificationItem, VideoItem, GridVideoItem, GridPlaylistItem, ChannelMetadata, ShelfRenderer }; | ||
module.exports = { VideoResultItem, SearchSuggestionItem, PlaylistItem, NotificationItem, VideoItem, GridVideoItem, GridPlaylistItem, ChannelMetadata, ShelfRenderer, CommentThread }; |
@@ -21,3 +21,3 @@ 'use strict'; | ||
name: renderer?.ownerText?.runs[0]?.text, | ||
url: `${Constants.URLS.YT_BASE}${renderer.ownerText.runs[0].navigationEndpoint?.browseEndpoint?.canonicalBaseUrl}` | ||
url: `${Constants.URLS.YT_BASE}${renderer?.ownerText?.runs[0].navigationEndpoint?.browseEndpoint?.canonicalBaseUrl}` | ||
}, | ||
@@ -24,0 +24,0 @@ metadata: { |
@@ -10,4 +10,4 @@ 'use strict'; | ||
const list_item = item.musicResponsiveListItemRenderer; | ||
const watch_playlist_endpoint = list_item.overlay.musicItemThumbnailOverlayRenderer | ||
.content.musicPlayButtonRenderer.playNavigationEndpoint.watchPlaylistEndpoint; | ||
const watch_playlist_endpoint = list_item?.overlay?.musicItemThumbnailOverlayRenderer | ||
?.content?.musicPlayButtonRenderer?.playNavigationEndpoint?.watchPlaylistEndpoint; | ||
@@ -14,0 +14,0 @@ return { |
'use strict'; | ||
const Fs = require('fs'); | ||
const Proto = require('protons'); | ||
const messages = require('./messages'); | ||
/** | ||
* Encodes advanced search filters. | ||
* | ||
* @param {string} period - Period in which a video is uploaded: any | hour | day | week | month | year | ||
* @param {string} duration - The duration of a video: any | short | long | ||
* @param {string} order - The order of the search results: relevance | rating | age | views | ||
* @returns {string} | ||
*/ | ||
function encodeSearchFilter(period, duration, order) { | ||
const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/youtube.proto`)); | ||
class Proto { | ||
/** | ||
* Encodes visitor data. | ||
* | ||
* @param {string} id | ||
* @param {number} timestamp | ||
* | ||
* @returns {string} | ||
*/ | ||
static encodeVisitorData(id, timestamp) { | ||
const buf = messages.VisitorData.encode({ id, timestamp }); | ||
return encodeURIComponent(Buffer.from(buf).toString('base64')); | ||
} | ||
/** | ||
* Encodes search filters. | ||
* | ||
* @param {string} period | ||
* @param {string} duration | ||
* @param {string} order | ||
* | ||
* @todo implement remaining filters. | ||
* | ||
* @returns {string} | ||
*/ | ||
static encodeSearchFilter(period, duration, order) { | ||
const periods = { 'any': null, 'hour': 1, 'day': 2, 'week': 3, 'month': 4, 'year': 5 }; | ||
const durations = { 'any': null, 'short': 1, 'long': 2 }; | ||
const orders = { 'relevance': null, 'rating': 1, 'age': 2, 'views': 3 }; | ||
const periods = { 'any': null, 'hour': 1, 'day': 2, 'week': 3, 'month': 4, 'year': 5 }; | ||
const durations = { 'any': null, 'short': 1, 'long': 2 }; | ||
const orders = { 'relevance': null, 'rating': 1, 'age': 2, 'views': 3 }; | ||
const buf = messages.SearchFilter.encode({ | ||
number: orders[order], | ||
filter: { | ||
param_0: periods[period], | ||
param_1: (period == 'hour' && order == 'relevance') ? null : 1, | ||
param_2: durations[duration] | ||
} | ||
}); | ||
const search_filter_buff = youtube_proto.SearchFilter.encode({ | ||
number: orders[order], | ||
filter: { | ||
param_0: periods[period], | ||
param_1: (period == 'hour' && order == 'relevance') ? null : 1, | ||
param_2: durations[duration] | ||
} | ||
}); | ||
return encodeURIComponent(Buffer.from(buf).toString('base64')); | ||
} | ||
/** | ||
* Encodes livechat message parameters. | ||
* | ||
* @param {string} channel_id | ||
* @param {string} video_id | ||
* | ||
* @returns {string} | ||
*/ | ||
static encodeMessageParams(channel_id, video_id) { | ||
const buf = messages.LiveMessageParams.encode({ | ||
params: { ids: { channel_id, video_id } }, | ||
number_0: 1, number_1: 4 | ||
}); | ||
return encodeURIComponent(Buffer.from(search_filter_buff).toString('base64')); | ||
} | ||
return Buffer.from(encodeURIComponent(Buffer.from(buf).toString('base64'))).toString('base64'); | ||
} | ||
/** | ||
* Encodes comment section parameters. | ||
* | ||
* @param {string} video_id | ||
* @param {object} options | ||
* @param {string} options.type | ||
* @param {string} options.sort_by | ||
* | ||
* @returns {string} | ||
*/ | ||
static encodeCommentsSectionParams(video_id, options = {}) { | ||
const sort_menu = { TOP_COMMENTS: 0, NEWEST_FIRST: 1 }; | ||
const buf = messages.GetCommentsSectionParams.encode({ | ||
ctx: { video_id }, | ||
unk_param: 6, | ||
params: { | ||
opts: { | ||
video_id, | ||
sort_by: sort_menu[options.sort_by || 'TOP_COMMENTS'], | ||
type: options.type || 2 | ||
}, | ||
target: 'comments-section' | ||
} | ||
}); | ||
return encodeURIComponent(Buffer.from(buf).toString('base64')); | ||
} | ||
/** | ||
* Encodes replies thread parameters. | ||
* | ||
* @param {string} video_id | ||
* @param {string} comment_id | ||
* | ||
* @returns {string} | ||
*/ | ||
static encodeCommentRepliesParams(video_id, comment_id) { | ||
const buf = messages.GetCommentsSectionParams.encode({ | ||
ctx: { video_id }, | ||
unk_param: 6, | ||
params: { | ||
replies_opts: { | ||
video_id, comment_id, | ||
unkopts: { unk_param: 0 }, | ||
unk_param_1: 1, unk_param_2: 10, | ||
channel_id: ' ' // Seems like this can be omitted | ||
}, | ||
target: `comment-replies-item-${comment_id}` | ||
} | ||
}); | ||
return encodeURIComponent(Buffer.from(buf).toString('base64')); | ||
} | ||
/** | ||
* Encodes comment parameters. | ||
* | ||
* @param {string} video_id | ||
* @returns {string} | ||
*/ | ||
static encodeCommentParams(video_id) { | ||
const buf = messages.CreateCommentParams.encode({ | ||
video_id, params: { index: 0 }, | ||
number: 7 | ||
}); | ||
return encodeURIComponent(Buffer.from(buf).toString('base64')); | ||
} | ||
/** | ||
* Encodes comment reply parameters. | ||
* | ||
* @param {string} comment_id | ||
* @param {string} video_id | ||
* | ||
* @return {string} | ||
*/ | ||
static encodeCommentReplyParams(comment_id, video_id) { | ||
const buf = messages.CreateCommentReplyParams.encode({ | ||
video_id, comment_id, | ||
params: { unk_num: 0 }, | ||
unk_num: 7 | ||
}); | ||
/** | ||
* Encodes livestream message parameters. | ||
* | ||
* @param {string} channel_id - The id of the channel hosting the livestream. | ||
* @param {string} video_id - The id of the livestream. | ||
* @returns {string} | ||
*/ | ||
function encodeMessageParams(channel_id, video_id) { | ||
const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/youtube.proto`)); | ||
return encodeURIComponent(Buffer.from(buf).toString('base64')); | ||
} | ||
/** | ||
* Encodes comment action parameters. | ||
* | ||
* @param {string} type | ||
* @param {string} comment_id | ||
* @param {string} video_id | ||
* @param {string} [text] | ||
* @param {string} [target_language] | ||
* | ||
* @returns {string} | ||
*/ | ||
static encodeCommentActionParams(type, args = {}) { | ||
const data = {}; | ||
data.type = type; | ||
data.video_id = args.video_id || ''; | ||
data.comment_id = args.comment_id || ''; | ||
data.unk_num = 2; | ||
if (args.hasOwnProperty('text')) { | ||
args.comment_id && (delete data.unk_num); | ||
data.translate_comment_params = { | ||
params: { | ||
comment: { | ||
text: args.text | ||
} | ||
}, | ||
comment_id: args.comment_id || '', | ||
target_language: args.target_language | ||
} | ||
} | ||
const buf = messages.PeformCommentActionParams.encode(data); | ||
return encodeURIComponent(Buffer.from(buf).toString('base64')); | ||
} | ||
/** | ||
* Encodes notification preference parameters. | ||
* | ||
* @param {string} channel_id | ||
* @param {number} index | ||
* | ||
* @returns {string} | ||
*/ | ||
static encodeNotificationPref(channel_id, index) { | ||
const buf = messages.NotificationPreferences.encode({ | ||
channel_id, pref_id: { index }, | ||
number_0: 0, number_1: 4 | ||
}); | ||
const buf = youtube_proto.LiveMessageParams.encode({ | ||
params: { | ||
ids: { channel_id, video_id } | ||
}, | ||
number_0: 1, | ||
number_1: 4 | ||
}); | ||
return Buffer.from(encodeURIComponent(Buffer.from(buf).toString('base64'))).toString('base64'); | ||
} | ||
/** | ||
* Encodes comment parameters. | ||
* | ||
* @param {string} video_id - The id of the video you're commenting on. | ||
* @returns {string} | ||
*/ | ||
function encodeCommentParams(video_id) { | ||
const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/youtube.proto`)); | ||
const buf = youtube_proto.CreateCommentParams.encode({ | ||
video_id, | ||
params: { index: 0 }, | ||
number: 7 | ||
}); | ||
return encodeURIComponent(Buffer.from(buf).toString('base64')); | ||
} | ||
/** | ||
* Encodes comment reply parameters. | ||
* | ||
* @param {string} comment_id - The id of the comment. | ||
* @param {string} video_id - The id of the video you're commenting on. | ||
* @returns {string} | ||
*/ | ||
function encodeCommentReplyParams(comment_id, video_id) { | ||
const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/youtube.proto`)); | ||
const buf = youtube_proto.CreateCommentReplyParams.encode({ | ||
video_id, comment_id, | ||
params: { unk_num: 0 }, | ||
unk_num: 7 | ||
}); | ||
return encodeURIComponent(Buffer.from(buf).toString('base64')); | ||
} | ||
/** | ||
* Encodes comment action parameters (liking, disliking, reporting a comment etc). | ||
* | ||
* @param {string} type - Type of action. | ||
* @param {string} comment_id - The id of the comment. | ||
* @param {string} video_id - The id of the video you're commenting on. | ||
* @param {string} channel_id - The id of the channel. | ||
* @returns {string} | ||
*/ | ||
function encodeCommentActionParams(type, comment_id, video_id, channel_id) { | ||
const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/youtube.proto`)); | ||
return encodeURIComponent(Buffer.from(buf).toString('base64')); | ||
} | ||
const buf = youtube_proto.PeformCommentActionParams.encode({ | ||
type, comment_id, channel_id, video_id, | ||
unk_num: 2, unk_num_1: 0, unk_num_2: 0, | ||
unk_num_3: "0", unk_num_4: 0, | ||
unk_num_5: 12, unk_num_6: 0, | ||
}); | ||
/** | ||
* Encodes sound info parameters. | ||
* | ||
* @param {string} id | ||
* @returns {string} | ||
*/ | ||
static encodeSoundInfoParams(id) { | ||
const data = { | ||
sound: { | ||
params: { | ||
ids: { | ||
id_1: id, | ||
id_2: id, | ||
id_3: id | ||
} | ||
} | ||
} | ||
} | ||
return encodeURIComponent(Buffer.from(buf).toString('base64')); | ||
const buf = messages.SoundInfoParams.encode(data); | ||
return encodeURIComponent(Buffer.from(buf).toString('base64')); | ||
} | ||
} | ||
/** | ||
* Encodes notification preferences. | ||
* | ||
* @param {string} channel_id - The id of the channel. | ||
* @param {string} index - The index of the preference id. | ||
* @returns {string} | ||
*/ | ||
function encodeNotificationPref(channel_id, index) { | ||
const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/youtube.proto`)); | ||
const buf = youtube_proto.NotificationPreferences.encode({ | ||
channel_id, | ||
pref_id: { index }, | ||
number_0: 0, | ||
number_1: 4 | ||
}); | ||
return encodeURIComponent(Buffer.from(buf).toString('base64')); | ||
} | ||
module.exports = { encodeMessageParams, encodeCommentParams, encodeCommentReplyParams, encodeCommentActionParams, encodeNotificationPref, encodeSearchFilter }; | ||
module.exports = Proto; |
@@ -33,13 +33,11 @@ 'use strict'; | ||
}, | ||
DEFAULT_HEADERS: (session) => { | ||
return { | ||
headers: { | ||
'Cookie': session.cookie, | ||
'user-agent': Utils.getRandomUserAgent('desktop').userAgent, | ||
'Referer': 'https://www.google.com/', | ||
'Accept': 'text/html', | ||
'Accept-Language': 'en-US,en', | ||
'Accept-Encoding': 'gzip' | ||
} | ||
}; | ||
CLIENTS: { | ||
YTMUSIC: { | ||
NAME: 'WEB_REMIX', | ||
VERSION: '1.20211213.00.00' | ||
}, | ||
ANDROID: { | ||
NAME: 'ANDROID', | ||
VERSION: '17.17.32' | ||
} | ||
}, | ||
@@ -54,48 +52,6 @@ STREAM_HEADERS: { | ||
}, | ||
INNERTUBE_HEADERS: (info) => { | ||
const origin = info.ytmusic && 'https://music.youtube.com' || 'https://www.youtube.com'; | ||
const headers = { | ||
'accept': '*/*', | ||
'user-agent': Utils.getRandomUserAgent('desktop').userAgent, | ||
'content-type': 'application/json', | ||
'accept-language': 'en-US,en;q=0.9', | ||
'x-goog-authuser': 0, | ||
'x-goog-visitor-id': info.session.context.client.visitorData || '', | ||
'x-youtube-client-name': 1, | ||
'x-youtube-client-version': info.session.context.client.clientVersion, | ||
'x-youtube-chrome-connected': 'source=Chrome,mode=0,enable_account_consistency=true,supervised=false,consistency_enabled_by_default=false', | ||
'x-origin': origin, | ||
'origin': origin | ||
}; | ||
const auth_creds = info.session.cookie.length && info.session.auth_apisid || `Bearer ${info.session.access_token}` | ||
if (info.session.logged_in) { | ||
headers.Cookie = info.session.cookie; | ||
headers.authorization = auth_creds; | ||
} | ||
return headers; | ||
INNERTUBE_HEADERS_BASE: { | ||
'accept': '*/*', | ||
'content-type': 'application/json', | ||
}, | ||
VIDEO_INFO_REQBODY: (id, sts, context) => { | ||
return { | ||
playbackContext: { | ||
contentPlaybackContext: { | ||
'currentUrl': '/watch?v=' + id, | ||
'vis': 0, | ||
'splay': false, | ||
'autoCaptionsDefaultOn': false, | ||
'autonavState': 'STATE_OFF', | ||
'html5Preference': 'HTML5_PREF_WANTS', | ||
'signatureTimestamp': sts, | ||
'referer': 'https://www.youtube.com', | ||
'lactMilliseconds': '-1' | ||
} | ||
}, | ||
context: context, | ||
videoId: id | ||
}; | ||
}, | ||
YTMUSIC_VERSION: '1.20211213.00.00', | ||
METADATA_KEYS: [ | ||
@@ -131,7 +87,11 @@ 'embed', 'view_count', 'average_rating', 'allow_ratings', | ||
}, | ||
SIG_REGEX: { | ||
ACTIONS: /;.{2}\.(?<name>.{2})\(.*?,(?<param>.*?)\)/g, | ||
FUNCTIONS: /(?<name>.{2}):function\(.*?\){(.*?)}/g | ||
}, | ||
NTOKEN_REGEX: { | ||
CALLS: /c\[(.*?)\]\((.+?)\)/g, | ||
PLACEHOLDERS: /c\[(.*?)\]=c/g, | ||
FUNCTIONS: /d\.push\(e\)|d\.reverse\(\)|d\[0\]\)\[0\]\)|f=d\[0];d\[0\]|d\.length;d\.splice\(e,1\)|function\(\){for\(var|function\(d,e,f\){var|function\(d\){for\(var|reverse\(\)\.forEach|unshift\(d\.pop\(\)\)|function\(d,e\){for\(var f/ | ||
}, | ||
FUNCS_REGEX: /d\.push\(e\)|d\.reverse\(\)|d\[0\]\)\[0\]\)|f=d\[0];d\[0\]|d\.length;d\.splice\(e,1\)|function\(\){for\(var|function\(d,e,f\){var|function\(d\){for\(var|reverse\(\)\.forEach|unshift\(d\.pop\(\)\)|function\(d,e\){for\(var f/, | ||
FUNCS: { | ||
@@ -150,2 +110,2 @@ PUSH: 'd.push(e)', | ||
} | ||
}; | ||
}; |
'use strict'; | ||
const Fs = require('fs'); | ||
const Crypto = require('crypto'); | ||
@@ -8,10 +7,13 @@ const UserAgent = require('user-agents'); | ||
function InnertubeError(message, info) { | ||
this.info = info || {}; | ||
this.stack = Error(message).stack; | ||
class InnertubeError extends Error { | ||
constructor (message, info) { | ||
super(message); | ||
info && (this.info = info); | ||
this.date = new Date(); | ||
this.version = require('../../package.json').version; | ||
} | ||
} | ||
InnertubeError.prototype = Object.create(Error.prototype); | ||
InnertubeError.prototype.constructor = InnertubeError; | ||
class ParsingError extends InnertubeError {}; | ||
@@ -35,3 +37,3 @@ class DownloadError extends InnertubeError {}; | ||
const result = Object.keys(flat_obj).find((entry) => entry.includes(key) && JSON.stringify(flat_obj[entry] || '{}').includes(target)); | ||
if (!result) throw new ParsingError(`Expected to find "${key}" with content "${target}" but got ${result}`, { key, target, data_snippet: `${JSON.stringify(flat_obj).slice(0, 300)}..` }); | ||
if (!result) throw new ParsingError(`Expected to find "${key}" with content "${target}" but got ${result}`, { key, target, data_snippet: `${JSON.stringify(flat_obj, null, 4).slice(0, 300)}..` }); | ||
return flat_obj[result]; | ||
@@ -91,2 +93,13 @@ } | ||
function generateRandomString(length) { | ||
const result = []; | ||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; | ||
for (let i = 0; i < length; i++) { | ||
result.push(alphabet.charAt(Math.floor(Math.random() * alphabet.length))); | ||
} | ||
return result.join(''); | ||
} | ||
/** | ||
@@ -133,5 +146,5 @@ * Converts time (h:m:s) to seconds. | ||
const errors = { UnavailableContentError, ParsingError, DownloadError, InnertubeError, MissingParamError, NoStreamingDataError }; | ||
const functions = { findNode, getRandomUserAgent, generateSidAuth, getStringBetweenStrings, camelToSnake, timeToSeconds, refineNTokenData }; | ||
const errors = { InnertubeError, UnavailableContentError, ParsingError, DownloadError, MissingParamError, NoStreamingDataError }; | ||
const functions = { findNode, getRandomUserAgent, generateSidAuth, generateRandomString, getStringBetweenStrings, camelToSnake, timeToSeconds, refineNTokenData }; | ||
module.exports = { ...functions, ...errors }; |
{ | ||
"name": "youtubei.js", | ||
"version": "1.4.1", | ||
"version": "1.4.2-d.1", | ||
"description": "A full-featured library that allows you to get detailed info about any video, subscribe, unsubscribe, like, dislike, comment, search, download videos/music and much more!", | ||
@@ -13,3 +13,4 @@ "main": "index.js", | ||
"scripts": { | ||
"test": "node test" | ||
"test": "node test", | ||
"build:types": "npx tsc" | ||
}, | ||
@@ -26,6 +27,10 @@ "types": "./typings/index.d.ts", | ||
"flat": "^5.0.2", | ||
"protons": "^2.0.3", | ||
"protocol-buffers-encodings": "^1.1.1", | ||
"user-agents": "^1.0.778", | ||
"uuid": "^8.3.2" | ||
}, | ||
"devDependencies": { | ||
"@types/node": "^17.0.31", | ||
"typescript": "^4.6.4" | ||
}, | ||
"repository": { | ||
@@ -58,2 +63,2 @@ "type": "git", | ||
] | ||
} | ||
} |
158
README.md
@@ -22,2 +22,6 @@ <h1 align=center>YouTube.js</h1> | ||
</a> | ||
<br> | ||
<a href="https://ko-fi.com/luanrt"> | ||
<img src="https://img.shields.io/badge/donate-30363D?style=for-the-badge&logo=GitHub-Sponsors&logoColor=#white"> | ||
</a> | ||
</p> | ||
@@ -47,3 +51,3 @@ | ||
<li><a href="#live-chats">Livechats</a></li> | ||
<li><a href="#downloading-videos">Downloading videos</a></li> | ||
<li><a href="#download-videos">Download videos</a></li> | ||
<li><a href="#signing-in">Signing in</a></li> | ||
@@ -62,18 +66,15 @@ </ul> | ||
Innertube is an API used across all YouTube clients, it was [made to simplify](https://gizmodo.com/how-project-innertube-helped-pull-youtube-out-of-the-gu-1704946491) the internal structure of the platform and make it easy to push updates. This library takes advantage of that API, therefore providing a simple & efficient way to interact with YouTube programmatically. | ||
Innertube is an API used across all YouTube clients, it was created [to simplify](https://gizmodo.com/how-project-innertube-helped-pull-youtube-out-of-the-gu-1704946491) the internal structure of the platform in a way that updates, tweaks, and experiments can be easily made. This library handles all the low-level communication with Innertube, providing a simple and efficient way to interact with YouTube programmatically. | ||
And big thanks to [@gatecrasher777](https://github.com/gatecrasher777/ytcog) for his research on the workings of the Innertube API! | ||
And huge thanks to [@gatecrasher777](https://github.com/gatecrasher777/ytcog) for his research on the workings of the Innertube API! | ||
### Features | ||
As of now, this is one of the most advanced & stable YouTube libraries out there, here's a short summary of its features: | ||
- Search videos, playlists, music, albums etc. | ||
- Subscribe/Unsubscribe/Like/Dislike/Comment etc. | ||
- Get subscriptions/home feed, notifications and watch history. | ||
- Search videos, playlists, music, albums, artists, etc. | ||
- Subscribe, unsubscribe, like, dislike, post comments, replies, and etc. | ||
- Get subscriptions/home feed, notifications, watch history, and more. | ||
- Easily sign in to any Google Account. | ||
- Fetch live chat & live stats. | ||
- Manage account settings. | ||
- Create/delete playlists. | ||
- Manage playlists. | ||
- Download videos. | ||
@@ -83,3 +84,3 @@ | ||
Do note that you must be signed-in to perform actions that involve an account; such as commenting, liking/disliking videos, sending messages to a live chat, etc. | ||
Do note that you must be signed in to perform actions that involve an account; such as commenting, liking/disliking videos, sending messages to a live chat, etc. | ||
@@ -108,15 +109,20 @@ <!-- GETTING STARTED --> | ||
``` | ||
- Git (bleeding-edge version): | ||
```bash | ||
npm install git+https://github.com/LuanRT/YouTube.js.git | ||
``` | ||
<!-- USAGE --> | ||
## Usage | ||
First of all we're gonna start by initializing the Innertube instance. | ||
And to make things faster, you should do this only once and reuse the Innertube object when needed. | ||
Create an Innertube instance (or session): | ||
```js | ||
const Innertube = require('youtubei.js'); | ||
const youtube = await new Innertube(); | ||
const youtube = await new Innertube({ gl: 'US' }); // all parameters are optional. | ||
``` | ||
### Doing a simple search | ||
To improve performance, the Innertube instance should be initialized once and then reused throughout your program. | ||
### A simple search: | ||
YouTube: | ||
@@ -254,3 +260,3 @@ ```js | ||
### Get search suggestions: | ||
### Search suggestions: | ||
```js | ||
@@ -278,3 +284,3 @@ const suggestions = await youtube.getSearchSuggestions('QUERY', { | ||
### Get video info: | ||
### Video info: | ||
@@ -339,6 +345,7 @@ ```js | ||
### Get comments: | ||
### Comments | ||
```js | ||
const response = await youtube.getComments('VIDEO_ID'); | ||
// Sorting options: `TOP_COMMENTS` and `NEWEST_FIRST` | ||
const comments = await youtube.getComments('VIDEO_ID', 'TOP_COMMENTS'); | ||
``` | ||
@@ -349,3 +356,3 @@ Alternatively you can use: | ||
const video = await youtube.getDetails('VIDEO_ID'); | ||
const response = await video.getComments(); | ||
const comments = await video.getComments(); | ||
``` | ||
@@ -358,3 +365,5 @@ <details> | ||
{ | ||
comments: [ | ||
page_count: number, | ||
comment_count: number, | ||
items: [ | ||
{ | ||
@@ -364,3 +373,3 @@ text: string, | ||
name: string, | ||
thumbnail: [ | ||
thumbnails: [ | ||
{ | ||
@@ -380,2 +389,3 @@ url: string, | ||
is_channel_owner: boolean, | ||
is_reply: boolean, | ||
like_count: number, | ||
@@ -387,4 +397,3 @@ reply_count: number, | ||
//... | ||
], | ||
comment_count: string // not available in continuations | ||
] | ||
} | ||
@@ -396,7 +405,13 @@ ``` | ||
Reply to, like and dislike comments: | ||
Reply to, like/dislike, translate and report a comment: | ||
```js | ||
await response.comments[0].like(); | ||
await response.comments[0].dislike(); | ||
await response.comments[0].reply('Nice comment!'); | ||
const top_comment = comments.items[0]; | ||
await top_comment.like(); | ||
await top_comment.dislike(); | ||
await top_comment.report(); | ||
await top_comment.reply('Nice comment!'); | ||
// Note: only ISO language codes are accepted | ||
await top_comment.translate('ru'); | ||
``` | ||
@@ -406,3 +421,3 @@ | ||
```js | ||
const replies = await response.comments[0].getReplies(); | ||
const replies = await top_comment.getReplies(); | ||
``` | ||
@@ -412,7 +427,7 @@ | ||
```js | ||
const continuation = await response.getContinuation(); | ||
const continuation = await comments.getContinuation(); | ||
const replies_continuation = await replies.getContinuation(); | ||
``` | ||
### Get home feed: | ||
### Home feed: | ||
```js | ||
@@ -473,3 +488,3 @@ const homefeed = await youtube.getHomeFeed(); | ||
### Get watch history: | ||
### Watch history: | ||
```js | ||
@@ -535,3 +550,3 @@ const history = await youtube.getHistory(); | ||
### Get subscriptions feed: | ||
### Subscriptions feed: | ||
```js | ||
@@ -597,3 +612,3 @@ const mysubsfeed = await youtube.getSubscriptionsFeed(); | ||
### Get trending content: | ||
### Trending content: | ||
@@ -629,3 +644,3 @@ ```js | ||
### Get song lyrics: | ||
### Song lyrics: | ||
```js | ||
@@ -636,3 +651,3 @@ const search = await youtube.search('Never give you up', { client: 'YTMUSIC' }); | ||
### Get notifications: | ||
### Notifications: | ||
@@ -782,23 +797,43 @@ ```js | ||
``` | ||
* Playlists: | ||
```js | ||
// Create a playlist: | ||
await youtube.playlist.create('NAME', 'VIDEO_ID'); | ||
* Playlists: | ||
```js | ||
const videos = [ | ||
'VIDEO_ID1', | ||
'VIDEO_ID2', | ||
'VIDEO_ID3' | ||
//... | ||
]; | ||
// Create and delete a playlist: | ||
await youtube.playlist.create('My Playlist', videos); | ||
await youtube.playlist.delete('PLAYLIST_ID'); | ||
// Delete a playlist: | ||
await youtube.playlist.delete('PLAYLIST_ID'); | ||
// Add videos to a playlist: | ||
await youtube.playlist.addVideos('PLAYLIST_ID', [ 'VIDEO_ID1', 'VIDEO_ID2' ]); | ||
``` | ||
* Change notification preferences: | ||
```js | ||
// Options: ALL | NONE | PERSONALIZED | ||
await youtube.interact.changeNotificationPreferences('CHANNEL_ID', 'ALL'); | ||
``` | ||
// Add and remove videos from a playlist: | ||
await youtube.playlist.addVideos('PLAYLIST_ID', videos); | ||
await youtube.playlist.removeVideos('PLAYLIST_ID', videos); | ||
``` | ||
These methods will always return ```{ success: true, status_code: 200 }``` if successful. | ||
* Translate (does not require sign in) | ||
```js | ||
await youtube.interact.translate('Hi mom!', 'ru'); | ||
``` | ||
* Change notification preferences: | ||
```js | ||
// Options: ALL | NONE | PERSONALIZED | ||
await youtube.interact.setNotificationPreferences('CHANNEL_ID', 'ALL'); | ||
``` | ||
Response schema: | ||
```js | ||
{ | ||
success: boolean, | ||
status_code: number, | ||
playlist_id?: string, | ||
translated_content?: string, | ||
data?: object | ||
} | ||
``` | ||
### Account Settings | ||
@@ -877,3 +912,3 @@ It is also possible to manage an account's settings: | ||
YouTube.js isn't able to download live content yet, but it does allow you to fetch live chats plus you can also send messages! | ||
Currently, the library can retrieve live chat messages, stats and also send messages. | ||
@@ -919,3 +954,3 @@ ```js | ||
### Downloading videos: | ||
### Download videos: | ||
--- | ||
@@ -1050,3 +1085,3 @@ | ||
### Signing-in: | ||
### Signing in: | ||
--- | ||
@@ -1091,3 +1126,3 @@ | ||
``` | ||
Sign-out: | ||
Sign out: | ||
```js | ||
@@ -1106,3 +1141,3 @@ const response = await youtube.signOut(); | ||
async function start() { | ||
const youtube = await new Innertube(COOKIE_HERE); | ||
const youtube = await new Innertube({ cookie: '...' }); | ||
//... | ||
@@ -1120,2 +1155,7 @@ } | ||
<!-- CONTRIBUTORS --> | ||
## Contributors | ||
<a href="https://github.com/LuanRT/YouTube.js/graphs/contributors"> | ||
<img src="https://contrib.rocks/image?repo=LuanRT/YouTube.js" /> | ||
</a> | ||
@@ -1122,0 +1162,0 @@ <!-- CONTACT --> |
@@ -0,1 +1,3 @@ | ||
// TODO: Refactor this to use a testing library | ||
'use strict'; | ||
@@ -6,3 +8,3 @@ | ||
const NToken = require('../lib/deciphers/NToken'); | ||
const SigDecipher = require('../lib/deciphers/Sig'); | ||
const Signature = require('../lib/deciphers/Signature'); | ||
const Constants = require('./constants'); | ||
@@ -28,6 +30,6 @@ | ||
assert(!(ytsearch_suggestions instanceof Error), `should retrieve YouTube search suggestions`, ytsearch_suggestions); | ||
const ytmsearch_suggestions = await youtube.getSearchSuggestions('test', { client: 'YTMUSIC' }).catch((error) => error); | ||
assert(!(ytmsearch_suggestions instanceof Error), `should retrieve YouTube Music search suggestions`, ytmsearch_suggestions); | ||
const ytmsearch_suggestions = await youtube.getSearchSuggestions('test', { client: 'YTMUSIC' }); | ||
assert(!(ytmsearch_suggestions instanceof Error), `should retrieve YouTube Music search suggestions`); | ||
const details = await youtube.getDetails(Constants.test_video_id).catch((error) => error); | ||
@@ -45,5 +47,2 @@ assert(!(details instanceof Error), `should retrieve details for ${Constants.test_video_id}`, details); | ||
const lyrics = await youtube.getLyrics(Constants.test_song_id).catch((error) => error); | ||
assert(!(lyrics instanceof Error), `should retrieve song lyrics`, lyrics); | ||
const video = await downloadVideo(Constants.test_video_id, youtube).catch((error) => error); | ||
@@ -56,4 +55,4 @@ assert(!(video instanceof Error), `should download video (${Constants.test_video_id})`, video); | ||
const transformed_url = new SigDecipher(Constants.test_url, { sig_decipher_sc: Constants.sig_decipher_sc }).decipher(); | ||
assert(transformed_url == Constants.expected_url, `should correctly decipher signature`, transformed_url); | ||
const transformed_url = new Signature(Constants.test_url, Constants.sig_decipher_sc).decipher(); | ||
assert(transformed_url == Constants.expected_url, `should decipher signature`, transformed_url); | ||
@@ -77,3 +76,3 @@ if (failed_tests_count > 0) | ||
const pass_fail = outcome ? 'pass' : 'fail'; | ||
console.info(pass_fail, ':', description); | ||
@@ -80,0 +79,0 @@ !outcome && (failed_tests_count += 1); |
@@ -1,181 +0,2 @@ | ||
interface AuthInfo { | ||
access_token: string; | ||
refresh_token: string; | ||
expires: Date; | ||
} | ||
interface AccountInfo { | ||
name: string; | ||
photo: Record<string, any>[]; | ||
country: string; | ||
language: string; | ||
} | ||
interface SearchOptions { | ||
client: 'YTMUSIC' | 'YOUTUBE'; | ||
period: 'any' | 'hour' | 'day' | 'week' | 'month' | 'year'; | ||
order: 'relevance' | 'rating' | 'age' | 'views'; | ||
duration: 'any' | 'short' | 'long'; | ||
} | ||
interface YouTubeSearch { | ||
query: string; | ||
corrected_query: string; | ||
estimated_results: number; | ||
videos: any[]; | ||
} | ||
interface YouTubeMusicSearch { | ||
query: string; | ||
corrected_query: string; | ||
results: { | ||
top_result?: any[]; | ||
songs?: any[]; | ||
albums?: any[]; | ||
videos?: any[]; | ||
community_playlists?: any[]; | ||
artists?: any[]; | ||
} | ||
} | ||
type SearchResults = YouTubeSearch | YouTubeMusicSearch; | ||
type ClientOption = Pick<SearchOptions, 'client'>; | ||
interface Suggestion { | ||
text: string; | ||
bold_text: string; | ||
} | ||
interface ApiStatus { | ||
success: boolean; | ||
status_code: number; | ||
data: object; | ||
message?: string; | ||
} | ||
interface Comments { | ||
comments: any[]; | ||
comment_count?: string; | ||
} | ||
interface Video { | ||
title: string; | ||
description: string; | ||
thumbnail: object; | ||
metadata: Record<any, any>; | ||
like: () => Promise<ApiStatus>; | ||
dislike: () => Promise<ApiStatus>; | ||
removeLike: () => Promise<ApiStatus>; | ||
subscribe: () => Promise<ApiStatus>; | ||
unsubscribe: () => Promise<ApiStatus>; | ||
comment: (text: string) => Promise<ApiStatus>; | ||
getComments: () => Promise<Comments>; | ||
getLivechat: () => any; // TODO type LiveChat | ||
changeNotificationPreferences: () => Promise<ApiStatus>; | ||
} | ||
interface Channel { | ||
title: string; | ||
description: string; | ||
metadata: object; | ||
content: object; | ||
} | ||
interface PlayList { | ||
description: string; | ||
items: any[]; | ||
title: string; | ||
total_items: string | number; | ||
duration?: string; | ||
last_updated?: string; | ||
views?: string; | ||
year?: string; | ||
} | ||
interface CommentData { | ||
token: string; | ||
channel_id: string; | ||
} | ||
interface History { | ||
items: { | ||
date: string; | ||
videos: any[]; | ||
}[]; | ||
} | ||
interface SubscriptionFeed { | ||
items: { | ||
date: string; | ||
videos: any[]; | ||
}[]; | ||
} | ||
interface HomeFeed { | ||
videos: { | ||
id: string; | ||
title: string; | ||
description: string; | ||
channel: string; | ||
metadata: Record<string, any>; | ||
}[]; | ||
} | ||
interface Trending { | ||
now: { | ||
content: { | ||
title: string; | ||
videos: []; | ||
}[]; | ||
}; | ||
music: { getVideos: () => Promise<Array>; }; | ||
gaming: { getVideos: () => Promise<Array>; }; | ||
movies: { getVideos: () => Promise<Array>; }; | ||
} | ||
interface Notifications { | ||
items: { | ||
title: string; | ||
sent_time: string; | ||
channel_name: string; | ||
channel_thumbnail: Record<string, any>; | ||
video_thumbnail: Record<string, any>; | ||
video_url: string; | ||
read: boolean; | ||
notification_id: string; | ||
}[]; | ||
} | ||
interface StreamingData { | ||
selected_format: Record<string, any>; | ||
formats: any[]; | ||
} | ||
interface StreamingOptions { | ||
quality?: string; | ||
type?: string; | ||
format?: string; | ||
} | ||
export default class Innertube { | ||
constructor(cookie?: string) | ||
public signIn(auth_info: AuthInfo): Promise<void>; | ||
public signOut(): Promise<ApiStatus>; | ||
public getAccountInfo(): Promise<AccountInfo>; | ||
public search(query: string, options: SearchOptions): Promise<SearchResults>; | ||
public getSearchSuggestions(options: ClientOption): Promise<Suggestion>; | ||
public getDetails(video_id: string): Promise<ApiStatus>; | ||
public getChannel(id: string): Promise<Channel>; | ||
public getLyrics(video_id: string): Promise<string>; | ||
public getPlaylist(playlist_id: string, options?: ClientOption): Promise<PlayList>; | ||
public getComments(video_id: string, options?: CommentData): Promise<Comments[]>; | ||
public getHistory(): Promise<History>; | ||
public getHomeFeed(): Promise<HomeFeed>; | ||
public getTrending(): Promise<Trending>; | ||
public getSubscriptionsFeed(): Promise<SubscriptionFeed>; | ||
public getNotifications(): Promise<Notifications>; | ||
public getUnseenNotificationsCount(): Promise<number>; | ||
public getStreamingData(id: string, options?: StreamingOptions): Promise<StreamingData>; | ||
public download(id: string, options?: StreamingOptions): ReadableStream; | ||
} | ||
declare const _exports: typeof import("./lib/Innertube"); | ||
export = _exports; |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
No v1
QualityPackage is not semver >=1. This means it is not stable and does not support ^ ranges.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
297939
77
7447
1152
3
2
1
1
+ Addedb4a@1.6.7(transitive)
+ Addedprotocol-buffers-encodings@1.2.0(transitive)
+ Addedvarint@5.0.0(transitive)
- Removedprotons@^2.0.3
- Removedmultiformats@9.9.0(transitive)
- Removedprotocol-buffers-schema@3.6.0(transitive)
- Removedprotons@2.0.3(transitive)
- Removeduint8arrays@3.1.1(transitive)
- Removedvarint@5.0.2(transitive)