Compare commits

...

58 Commits

Author SHA1 Message Date
LuanRT
6d30aa3228 docs: oops 2022-03-03 03:37:47 -03:00
LuanRT
d33cb0b576 docs: add unsubscribe() snippet 2022-03-03 03:34:02 -03:00
LuanRT
51af4c3ffe chore: add issue & pull request template 2022-03-03 03:29:08 -03:00
LuanRT
b577a79893 chore: update lock file 2022-03-03 02:40:29 -03:00
LuanRT
da0c5e5887 chore(release): v1.3.5 2022-03-03 02:31:22 -03:00
LuanRT
b47350894d 2.0.0-0 2022-03-03 02:23:22 -03:00
LuanRT
c0387017e3 docs: add more examples 2022-03-03 02:22:48 -03:00
LuanRT
b286bc43df chore: update tests 2022-03-03 02:21:58 -03:00
LuanRT
61028a2ab9 style: format and refactor code 2022-03-03 02:21:32 -03:00
LuanRT
254588da81 feat: add acc settings and alternative to download 2022-03-03 02:18:03 -03:00
LuanRT
ef3e54775c feat: add watch history and playlist support 2022-03-03 02:13:00 -03:00
dependabot[bot]
30cec36660 Merge pull request #12 from LuanRT/dependabot/npm_and_yarn/follow-redirects-1.14.8 2022-02-14 16:26:42 +00:00
dependabot[bot]
427a1bd396 build(deps): bump follow-redirects from 1.14.7 to 1.14.8
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.14.7 to 1.14.8.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.14.7...v1.14.8)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-14 16:24:09 +00:00
LuanRT
cf4901fd3c feat: automatically delete old players 2022-02-13 19:09:40 -03:00
LuanRT
2fd98a021f format: remove white space 2022-02-13 19:08:14 -03:00
LuanRT
cd64e30b69 chore: simplify format selection 2022-02-13 19:06:56 -03:00
LuanRT
2b5027eb06 fix: getLyrics() only working when signed-in 2022-02-06 15:26:16 -03:00
LuanRT
0c9f7135bf docs: oops 2022-02-05 19:25:23 -03:00
LuanRT
ce8a109398 docs: update table of contents 2022-02-05 19:23:07 -03:00
LuanRT
6aaa3360e8 docs: update YouTube Music examples 2022-02-05 19:17:12 -03:00
LuanRT
89c018c431 refactor: move getLyrics to Innertube.js 2022-02-05 19:16:36 -03:00
LuanRT
339a01f3a9 chore(release): v1.3.0 2022-02-05 18:45:30 -03:00
LuanRT
dd3f4c0009 chore: format code & other minor changes 2022-02-05 18:32:25 -03:00
LuanRT
7cd41e1d8a docs: add YouTube Music examples 2022-02-05 18:31:28 -03:00
LuanRT
6ac8561af2 feat: add lyrics support 2022-02-05 18:30:21 -03:00
LuanRT
b4607d531f fix(OAuth): secret not found due to bad regex 2022-02-04 15:20:35 -03:00
LuanRT
b3a1cdc1cd chore: remove ntoken 'translate' func var names 2022-02-03 04:54:37 -03:00
LuanRT
fd662df93d style: remove extra white space 2022-02-02 06:12:38 -03:00
LuanRT
8a1f4b4e55 Merge branch 'main' of https://github.com/LuanRT/YouTube.js 2022-02-02 06:04:49 -03:00
LuanRT
4ff83bdc3f style: add missing semi & rename some variables 2022-02-02 06:04:39 -03:00
LuanRT
c81e8e29ac chore: remove unnecessary condition 2022-02-01 18:49:17 -03:00
LuanRT
d5f884ff9b refactor: move refineNTokenData function to Utils 2022-02-01 16:47:02 -03:00
LuanRT
5517c2f202 fix: ntoken function 'translate2' not being parsed 2022-02-01 16:12:47 -03:00
LuanRT
3493a82765 chore: remove more unused variables 2022-01-31 04:31:45 -03:00
LuanRT
07f02d0dc1 chore: remove unnecessary variable 2022-01-30 20:47:26 -03:00
LuanRT
b2afa86744 chore: update deps 2022-01-30 20:43:29 -03:00
LuanRT
a1caa60750 1.2.9 2022-01-30 20:34:18 -03:00
LuanRT
e1dd718832 chore: format code and other minor changes 2022-01-30 20:28:00 -03:00
LuanRT
222bf1e61f fix: ntoken 'translate2' function not being parsed 2022-01-30 20:26:02 -03:00
LuanRT
3b48de20dd fix: oauth identity creds regex no longer working 2022-01-30 20:20:52 -03:00
LuanRT
348d901935 chore: update tests 2022-01-30 20:19:11 -03:00
LuanRT
94b12002ff feat: implement continuation requests for YTMusic 2022-01-18 15:55:07 -03:00
LuanRT
2720e8f251 chore(livechat): remove console.log 2022-01-18 15:52:35 -03:00
LuanRT
a8a1ec2182 fix (tests): video used for tests is no longer available 2022-01-18 15:50:43 -03:00
LuanRT
ee0d1bef40 deps: remove time-to-seconds dependency 2022-01-18 15:46:54 -03:00
LuanRT
5cad39ee44 fix: polling interval missing 2022-01-07 18:59:17 -03:00
LuanRT
e8ca248919 feat: add home feed support 2022-01-07 18:50:00 -03:00
LuanRT
44d09026b5 chore: simplify video details parser 2022-01-07 18:46:29 -03:00
LuanRT
ff044f4216 fix: error polling livechat due to dislikes 2022-01-07 18:45:30 -03:00
LuanRT
8153e6178c fix: subsfeed sections placeholders missing 2022-01-05 16:36:48 -03:00
LuanRT
ee3f1b4638 chore: update examples 2022-01-05 16:31:52 -03:00
LuanRT
86c8a7e0d2 fix: filter out undefined search results 2021-12-31 04:05:38 -03:00
LuanRT
b375ae2f06 chore: fix typo 2021-12-31 03:35:05 -03:00
LuanRT
2ff4b2ea95 test: remove node 12 build 2021-12-31 03:27:54 -03:00
LuanRT
599ab69107 refactor: rewrite inefficient code and add docs 2021-12-31 03:19:58 -03:00
LuanRT
c6c6dc24bd feat: add support for music search 2021-12-31 03:15:59 -03:00
LuanRT
fa2e0724c6 docs: fix a typo 2021-12-22 15:27:26 -03:00
LuanRT
6af689ada6 docs: improve documentation & add unseen notifications example 2021-12-22 15:22:10 -03:00
22 changed files with 2275 additions and 896 deletions

19
.github/ISSUE_TEMPLATE/FEATURE.md vendored Normal file
View File

@@ -0,0 +1,19 @@
---
name: Feature Request
about: Use this template for requesting new features
title: "[FEATURE NAME]"
labels: enhancement
assignees:
---
## Expected Behavior
Please describe the behavior you are expecting
## Current Behavior
What is the current behavior?
## Sample Code
If applicable, provide a sample code snippet that demonstrates the gist of the feature you're proposing. This can be either from a usage standpoint, or an implementation standpoint.

39
.github/ISSUE_TEMPLATE/ISSUE.md vendored Normal file
View File

@@ -0,0 +1,39 @@
---
name: Issue Report
about: Use this template to report a problem
title: "[VERSION] [PROBLEM SUMMARY]"
labels: bug
assignees:
---
## Expected Behavior
Please describe the behavior you are expecting
## Current Behavior
What is the current behavior?
## Failure Information (for bugs)
Please help provide information about the failure if this is a bug. If it is not a bug, please remove the rest of this template.
### Steps to Reproduce
Please provide detailed steps for reproducing the issue.
1. step 1
2. step 2
3. you get it...
### Failure Logs
Please include any relevant log snippets or files here.
## Checklist
- [ ] I am running the latest version
- [ ] I checked the documentation and found no answer
- [ ] I checked to make sure that this issue has not already been filed
- [ ] I'm reporting the issue to the correct repository (for multi-repository projects)
- [ ] I have provided sufficient information

15
.github/ISSUE_TEMPLATE/QUESTION.md vendored Normal file
View File

@@ -0,0 +1,15 @@
---
name: Question
about: Use this template to ask a question about the project
title: "[QUESTION SUMMARY]"
labels: question
assignees:
---
## Question
State your question
## Sample Code
Please include relevant code snippets or files that provide context for your question.

1
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1 @@
blank_issues_enabled: false

View File

@@ -0,0 +1,27 @@
# Pull Request Template
## Description
Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change.
Fixes # (issue)
## Type of change
Please delete options that are not relevant.
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] This change requires a documentation update
## Checklist:
- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my own code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] I have checked my code and corrected any misspellings

View File

@@ -16,7 +16,7 @@ jobs:
strategy:
matrix:
node-version: [ 12.x, 14.x, 15.x]
node-version: [ 14.x, 15.x, 16.x ]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps:

966
README.md

File diff suppressed because it is too large Load Diff

View File

@@ -7,18 +7,18 @@ const creds = fs.existsSync(creds_path) && JSON.parse(fs.readFileSync(creds_path
async function start() {
const youtube = await new Innertube();
youtube.on('auth', (data) => {
youtube.ev.on('auth', (data) => {
if (data.status === 'AUTHORIZATION_PENDING') {
console.info(`Hello!\nOn your phone or computer, go to ${data.verification_url} and enter the code ${data.code}`);
} else if (data.status === 'SUCCESS') {
fs.writeFileSync(creds_path, JSON.stringify({ access_token: data.access_token, refresh_token: data.refresh_token, expires: data.expires }));
fs.writeFileSync(creds_path, JSON.stringify(data.credentials));
console.info('Successfully signed-in, enjoy!');
}
});
youtube.on('update-credentials', (data) => {
fs.writeFileSync(creds_path, JSON.stringify({ access_token: data.access_token, refresh_token: data.refresh_token, expires: data.expires }));
youtube.ev.on('update-credentials', (data) => {
fs.writeFileSync(creds_path, JSON.stringify(data.credentials));
console.info('Credentials updated!', data);
});

View File

@@ -5,10 +5,19 @@ const Axios = require('axios');
const Utils = require('./Utils');
const Constants = require('./Constants');
/**
* 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.<object>} { success: boolean, status_code: number } | { success: boolean, status_code: number, message: string }
*/
async function engage(session, engagement_type, args = {}) {
if (!session.logged_in) throw new Error('You are not signed-in');
let data;
switch (engagement_type) {
case 'like/like':
case 'like/dislike':
@@ -24,49 +33,104 @@ async function engage(session, engagement_type, args = {}) {
case 'subscription/unsubscribe':
data = {
context: session.context,
channelIds: [args.channel_id]
channelIds: [args.channel_id],
params: engagement_type == 'subscription/subscribe' ? 'EgIIAhgA' : 'CgIIAhgA'
};
break;
case 'comment/create_comment':
data = {
context: session.context,
commentText: args.text,
createCommentParams: Utils.generateCommentParams(args.video_id)
createCommentParams: Utils.encodeCommentParams(args.video_id)
};
break;
default:
}
const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/${engagement_type}${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session, id: args.video_id, data })).catch((error) => error);
const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/${engagement_type}${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session, id: args.video_id, data })).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
return {
success: true,
status_code: response.status
};
}
async function browse(session, action_type) {
if (!session.logged_in) throw new Error('You are not signed-in');
/**
* Accesses YouTube's various sections.
*
* @param {Innertube} session - A valid Innertube session.
* @param {string} action_type - Type of action.
* @param {object} args - Action argumenets.
* @returns {Promise.<object>} { success: boolean, status_code: number, data: object } | { success: boolean, status_code: number, message: string }
*/
async function browse(session, action_type, args = {}) {
if (!session.logged_in && (action_type != 'lyrics' || action_type != 'music_playlist'))
throw new Error('You are not signed-in');
let data;
switch (action_type) { // TODO: Handle more actions
switch (action_type) {
case 'account_notifications':
data = {
context: session.context,
browseId: 'SPaccount_notifications'
};
break;
case 'account_privacy':
data = {
context: session.context,
browseId: 'SPaccount_privacy'
};
break;
case 'history':
data = {
context: session.context,
browseId: 'FEhistory'
}
break;
case 'home_feed':
data = {
context: session.context,
browseId: 'FEwhat_to_watch'
};
break;
case 'subscriptions_feed':
data = {
context: session.context,
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_URL;
context.client.clientVersion = Constants.YTMUSIC_VERSION;
context.client.clientName = 'WEB_REMIX';
data = {
context,
browseId: args.browse_id
}
break;
case 'playlist':
data = {
context: session.context,
browseId: args.browse_id
}
break;
default:
}
const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/browse${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session })).catch((error) => error);
const client_domain = args.ytmusic && Constants.URLS.YT_MUSIC_URL || Constants.URLS.YT_BASE_URL;
const response = await Axios.post(`${client_domain}/youtubei/v1/browse${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session, ytmusic: args.ytmusic })).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
return {
success: true,
status_code: response.status,
@@ -74,17 +138,132 @@ async function browse(session, action_type) {
};
}
async function search(session, args = {}) {
/**
* Account settings endpoints.
*
* @param {Innertube} session - A valid Innertube session.
* @param {string} action_type - Type of action.
* @param {object} args - Action argumenets.
* @returns {Promise.<object>} { success: boolean, status_code: number, data: object } | { success: boolean, status_code: number, message: string }
*/
async function account(session, action_type, args = {}) {
if (!session.logged_in) throw new Error('You are not signed-in');
let data;
switch (action_type) {
case 'account/account_menu':
data = { context: session.context };
break;
case 'account/set_setting':
data = {
context: session.context,
newValue: {
boolValue: args.new_value
},
settingItemId: args.setting_item_id
}
break;
default:
break;
}
const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/${action_type}${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session })).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
return {
success: true,
status_code: response.status,
data: response.data
};
}
/**
* Accesses YouTube Music endpoints under /youtubei/v1/music/.
*
* @param {Innertube} session - A valid Innertube session.
* @param {string} action_type - Type of action.
* @param {object} args - Action arguments.
* @todo Implement more actions.
* @returns
*/
async function music(session, action_type, 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_URL;
context.client.clientVersion = Constants.YTMUSIC_VERSION;
context.client.clientName = 'WEB_REMIX';
let data;
switch (action_type) {
case 'get_search_suggestions':
data = {
context,
input: args.input || ''
};
break;
default:
break;
}
const response = await Axios.post(`${Constants.URLS.YT_MUSIC_URL}/youtubei/v1/music/${action_type}${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session, ytmusic: true })).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
return {
success: true,
status_code: response.status,
data: response.data
};
}
/**
* Performs searches on YouTube.
*
* @param {Innertube} session - A valid Innertube session.
* @param {string} client - YouTube client: YOUTUBE | YTMUSIC
* @param {object} args - Search arguments.
* @returns {Promise.<object>} - { success: boolean, status_code: number, data: object } | { success: boolean, status_code: number, message: string }
*/
async function search(session, client, args = {}) {
if (!args.query) throw new Error('No query was provided');
const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/search${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify({
context: session.context,
params: Utils.encodeFilter(args.options.period, args.options.duration, args.options.order),
query: args.query
}), Constants.INNERTUBE_REQOPTS({ session })).catch((error) => error);
let data;
switch (client) {
case 'YOUTUBE':
data = {
context: session.context,
params: Utils.encodeFilter(args.options.period, args.options.duration, args.options.order),
query: args.query
};
break;
case '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_URL;
context.client.clientVersion = Constants.YTMUSIC_VERSION;
context.client.clientName = 'WEB_REMIX';
data = {
context: context,
query: args.query
};
break;
default:
break;
}
const response = await Axios.post(`${client === 'YOUTUBE' && Constants.URLS.YT_BASE_URL || Constants.URLS.YT_MUSIC_URL}/youtubei/v1/search${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session, ytmusic: client === 'YTMUSIC' })).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
return {
success: true,
status_code: response.status,
@@ -92,9 +271,17 @@ async function search(session, args = {}) {
};
}
/**
* Interacts with YouTube's notification system.
*
* @param {Innertube} session - A valid Innertube session.
* @param {string} action_type - Type of action.
* @param {object} args - Action arguments.
* @returns {Promise.<object>} - { success: boolean, status_code: number, data: object } | { success: boolean, status_code: number, message: string }
*/
async function notifications(session, action_type, args = {}) {
if (!session.logged_in) throw new Error('You are not signed-in');
let data;
switch (action_type) {
@@ -119,11 +306,12 @@ async function notifications(session, action_type, args = {}) {
default:
}
const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/notification/${action_type}${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session })).catch((error) => error);
const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/notification/${action_type}${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session })).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
if (action_type === 'modify_channel_preference') return { success: true, status_code: response.status };
return {
success: true,
status_code: response.status,
@@ -131,13 +319,28 @@ async function notifications(session, action_type, args = {}) {
};
}
/**
* Interacts with YouTube's livechat system.
*
* @param {Innertube} session - A valid Innertube session.
* @param {string} action_type - Type of action.
* @param {object} args - Action arguments.
* @returns {Promise.<object>} - { success: boolean, status_code: number, data: object } | { success: boolean, status_code: number, message: string }
*/
async function livechat(session, action_type, args = {}) {
let data;
switch (action_type) {
case 'live_chat/get_live_chat':
data = {
context: session.context,
continuation: args.ctoken
};
break;
case 'live_chat/send_message':
data = {
context: session.context,
params: Utils.generateMessageParams(args.channel_id, args.video_id),
params: Utils.encodeMessageParams(args.channel_id, args.video_id),
clientMessageId: `ytjs-${Uuid.v4()}`,
richMessage: {
textSegments: [{ text: args.text }]
@@ -155,51 +358,85 @@ async function livechat(session, action_type, args = {}) {
params: args.cmd_params
};
break;
case 'updated_metadata':
data = {
context: session.context,
videoId: args.video_id
};
args.continuation && (data.continuation = args.continuation);
break;
default:
}
const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/${action_type}${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session, params: args.params })).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
return {
success: true,
status_code: response.status,
data: response.data
};
const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/${action_type}${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session, params: args.params })).catch((error) => error);
if (response instanceof Error) return { success: false, message: response.message };
return { success: true, data: response.data };
}
/**
* Gets detailed data for a video.
*
* @param {Innertube} session - A valid Innertube session.
* @param {object} args - Request arguments.
* @returns {Promise.<object>} - Video data.
*/
async function getVideoInfo(session, args = {}) {
let response;
!args.is_desktop && (response = await Axios.get(`${Constants.URLS.YT_WATCH_PAGE}?v=${args.id}&t=8s&pbj=1`, Constants.INNERTUBE_REQOPTS({ session, id: args.id, desktop: false })).catch((error) => error)) ||
(response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/player${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify(Constants.VIDEO_INFO_REQBODY(args.id, session.sts, session.context)), Constants.INNERTUBE_REQOPTS({ session, id: args.id, desktop: true })).catch((error) => error));
!args.desktop &&
(response = await Axios.get(`${Constants.URLS.YT_WATCH_PAGE}?v=${args.id}&t=8s&pbj=1`, Constants.INNERTUBE_REQOPTS({ session, id: args.id, desktop: false })).catch((error) => error)) ||
(response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/player${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify(Constants.VIDEO_INFO_REQBODY(args.id, session.sts, session.context)), Constants.INNERTUBE_REQOPTS({ session, id: args.id, desktop: true })).catch((error) => error));
if (response instanceof Error) throw new Error(`Could not get video info: ${response.message}`);
return response.data;
}
async function getContinuation(session, info = {}) {
/**
* Requests continuation for previously performed actions.
*
* @param {Innertube} session - A valid Innertube session.
* @param {object} args - Continuation arguments.
* @returns {Promise.<object>} - { success: boolean, status_code: number, data: object } | { success: boolean, status_code: number, message: string }
*/
async function getContinuation(session, args = {}) {
let data = { context: session.context };
info.continuation_token && (data.continuation = info.continuation_token);
args.continuation_token && (data.continuation = args.continuation_token);
if (info.video_id) {
data.videoId = info.video_id;
data.racyCheckOk = true;
data.contentCheckOk = false;
data.autonavState = 'STATE_NONE';
data.playbackContext = {
vis: 0,
lactMilliseconds: '-1'
};
data.captionsRequested = false;
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_URL;
context.client.clientVersion = Constants.YTMUSIC_VERSION;
context.client.clientName = 'WEB_REMIX';
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 Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/next${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session })).catch((error) => error);
const client_domain = args.ytmusic && Constants.URLS.YT_MUSIC_URL || Constants.URLS.YT_BASE_URL;
const response = await Axios.post(`${client_domain}/youtubei/v1/next${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session, ytmusic: args.ytmusic })).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
return {
success: true,
status_code: response.status,
@@ -207,4 +444,24 @@ async function getContinuation(session, info = {}) {
};
}
module.exports = { engage, browse, search, notifications, livechat, getVideoInfo, getContinuation };
/**
* Gets search suggestions.
*
* @param {Innertube} session
* @param {string} query
* @returns
*/
async function getYTSearchSuggestions(session, query) {
const response = await Axios.get(`${Constants.URLS.YT_SUGGESTIONS}search?client=firefox&ds=yt&q=${query}`,
Constants.DEFAULT_HEADERS(session)).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
return {
success: true,
status_code: response.status,
data: response.data
};
}
module.exports = { engage, browse, account, music, search, notifications, livechat, getVideoInfo, getContinuation, getYTSearchSuggestions };

View File

@@ -5,8 +5,10 @@ const Utils = require('./Utils');
module.exports = {
URLS: {
YT_BASE_URL: 'https://www.youtube.com',
YT_MUSIC_URL: 'https://music.youtube.com',
YT_MOBILE_URL: 'https://m.youtube.com',
YT_WATCH_PAGE: 'https://m.youtube.com/watch'
YT_WATCH_PAGE: 'https://m.youtube.com/watch',
YT_SUGGESTIONS: 'https://suggestqueries.google.com/complete/'
},
OAUTH: {
SCOPE: 'http://gdata.youtube.com https://www.googleapis.com/auth/youtube-paid-content',
@@ -46,6 +48,9 @@ module.exports = {
},
INNERTUBE_REQOPTS: (info) => {
info.desktop === undefined && (info.desktop = true);
const origin = info.ytmusic && 'https://music.youtube.com' ||
info.desktop && 'https://www.youtube.com' || 'https://m.youtube.com';
let req_opts = {
params: info.params || {},
headers: {
@@ -58,8 +63,8 @@ module.exports = {
'x-youtube-client-name': info.desktop ? 1 : 2,
'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': info.desktop ? 'https://www.youtube.com' : 'https://m.youtube.com',
'origin': info.desktop ? 'https://www.youtube.com' : 'https://m.youtube.com',
'x-origin': origin,
'origin': origin,
}
};
@@ -91,11 +96,38 @@ module.exports = {
videoId: id
};
},
YTMUSIC_VERSION: '1.20211213.00.00',
METADATA_KEYS: [
'embed', 'view_count', 'average_rating',
'length_seconds', 'channel_id', 'channel_url',
'external_channel_id', 'is_live_content', 'is_family_safe',
'is_unlisted', 'is_private', 'has_ypc_metadata',
'category', 'owner_channel_name', 'publish_date',
'upload_date', 'keywords', 'available_countries',
'owner_profile_url'
],
BLACKLISTED_KEYS: [
'is_owner_viewing', 'is_unplugged_corpus',
'is_crawlable', 'allow_ratings', 'author'
],
ACCOUNT_SETTINGS: {
// Notifications
SUBSCRIPTIONS: 'NOTIFICATION_SUBSCRIPTION_NOTIFICATIONS',
RECOMMENDED_VIDEOS: 'NOTIFICATION_RECOMMENDATION_WEB_CONTROL',
CHANNEL_ACTIVITY: 'NOTIFICATION_COMMENT_WEB_CONTROL',
COMMENT_REPLIES: 'NOTIFICATION_COMMENT_REPLY_OTHER_WEB_CONTROL',
USER_MENTION: 'NOTIFICATION_USER_MENTION_WEB_CONTROL',
SHARED_CONTENT: 'NOTIFICATION_RETUBING_WEB_CONTROL',
// Privacy
PLAYLISTS_PRIVACY: 'PRIVACY_DISCOVERABLE_SAVED_PLAYLISTS',
SUBSCRIPTIONS_PRIVACY: 'PRIVACY_DISCOVERABLE_SUBSCRIPTIONS'
},
BASE64_DIALECT: {
NORMAL: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'.split(''),
REVERSE: '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_'.split('')
},
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 h=f|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: {
PUSH: 'd.push(e)',
REVERSE_1: 'd.reverse()',
@@ -107,90 +139,6 @@ module.exports = {
ROTATE_2: 'unshift(d.pop())',
BASE64_DIA: 'function(){for(var',
TRANSLATE_1: 'function(d,e){for(var f',
TRANSLATE_2: 'function(d,e,f){var h=f'
},
// Helper functions, felt like Utils.js wasn't the right place for them:
formatNTransformData: (data) => {
return data
.replace(/function\(d,e\)/g, '"function(d,e)').replace(/function\(d\)/g, '"function(d)')
.replace(/function\(\)/g, '"function()').replace(/function\(d,e,f\)/g, '"function(d,e,f)')
.replace(/\[function\(d,e,f\)/g, '["function(d,e,f)')
.replace(/,b,/g, ',"b",').replace(/,b/g, ',"b"').replace(/b,/g, '"b",').replace(/b]/g, '"b"]')
.replace(/\[b/g, '["b"').replace(/}]/g, '"]').replace(/},/g, '}",').replace(/""/g, '')
.replace(/length]\)}"/g, 'length])}');
},
formatVideoData: (data, context, is_desktop) => {
let video_details = {};
let metadata = {};
if (is_desktop) {
metadata.embed = data.microformat.playerMicroformatRenderer.embed;
metadata.view_count = parseInt(data.videoDetails.viewCount);
metadata.average_rating = data.videoDetails.averageRating;
metadata.length_seconds = data.microformat.playerMicroformatRenderer.lengthSeconds;
metadata.channel_id = data.videoDetails.channelId;
metadata.channel_url = data.microformat.playerMicroformatRenderer.ownerProfileUrl;
metadata.external_channel_id = data.microformat.playerMicroformatRenderer.externalChannelId;
metadata.is_live_content = data.videoDetails.isLiveContent;
metadata.is_family_safe = data.microformat.playerMicroformatRenderer.isFamilySafe;
metadata.is_unlisted = data.microformat.playerMicroformatRenderer.isUnlisted;
metadata.is_private = data.videoDetails.isPrivate;
metadata.has_ypc_metadata = data.microformat.playerMicroformatRenderer.hasYpcMetadata;
metadata.category = data.microformat.playerMicroformatRenderer.category;
metadata.channel_name = data.microformat.playerMicroformatRenderer.ownerChannelName;
metadata.publish_date = data.microformat.playerMicroformatRenderer.publishDate || 'N/A';
metadata.upload_date = data.microformat.playerMicroformatRenderer.uploadDate || 'N/A';
metadata.keywords = data.videoDetails.keywords || [];
metadata.available_qualities = [...new Set(data.streamingData.adaptiveFormats.filter(v => v.qualityLabel).map(v => v.qualityLabel).sort((a, b) => +a.replace(/\D/gi, '') - +b.replace(/\D/gi, '')))];
video_details.id = data.videoDetails.videoId;
video_details.title = data.videoDetails.title;
video_details.description = data.videoDetails.shortDescription;
video_details.thumbnail = data.videoDetails.thumbnail.thumbnails.slice(-1)[0];
video_details.metadata = metadata;
} else {
const is_dislike_available = data[3].response.contents.singleColumnWatchNextResults.results.results.contents[1].slimVideoMetadataSectionRenderer.contents[1].slimVideoActionBarRenderer.buttons[1].slimMetadataToggleButtonRenderer.button.toggleButtonRenderer.defaultText.accessibility && true || false;
metadata.embed = data[2].playerResponse.microformat.playerMicroformatRenderer.embed;
metadata.likes = parseInt(data[3].response.contents.singleColumnWatchNextResults.results.results.contents[1].slimVideoMetadataSectionRenderer.contents[1].slimVideoActionBarRenderer.buttons[0].slimMetadataToggleButtonRenderer.button.toggleButtonRenderer.defaultText.accessibility.accessibilityData.label.replace(/\D/g, ''));
metadata.dislikes = is_dislike_available && parseInt(data[3].response.contents.singleColumnWatchNextResults.results.results.contents[1].slimVideoMetadataSectionRenderer.contents[1].slimVideoActionBarRenderer.buttons[1].slimMetadataToggleButtonRenderer.button.toggleButtonRenderer.defaultText.accessibility.accessibilityData.label.replace(/\D/g, '')) || 0;
metadata.view_count = parseInt(data[2].playerResponse.videoDetails.viewCount);
metadata.average_rating = data[2].playerResponse.videoDetails.averageRating;
metadata.length_seconds = data[2].playerResponse.microformat.playerMicroformatRenderer.lengthSeconds;
metadata.channel_id = data[2].playerResponse.videoDetails.channelId;
metadata.channel_url = data[2].playerResponse.microformat.playerMicroformatRenderer.ownerProfileUrl;
metadata.external_channel_id = data[2].playerResponse.microformat.playerMicroformatRenderer.externalChannelId;
metadata.is_live_content = data[2].playerResponse.videoDetails.isLiveContent;
metadata.is_family_safe = data[2].playerResponse.microformat.playerMicroformatRenderer.isFamilySafe;
metadata.is_unlisted = data[2].playerResponse.microformat.playerMicroformatRenderer.isUnlisted;
metadata.is_private = data[2].playerResponse.videoDetails.isPrivate;
metadata.has_ypc_metadata = data[2].playerResponse.microformat.playerMicroformatRenderer.hasYpcMetadata;
metadata.category = data[2].playerResponse.microformat.playerMicroformatRenderer.category;
metadata.channel_name = data[2].playerResponse.microformat.playerMicroformatRenderer.ownerChannelName;
metadata.publish_date = data[2].playerResponse.microformat.playerMicroformatRenderer.publishDate;
metadata.upload_date = data[2].playerResponse.microformat.playerMicroformatRenderer.uploadDate;
metadata.keywords = data[2].playerResponse.videoDetails.keywords;
metadata.available_qualities = [...new Set(data[2].playerResponse.streamingData.adaptiveFormats.filter(v => v.qualityLabel).map(v => v.qualityLabel).sort((a, b) => +a.replace(/\D/gi, '') - +b.replace(/\D/gi, '')))];
video_details.id = data[2].playerResponse.videoDetails.videoId;
video_details.title = data[2].playerResponse.videoDetails.title;
video_details.description = data[2].playerResponse.videoDetails.shortDescription;
video_details.thumbnail = data[2].playerResponse.videoDetails.thumbnail.thumbnails.slice(-1)[0];
// Placeholders for functions
video_details.like = () => {};
video_details.dislike = () => {};
video_details.removeLike = () => {};
video_details.subscribe = () => {};
video_details.unsubscribe = () => {};
video_details.comment = () => {};
video_details.getComments = () => {};
video_details.setNotificationPref = () => {};
video_details.getLivechat = () => {};
// Additional metadata
video_details.metadata = metadata;
}
return video_details;
TRANSLATE_2: 'function(d,e,f){var'
}
};

View File

@@ -5,76 +5,258 @@ const Stream = require('stream');
const OAuth = require('./OAuth');
const Utils = require('./Utils');
const Player = require('./Player');
const Parser = require('./Parser');
const NToken = require('./NToken');
const Actions = require('./Actions');
const Livechat = require('./Livechat');
const Constants = require('./Constants');
const SigDecipher = require('./Sig');
const EventEmitter = require('events');
const TimeToSeconds = require('time-to-seconds');
const CancelToken = Axios.CancelToken;
class Innertube {
#player;
#retry_count;
/**
* ```js
* const Innertube = require('youtubei.js');
* const youtube = await new Innertube();
* ```
* @param {string} [cookie]
* @returns {Innertube}
* @constructor
*/
constructor(cookie) {
this.cookie = cookie || '';
this.retry_count = 0;
return this.init();
this.#retry_count = 0;
return this.#init();
}
async init() {
async #init() {
const response = await Axios.get(Constants.URLS.YT_BASE_URL, Constants.DEFAULT_HEADERS(this)).catch((error) => error);
if (response instanceof Error) throw new Error(`Could not extract Innertube data: ${response.message}`);
if (response instanceof Error) throw new Error(`Could not retrieve Innertube session: ${response.message}`);
try {
const innertube_data = JSON.parse(`{${Utils.getStringBetweenStrings(response.data, 'ytcfg.set({', '});')}}`);
if (innertube_data.INNERTUBE_CONTEXT) {
this.context = innertube_data.INNERTUBE_CONTEXT;
this.key = innertube_data.INNERTUBE_API_KEY;
this.id_token = innertube_data.ID_TOKEN;
this.session_token = innertube_data.XSRF_TOKEN;
this.player_url = innertube_data.PLAYER_JS_URL;
this.logged_in = innertube_data.LOGGED_IN;
this.sts = innertube_data.STS;
const data = JSON.parse(`{${Utils.getStringBetweenStrings(response.data, 'ytcfg.set({', '});')}}`);
if (data.INNERTUBE_CONTEXT) {
this.key = data.INNERTUBE_API_KEY;
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';
this.player = new Player(this);
await this.player.init();
/**
* @event auth - Fired when signing in to an account.
* @event update-credentials - Fired when the access token is no longer valid.
* @type {EventEmitter}
*/
this.ev = new EventEmitter();
this.#player = new Player(this);
await this.#player.init();
if (this.logged_in && this.cookie.length > 1) {
this.auth_apisid = Utils.getStringBetweenStrings(this.cookie, 'PAPISID=', ';');
this.auth_apisid = Utils.generateSidAuth(this.auth_apisid);
}
this.ev = new EventEmitter();
this.#initMethods();
} else {
this.retry_count += 1;
if (this.retry_count >= 10) throw new Error('Could not retrieve Innertube data');
return this.init();
throw new Error('Could not retrieve Innertube session due to unknown reasons');
}
} catch (err) {
this.retry_count += 1;
if (this.retry_count >= 10) throw new Error('Could not retrieve Innertube data');
return this.init();
this.#retry_count += 1;
if (this.#retry_count >= 10) throw new Error(`Could not retrieve Innertube session: ${err.message}`);
return this.#init();
}
return this;
}
#initMethods() {
this.account = {
info: () => this.getAccountInfo(),
settings: {
notifications: {
/**
* Notify about activity from the channels you're subscribed to.
*
* @param {boolean} new_value
* @returns {Promise<{success: boolean; status_code: string; }>}
*/
setSubscriptions: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.SUBSCRIPTIONS, 'account_notifications', new_value),
/**
* Recommended content notifications.
*
* @param {boolean} new_value
* @returns {Promise<{success: boolean; status_code: string; }>}
*/
setRecommendedVideos: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.RECOMMENDED_VIDEOS, 'account_notifications', new_value),
/**
* Notify about activity on your channel.
*
* @param {boolean} new_value
* @returns {Promise<{success: boolean; status_code: string; }>}
*/
setChannelActivity: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.CHANNEL_ACTIVITY, 'account_notifications', new_value),
/**
* Notify about replies to your comments.
*
* @param {boolean} new_value
* @returns {Promise<{success: boolean; status_code: string; }>}
*/
setCommentReplies: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.COMMENT_REPLIES, 'account_notifications', new_value),
/**
* Notify when others mention your channel.
*
* @param {boolean} new_value
* @returns {Promise<{success: boolean; status_code: string; }>}
*/
setMentions: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.USER_MENTION, 'account_notifications', new_value),
/**
* Notify when others share your content on their channels.
*
* @param {boolean} new_value
* @returns {Promise<{success: boolean; status_code: string; }>}
*/
setSharedContent: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.SHARED_CONTENT, 'account_notifications', new_value)
},
privacy: {
/**
* If set to true, your subscriptions won't be visible to others.
*
* @param {boolean} new_value
* @returns {Promise<{success: boolean; status_code: string; }>}
*/
setSubscriptionsPrivate: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.SUBSCRIPTIONS_PRIVACY, 'account_privacy', new_value),
/**
* If set to true, saved playlists won't appear on your channel.
*
* @param {boolean} new_value
* @returns {Promise<{success: boolean; status_code: string; }>}
*/
setSavedPlaylistsPrivate: (new_value) => this.#setSetting(Constants.ACCOUNT_SETTINGS.PLAYLISTS_PRIVACY, 'account_privacy', new_value)
}
}
}
this.interact = {
/**
* Likes a given video.
*
* @param {string} video_id
* @returns {Promise<{success: boolean; status_code: string; }>}
*/
like: (video_id) => Actions.engage(this, 'like/like', { video_id }),
/**
* Diskes a given video.
*
* @param {string} video_id
* @returns {Promise<{success: boolean; status_code: string; }>}
*/
dislike: (video_id) => Actions.engage(this, 'like/dislike', { video_id }),
/**
* Removes a like/dislike.
*
* @param {string} video_id
* @returns {Promise<{success: boolean; status_code: string; }>}
*/
removeLike: (video_id) => Actions.engage(this, 'like/removelike', { video_id }),
/**
* Posts a comment on a given video.
*
* @param {string} video_id
* @param {string} text
* @returns {Promise<{success: boolean; status_code: string; }>}
*/
comment: (video_id, text) => Actions.engage(this, 'comment/create_comment', { video_id, text }),
/**
* Subscribes to a given channel.
*
* @param {string} channel_id
* @returns {Promise<{success: boolean; status_code: string; }>}
*/
subscribe: (channel_id) => Actions.engage(this, 'subscription/subscribe', { channel_id }),
/**
* Unsubscribes from a given channel.
*
* @param {string} channel_id
* @returns {Promise<{success: boolean; status_code: string; }>}
*/
unsubscribe: (channel_id) => Actions.engage(this, 'subscription/unsubscribe', { channel_id }),
/**
* Changes notification preferences for a given channel.
* Only works with channels you are subscribed to.
*
* @param {string} channel_id
* @param {string} type PERSONALIZED | ALL | NONE
* @returns {Promise<{success: boolean; status_code: string; }>}
*/
changeNotificationPreferences: (channel_id, type) => Actions.notifications(this, 'modify_channel_preference', { channel_id, pref: type || 'NONE' }),
};
}
/**
* Internal method to perform changes on an account's settings.
*
* @param {string} setting_id
* @param {string} type
* @param {string} new_value
* @returns {Promise<{success: boolean; status_code: string; }>}
*/
async #setSetting(setting_id, type, new_value) {
const response = await Actions.browse(this, type);
const contents = response.data.contents.twoColumnBrowseResultsRenderer.tabs[0]
.tabRenderer.content.sectionListRenderer.contents[1]
.itemSectionRenderer.contents.find((content) => content.settingsOptionsRenderer.options)
.settingsOptionsRenderer.options;
const option = contents.find((option) => option.settingsSwitchRenderer.enableServiceEndpoint.setSettingEndpoint.settingItemIdForClient == setting_id);
const setting_item_id = option.settingsSwitchRenderer.enableServiceEndpoint.setSettingEndpoint.settingItemId;
const set_setting = await Actions.account(this, 'account/set_setting', { new_value, setting_item_id });
return {
success: set_setting.success,
status_code: response.status_code,
}
}
/**
* Signs-in to a google account.
*
* @param {object} auth_info
* @param {string} auth_info.access_token - Token used to sign in.
* @param {string} auth_info.refresh_token - Token used to get a new access token.
* @param {Date} auth_info.expires - Access token's expiration date, which is usually 24hrs-ish
* @returns {Promise.<void>}
*/
signIn(auth_info = {}) {
return new Promise(async (resolve, reject) => {
return new Promise(async (resolve) => {
const oauth = new OAuth(auth_info);
if (auth_info.access_token) {
const is_valid = await oauth.isTokenValid(auth_info.expires);
if (!is_valid) {
const new_tokens = await oauth.refreshAccessToken(auth_info.refresh_token);
auth_info.refresh_token = new_tokens.credentials.refresh_token;
auth_info.access_token = new_tokens.credentials.access_token;
this.ev.emit('update-credentials', {
credentials: new_tokens.credentials,
status: new_tokens.status
});
if (!oauth.isTokenValid()) {
const tokens = await oauth.refreshAccessToken();
auth_info.refresh_token = tokens.credentials.refresh_token;
auth_info.access_token = tokens.credentials.access_token;
this.ev.emit('update-credentials', { credentials: tokens.credentials, status: tokens.status });
}
this.access_token = auth_info.access_token;
@@ -85,15 +267,10 @@ class Innertube {
} else {
oauth.on('auth', (data) => {
if (data.status === 'SUCCESS') {
this.ev.emit('auth', { credentials: data.credentials, status: data.status });
this.access_token = data.credentials.access_token;
this.refresh_token = data.credentials.refresh_token;
this.logged_in = true;
this.ev.emit('auth', {
credentials: data.credentials,
status: data.status
});
resolve();
} else {
this.ev.emit('auth', data);
@@ -103,74 +280,167 @@ class Innertube {
});
}
async search(query, options = { period: 'any', order: 'relevance', duration: 'any' }) {
const response = await Actions.search(this, { query, options });
/**
* Returns information about the account being used.
* @returns {Promise<{ name: string; photo: Array<object>; country: string; language: string; }>}
*/
async getAccountInfo() {
const response = await Actions.account(this, 'account/account_menu');
if (!response.success) throw new Error('Could not get account info');
const menu = response.data.actions[0].openPopupAction.popup.multiPageMenuRenderer;
return {
name: menu.header.activeAccountHeaderRenderer.accountName.simpleText,
photo: menu.header.activeAccountHeaderRenderer.accountPhoto.thumbnails,
country: menu.sections[1].multiPageMenuSectionRenderer.items[2].compactLinkRenderer.subtitle.simpleText,
language: menu.sections[1].multiPageMenuSectionRenderer.items[1].compactLinkRenderer.subtitle.simpleText
}
}
/**
* Searches on YouTube.
*
* @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: [] } |
* { songs: []; videos: []; albums: []; 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 Error(`Could not search on YouTube: ${response.message}`);
const content = response.data.contents.twoColumnSearchResultsRenderer.primaryContents.sectionListRenderer.contents[0].itemSectionRenderer.contents;
const search = {};
const data = new Parser(this, response.data, {
client: options.client,
data_type: 'SEARCH',
query
}).parse();
search.search_metadata = {};
search.search_metadata.query = content[0].showingResultsForRenderer ? content[0].showingResultsForRenderer.originalQuery.simpleText : query;
search.search_metadata.corrected_query = content[0].showingResultsForRenderer ? content[0].showingResultsForRenderer.correctedQueryEndpoint.searchEndpoint.query : query;
search.search_metadata.estimated_results = parseInt(response.data.estimatedResults);
search.videos = content.map((data) => {
if (!data.videoRenderer) return;
const video = data.videoRenderer;
return {
title: video.title.runs[0].text,
description: video.detailedMetadataSnippets && video.detailedMetadataSnippets[0].snippetText.runs.map((item) => item.text).join('') || 'N/A',
author: video.ownerText.runs[0].text,
id: video.videoId,
url: `https://youtu.be/${video.videoId}`,
channel_url: `${Constants.URLS.YT_BASE_URL}${video.ownerText.runs[0].navigationEndpoint.commandMetadata.webCommandMetadata.url}`,
metadata: {
view_count: video.viewCountText && video.viewCountText.simpleText || 'N/A',
short_view_count_text: {
simple_text: video.shortViewCountText && video.shortViewCountText.simpleText || 'N/A',
accessibility_label: video.shortViewCountText && (video.shortViewCountText.accessibility && video.shortViewCountText.accessibility.accessibilityData.label || 'N/A') || 'N/A',
},
thumbnails: video.thumbnail.thumbnails,
duration: {
seconds: TimeToSeconds(video.lengthText && video.lengthText.simpleText || '0'),
simple_text: video.lengthText && video.lengthText.simpleText || 'N/A',
accessibility_label: video.lengthText && video.lengthText.accessibility.accessibilityData.label || 'N/A'
},
published: video.publishedTimeText && video.publishedTimeText.simpleText || 'N/A',
badges: video.badges && video.badges.map((item) => item.metadataBadgeRenderer.label) || 'N/A',
owner_badges: video.ownerBadges && video.ownerBadges.map((item) => item.metadataBadgeRenderer.tooltip) || 'N/A '
}
};
}).filter((video_block) => video_block !== undefined);
return search;
return data;
}
async getDetails(id) {
if (!id) throw new Error('You must provide a video id');
/**
* Gets search suggestions.
*
* @param {string} input - The search query.
* @param {string} [client='YOUTUBE'] - Client used to retrieve search suggestions, can be: `YOUTUBE` or `YTMUSIC`.
* @returns {Promise.<[{ text: string; bold_text: string }]>}
*/
async getSearchSuggestions(input, { client = 'YOUTUBE' }) {
if (client == 'YOUTUBE') {
const response = await Actions.getYTSearchSuggestions(this, input);
if (!response.success) throw new Error('Could not get search suggestions');
const data = await Actions.getVideoInfo(this, { id, is_desktop: false });
const video_data = Constants.formatVideoData(data, this, false);
return response.data[1].map((item) => {
return {
text: item.trim(),
bold_text: response.data[0].trim()
};
});
} else if (client == 'YTMUSIC') {
const response = await Actions.music(this, 'get_search_suggestions', { input });
if (!response.success) throw new Error('Could not get search suggestions');
if (!response.data.contents) return [];
if (video_data.metadata.is_live_content) {
const data_continuation = await Actions.getContinuation(this, { video_id: id });
if (!data_continuation.data.contents.twoColumnWatchNextResults.conversationBar) return;
video_data.getLivechat = () => new Livechat(this, data_continuation.data.contents.twoColumnWatchNextResults.conversationBar.liveChatRenderer.continuations[0].reloadContinuationData.continuation, video_data.metadata.channel_id, id);
const contents = response.data.contents[0].searchSuggestionsSectionRenderer.contents;
return contents.map((item) => {
let suggestion;
if (item.historySuggestionRenderer) {
suggestion = item.historySuggestionRenderer.suggestion;
} else {
suggestion = item.searchSuggestionRenderer.suggestion;
}
return {
text: suggestion.runs.map((run) => run.text).join('').trim(),
bold_text: suggestion.runs[0].text.trim()
};
});
}
}
/**
* Gets details for a video.
*
* @param {string} video_id - The id of the video.
* @return {Promise.<{ title: string; description: string; thumbnail: []; metadata: {} }>}
*/
async getDetails(video_id) {
if (!video_id) throw new Error('You must provide a video id');
const data = await Actions.getVideoInfo(this, { id: video_id, is_desktop: false });
const refined_data = new Parser(this, data, { client: 'YOUTUBE', data_type: 'VIDEO_INFO', desktop_v: false }).parse();
if (refined_data.metadata.is_live_content) {
const data_continuation = await Actions.getContinuation(this, { video_id });
if (data_continuation.data.contents.twoColumnWatchNextResults.conversationBar) {
refined_data.getLivechat = () => new Livechat(this, data_continuation.data.contents.twoColumnWatchNextResults.conversationBar.liveChatRenderer.continuations[0].reloadContinuationData.continuation, refined_data.metadata.channel_id, video_id);
} else {
refined_data.getLivechat = () => { };
}
} else {
video_data.getLivechat = () => {};
refined_data.getLivechat = () => { };
}
video_data.like = () => Actions.engage(this, 'like/like', { video_id: id });
video_data.dislike = () => Actions.engage(this, 'like/dislike', { video_id: id });
video_data.removeLike = () => Actions.engage(this, 'like/removelike', { video_id: id });
video_data.subscribe = () => Actions.engage(this, 'subscription/subscribe', { video_id: id, channel_id: video_data.metadata.channel_id });
video_data.unsubscribe = () => Actions.engage(this, 'subscription/unsubscribe', { video_id: id, channel_id: video_data.metadata.channel_id });
video_data.comment = text => Actions.engage(this, 'comment/create_comment', { video_id: id, text });
video_data.getComments = () => this.getComments(id);
video_data.setNotificationPref = pref => Actions.notifications(this, 'modify_channel_preference', { channel_id: video_data.metadata.channel_id, pref: pref || 'NONE' });
refined_data.like = () => Actions.engage(this, 'like/like', { video_id });
refined_data.dislike = () => Actions.engage(this, 'like/dislike', { video_id });
refined_data.removeLike = () => Actions.engage(this, 'like/removelike', { video_id });
refined_data.subscribe = () => Actions.engage(this, 'subscription/subscribe', { channel_id: refined_data.metadata.channel_id });
refined_data.unsubscribe = () => Actions.engage(this, 'subscription/unsubscribe', { channel_id: refined_data.metadata.channel_id });
refined_data.comment = (text) => Actions.engage(this, 'comment/create_comment', { video_id, text });
refined_data.getComments = () => this.getComments(video_id);
refined_data.changeNotificationPreferences = (type) => Actions.notifications(this, 'modify_channel_preference', { channel_id: refined_data.metadata.channel_id, pref: type || 'NONE' });
return video_data;
return refined_data;
}
/**
* Retrieves the lyrics for a given song if available.
*
* @param {string} video_id
* @returns {Promise.<string>} Song lyrics
*/
async getLyrics(video_id) {
const data_continuation = await Actions.getContinuation(this, { video_id: video_id, ytmusic: true });
const lyrics_tab = data_continuation.data.contents.singleColumnMusicWatchNextResultsRenderer.tabbedRenderer
.watchNextTabbedResultsRenderer.tabs.find((obj) => obj.tabRenderer.title == 'Lyrics');
const response = await Actions.browse(this, 'lyrics', { ytmusic: true, browse_id: lyrics_tab.tabRenderer.endpoint.browseEndpoint.browseId });
if (!response.data.contents.sectionListRenderer) throw new Error(response.data.contents.messageRenderer.text.runs[0].text);
const lyrics = response.data.contents.sectionListRenderer.contents[0].musicDescriptionShelfRenderer.description.runs[0].text;
return lyrics;
}
/**
* Parses a given playlist.
*
* @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: [] }>}
*/
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}` });
const data = new Parser(this, response.data, { client: options.client, data_type: 'PLAYLIST' }).parse();
return data;
}
/**
* Gets the comments section of a video.
*
* @param {string} video_id - The id of the video.
* @param {string} [token] - Continuation token (optional).
* @return {Promise.<[{ comments: []; comment_count: string }]>
*/
async getComments(video_id, token) {
let comment_section_token;
@@ -220,9 +490,79 @@ class Innertube {
return comments_section;
}
/**
* Returns your watch history.
* @returns {Promise.<[{ id: string; title: string; channel: string; metadata: {} }]>}
*/
async getHistory() {
const response = await Actions.browse(this, 'history');
const contents = response.data.contents.twoColumnBrowseResultsRenderer.tabs[0]
.tabRenderer.content.sectionListRenderer.contents;
const history = [];
contents.forEach((section) => {
if (!section.itemSectionRenderer) return;
const section_items = section.itemSectionRenderer.contents;
section_items.forEach((item) => {
const content = {
id: item.videoRenderer.videoId,
title: item.videoRenderer.title.runs.map((run) => run.text).join(' '),
channel: item.videoRenderer.shortBylineText && item.videoRenderer.shortBylineText.runs[0].text || 'N/A',
metadata: {
view_count: item.videoRenderer.viewCountText && item.videoRenderer.viewCountText.simpleText || 'N/A',
thumbnail: item.videoRenderer.thumbnail && item.videoRenderer.thumbnail.thumbnails.slice(-1)[0] || [],
moving_thumbnail: item.videoRenderer.richThumbnail && item.videoRenderer.richThumbnail.movingThumbnailRenderer.movingThumbnailDetails.thumbnails[0] || [],
published: item.videoRenderer.publishedTimeText && item.videoRenderer.publishedTimeText.simpleText || 'N/A',
duration: item.videoRenderer.lengthText && item.videoRenderer.lengthText.simpleText || 'N/A',
badges: item.videoRenderer.badges && item.videoRenderer.badges.map((badge) => badge.metadataBadgeRenderer.label) || 'N/A',
owner_badges: item.videoRenderer.ownerBadges && item.videoRenderer.ownerBadges.map((badge) => badge.metadataBadgeRenderer.tooltip) || 'N/A'
}
};
history.push(content);
});
});
return history;
}
/**
* Returns YouTube's home feed.
* @returns {Promise.<[{ id: string; title: string; channel: string; metadata: {} }]>}
*/
async getHomeFeed() {
const response = await Actions.browse(this, 'home_feed');
if (!response.success) throw new Error('Could not get home feed');
const contents = response.data.contents.twoColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.richGridRenderer.contents;
return contents.map((item) => {
const content = item.richItemRenderer && item.richItemRenderer.content.videoRenderer &&
item.richItemRenderer.content || undefined;
if (content) return {
id: content.videoRenderer.videoId,
title: content.videoRenderer.title.runs.map((run) => run.text).join(' '),
channel: content.videoRenderer.shortBylineText && content.videoRenderer.shortBylineText.runs[0].text || 'N/A',
metadata: {
view_count: content.videoRenderer.viewCountText && content.videoRenderer.viewCountText.simpleText || 'N/A',
thumbnail: content.videoRenderer.thumbnail && content.videoRenderer.thumbnail.thumbnails.slice(-1)[0] || [],
moving_thumbnail: content.videoRenderer.richThumbnail && content.videoRenderer.richThumbnail.movingThumbnailRenderer.movingThumbnailDetails.thumbnails[0] || [],
published: content.videoRenderer.publishedTimeText && content.videoRenderer.publishedTimeText.simpleText || 'N/A',
badges: content.videoRenderer.badges && content.videoRenderer.badges.map((badge) => badge.metadataBadgeRenderer.label) || [],
owner_badges: content.videoRenderer.ownerBadges && content.videoRenderer.ownerBadges.map((badge) => badge.metadataBadgeRenderer.tooltip) || []
}
}
}).filter((video) => video);
}
/**
* Returns your subscription feed.
* @returns {Promise.<{ today: []; yesterday: []; this_week: [] }>}
*/
async getSubscriptionsFeed() {
const response = await Actions.browse(this, 'subscriptions_feed');
if (!response.success) throw new Error('Could not fetch subscriptions feed');
if (!response.success) throw new Error('Could not get subscriptions feed');
const contents = response.data.contents.twoColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents;
const subscriptions_feed = {};
@@ -238,12 +578,12 @@ class Innertube {
section_items.forEach((item) => {
const content = {
title: item.gridVideoRenderer.title.runs.map((run) => run.text).join(' '),
id: item.gridVideoRenderer.videoId,
title: item.gridVideoRenderer.title.runs.map((run) => run.text).join(' '),
channel: item.gridVideoRenderer.shortBylineText && item.gridVideoRenderer.shortBylineText.runs[0].text || 'N/A',
metadata: {
view_count: item.gridVideoRenderer.viewCountText && item.gridVideoRenderer.viewCountText.simpleText || 'N/A',
thumbnail: item.gridVideoRenderer.thumbnail && item.gridVideoRenderer.thumbnail.thumbnails || [],
thumbnail: item.gridVideoRenderer.thumbnail && item.gridVideoRenderer.thumbnail.thumbnails.slice(-1)[0] || [],
published: item.gridVideoRenderer.publishedTimeText && item.gridVideoRenderer.publishedTimeText.simpleText || 'N/A',
badges: item.gridVideoRenderer.badges && item.gridVideoRenderer.badges.map((badge) => badge.metadataBadgeRenderer.label) || 'N/A',
owner_badges: item.gridVideoRenderer.ownerBadges && item.gridVideoRenderer.ownerBadges.map((badge) => badge.metadataBadgeRenderer.tooltip) || 'N/A'
@@ -257,6 +597,10 @@ class Innertube {
return subscriptions_feed;
}
/**
* Retrieves your notifications.
* @returns {Promise.<[{ title: string; sent_time: string; channel_name: string; channel_thumbnail: {}; video_thumbnail: {}; video_url: string; read: boolean; notification_id: string }]>}
*/
async getNotifications() {
const response = await Actions.notifications(this, 'get_notification_menu');
if (!response.success) throw new Error('Could not fetch notifications');
@@ -279,12 +623,115 @@ class Innertube {
}).filter((notification_block) => notification_block);
}
/**
* Returns unseen notifications count.
* @returns {Promise.<number>} unseen notifications count.
*/
async getUnseenNotificationsCount() {
const response = await Actions.notifications(this, 'get_unseen_count');
if (!response.success) throw new Error('Could not fetch unseen notifications count');
if (!response.success) throw new Error('Could not get unseen notifications count');
return response.data.unseenCount;
}
/**
* Internal method to process and filter formats.
*
* @param {object} options
* @param {object} video_data
* @returns {object.<{ selected_format: {}; formats: [] }>}
*/
#chooseFormat(options, video_data) {
let formats = [];
formats = formats
.concat(video_data.streamingData.formats || [])
.concat(video_data.streamingData.adaptiveFormats || []);
formats.forEach((format) => {
format.url = format.url || format.signatureCipher || format.cipher;
if (format.signatureCipher || format.cipher) {
format.url = new SigDecipher(format.url, this.#player).decipher();
}
const url_components = new URL(format.url);
url_components.searchParams.set('cver', this.context.client.clientVersion);
url_components.searchParams.set('ratebypass', 'yes');
url_components.searchParams.set('n', new NToken(this.#player.ntoken_sc).transform(url_components.searchParams.get('n')));
format.url = url_components.toString();
format.has_audio = !!format.audioBitrate || !!format.audioQuality;
format.has_video = !!format.qualityLabel;
delete format.cipher;
delete format.signatureCipher;
});
formats.hls_manifest_url = video_data.streamingData.hlsManifestUrl || undefined;
formats.dash_manifest_url = video_data.streamingData.dashManifestUrl || undefined;
let format;
let bitrates;
let filtered_formats;
filtered_formats = ({
'video': formats.filter((format) => format.has_video && !format.has_audio),
'audio': formats.filter((format) => format.has_audio && !format.has_video),
'videoandaudio': formats.filter((format) => format.has_video && format.has_audio)
})[options.type] || formats.filter((format) => format.has_video && format.has_audio);
if (options.type != 'videoandaudio') {
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')));
streams == undefined || streams.length == 0 &&
(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];
}
return { selected_format: format, formats };
}
/**
* An alternative to {@link download}.
* Returns deciphered streaming data.
*
* @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
* @returns {Promise.<{ selected_format: {}; formats: [] }>}
*/
async getStreamingData(id, options = {}) {
options.quality = options.quality || '360p';
options.type = options.type || 'videoandaudio';
options.format = options.format || 'mp4';
const data = await Actions.getVideoInfo(this, { id, desktop: true });
const streaming_data = this.#chooseFormat(options, data);
if (!streaming_data.selected_format) throw new Error('Could not find any suitable format.');
return streaming_data;
}
/**
* Downloads a given video. If you only need the direct download link take a look at {@link getStreamingData}.
*
* @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}
*/
download(id, options = {}) {
if (!id) throw new Error('Missing video id');
@@ -296,78 +743,22 @@ class Innertube {
let cancelled = false;
const stream = new Stream.PassThrough();
Actions.getVideoInfo(this, { id, is_desktop: true }).then(async (video_data) => {
let formats = [];
Actions.getVideoInfo(this, { id, desktop: true }).then(async (video_data) => {
if (video_data.playabilityStatus.status === 'LOGIN_REQUIRED')
return stream.emit('error', { message: 'You must login to download age-restricted videos.', error_type: 'LOGIN_REQUIRED', playability_status: video_data.playabilityStatus.status });
if (!video_data.streamingData)
return stream.emit('error', { message: 'Streaming data not available.', error_type: 'NO_STREAMING_DATA', playability_status: video_data.playabilityStatus.status });
if (video_data.playabilityStatus.status === 'LOGIN_REQUIRED') return stream.emit('error', { message: 'You must login to download age-restricted videos.', error_type: 'LOGIN_REQUIRED', playability_status: video_data.playabilityStatus.status });
if (!video_data.streamingData) return stream.emit('error', { message: 'Streaming data not available.', error_type: 'NO_STREAMING_DATA', playability_status: video_data.playabilityStatus.status });
const { selected_format: format, formats } = this.#chooseFormat(options, video_data);
formats = formats.concat(video_data.streamingData.formats || []).concat(video_data.streamingData.adaptiveFormats || []);
formats.forEach((format) => {
format.url = format.url || format.signatureCipher || format.cipher;
if (format.signatureCipher || format.cipher) {
format.url = new SigDecipher(format.url, this.context.client.clientVersion, this.player).decipher();
} else {
const url_components = new URL(format.url);
url_components.searchParams.set('cver', this.context.client.clientVersion);
url_components.searchParams.set('ratebypass', 'yes');
url_components.searchParams.set('n', new NToken(this.player.ntoken_sc).transform(url_components.searchParams.get('n')));
format.url = url_components.toString();
}
format.has_audio = !!format.audioBitrate || !!format.audioQuality;
format.has_video = !!format.qualityLabel;
delete format.cipher;
delete format.signatureCipher;
});
formats.hls_manifest_url = video_data.streamingData.hlsManifestUrl || undefined;
formats.dash_manifest_url = video_data.streamingData.dashManifestUrl || undefined;
const video_details = Constants.formatVideoData(video_data, this, true);
let url;
let bitrates;
let filtered_streams;
switch (options.type) {
case 'video':
filtered_streams = formats.filter((format) => format.has_video && !format.has_audio);
break;
case 'audio':
filtered_streams = formats.filter((format) => format.has_audio && !format.has_video);
break;
case 'videoandaudio':
filtered_streams = formats.filter((format) => format.has_video && format.has_audio);
break;
default:
filtered_streams = formats.filter((format) => format.has_video && format.has_audio);
break;
}
if (options.type != 'videoandaudio') {
let streams;
options.type != 'audio' && (streams = filtered_streams.filter((format) => format.mimeType.includes(options.format || 'mp4') && format.qualityLabel == options.quality)) ||
(streams = filtered_streams.filter((format) => format.mimeType.includes(options.format || 'mp4')));
streams == undefined || streams.length == 0 && (streams = filtered_streams.filter((format) => format.quality == 'medium'));
bitrates = streams.map((format) => format.bitrate);
url = streams.filter((format) => format.bitrate === Math.max(...bitrates))[0];
}
const selected_format = options.type == 'videoandaudio' ? filtered_streams[0] : url;
if (!selected_format) {
if (!format)
return stream.emit('error', { message: 'Could not find any suitable format.', type: 'FORMAT_UNAVAILABLE' });
} else {
stream.emit('info', { video_details, selected_format, formats });
}
const video_details = new Parser(this, video_data, { client: 'YOUTUBE', data_type: 'VIDEO_INFO', desktop_v: true }).parse();
stream.emit('info', { video_details, selected_format: format, formats });
if (options.type == 'videoandaudio' && !options.range) {
const response = await Axios.get(selected_format.url, {
const response = await Axios.get(format.url, {
responseType: 'stream',
cancelToken: new CancelToken(function executor(c) { cancel = c; }),
headers: Constants.STREAM_HEADERS
@@ -386,11 +777,23 @@ class Innertube {
downloaded_size += chunk.length;
let size = (response.headers['content-length'] / 1024 / 1024).toFixed(2);
let percentage = Math.floor((downloaded_size / response.headers['content-length']) * 100);
stream.emit('progress', { chunk_size: chunk.length, downloaded_size: (downloaded_size / 1024 / 1024).toFixed(2), percentage, size, raw_data: { chunk_size: chunk.length, downloaded: downloaded_size, size: response.headers['content-length'] } });
stream.emit('progress', {
size,
percentage,
chunk_size: chunk.length,
downloaded_size: (downloaded_size / 1024 / 1024).toFixed(2),
raw_data: {
chunk_size: chunk.length,
downloaded: downloaded_size,
size: response.headers['content-length']
}
});
});
response.data.on('error', (err) => {
cancelled && stream.emit('error', { message: 'The download was cancelled.', type: 'DOWNLOAD_CANCELLED' }) ||
cancelled &&
stream.emit('error', { message: 'The download was cancelled.', type: 'DOWNLOAD_CANCELLED' }) ||
stream.emit('error', { message: err.message, type: 'DOWNLOAD_ABORTED' });
});
@@ -406,10 +809,10 @@ class Innertube {
stream.emit('start');
const downloadChunk = async () => {
(chunk_end >= selected_format.contentLength || options.range) && (must_end = true);
options.range && (selected_format.contentLength = options.range.end);
(chunk_end >= format.contentLength || options.range) && (must_end = true);
options.range && (format.contentLength = options.range.end);
const response = await Axios.get(`${selected_format.url}&range=${chunk_start}-${chunk_end || ''}`, {
const response = await Axios.get(`${format.url}&range=${chunk_start}-${chunk_end || ''}`, {
responseType: 'stream',
cancelToken: new CancelToken(function executor(c) { cancel = c; }),
headers: Constants.STREAM_HEADERS
@@ -422,17 +825,27 @@ class Innertube {
response.data.on('data', (chunk) => {
downloaded_size += chunk.length;
let size = (selected_format.contentLength / 1024 / 1024).toFixed(2);
let percentage = Math.floor((downloaded_size / selected_format.contentLength) * 100);
stream.emit('progress', { chunk_size: chunk.length, downloaded_size: (downloaded_size / 1024 / 1024).toFixed(2), percentage, size, raw_data: { chunk_size: chunk.length, downloaded: downloaded_size, size: response.headers['content-length'] } });
let size = (format.contentLength / 1024 / 1024).toFixed(2);
let percentage = Math.floor((downloaded_size / format.contentLength) * 100);
stream.emit('progress', {
size,
percentage,
chunk_size: chunk.length,
downloaded_size: (downloaded_size / 1024 / 1024).toFixed(2),
raw_data: {
chunk_size: chunk.length,
downloaded: downloaded_size,
size: response.headers['content-length']
}
});
});
response.data.on('error', (err) => {
if (cancelled) {
stream.emit('error', { message: 'The download was cancelled.', type: 'DOWNLOAD_CANCELLED' });
} else {
cancelled &&
stream.emit('error', { message: 'The download was cancelled.', type: 'DOWNLOAD_CANCELLED' }) ||
stream.emit('error', { message: err.message, type: 'DOWNLOAD_ABORTED' });
}
});
response.data.on('end', () => {
@@ -445,6 +858,7 @@ class Innertube {
response.data.pipe(stream, { end: must_end });
};
downloadChunk();
}
});

View File

@@ -1,8 +1,6 @@
'use strict';
const Axios = require('axios');
const Actions = require('./Actions');
const Constants = require('./Constants');
const EventEmitter = require('events');
class Livechat extends EventEmitter {
@@ -19,10 +17,53 @@ class Livechat extends EventEmitter {
this.poll_intervals_ms = 1000;
this.running = true;
this.poll();
this.#poll();
}
enqueueActionGroup(group) {
async #poll() {
if (!this.running) return;
const livechat = await Actions.livechat(this.session, 'live_chat/get_live_chat', { ctoken: this.ctoken });
if (!livechat.success) {
this.emit('error', { message: `Failed polling livechat: ${livechat.message}. Retrying...` });
return await this.#poll();
}
const continuation_contents = livechat.data.continuationContents;
const action_group = continuation_contents.liveChatContinuation.actions;
this.#enqueueActionGroup(action_group);
this.message_queue.forEach((message) => {
if (this.id_cache.includes(message.id)) return;
setTimeout(() => this.emit('chat-update', message), message.timestamp / 1000 - new Date().getTime());
this.id_cache.push(message.id);
});
this.message_queue = [];
const data = { video_id: this.video_id };
if (this.metadata_ctoken) data.continuation = this.metadata_ctoken;
const updated_metadata = await Actions.livechat(this.session, 'updated_metadata', data);
if (!updated_metadata.success) {
this.emit('error', { message: `Failed polling livechat metadata: ${livechat.message}.` });
}
this.metadata_ctoken = updated_metadata.data.continuation.timedContinuationData.continuation;
const metadata = updated_metadata.data.actions;
this.emit('update-metadata', {
likes: metadata[1].updateToggleButtonTextAction.defaultText.simpleText,
view_count: {
simple_text: metadata[0].updateViewershipAction.viewCount.videoViewCountRenderer.viewCount.simpleText,
short_view_count: metadata[0].updateViewershipAction.viewCount.videoViewCountRenderer.extraShortViewCount.simpleText
}
});
this.livechat_poller = setTimeout(async () => await this.#poll(), this.poll_intervals_ms);
}
#enqueueActionGroup(group) {
group.forEach((action) => {
if (!action.addChatItemAction) return; //TODO: handle different action types
const message_content = action.addChatItemAction.item.liveChatTextMessageRenderer;
@@ -43,48 +84,6 @@ class Livechat extends EventEmitter {
});
}
async poll() {
if (!this.running) return;
let data;
data = { context: this.session.context, continuation: this.ctoken };
const livechat = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/live_chat/get_live_chat${this.session.logged_in && this.session.cookie.length < 1 ? '' : `?key=${this.session.key}`}`, JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session: this.session, data, desktop: true }));
if (livechat instanceof Error) throw new Error(`Error polling livechat: ${livechat.message}`);
const continuation_contents = livechat.data.continuationContents;
const action_group = continuation_contents.liveChatContinuation.actions;
this.enqueueActionGroup(action_group);
// Why don't we just emit the message directly? Well, enqueueing the messages is necessary so they are not emitted in a “messy” way, funny enough that's exactly how YouTube does it in its live chat js script.
this.message_queue.forEach((message, index) => {
if (this.id_cache.includes(message.id)) return;
setTimeout(() => this.emit('chat-update', message), message.timestamp / 1000 - new Date().getTime());
this.id_cache.push(message.id);
});
this.message_queue = [];
data = { context: this.session.context, videoId: this.video_id };
if (this.metadata_ctoken) data.continuation = this.metadata_ctoken;
const updated_metadata = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/updated_metadata${this.session.logged_in && this.session.cookie.length < 1 ? '' : `?key=${this.session.key}`}`, JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session: this.session, data, desktop: true }));
if (updated_metadata instanceof Error) throw new Error(`Error polling updated metadata: ${updated_metadata.message}`);
this.metadata_ctoken = updated_metadata.data.continuation.timedContinuationData.continuation;
const metadata = updated_metadata.data.actions;
this.emit('update-metadata', {
likes: metadata[1].updateToggleButtonTextAction.defaultText.simpleText,
dislikes: metadata[2].updateToggleButtonTextAction.defaultText.simpleText,
view_count: {
simple_text: metadata[0].updateViewershipAction.viewCount.videoViewCountRenderer.viewCount.simpleText,
short_view_count: metadata[0].updateViewershipAction.viewCount.videoViewCountRenderer.extraShortViewCount.simpleText
}
});
this.livechat_poller = setTimeout(async () => await this.poll(), this.poll_intervals_ms);
}
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 });
if (!message.success) return message;
@@ -104,7 +103,7 @@ class Livechat extends EventEmitter {
return {
success: true,
status_code: message.status_code,
deleteMessage,
deleteMessage: deleteMessage,
message_data: {
text: message.data.actions[0].addChatItemAction.item.liveChatTextMessageRenderer.message.runs.map((item) => item.text).join(' '),
author: {
@@ -118,8 +117,12 @@ class Livechat extends EventEmitter {
};
}
/**
* Blocks a user.
* @todo Implement this method.
* @param {object} msg_params
*/
async blockUser(msg_params) {
/* TODO: Implement this */
throw new Error('Not implemented');
}

View File

@@ -6,104 +6,135 @@ const Constants = require('./Constants');
class NToken {
constructor(raw_code) {
this.raw_code = raw_code;
this.null_placeholder_regex = /c\[(.*?)\]=c/g;
this.transformation_calls_regex = /c\[(.*?)\]\((.+?)\)/g;
this.placeholders_regex = /c\[(.*?)\]=c/g;
this.calls_regex = /c\[(.*?)\]\((.+?)\)/g;
}
/**
* Solves throttling challange by transforming the n token.
*
* @param {string} n token.
* @returns {string} transformed token.
*/
transform(n) {
let n_token = n.split('');
try {
let transformations = this.getTransformationData();
let transformations = this.#getTransformationData();
transformations = transformations.map((el) => {
if (el != null && typeof el != 'number') {
const is_reverse_base64 = el.includes('case 65:');
(({ // Identifies the transformation functions and emulates them accordingly.
[Constants.FUNCS.PUSH]: () => el = (arr, i) => this.push(arr, i),
[Constants.FUNCS.SPLICE]: () => el = (arr, i) => this.splice(arr, i),
[Constants.FUNCS.SWAP0_1]: () => el = (arr, i) => this.swap0(arr, i),
[Constants.FUNCS.SWAP0_2]: () => el = (arr, i) => this.swap0(arr, i),
[Constants.FUNCS.ROTATE_1]: () => el = (arr, i) => this.rotate(arr, i),
[Constants.FUNCS.ROTATE_2]: () => el = (arr, i) => this.rotate(arr, i),
[Constants.FUNCS.REVERSE_1]: () => el = (arr) => this.reverse(arr),
[Constants.FUNCS.REVERSE_2]: () => el = (arr) => this.reverse(arr),
[Constants.FUNCS.BASE64_DIA]: () => el = () => this.getBase64Dia(is_reverse_base64),
[Constants.FUNCS.TRANSLATE_1]: () => el = (arr, token) => this.translate1(arr, token, is_reverse_base64),
[Constants.FUNCS.TRANSLATE_2]: () => el = (arr, token, base64_dic) => this.translate2(arr, token, base64_dic)
})[this.getFunc(el)] || (() => el === 'b' && (el = n_token)))();
(({ // Identifies the transformation functions
[Constants.FUNCS.PUSH]: () => el = (arr, i) => this.#push(arr, i),
[Constants.FUNCS.SPLICE]: () => el = (arr, i) => this.#splice(arr, i),
[Constants.FUNCS.SWAP0_1]: () => el = (arr, i) => this.#swap0(arr, i),
[Constants.FUNCS.SWAP0_2]: () => el = (arr, i) => this.#swap0(arr, i),
[Constants.FUNCS.ROTATE_1]: () => el = (arr, i) => this.#rotate(arr, i),
[Constants.FUNCS.ROTATE_2]: () => el = (arr, i) => this.#rotate(arr, i),
[Constants.FUNCS.REVERSE_1]: () => el = (arr) => this.#reverse(arr),
[Constants.FUNCS.REVERSE_2]: () => el = (arr) => this.#reverse(arr),
[Constants.FUNCS.BASE64_DIA]: () => el = () => this.#getBase64Dia(is_reverse_base64),
[Constants.FUNCS.TRANSLATE_1]: () => el = (arr, token) => this.#translate1(arr, token, is_reverse_base64),
[Constants.FUNCS.TRANSLATE_2]: () => el = (arr, token, base64_dic) => this.#translate2(arr, token, base64_dic)
})[this.#getFunc(el)] || (() => el === 'b' && (el = n_token)))();
}
return el;
});
// Fills the null placeholders with a copy of the transformations array.
let null_placeholder_positions = [...this.raw_code.matchAll(this.null_placeholder_regex)].map((item) => parseInt(item[1]));
null_placeholder_positions.forEach((pos) => transformations[pos] = transformations);
// Fills all placeholders with the transformations array
const placeholder_indexes = [...this.raw_code.matchAll(this.placeholders_regex)].map((item) => parseInt(item[1]));
placeholder_indexes.forEach((i) => transformations[i] = transformations);
// Parses and emulates calls to functions of the transformations array.
let transformation_calls = [...Utils.getStringBetweenStrings(this.raw_code.replace(/\n/g, ''), 'try{', '}catch').matchAll(this.transformation_calls_regex)].map((params) => ({ index: params[1], params: params[2] }));
transformation_calls.forEach((data) => {
// Parses and emulates calls to the functions of the transformations array
const function_calls = [...Utils.getStringBetweenStrings(this.raw_code.replace(/\n/g, ''), 'try{', '}catch')
.matchAll(this.calls_regex)].map((params) => ({ index: params[1], params: params[2] }));
function_calls.forEach((data) => {
const param_index = data.params.split(',').map((param) => param.match(/c\[(.*?)\]/)[1]);
const base64_dia = (param_index[2] && transformations[param_index[2]]());
transformations[data.index](transformations[param_index[0]], transformations[param_index[1]], base64_dia);
});
} catch (err) {
console.error('Could not transform n-token, download may be throttled:', err);
console.error(`Could not transform n-token (${n}), download may be throttled:`, err.message);
return n;
}
return n_token.join('');
}
getFunc(el) {
#getFunc(el) {
return el.match(Constants.FUNCS_REGEX);
}
getTransformationData() {
/**
* Takes the n-transform data, refines it, and then returns a readable json array.
* @returns {object}
*/
#getTransformationData() {
const data = `[${Utils.getStringBetweenStrings(this.raw_code.replace(/\n/g, ''), 'c=[', '];c')}]`;
return JSON.parse(Constants.formatNTransformData(data));
return JSON.parse(Utils.refineNTokenData(data));
}
translate1(arr, token, is_reverse_base64) {
/**
* Gets a base64 alphabet and uses it as a lookup table to modify n.
* @returns
*/
#translate1(arr, token, is_reverse_base64) {
const characters = is_reverse_base64 && Constants.BASE64_DIALECT.REVERSE || Constants.BASE64_DIALECT.NORMAL;
arr.forEach(function(char, index, loc) {
arr.forEach(function (char, index, loc) {
this.push(loc[index] = characters[(characters.indexOf(char) - characters.indexOf(this[index]) + 64) % characters.length]);
}, token.split(''));
}
translate2(arr, token, characters) {
#translate2(arr, token, characters) {
let chars_length = characters.length;
arr.forEach(function(char, index, loc) {
arr.forEach(function (char, index, loc) {
this.push(loc[index] = characters[(characters.indexOf(char) - characters.indexOf(this[index]) + index + chars_length--) % characters.length]);
}, token.split(''));
}
getBase64Dia(is_reverse_base64) {
/**
* Returns the requested base64 dialect, currently this is only used by 'translate2'.
* @returns {string[]}
*/
#getBase64Dia(is_reverse_base64) {
const characters = is_reverse_base64 && Constants.BASE64_DIALECT.REVERSE || Constants.BASE64_DIALECT.NORMAL;
return characters;
}
swap0(arr, index) {
const old_value = arr[0];
/**
* Swaps the first element with the one at the given index.
* @returns
*/
#swap0(arr, index) {
const old_elem = arr[0];
index = (index % arr.length + arr.length) % arr.length;
arr[0] = arr[index];
arr[index] = old_value;
arr[index] = old_elem;
}
rotate(arr, index) {
/**
* Rotates elements of the array.
* @returns
*/
#rotate(arr, index) {
index = (index % arr.length + arr.length) % arr.length;
arr.splice(-index).reverse().forEach((el) => arr.unshift(el));
}
splice(arr, index) {
/**
* Deletes one element at the given index.
* @returns
*/
#splice(arr, index) {
index = (index % arr.length + arr.length) % arr.length;
arr.splice(index, 1);
}
reverse(arr) {
#reverse(arr) {
arr.reverse();
}
push(arr, item) {
#push(arr, item) {
arr.push(item);
}
}

View File

@@ -1,7 +1,6 @@
'use strict';
const Axios = require('axios');
const Utils = require('./Utils');
const Constants = require('./Constants');
const EventEmitter = require('events');
const Uuid = require('uuid');
@@ -9,6 +8,7 @@ const Uuid = require('uuid');
class OAuth extends EventEmitter {
constructor(auth_info) {
super();
this.auth_info = auth_info;
this.refresh_interval = 5;
this.oauth_code_url = `${Constants.URLS.YT_BASE_URL}/o/oauth2/device/code`;
@@ -20,14 +20,18 @@ class OAuth extends EventEmitter {
this.scope = Constants.OAUTH.SCOPE;
this.auth_script_regex = /<script id=\"base-js\" src=\"(.*?)\" nonce=".*?"><\/script>/;
this.identity_regex = /var .+?=\"(?<id>.+?)\",.+?=\"(?<secret>.+?)\"/;
this.identity_regex = /.+?={};var .+?={clientId:\"(?<id>.+?)\",.+?:\"(?<secret>.+?)\"},/;
if (auth_info.access_token) return;
this.requestAuthCode();
this.#requestAuthCode();
}
async requestAuthCode() {
const identity = await this.getClientIdentity();
/**
* Asks the OAuth server for an auth code.
* @returns {Promise.<void>}
*/
async #requestAuthCode() {
const identity = await this.#getClientIdentity();
this.client_id = identity.id;
this.client_secret = identity.secret;
@@ -56,11 +60,16 @@ class OAuth extends EventEmitter {
this.refresh_interval = response.data.interval;
// Keeps requesting at a specific rate until the authorization is granted or denied.
this.waitForAuth(response.data.device_code);
this.#waitForAuth(response.data.device_code);
}
waitForAuth(device_code) {
/**
* Waits for sign-in authorization.
*
* @param {string} device_code Client's device code.
* @returns
*/
#waitForAuth(device_code) {
const data = {
client_id: this.client_id,
client_secret: this.client_secret,
@@ -80,7 +89,7 @@ class OAuth extends EventEmitter {
switch (response.data.error) {
case 'slow_down':
case 'authorization_pending':
this.waitForAuth(device_code);
this.#waitForAuth(device_code);
break;
case 'access_denied':
this.emit('auth', {
@@ -93,7 +102,7 @@ class OAuth extends EventEmitter {
error: 'The device code has expired, requesting a new one.',
status: 'DEVICE_CODE_EXPIRED'
});
this.requestAuthCode();
this.#requestAuthCode();
break;
default:
}
@@ -113,13 +122,17 @@ class OAuth extends EventEmitter {
}, 1000 * this.refresh_interval);
}
async refreshAccessToken(refresh_token) {
const identity = await this.getClientIdentity();
/**
* Gets a new access token using a refresh token.
* @returns {object.<{ credentials: { access_token: string; refresh_token: string; expires: Date }; status: string }>}
*/
async refreshAccessToken() {
const identity = await this.#getClientIdentity();
const data = {
client_id: identity.id,
client_secret: identity.secret,
refresh_token,
refresh_token: this.auth_info.refresh_token,
grant_type: 'refresh_token',
};
@@ -144,7 +157,7 @@ class OAuth extends EventEmitter {
return {
credentials: {
refresh_token: refresh_token,
refresh_token: this.auth_info.refresh_token,
access_token: response.data.access_token,
expires: expiration_date
},
@@ -153,29 +166,35 @@ class OAuth extends EventEmitter {
};
}
async isTokenValid(expiration_date) {
const timestamp = new Date(expiration_date).getTime();
const is_valid = new Date().getTime() < timestamp;
return is_valid;
}
async getClientIdentity() {
// The first request is made to get the auth script url, hard-coding it isn't viable as it changes overtime.
/**
* Gets client identity data.
* @returns {Promise.<{ id: string; secret: string }>}
*/
async #getClientIdentity() {
// This request is made to get the auth script url, hard-coding it isn't viable as it changes overtime.
const yttv_response = await Axios.get(`${Constants.URLS.YT_BASE_URL}/tv`, Constants.OAUTH.HEADERS).catch((error) => error);
if (yttv_response instanceof Error) throw new Error(`Could not extract client identity: ${yttv_response.message}`);
// Here we get the script and extract the necessary data to proceed with the auth flow.
// Here we download the script and extract the necessary data to proceed with the auth flow.
const url_body = this.auth_script_regex.exec(yttv_response.data)[1];
const script_url = `${Constants.URLS.YT_BASE_URL}/${url_body}`;
const response = await Axios.get(script_url, Constants.DEFAULT_HEADERS).catch((error) => error);
if (response instanceof Error) throw new Error(`Could not extract client identity: ${response.message}`);
const identity_function = Utils.getStringBetweenStrings(response.data, 'setQuery("");', '{useGaiaSandbox:');
const client_identity = identity_function.replace(/\n/g, '').match(this.identity_regex);
const client_identity = response.data.replace(/\n/g, '').match(this.identity_regex);
return client_identity.groups;
}
/**
* Checks access token validity.
* @returns {boolean} true | false
*/
isTokenValid() {
const timestamp = new Date(this.auth_info.expires).getTime();
const is_valid = new Date().getTime() < timestamp;
return is_valid;
}
}
module.exports = OAuth;

287
lib/Parser.js Normal file
View File

@@ -0,0 +1,287 @@
'use strict';
const Utils = require('./Utils');
const Constants = require('./Constants');
/**
* Takes raw data from the Innertube API and refines it.
* Mainly used for video data and search results, as those are more complex to parse.
*/
class Parser {
constructor(session, data, args = {}) {
this.session = session;
this.data = data;
this.args = args;
}
parse() {
return this.args.client === 'YOUTUBE' ? ({
SEARCH: () => this.#parseVideoSearch(),
PLAYLIST: () => this.#parsePlaylist(),
VIDEO_INFO: () => this.#parseVideoInfo()
})[this.args.data_type]() : ({
SEARCH: () => this.#parseMusicSearch(),
PLAYLIST: () => this.#parseMusicPlaylist()
})[this.args.data_type]();
}
#parseVideoSearch() {
const response = {};
const contents = this.data.contents.twoColumnSearchResultsRenderer
.primaryContents.sectionListRenderer.contents[0].itemSectionRenderer
.contents;
// TODO: Implement search continuation
// const continuation_token = this.data.contents.twoColumnSearchResultsRenderer
// .primaryContents.sectionListRenderer.contents[1].continuationItemRenderer
// .continuationEndpoint.continuationCommand.token;
response.query = contents[0].showingResultsForRenderer && contents[0].showingResultsForRenderer.originalQuery.simpleText || this.args.query;
response.corrected_query = contents[0].showingResultsForRenderer && contents[0].showingResultsForRenderer.correctedQueryEndpoint.searchEndpoint.query || this.args.query;
response.estimated_results = parseInt(this.data.estimatedResults);
response.videos = contents.map((data) => {
if (!data.videoRenderer) return;
const video = data.videoRenderer;
return {
title: video.title.runs[0].text,
description: video.detailedMetadataSnippets && video.detailedMetadataSnippets[0].snippetText.runs.map((item) => item.text).join('') || 'N/A',
author: video.ownerText.runs[0].text,
id: video.videoId,
url: `https://youtu.be/${video.videoId}`,
channel_url: `${Constants.URLS.YT_BASE_URL}${video.ownerText.runs[0].navigationEndpoint.commandMetadata.webCommandMetadata.url}`,
metadata: {
view_count: video.viewCountText && video.viewCountText.simpleText || 'N/A',
short_view_count_text: {
simple_text: video.shortViewCountText && video.shortViewCountText.simpleText || 'N/A',
accessibility_label: video.shortViewCountText && (video.shortViewCountText.accessibility && video.shortViewCountText.accessibility.accessibilityData.label || 'N/A') || 'N/A',
},
thumbnails: video.thumbnail.thumbnails,
duration: {
seconds: Utils.timeToSeconds(video.lengthText && video.lengthText.simpleText || '0'),
simple_text: video.lengthText && video.lengthText.simpleText || 'N/A',
accessibility_label: video.lengthText && video.lengthText.accessibility.accessibilityData.label || 'N/A'
},
published: video.publishedTimeText && video.publishedTimeText.simpleText || 'N/A',
badges: video.badges && video.badges.map((item) => item.metadataBadgeRenderer.label) || 'N/A',
owner_badges: video.ownerBadges && video.ownerBadges.map((item) => item.metadataBadgeRenderer.tooltip) || 'N/A '
}
};
}).filter((video) => video);
return response;
}
#parsePlaylist() {
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,
views: details.playlistSidebarPrimaryInfoRenderer.stats[1].simpleText
}
const playlist_content = this.data.contents.twoColumnBrowseResultsRenderer.tabs[0]
.tabRenderer.content.sectionListRenderer.contents[0]
.itemSectionRenderer.contents[0].playlistVideoListRenderer.contents;
const items = playlist_content.map((item) => {
if (item.playlistVideoRenderer)
return {
id: item.playlistVideoRenderer.videoId,
title: item.playlistVideoRenderer.title.runs[0].text,
author: item.playlistVideoRenderer.shortBylineText.runs[0].text,
duration: {
seconds: Utils.timeToSeconds(item.playlistVideoRenderer.lengthText && item.playlistVideoRenderer.lengthText.simpleText || '0'),
simple_text: item.playlistVideoRenderer.lengthText && item.playlistVideoRenderer.lengthText.simpleText || 'N/A',
accessibility_label: item.playlistVideoRenderer.lengthText && item.playlistVideoRenderer.lengthText.accessibility.accessibilityData.label || 'N/A'
},
thumbnail: item.playlistVideoRenderer.thumbnail.thumbnails,
}
});
return {
...metadata,
items
}
}
#parseMusicSearch() {
const tabs = this.data.contents.tabbedSearchResultsRenderer.tabs;
const contents = tabs[0].tabRenderer.content.sectionListRenderer.contents;
if (contents.length <= 1)
return { songs: [], videos: [], albums: [], playlists: [] };
const songs_ms = contents.find((content) => content.musicShelfRenderer.title.runs[0].text == 'Songs');
const songs = songs_ms.musicShelfRenderer.contents.map((item) => {
const list_item = item.musicResponsiveListItemRenderer;
if (list_item.playlistItemData)
return {
id: list_item.playlistItemData.videoId,
title: list_item.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text,
artist: list_item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[2].text,
album: list_item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[4].text,
duration: list_item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[6].text,
thumbnail: list_item.thumbnail.musicThumbnailRenderer.thumbnail,
getLyrics: () => this.session.getLyrics(list_item.playlistItemData.videoId)
};
}).filter((item) => item); // Filters out undefined items, which are usually generated by unavailable videos.
const videos_ms = contents.find((content) => content.musicShelfRenderer.title.runs[0].text == 'Videos');
const videos = videos_ms.musicShelfRenderer.contents.map((item) => {
const list_item = item.musicResponsiveListItemRenderer;
if (list_item.playlistItemData)
return {
id: list_item.playlistItemData.videoId,
title: list_item.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text,
author: list_item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[2].text,
views: list_item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[4].text,
duration: list_item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[6].text,
thumbnail: list_item.thumbnail.musicThumbnailRenderer.thumbnail,
getLyrics: () => this.session.getLyrics(list_item.playlistItemData.videoId)
};
}).filter((item) => item);
const albums_ms = contents.find((content) => content.musicShelfRenderer.title.runs[0].text == 'Albums');
const albums = albums_ms.musicShelfRenderer.contents.map((item) => {
const list_item = item.musicResponsiveListItemRenderer;
return {
title: list_item.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text,
author: list_item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[2].text,
year: list_item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs.find((run) => /^[12][0-9]{3}$/.test(run.text)).text,
thumbnail: list_item.thumbnail.musicThumbnailRenderer.thumbnail,
};
});
const playlists_ms = contents.find((content) => content.musicShelfRenderer.title.runs[0].text == 'Community playlists');
const playlists = playlists_ms.musicShelfRenderer.contents.map((item) => {
const list_item = item.musicResponsiveListItemRenderer;
const watch_playlist_endpoint = list_item.overlay.musicItemThumbnailOverlayRenderer.content.musicPlayButtonRenderer
.playNavigationEndpoint.watchPlaylistEndpoint;
return {
id: watch_playlist_endpoint.playlistId,
title: list_item.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text,
author: list_item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[2].text,
channel_id: list_item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[2].navigationEndpoint.browseEndpoint.browseId,
total_items: parseInt(list_item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[4].text.match(/\d+/g)),
};
});
return { songs, videos, albums, playlists };
}
#parseMusicPlaylist() {
const details = this.data.header.musicDetailHeaderRenderer;
const metadata = {
title: details.title.runs[0].text,
description: details.description && details.description.runs.map((run) => run.text).join('') || 'N/A',
total_items: parseInt(details.secondSubtitle.runs[0].text.match(/\d+/g)),
duration: details.secondSubtitle.runs[2].text,
year: details.subtitle.runs[4].text
};
const contents = this.data.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents;
const playlist_content = contents[0].musicPlaylistShelfRenderer.contents;
const items = playlist_content.map((item) => {
const item_renderer = item.musicResponsiveListItemRenderer;
const fixed_columns = item_renderer.fixedColumns;
const flex_columns = item_renderer.flexColumns;
return {
id: item_renderer.playlistItemData && item_renderer.playlistItemData.videoId,
title: flex_columns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text,
author: flex_columns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text,
duration: fixed_columns[0].musicResponsiveListItemFixedColumnRenderer.text.runs[0].text,
thumbnail: item_renderer.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails
}
}).filter((item) => item.id);
return {
...metadata,
items
}
}
#parseVideoInfo() {
const desktop_v = this.args.desktop_v;
const playability_status = desktop_v && this.data.playabilityStatus ||
this.data[2].playerResponse.playabilityStatus;
if (playability_status.status == 'ERROR')
throw new Error(`Could not retrieve details for this video: ${playability_status.status} - ${playability_status.reason}`);
const details = desktop_v && this.data.videoDetails ||
this.data[2].playerResponse.videoDetails;
const microformat = desktop_v && this.data.microformat.playerMicroformatRenderer ||
this.data[2].playerResponse.microformat.playerMicroformatRenderer;
const streaming_data = desktop_v && this.data.streamingData ||
this.data[2].playerResponse.streamingData;
const response = {
id: '',
title: '',
description: '',
thumbnail: [],
metadata: {}
};
const mf_raw_data = Object.entries(microformat);
const dt_raw_data = Object.entries(details);
mf_raw_data.forEach((entry) => {
const key = Utils.camelToSnake(entry[0]);
if (Constants.METADATA_KEYS.includes(key)) {
key == 'view_count' && (response.metadata[key] = parseInt(entry[1])) ||
key == 'owner_profile_url' && (response.metadata.channel_url = entry[1]) ||
key == 'owner_channel_name' && (response.metadata.channel_name = entry[1]) ||
(response.metadata[key] = entry[1]);
} else {
response[key] = entry[1];
}
});
dt_raw_data.forEach((entry) => {
const key = Utils.camelToSnake(entry[0]);
if (Constants.BLACKLISTED_KEYS.includes(key)) return;
if (Constants.METADATA_KEYS.includes(key)) {
key == 'view_count' && (response.metadata[key] = parseInt(entry[1])) ||
(response.metadata[key] = entry[1]);
} else {
key == 'short_description' && (response.description = entry[1]) ||
key == 'thumbnail' && (response.thumbnail = entry[1].thumbnails.slice(-1)[0]) ||
key == 'video_id' && (response.id = entry[1]) ||
(response[key] = entry[1]);
}
});
if (!desktop_v) {
const dislike_available = this.data[3].response.contents.singleColumnWatchNextResults.results.results.contents[1]
.slimVideoMetadataSectionRenderer.contents[1].slimVideoActionBarRenderer.buttons[1].slimMetadataToggleButtonRenderer
.button.toggleButtonRenderer.defaultText.accessibility && true || false;
response.metadata.likes = parseInt(this.data[3].response.contents.singleColumnWatchNextResults.results.results.contents[1]
.slimVideoMetadataSectionRenderer.contents[1].slimVideoActionBarRenderer.buttons[0].slimMetadataToggleButtonRenderer
.button.toggleButtonRenderer.defaultText.accessibility.accessibilityData.label.replace(/\D/g, ''));
response.metadata.dislikes = dislike_available && parseInt(this.data[3].response.contents.singleColumnWatchNextResults.results.results.contents[1]
.slimVideoMetadataSectionRenderer.contents[1].slimVideoActionBarRenderer.buttons[1].slimMetadataToggleButtonRenderer
.button.toggleButtonRenderer.defaultText.accessibility.accessibilityData.label.replace(/\D/g, '')) || 0;
}
response.metadata.available_qualities = [...new Set(streaming_data.adaptiveFormats.filter(v => v.qualityLabel)
.map(v => v.qualityLabel).sort((a, b) => + a.replace(/\D/gi, '') - + b.replace(/\D/gi, '')))];
return response;
}
}
module.exports = Parser;

View File

@@ -1,45 +1,48 @@
'use strict';
const fs = require('fs');
const Fs = require('fs');
const Axios = require('axios');
const Utils = require('./Utils');
const Constants = require('./Constants');
class Player {
constructor(innertube_session) {
this.session = innertube_session;
constructor(session) {
this.session = session;
this.player_name = Utils.getStringBetweenStrings(this.session.player_url, '/player/', '/');
this.tmp_cache_dir = __dirname.slice(0, -3) + 'cache';
}
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.getSigDecipherCode(player_data);
this.getNEncoder(player_data);
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);
} else {
const response = await Axios.get(`${Constants.URLS.YT_BASE_URL}${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);
try {
// 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);
} catch (err) {}
// Deletes old players
Fs.existsSync(this.tmp_cache_dir) && Fs.rmSync(this.tmp_cache_dir, { recursive: true });
this.getSigDecipherCode(response.data);
this.getNEncoder(response.data);
// 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);
} catch (err) { }
this.sig_decipher_sc = this.#getSigDecipherCode(response.data);
this.ntoken_sc = this.#getNEncoder(response.data);
}
}
getSigDecipherCode(data) {
const manipulation_algorithm_code = Utils.getStringBetweenStrings(data, 'this.audioTracks};var', '};');
const manipulation_sequence_code = Utils.getStringBetweenStrings(data, 'function(a){a=a.split("")', 'return a.join("")}');
this.sig_decipher_sc = manipulation_algorithm_code + manipulation_sequence_code;
#getSigDecipherCode(data) {
const sig_alg_sc = Utils.getStringBetweenStrings(data, 'this.audioTracks};var', '};');
const sig_data = Utils.getStringBetweenStrings(data, 'function(a){a=a.split("")', 'return a.join("")}');
return sig_alg_sc + sig_data;
}
getNEncoder(data) {
this.ntoken_sc = `var b=a.split("")${Utils.getStringBetweenStrings(data, 'b=a.split("")', '}return b.join("")}')}} return b.join("");`;
#getNEncoder(data) {
return `var b=a.split("")${Utils.getStringBetweenStrings(data, 'b=a.split("")', '}return b.join("")}')}} return b.join("");`;
}
}

View File

@@ -1,20 +1,21 @@
'use strict';
const NToken = require('./NToken');
const QueryString = require('querystring');
class SigDecipher {
constructor(url, cver, player) {
constructor(url, player) {
this.url = url;
this.cver = cver;
this.player = player;
this.func_regex = /(.{2}):function\(.*?\){(.*?)}/g;
this.actions_regex = /;.{2}\.(.{2})\(.*?,(.*?)\)/g;
}
/**
* Deciphers signature.
*/
decipher() {
const args = QueryString.parse(this.url);
const functions = this.getFunctions();
const functions = this.#getFunctions();
function splice(arr, end) {
arr.splice(0, end);
@@ -49,14 +50,11 @@ class SigDecipher {
}
const url_components = new URL(args.url);
args.sp !== undefined ? url_components.searchParams.set(args.sp, signature.join('')) : url_components.searchParams.set('signature', signature.join(''));
url_components.searchParams.set('cver', this.cver);
url_components.searchParams.set('ratebypass', 'yes');
url_components.searchParams.set('n', new NToken(this.player.ntoken_sc).transform(url_components.searchParams.get('n')));
args.sp ? url_components.searchParams.set(args.sp, signature.join('')) : url_components.searchParams.set('signature', signature.join(''));
return url_components.toString();
}
getFunctions() {
#getFunctions() {
let func;
let func_name = [];

View File

@@ -5,6 +5,12 @@ const Proto = require('protons');
const Crypto = require('crypto');
const UserAgent = require('user-agents');
/**
* Returns a random user agent.
*
* @param {string} type - mobile | desktop
* @returns {object}
*/
function getRandomUserAgent(type) {
switch (type) {
case 'mobile':
@@ -15,6 +21,12 @@ function getRandomUserAgent(type) {
}
}
/**
* Generates an authentication token from a cookies' sid.
*
* @param {string} sid - Sid extracted from cookies
* @returns {string}
*/
function generateSidAuth(sid) {
const youtube = 'https://www.youtube.com';
const timestamp = Math.floor(new Date().getTime() / 1000);
@@ -27,6 +39,13 @@ function generateSidAuth(sid) {
return ['SAPISIDHASH', [timestamp, gen_hash].join('_')].join(' ');
}
/**
* Gets a string between two delimiters.
*
* @param {string} data - The data.
* @param {string} start_string - Start string.
* @param {string} end_string - End string.
*/
function getStringBetweenStrings(data, start_string, end_string) {
const regex = new RegExp(`${escapeStringRegexp(start_string)}(.*?)${escapeStringRegexp(end_string)}`, "s");
const match = data.match(regex);
@@ -37,6 +56,38 @@ function escapeStringRegexp(string) {
return string.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&").replace(/-/g, "\\x2d");
}
/**
* Converts time (h:m:s) to seconds.
*
* @param {string} time
* @returns {number} seconds
*/
function timeToSeconds(time) {
let params = time.split(':');
return parseInt(({
3: +params[0] * 3600 + +params[1] * 60 + +params[2],
2: +params[0] * 60 + +params[1],
1: +params[0]
})[params.length]);
}
/**
* Converts strings in camelCase to snake_case.
*
* @param {string} string The string in camelCase.
* @returns {string}
*/
function camelToSnake(string) {
return string[0].toLowerCase() + string.slice(1, string.length).replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
}
/**
* Encodes notification preferences protobuf.
*
* @param {string} channel_id
* @param {string} index
* @returns {string}
*/
function encodeNotificationPref(channel_id, index) {
const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/proto/youtube.proto`));
@@ -52,7 +103,14 @@ function encodeNotificationPref(channel_id, index) {
return encodeURIComponent(Buffer.from(buf).toString('base64'));
}
function generateMessageParams(channel_id, video_id) {
/**
* Encodes livestream message protobuf.
*
* @param {string} channel_id
* @param {string} video_id
* @returns {string}
*/
function encodeMessageParams(channel_id, video_id) {
const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/proto/youtube.proto`));
const buf = youtube_proto.LiveMessageParams.encode({
@@ -69,7 +127,13 @@ function generateMessageParams(channel_id, video_id) {
return Buffer.from(encodeURIComponent(Buffer.from(buf).toString('base64'))).toString('base64');
}
function generateCommentParams(video_id) {
/**
* Encodes comment params protobuf.
*
* @param {string} video_id
* @returns {string}
*/
function encodeCommentParams(video_id) {
const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/proto/youtube.proto`));
const buf = youtube_proto.CreateCommentParams.encode({
@@ -83,6 +147,14 @@ function generateCommentParams(video_id) {
return encodeURIComponent(Buffer.from(buf).toString('base64'));
}
/**
* Encodes search filter protobuf
*
* @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 encodeFilter(period, duration, order) {
const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/proto/youtube.proto`));
@@ -102,4 +174,20 @@ function encodeFilter(period, duration, order) {
return encodeURIComponent(Buffer.from(search_filter_buff).toString('base64'));
}
module.exports = { getRandomUserAgent, generateSidAuth, getStringBetweenStrings, generateMessageParams, generateCommentParams, encodeNotificationPref, encodeFilter };
/**
* Turns the ntoken transform data into a valid json array
*
* @param {string} data
* @returns {string}
*/
function refineNTokenData(data) {
return data
.replace(/function\(d,e\)/g, '"function(d,e)').replace(/function\(d\)/g, '"function(d)')
.replace(/function\(\)/g, '"function()').replace(/function\(d,e,f\)/g, '"function(d,e,f)')
.replace(/\[function\(d,e,f\)/g, '["function(d,e,f)').replace(/,b,/g, ',"b",')
.replace(/,b/g, ',"b"').replace(/b,/g, '"b",').replace(/b]/g, '"b"]')
.replace(/\[b/g, '["b"').replace(/}]/g, '"]').replace(/},/g, '}",')
.replace(/""/g, '').replace(/length]\)}"/g, 'length])}');
}
module.exports = { getRandomUserAgent, generateSidAuth, getStringBetweenStrings, camelToSnake, timeToSeconds, encodeMessageParams, encodeCommentParams, encodeNotificationPref, encodeFilter, refineNTokenData };

57
package-lock.json generated
View File

@@ -1,17 +1,16 @@
{
"name": "youtubei.js",
"version": "1.2.8",
"version": "1.3.5",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "youtubei.js",
"version": "1.2.8",
"version": "1.3.0",
"license": "MIT",
"dependencies": {
"axios": "^0.21.4",
"protons": "^2.0.3",
"time-to-seconds": "^1.1.5",
"user-agents": "^1.0.778",
"uuid": "^8.3.2"
}
@@ -54,9 +53,9 @@
}
},
"node_modules/follow-redirects": {
"version": "1.14.6",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.6.tgz",
"integrity": "sha512-fhUl5EwSJbbl8AR+uYL2KQDxLkdSjZGR36xy46AO7cOMTrCMON6Sa28FmAnC2tRTDbd/Uuzz3aJBv7EBN7JH8A==",
"version": "1.14.8",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.8.tgz",
"integrity": "sha512-1x0S9UVJHsQprFcEC/qnNzBLcIxsjAV905f/UkQxbclCsoTWlacCNOpQa/anodLl2uaEKFhfWOvM2Qg77+15zA==",
"funding": [
{
"type": "individual",
@@ -78,9 +77,9 @@
"integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8="
},
"node_modules/multiformats": {
"version": "9.5.4",
"resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.5.4.tgz",
"integrity": "sha512-MFT8e8BOLX7OZKfSBGm13FwYvJVI6MEcZ7hujUCpyJwvYyrC1anul50A0Ee74GdeJ77aYTO6YU1vO+oF8NqSIw=="
"version": "9.6.4",
"resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.6.4.tgz",
"integrity": "sha512-fCCB6XMrr6CqJiHNjfFNGT0v//dxOBMrOMqUIzpPc/mmITweLEyhvMpY9bF+jZ9z3vaMAau5E8B68DW77QMXkg=="
},
"node_modules/protocol-buffers-schema": {
"version": "3.6.0",
@@ -106,15 +105,6 @@
"varint": "~5.0.0"
}
},
"node_modules/time-to-seconds": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/time-to-seconds/-/time-to-seconds-1.1.5.tgz",
"integrity": "sha512-mpzJDHGF4VdhiahyusCUSy+BWJdN3q8Cluzfy0n7GMU9IIj+HJDX9bbbr7wVSUiqmRn1vqhhfECgdfj+SByu2A==",
"engines": {
"node": ">=15.0.1",
"vscode": "^1.22.0"
}
},
"node_modules/uint8arrays": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.0.0.tgz",
@@ -137,9 +127,9 @@
}
},
"node_modules/user-agents": {
"version": "1.0.869",
"resolved": "https://registry.npmjs.org/user-agents/-/user-agents-1.0.869.tgz",
"integrity": "sha512-fjHZWYMi0CBF8ni/TIcCUFB7GiTTT+UcCAxT9/kNbPd/d5PR9VZ0wOeWZ5McbdJ42PRdFWc1ZZUMJIvpNSsewg==",
"version": "1.0.943",
"resolved": "https://registry.npmjs.org/user-agents/-/user-agents-1.0.943.tgz",
"integrity": "sha512-0qfnGXlAO9jbfiFAnhKG3CIWxyfr7s5WgwVlV7ns2jnl8BwQI0rTJ4bdx0HR7FasInT9TCvsulmZgLgBQHbkZA==",
"dependencies": {
"dot-json": "^1.2.2",
"lodash.clonedeep": "^4.5.0"
@@ -189,9 +179,9 @@
}
},
"follow-redirects": {
"version": "1.14.6",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.6.tgz",
"integrity": "sha512-fhUl5EwSJbbl8AR+uYL2KQDxLkdSjZGR36xy46AO7cOMTrCMON6Sa28FmAnC2tRTDbd/Uuzz3aJBv7EBN7JH8A=="
"version": "1.14.8",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.8.tgz",
"integrity": "sha512-1x0S9UVJHsQprFcEC/qnNzBLcIxsjAV905f/UkQxbclCsoTWlacCNOpQa/anodLl2uaEKFhfWOvM2Qg77+15zA=="
},
"lodash.clonedeep": {
"version": "4.5.0",
@@ -199,9 +189,9 @@
"integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8="
},
"multiformats": {
"version": "9.5.4",
"resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.5.4.tgz",
"integrity": "sha512-MFT8e8BOLX7OZKfSBGm13FwYvJVI6MEcZ7hujUCpyJwvYyrC1anul50A0Ee74GdeJ77aYTO6YU1vO+oF8NqSIw=="
"version": "9.6.4",
"resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.6.4.tgz",
"integrity": "sha512-fCCB6XMrr6CqJiHNjfFNGT0v//dxOBMrOMqUIzpPc/mmITweLEyhvMpY9bF+jZ9z3vaMAau5E8B68DW77QMXkg=="
},
"protocol-buffers-schema": {
"version": "3.6.0",
@@ -227,11 +217,6 @@
"varint": "~5.0.0"
}
},
"time-to-seconds": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/time-to-seconds/-/time-to-seconds-1.1.5.tgz",
"integrity": "sha512-mpzJDHGF4VdhiahyusCUSy+BWJdN3q8Cluzfy0n7GMU9IIj+HJDX9bbbr7wVSUiqmRn1vqhhfECgdfj+SByu2A=="
},
"uint8arrays": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.0.0.tgz",
@@ -254,9 +239,9 @@
}
},
"user-agents": {
"version": "1.0.869",
"resolved": "https://registry.npmjs.org/user-agents/-/user-agents-1.0.869.tgz",
"integrity": "sha512-fjHZWYMi0CBF8ni/TIcCUFB7GiTTT+UcCAxT9/kNbPd/d5PR9VZ0wOeWZ5McbdJ42PRdFWc1ZZUMJIvpNSsewg==",
"version": "1.0.943",
"resolved": "https://registry.npmjs.org/user-agents/-/user-agents-1.0.943.tgz",
"integrity": "sha512-0qfnGXlAO9jbfiFAnhKG3CIWxyfr7s5WgwVlV7ns2jnl8BwQI0rTJ4bdx0HR7FasInT9TCvsulmZgLgBQHbkZA==",
"requires": {
"dot-json": "^1.2.2",
"lodash.clonedeep": "^4.5.0"
@@ -273,4 +258,4 @@
"integrity": "sha512-lKxKYG6H03yCZUpAGOPOsMcGxd1RHCu1iKvEHYDPmTyq2HueGhD73ssNBqqQWfvYs04G9iUFRvmAVLW20Jw6ow=="
}
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "youtubei.js",
"version": "1.2.8",
"version": "1.3.5",
"description": "An object-oriented library that allows you to search, get detailed info about videos, subscribe, unsubscribe, like, dislike, comment, download videos and much more!",
"main": "index.js",
"scripts": {
@@ -15,7 +15,6 @@
"dependencies": {
"axios": "^0.21.4",
"protons": "^2.0.3",
"time-to-seconds": "^1.1.5",
"user-agents": "^1.0.778",
"uuid": "^8.3.2"
},
@@ -24,23 +23,24 @@
"url": "git+https//github.com/LuanRT/YouTube.js.git"
},
"keywords": [
"yt",
"youtube",
"youtube-dl",
"youtubedl",
"youtube-downloader",
"innertube",
"innertubeapi",
"livechat",
"dl",
"api",
"search",
"like",
"dislike",
"search",
"comment",
"automation",
"downloader",
"comments-section",
"youtube-downloader"
"downloader"
],
"bugs": {
"url": "https://github.com/LuanRT/YouTube.js/issues"
},
"homepage": "https://github.com/LuanRT/YouTube.js#readme"
}
}

View File

@@ -2,12 +2,11 @@
module.exports = {
test_url: 's=t%3DQ%3DAv2TLJ2sbQFV5msp4j7v71gS1rsXNd6QH2V1KpxGlaOD%3DIC46mVzTVTW_2zttE32HKH7XO1jkyfOJs58avqMLKdvRdgIQRw8JQ0qOA&sp=sig&url=https://r1---sn-hxtxgcg-8qjl.googlevideo.com/videoplayback%3Fexpire%3D1635863482%26ei%3DWveAYdqsB6KPobIPjtWwYA%26ip%3D128.201.98.50%26id%3Do-ABuHwkfRnd4hOQoDKRKn7ZHvuLEPAPKkYhiYKpTwLrY7%26itag%3D18%26source%3Dyoutube%26requiressl%3Dyes%26mh%3DG3%26mm%3D31%252C29%26mn%3Dsn-hxtxgcg-8qjl%252Csn-gpv7dned%26ms%3Dau%252Crdu%26mv%3Dm%26mvi%3D1%26pl%3D24%26initcwndbps%3D397500%26vprv%3D1%26mime%3Dvideo%252Fmp4%26ns%3Dv9CYauI2ycUgrV6wOERCNxsG%26gir%3Dyes%26clen%3D7275579%26ratebypass%3Dyes%26dur%3D218.290%26lmt%3D1540416860737282%26mt%3D1635841731%26fvip%3D4%26fexp%3D24001373%252C24007246%26c%3DWEB%26txp%3D5531432%26n%3DD8yGa-DC5m2Dwv--%26sparams%3Dexpire%252Cei%252Cip%252Cid%252Citag%252Csource%252Crequiressl%252Cvprv%252Cmime%252Cns%252Cgir%252Cclen%252Cratebypass%252Cdur%252Clmt%26lsparams%3Dmh%252Cmm%252Cmn%252Cms%252Cmv%252Cmvi%252Cpl%252Cinitcwndbps%26lsig%3DAG3C_xAwRAIgdS6ux5rh5ulfwh8c6_Kt2cOdyS51OxPlxSUoB5k5x9YCICOgRiuFsZwAqJmxvBrCuq3CKk1S4YeAxEq3zPLvzAvX',
expected_url: 'https://r1---sn-hxtxgcg-8qjl.googlevideo.com/videoplayback?expire=1635863482&ei=WveAYdqsB6KPobIPjtWwYA&ip=128.201.98.50&id=o-ABuHwkfRnd4hOQoDKRKn7ZHvuLEPAPKkYhiYKpTwLrY7&itag=18&source=youtube&requiressl=yes&mh=G3&mm=31%2C29&mn=sn-hxtxgcg-8qjl%2Csn-gpv7dned&ms=au%2Crdu&mv=m&mvi=1&pl=24&initcwndbps=397500&vprv=1&mime=video%2Fmp4&ns=v9CYauI2ycUgrV6wOERCNxsG&gir=yes&clen=7275579&ratebypass=yes&dur=218.290&lmt=1540416860737282&mt=1635841731&fvip=4&fexp=24001373%2C24007246&c=WEB&txp=5531432&n=omhIaB28Jepv6Q&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cratebypass%2Cdur%2Clmt&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgdS6ux5rh5ulfwh8c6_Kt2cOdyS51OxPlxSUoB5k5x9YCICOgRiuFsZwAqJmxvBrCuq3CKk1S4YeAxEq3zPLvzAvX&sig=AOq0QJ8wRQIgdRvdKLMqva85sJOfykj1OX7HKH23Ettz2_WTVTzVm64CIQDOalGxpK1V2Ht6dNXsr1Sg17v7j4psm5VFQbs2JLT2vA%3D%3D&cver=2.20211101.01.00',
expected_url: 'https://r1---sn-hxtxgcg-8qjl.googlevideo.com/videoplayback?expire=1635863482&ei=WveAYdqsB6KPobIPjtWwYA&ip=128.201.98.50&id=o-ABuHwkfRnd4hOQoDKRKn7ZHvuLEPAPKkYhiYKpTwLrY7&itag=18&source=youtube&requiressl=yes&mh=G3&mm=31%2C29&mn=sn-hxtxgcg-8qjl%2Csn-gpv7dned&ms=au%2Crdu&mv=m&mvi=1&pl=24&initcwndbps=397500&vprv=1&mime=video%2Fmp4&ns=v9CYauI2ycUgrV6wOERCNxsG&gir=yes&clen=7275579&ratebypass=yes&dur=218.290&lmt=1540416860737282&mt=1635841731&fvip=4&fexp=24001373%2C24007246&c=WEB&txp=5531432&n=D8yGa-DC5m2Dwv--&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cratebypass%2Cdur%2Clmt&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgdS6ux5rh5ulfwh8c6_Kt2cOdyS51OxPlxSUoB5k5x9YCICOgRiuFsZwAqJmxvBrCuq3CKk1S4YeAxEq3zPLvzAvX&sig=AOq0QJ8wRQIgdRvdKLMqva85sJOfykj1OX7HKH23Ettz2_WTVTzVm64CIQDOalGxpK1V2Ht6dNXsr1Sg17v7j4psm5VFQbs2JLT2vA%3D%3D',
original_ntoken: 'PqjqqJjdB9K821VIisj',
expected_ntoken: 'AxwyS-osUl1WhMUd1',
client_version: '2.20211101.01.00',
test_video_id: 'FT_nzxtgXEw',
test_video_id_1: 'YE7VzlLtp-4',
test_video_id: 'dQw4w9WgXcQ',
sig_decipher_sc: `fB={RP:function(a,b){a.splice(0,b)},
Td:function(a){a.reverse()},
kq:function(a,b){var c=a[0];a[0]=a[b%a.length];a[b%a.length]=c};fB.kq(a,35);fB.RP(a,2);fB.kq(a,46);fB.Td(a,6);`,

View File

@@ -10,26 +10,27 @@ let failed_tests = 0;
async function performTests() {
const youtube = await new Innertube().catch((error) => error);
assert(youtube instanceof Error ? false : true, `should retrieve Innertube configuration data`, youtube);
assert(!(youtube instanceof Error), `should retrieve Innertube configuration data`, youtube);
if (!(youtube instanceof Error)) {
const search = await youtube.search('Carl Sagan - Documentary').catch((error) => error);
assert((search instanceof Error ? false : true) && search.videos.length >= 1, `should search videos`, search);
assert(!(search instanceof Error) && search.videos.length >= 1, `should search videos`, search);
const details = await youtube.getDetails(Constants.test_video_id).catch((error) => error);
assert(details instanceof Error ? false : true, `should retrieve details for ${Constants.test_video_id}`, details);
assert(!(details instanceof Error), `should retrieve details for ${Constants.test_video_id}`, details);
const comments = await youtube.getComments(Constants.test_video_id).catch((error) => error);
assert(comments instanceof Error ? false : true, `should retrieve comments for ${Constants.test_video_id}`, comments);
assert(!(comments instanceof Error), `should retrieve comments for ${Constants.test_video_id}`, comments);
const video = await downloadVideo(Constants.test_video_id_1, youtube).catch((error) => error);
assert(video instanceof Error ? false : true, `should download video (${Constants.test_video_id_1})`, video);
const video = await downloadVideo(Constants.test_video_id, youtube).catch((error) => error);
assert(!(video instanceof Error), `should download video (${Constants.test_video_id})`, video);
}
const n_token = new NToken(Constants.n_scramble_sc).transform(Constants.original_ntoken);
assert(n_token == Constants.expected_ntoken, `should transform n token into ${Constants.expected_ntoken}`, n_token);
const transformed_url = new SigDecipher(Constants.test_url, Constants.client_version, { sig_decipher_sc: Constants.sig_decipher_sc, ntoken_sc: Constants.n_scramble_sc }).decipher();
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);
if (failed_tests > 0)
@@ -49,9 +50,12 @@ function downloadVideo(id, youtube) {
function assert(outcome, description, data) {
const pass_fail = outcome ? 'pass' : 'fail';
!outcome && (failed_tests += 1);
console.info(pass_fail, ':', description, !outcome && `\nError: ${data}` || '');
return outcome;
};
performTests();
console.info(pass_fail, ':', description);
!outcome && (failed_tests += 1);
!outcome && console.error('Error: ', data);
return outcome;
}
performTests();