mirror of
https://github.com/LuanRT/YouTube.js.git
synced 2026-06-13 09:32:12 +00:00
Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
398cd8728d | ||
|
|
459c30528e | ||
|
|
6e1e96610c | ||
|
|
6d30aa3228 | ||
|
|
d33cb0b576 | ||
|
|
51af4c3ffe | ||
|
|
b577a79893 | ||
|
|
da0c5e5887 | ||
|
|
b47350894d | ||
|
|
c0387017e3 | ||
|
|
b286bc43df | ||
|
|
61028a2ab9 | ||
|
|
254588da81 | ||
|
|
ef3e54775c | ||
|
|
30cec36660 | ||
|
|
427a1bd396 | ||
|
|
cf4901fd3c | ||
|
|
2fd98a021f | ||
|
|
cd64e30b69 | ||
|
|
2b5027eb06 | ||
|
|
0c9f7135bf | ||
|
|
ce8a109398 | ||
|
|
6aaa3360e8 | ||
|
|
89c018c431 | ||
|
|
339a01f3a9 | ||
|
|
dd3f4c0009 | ||
|
|
7cd41e1d8a | ||
|
|
6ac8561af2 | ||
|
|
b4607d531f | ||
|
|
b3a1cdc1cd | ||
|
|
fd662df93d | ||
|
|
8a1f4b4e55 | ||
|
|
4ff83bdc3f | ||
|
|
c81e8e29ac | ||
|
|
d5f884ff9b | ||
|
|
5517c2f202 | ||
|
|
3493a82765 |
19
.github/ISSUE_TEMPLATE/FEATURE.md
vendored
Normal file
19
.github/ISSUE_TEMPLATE/FEATURE.md
vendored
Normal 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
39
.github/ISSUE_TEMPLATE/ISSUE.md
vendored
Normal 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
15
.github/ISSUE_TEMPLATE/QUESTION.md
vendored
Normal 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
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
27
.github/PULL_REQUEST_TEMPLATE/pull_request_template.md
vendored
Normal file
27
.github/PULL_REQUEST_TEMPLATE/pull_request_template.md
vendored
Normal 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
|
||||
238
lib/Actions.js
238
lib/Actions.js
@@ -8,15 +8,16 @@ const Constants = require('./Constants');
|
||||
/**
|
||||
* Performs direct interactions on YouTube.
|
||||
*
|
||||
* @param {object} session A valid Innertube session.
|
||||
* @param {Innertube} session A valid Innertube session.
|
||||
* @param {string} engagement_type Type of engagement.
|
||||
* @param {object} args Engagement arguments.
|
||||
* @returns {object} { success: boolean, status_code: number } | { success: boolean, status_code: number, message: string }
|
||||
* @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':
|
||||
@@ -32,7 +33,8 @@ 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':
|
||||
@@ -59,15 +61,36 @@ async function engage(session, engagement_type, args = {}) {
|
||||
/**
|
||||
* Accesses YouTube's various sections.
|
||||
*
|
||||
* @param {object} session A valid Innertube session.
|
||||
* @param {string} action_type Type of action.
|
||||
* @returns {object} { success: boolean, status_code: number, data: object } | { success: boolean, status_code: number, message: string }
|
||||
* @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) throw new Error('You are not signed-in');
|
||||
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,
|
||||
@@ -80,11 +103,73 @@ async function browse(session, action_type, args) {
|
||||
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 client_domain = args.ytmusic && Constants.URLS.YT_MUSIC_URL || Constants.URLS.YT_BASE_URL;
|
||||
const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/browse${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
|
||||
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,
|
||||
data: response.data
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 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 };
|
||||
@@ -96,18 +181,60 @@ async function browse(session, action_type, args) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {object} session A valid Innertube session.
|
||||
* @param {string} client YouTube client: YOUTUBE | YTMUSIC
|
||||
* @param {object} args Search arguments.
|
||||
* @returns {object} { success: boolean, status_code: number, data: object } | { success: boolean, status_code: number, message: string }
|
||||
* @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');
|
||||
|
||||
let data;
|
||||
|
||||
switch (client) {
|
||||
case 'YOUTUBE':
|
||||
data = {
|
||||
@@ -117,14 +244,14 @@ async function search(session, client, args = {}) {
|
||||
};
|
||||
break;
|
||||
case 'YTMUSIC':
|
||||
const yt_music_context = JSON.parse(JSON.stringify(session.context)); // deep copy the context obj so we don't accidentally change it
|
||||
const context = JSON.parse(JSON.stringify(session.context)); // deep copy the context obj so we don't accidentally change it
|
||||
|
||||
yt_music_context.client.originalUrl = Constants.URLS.YT_MUSIC_URL;
|
||||
yt_music_context.client.clientVersion = '1.20211213.00.00';
|
||||
yt_music_context.client.clientName = 'WEB_REMIX';
|
||||
context.client.originalUrl = Constants.URLS.YT_MUSIC_URL;
|
||||
context.client.clientVersion = Constants.YTMUSIC_VERSION;
|
||||
context.client.clientName = 'WEB_REMIX';
|
||||
|
||||
data = {
|
||||
context: yt_music_context,
|
||||
context: context,
|
||||
query: args.query
|
||||
};
|
||||
break;
|
||||
@@ -134,6 +261,7 @@ async function search(session, client, args = {}) {
|
||||
|
||||
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 {
|
||||
@@ -143,14 +271,13 @@ async function search(session, client, args = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Interacts with YouTube's notification system.
|
||||
*
|
||||
* @param {object} session A valid Innertube session.
|
||||
* @param {string} action_type Type of action.
|
||||
* @param {object} args Action arguments.
|
||||
* @returns {object} { success: boolean, status_code: number, data: object } | { success: boolean, status_code: number, message: string }
|
||||
* @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');
|
||||
@@ -181,6 +308,7 @@ async function notifications(session, action_type, args = {}) {
|
||||
|
||||
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 };
|
||||
|
||||
@@ -191,17 +319,17 @@ async function notifications(session, action_type, args = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Interacts with YouTube's livechat system.
|
||||
*
|
||||
* @param {object} session A valid Innertube session.
|
||||
* @param {string} action_type Type of action.
|
||||
* @param {object} args Action arguments.
|
||||
* @returns {object} { success: boolean, status_code: number, data: object } | { success: boolean, status_code: number, message: string }
|
||||
* @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 = {
|
||||
@@ -242,37 +370,38 @@ async function livechat(session, action_type, args = {}) {
|
||||
|
||||
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 {object} session A valid Innertube session.
|
||||
* @param {object} args Request arguments.
|
||||
* @returns {object} Video data.
|
||||
* @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)) ||
|
||||
!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;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Requests continuation for previously performed actions.
|
||||
*
|
||||
* @param {object} session A valid Innertube session.
|
||||
* @param {object} args Continuation arguments.
|
||||
* @returns {object} { success: boolean, status_code: number, data: object } | { success: boolean, status_code: number, message: string }
|
||||
* @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 };
|
||||
@@ -281,13 +410,13 @@ async function getContinuation(session, args = {}) {
|
||||
if (args.video_id) {
|
||||
data.videoId = args.video_id;
|
||||
if (args.ytmusic) {
|
||||
const yt_music_context = JSON.parse(JSON.stringify(session.context)); // deep copy the context obj so we don't accidentally change it
|
||||
const context = JSON.parse(JSON.stringify(session.context)); // deep copy the context obj so we don't accidentally change it
|
||||
|
||||
yt_music_context.client.originalUrl = Constants.URLS.YT_MUSIC_URL;
|
||||
yt_music_context.client.clientVersion = '1.20211213.00.00';
|
||||
yt_music_context.client.clientName = 'WEB_REMIX';
|
||||
context.client.originalUrl = Constants.URLS.YT_MUSIC_URL;
|
||||
context.client.clientVersion = Constants.YTMUSIC_VERSION;
|
||||
context.client.clientName = 'WEB_REMIX';
|
||||
|
||||
data.context = yt_music_context;
|
||||
data.context = context;
|
||||
data.isAudioOnly = true;
|
||||
data.tunerSettingValue = 'AUTOMIX_SETTING_NORMAL';
|
||||
} else {
|
||||
@@ -305,6 +434,7 @@ async function getContinuation(session, args = {}) {
|
||||
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 {
|
||||
@@ -314,4 +444,24 @@ async function getContinuation(session, args = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
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 };
|
||||
@@ -7,7 +7,8 @@ module.exports = {
|
||||
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',
|
||||
@@ -48,8 +49,8 @@ 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';
|
||||
|
||||
info.desktop && 'https://www.youtube.com' || 'https://m.youtube.com';
|
||||
|
||||
let req_opts = {
|
||||
params: info.params || {},
|
||||
headers: {
|
||||
@@ -95,6 +96,7 @@ module.exports = {
|
||||
videoId: id
|
||||
};
|
||||
},
|
||||
YTMUSIC_VERSION: '1.20211213.00.00',
|
||||
METADATA_KEYS: [
|
||||
'embed', 'view_count', 'average_rating',
|
||||
'length_seconds', 'channel_id', 'channel_url',
|
||||
@@ -105,14 +107,27 @@ module.exports = {
|
||||
'owner_profile_url'
|
||||
],
|
||||
BLACKLISTED_KEYS: [
|
||||
'is_owner_viewing', 'is_unplugged_corpus',
|
||||
'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 k=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()',
|
||||
@@ -124,16 +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 k=f'
|
||||
},
|
||||
// Just a helper function, felt like Utils.js wasn't the right place for it:
|
||||
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])}');
|
||||
TRANSLATE_2: 'function(d,e,f){var'
|
||||
}
|
||||
};
|
||||
629
lib/Innertube.js
629
lib/Innertube.js
@@ -15,9 +15,21 @@ const EventEmitter = require('events');
|
||||
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;
|
||||
this.#retry_count = 0;
|
||||
return this.#init();
|
||||
}
|
||||
|
||||
@@ -28,44 +40,216 @@ class Innertube {
|
||||
try {
|
||||
const data = JSON.parse(`{${Utils.getStringBetweenStrings(response.data, 'ytcfg.set({', '});')}}`);
|
||||
if (data.INNERTUBE_CONTEXT) {
|
||||
this.context = data.INNERTUBE_CONTEXT;
|
||||
this.key = data.INNERTUBE_API_KEY;
|
||||
this.id_token = data.ID_TOKEN;
|
||||
this.session_token = data.XSRF_TOKEN;
|
||||
this.context = data.INNERTUBE_CONTEXT;
|
||||
|
||||
this.player_url = data.PLAYER_JS_URL;
|
||||
this.logged_in = data.LOGGED_IN;
|
||||
this.sts = data.STS;
|
||||
|
||||
this.context.client.hl = 'en';
|
||||
this.context.client.gl = 'US';
|
||||
|
||||
/**
|
||||
* @event 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();
|
||||
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.#initMethods();
|
||||
} else {
|
||||
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 session: ${err.message}`);
|
||||
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 { refresh_token: string, access_token: string, expires: string }
|
||||
* @returns {Promise<void>}
|
||||
* @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) {
|
||||
if (!oauth.isTokenValid()) {
|
||||
@@ -96,41 +280,106 @@ class Innertube {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 { client: YOUTUBE | YTMUSIC, period: any | hour | day | week | month | year , order: relevance | rating | age | views, duration: any | short | long }
|
||||
* @returns {Promise<object>} Search results.
|
||||
* @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 refined_data = new Parser(this, response.data, {
|
||||
const data = new Parser(this, response.data, {
|
||||
client: options.client,
|
||||
data_type: 'SEARCH',
|
||||
query
|
||||
}).parse();
|
||||
|
||||
return refined_data;
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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');
|
||||
|
||||
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 [];
|
||||
|
||||
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} id The id of the video.
|
||||
* @param {string} video_id - The id of the video.
|
||||
* @return {Promise.<{ title: string; description: string; thumbnail: []; metadata: {} }>}
|
||||
*/
|
||||
async getDetails(id) {
|
||||
if (!id) throw new Error('You must provide a video id');
|
||||
async getDetails(video_id) {
|
||||
if (!video_id) throw new Error('You must provide a video id');
|
||||
|
||||
const data = await Actions.getVideoInfo(this, { id, is_desktop: false });
|
||||
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: id });
|
||||
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, id);
|
||||
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 = () => { };
|
||||
}
|
||||
@@ -138,23 +387,59 @@ class Innertube {
|
||||
refined_data.getLivechat = () => { };
|
||||
}
|
||||
|
||||
refined_data.like = () => Actions.engage(this, 'like/like', { video_id: id });
|
||||
refined_data.dislike = () => Actions.engage(this, 'like/dislike', { video_id: id });
|
||||
refined_data.removeLike = () => Actions.engage(this, 'like/removelike', { video_id: id });
|
||||
refined_data.subscribe = () => Actions.engage(this, 'subscription/subscribe', { video_id: id, channel_id: refined_data.metadata.channel_id });
|
||||
refined_data.unsubscribe = () => Actions.engage(this, 'subscription/unsubscribe', { video_id: id, channel_id: refined_data.metadata.channel_id });
|
||||
refined_data.comment = text => Actions.engage(this, 'comment/create_comment', { video_id: id, text });
|
||||
refined_data.getComments = () => this.getComments(id);
|
||||
refined_data.setNotificationPref = pref => Actions.notifications(this, 'modify_channel_preference', { channel_id: refined_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 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).
|
||||
* @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;
|
||||
@@ -205,9 +490,45 @@ 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<object>} home feed.
|
||||
* @returns {Promise.<[{ id: string; title: string; channel: string; metadata: {} }]>}
|
||||
*/
|
||||
async getHomeFeed() {
|
||||
const response = await Actions.browse(this, 'home_feed');
|
||||
@@ -228,8 +549,8 @@ class Innertube {
|
||||
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) || 'N/A',
|
||||
owner_badges: content.videoRenderer.ownerBadges && content.videoRenderer.ownerBadges.map((badge) => badge.metadataBadgeRenderer.tooltip) || '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);
|
||||
@@ -237,7 +558,7 @@ class Innertube {
|
||||
|
||||
/**
|
||||
* Returns your subscription feed.
|
||||
* @returns {Promise<object>} subs feed.
|
||||
* @returns {Promise.<{ today: []; yesterday: []; this_week: [] }>}
|
||||
*/
|
||||
async getSubscriptionsFeed() {
|
||||
const response = await Actions.browse(this, 'subscriptions_feed');
|
||||
@@ -277,8 +598,8 @@ class Innertube {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns your notifications.
|
||||
* @returns {Promise<object>} notifications.
|
||||
* 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');
|
||||
@@ -303,8 +624,8 @@ class Innertube {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the amount of notifications you haven't seen.
|
||||
* @returns {Promise<number>} unseen notifications count.
|
||||
* Returns unseen notifications count.
|
||||
* @returns {Promise.<number>} unseen notifications count.
|
||||
*/
|
||||
async getUnseenNotificationsCount() {
|
||||
const response = await Actions.notifications(this, 'get_unseen_count');
|
||||
@@ -313,10 +634,106 @@ class Innertube {
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads a video from YouTube.
|
||||
* Internal method to process and filter formats.
|
||||
*
|
||||
* @param {string} id The id of the video.
|
||||
* @param {object} options Download options: { quality?: string, type?: string, format?: string }
|
||||
* @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');
|
||||
|
||||
if (url_components.searchParams.get('n')) {
|
||||
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');
|
||||
@@ -329,77 +746,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;
|
||||
|
||||
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 {
|
||||
const refined_data = new Parser(this, video_data, { client: 'YOUTUBE', data_type: 'VIDEO_INFO', desktop_v: true }).parse();
|
||||
stream.emit('info', { video_details: refined_data, 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
|
||||
@@ -418,12 +780,24 @@ 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' })
|
||||
|| stream.emit('error', { message: err.message, type: 'DOWNLOAD_ABORTED' });
|
||||
cancelled &&
|
||||
stream.emit('error', { message: 'The download was cancelled.', type: 'DOWNLOAD_CANCELLED' }) ||
|
||||
stream.emit('error', { message: err.message, type: 'DOWNLOAD_ABORTED' });
|
||||
});
|
||||
|
||||
response.data.pipe(stream, { end: true });
|
||||
@@ -438,10 +812,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
|
||||
@@ -454,17 +828,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', () => {
|
||||
@@ -477,6 +861,7 @@ class Innertube {
|
||||
|
||||
response.data.pipe(stream, { end: must_end });
|
||||
};
|
||||
|
||||
downloadChunk();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
@@ -119,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');
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,21 +41,21 @@ class NToken {
|
||||
return el;
|
||||
});
|
||||
|
||||
// Fills the null placeholders with a copy of the transformations array
|
||||
const 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 the functions of the transformations array
|
||||
const 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] }));
|
||||
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] }));
|
||||
|
||||
transformation_calls.forEach((data) => {
|
||||
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 (${n}), 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('');
|
||||
@@ -71,11 +71,12 @@ class NToken {
|
||||
*/
|
||||
#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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
@@ -93,6 +94,7 @@ class NToken {
|
||||
|
||||
/**
|
||||
* 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;
|
||||
@@ -101,6 +103,7 @@ class NToken {
|
||||
|
||||
/**
|
||||
* Swaps the first element with the one at the given index.
|
||||
* @returns
|
||||
*/
|
||||
#swap0(arr, index) {
|
||||
const old_elem = arr[0];
|
||||
@@ -111,6 +114,7 @@ class NToken {
|
||||
|
||||
/**
|
||||
* Rotates elements of the array.
|
||||
* @returns
|
||||
*/
|
||||
#rotate(arr, index) {
|
||||
index = (index % arr.length + arr.length) % arr.length;
|
||||
@@ -119,6 +123,7 @@ class NToken {
|
||||
|
||||
/**
|
||||
* Deletes one element at the given index.
|
||||
* @returns
|
||||
*/
|
||||
#splice(arr, index) {
|
||||
index = (index % arr.length + arr.length) % arr.length;
|
||||
|
||||
@@ -20,7 +20,7 @@ class OAuth extends EventEmitter {
|
||||
this.scope = Constants.OAUTH.SCOPE;
|
||||
|
||||
this.auth_script_regex = /<script id=\"base-js\" src=\"(.*?)\" nonce=".*?"><\/script>/;
|
||||
this.identity_regex = /.+?={};var .+?={clientId:\"(?<id>.+?)\",si:\"(?<secret>.+?)\"},/;
|
||||
this.identity_regex = /.+?={};var .+?={clientId:\"(?<id>.+?)\",.+?:\"(?<secret>.+?)\"},/;
|
||||
|
||||
if (auth_info.access_token) return;
|
||||
this.#requestAuthCode();
|
||||
@@ -28,6 +28,7 @@ class OAuth extends EventEmitter {
|
||||
|
||||
/**
|
||||
* Asks the OAuth server for an auth code.
|
||||
* @returns {Promise.<void>}
|
||||
*/
|
||||
async #requestAuthCode() {
|
||||
const identity = await this.#getClientIdentity();
|
||||
@@ -66,6 +67,7 @@ class OAuth extends EventEmitter {
|
||||
* Waits for sign-in authorization.
|
||||
*
|
||||
* @param {string} device_code Client's device code.
|
||||
* @returns
|
||||
*/
|
||||
#waitForAuth(device_code) {
|
||||
const data = {
|
||||
@@ -122,7 +124,7 @@ class OAuth extends EventEmitter {
|
||||
|
||||
/**
|
||||
* Gets a new access token using a refresh token.
|
||||
* @returns {object} { credentials: { access_token: string, refresh_token: string, expires: string }, status: 'FAILED' | 'SUCCESS' }
|
||||
* @returns {object.<{ credentials: { access_token: string; refresh_token: string; expires: Date }; status: string }>}
|
||||
*/
|
||||
async refreshAccessToken() {
|
||||
const identity = await this.#getClientIdentity();
|
||||
@@ -166,7 +168,7 @@ class OAuth extends EventEmitter {
|
||||
|
||||
/**
|
||||
* Gets client identity data.
|
||||
* @returns {object} { id: string, secret: string }
|
||||
* @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.
|
||||
|
||||
157
lib/Parser.js
157
lib/Parser.js
@@ -1,7 +1,6 @@
|
||||
'use strict';
|
||||
|
||||
const Utils = require('./Utils');
|
||||
const Actions = require('./Actions');
|
||||
const Constants = require('./Constants');
|
||||
|
||||
/**
|
||||
@@ -18,10 +17,11 @@ class Parser {
|
||||
parse() {
|
||||
return this.args.client === 'YOUTUBE' ? ({
|
||||
SEARCH: () => this.#parseVideoSearch(),
|
||||
PLAYLIST: () => this.#parsePlaylist(),
|
||||
VIDEO_INFO: () => this.#parseVideoInfo()
|
||||
})[this.args.data_type]() : ({
|
||||
SEARCH: () => this.#parseMusicSearch(),
|
||||
SONG_INFO: () => { }
|
||||
PLAYLIST: () => this.#parseMusicPlaylist()
|
||||
})[this.args.data_type]();
|
||||
}
|
||||
|
||||
@@ -32,14 +32,14 @@ class Parser {
|
||||
.primaryContents.sectionListRenderer.contents[0].itemSectionRenderer
|
||||
.contents;
|
||||
|
||||
const continuation_token = this.data.contents.twoColumnSearchResultsRenderer
|
||||
.primaryContents.sectionListRenderer.contents[1].continuationItemRenderer
|
||||
.continuationEndpoint.continuationCommand.token;
|
||||
// TODO: Implement search continuation
|
||||
// const continuation_token = this.data.contents.twoColumnSearchResultsRenderer
|
||||
// .primaryContents.sectionListRenderer.contents[1].continuationItemRenderer
|
||||
// .continuationEndpoint.continuationCommand.token;
|
||||
|
||||
response.search_metadata = {};
|
||||
response.search_metadata.query = contents[0].showingResultsForRenderer && contents[0].showingResultsForRenderer.originalQuery.simpleText || this.args.query;
|
||||
response.search_metadata.corrected_query = contents[0].showingResultsForRenderer && contents[0].showingResultsForRenderer.correctedQueryEndpoint.searchEndpoint.query || this.args.query;
|
||||
response.search_metadata.estimated_results = parseInt(this.data.estimatedResults);
|
||||
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;
|
||||
@@ -73,45 +73,77 @@ class Parser {
|
||||
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;
|
||||
|
||||
/**
|
||||
* WIP
|
||||
**/
|
||||
const getLyrics = async (id) => {
|
||||
// const data_continuation = await Actions.getContinuation(this.session, { video_id: id, ytmusic: true });
|
||||
return undefined;
|
||||
}
|
||||
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;
|
||||
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: () => getLyrics(list_item.playlistItemData.videoId)
|
||||
};
|
||||
});
|
||||
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;
|
||||
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: () => getLyrics(list_item.playlistItemData.videoId)
|
||||
};
|
||||
});
|
||||
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) => {
|
||||
@@ -124,7 +156,56 @@ class Parser {
|
||||
};
|
||||
});
|
||||
|
||||
return { songs, videos, albums };
|
||||
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() {
|
||||
@@ -133,7 +214,7 @@ class Parser {
|
||||
const playability_status = desktop_v && this.data.playabilityStatus ||
|
||||
this.data[2].playerResponse.playabilityStatus;
|
||||
|
||||
if (playability_status.status == 'ERROR')
|
||||
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 ||
|
||||
|
||||
@@ -22,10 +22,13 @@ class Player {
|
||||
if (response instanceof Error) throw new Error('Could not download player script: ' + response.message);
|
||||
|
||||
try {
|
||||
// Deletes old players
|
||||
Fs.existsSync(this.tmp_cache_dir) && Fs.rmSync(this.tmp_cache_dir, { recursive: true });
|
||||
|
||||
// Caches the current player so we don't have to download it all the time.
|
||||
Fs.mkdirSync(this.tmp_cache_dir, { recursive: true });
|
||||
Fs.writeFileSync(`${this.tmp_cache_dir}/${this.player_name}.js`, response.data);
|
||||
} catch (err) {}
|
||||
} catch (err) { }
|
||||
|
||||
this.sig_decipher_sc = this.#getSigDecipherCode(response.data);
|
||||
this.ntoken_sc = this.#getNEncoder(response.data);
|
||||
|
||||
15
lib/Sig.js
15
lib/Sig.js
@@ -1,20 +1,18 @@
|
||||
'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.
|
||||
*/
|
||||
/**
|
||||
* Deciphers signature.
|
||||
*/
|
||||
decipher() {
|
||||
const args = QueryString.parse(this.url);
|
||||
const functions = this.#getFunctions();
|
||||
@@ -52,10 +50,7 @@ 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();
|
||||
}
|
||||
|
||||
|
||||
55
lib/Utils.js
55
lib/Utils.js
@@ -8,7 +8,8 @@ const UserAgent = require('user-agents');
|
||||
/**
|
||||
* Returns a random user agent.
|
||||
*
|
||||
* @param {string} type mobile | desktop
|
||||
* @param {string} type - mobile | desktop
|
||||
* @returns {object}
|
||||
*/
|
||||
function getRandomUserAgent(type) {
|
||||
switch (type) {
|
||||
@@ -23,7 +24,8 @@ function getRandomUserAgent(type) {
|
||||
/**
|
||||
* Generates an authentication token from a cookies' sid.
|
||||
*
|
||||
* @param {string} sid Sid extracted from cookies
|
||||
* @param {string} sid - Sid extracted from cookies
|
||||
* @returns {string}
|
||||
*/
|
||||
function generateSidAuth(sid) {
|
||||
const youtube = 'https://www.youtube.com';
|
||||
@@ -37,13 +39,12 @@ 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.
|
||||
* @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");
|
||||
@@ -59,7 +60,7 @@ function escapeStringRegexp(string) {
|
||||
* Converts time (h:m:s) to seconds.
|
||||
*
|
||||
* @param {string} time
|
||||
* @returns {string} seconds
|
||||
* @returns {number} seconds
|
||||
*/
|
||||
function timeToSeconds(time) {
|
||||
let params = time.split(':');
|
||||
@@ -74,6 +75,7 @@ function timeToSeconds(time) {
|
||||
* 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()}`);
|
||||
@@ -84,6 +86,7 @@ function camelToSnake(string) {
|
||||
*
|
||||
* @param {string} channel_id
|
||||
* @param {string} index
|
||||
* @returns {string}
|
||||
*/
|
||||
function encodeNotificationPref(channel_id, index) {
|
||||
const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/proto/youtube.proto`));
|
||||
@@ -100,12 +103,12 @@ function encodeNotificationPref(channel_id, index) {
|
||||
return encodeURIComponent(Buffer.from(buf).toString('base64'));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 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`));
|
||||
@@ -124,11 +127,11 @@ function encodeMessageParams(channel_id, video_id) {
|
||||
return Buffer.from(encodeURIComponent(Buffer.from(buf).toString('base64'))).toString('base64');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Encodes comment params protobuf.
|
||||
*
|
||||
* @param {string} video_id
|
||||
* @returns {string}
|
||||
*/
|
||||
function encodeCommentParams(video_id) {
|
||||
const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/proto/youtube.proto`));
|
||||
@@ -144,21 +147,21 @@ function encodeCommentParams(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
|
||||
* @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`));
|
||||
|
||||
const periods = { 'any': null, 'hour': 1, 'day': 2, 'week': 3, 'month': 4, 'year': 5 };
|
||||
const durations = { 'any': null, 'short' : 1, 'long': 2 };
|
||||
const orders = { 'relevance': null, 'rating': 1, 'age': 2, 'views' : 3 };
|
||||
|
||||
const durations = { 'any': null, 'short': 1, 'long': 2 };
|
||||
const orders = { 'relevance': null, 'rating': 1, 'age': 2, 'views': 3 };
|
||||
|
||||
const search_filter_buff = youtube_proto.SearchFilter.encode({
|
||||
number: orders[order],
|
||||
filter: {
|
||||
@@ -167,8 +170,24 @@ function encodeFilter(period, duration, order) {
|
||||
param_2: durations[duration]
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return encodeURIComponent(Buffer.from(search_filter_buff).toString('base64'));
|
||||
}
|
||||
|
||||
module.exports = { getRandomUserAgent, generateSidAuth, getStringBetweenStrings, camelToSnake, timeToSeconds, encodeMessageParams, encodeCommentParams, 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 };
|
||||
40
package-lock.json
generated
40
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "youtubei.js",
|
||||
"version": "1.2.9",
|
||||
"version": "1.3.6",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "youtubei.js",
|
||||
"version": "1.2.9",
|
||||
"version": "1.3.6",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^0.21.4",
|
||||
@@ -53,9 +53,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.14.7",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.7.tgz",
|
||||
"integrity": "sha512-+hbxoLbFMbRKDwohX8GkTataGqO6Jb7jGwpAlwgy2bIz25XtRm7KEzJM76R1WiNT5SwZkX4Y75SwBolkpmE7iQ==",
|
||||
"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",
|
||||
@@ -77,9 +77,9 @@
|
||||
"integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8="
|
||||
},
|
||||
"node_modules/multiformats": {
|
||||
"version": "9.6.2",
|
||||
"resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.6.2.tgz",
|
||||
"integrity": "sha512-1dKng7RkBelbEZQQD2zvdzYKgUmtggpWl+GXQBYhnEGGkV6VIYfWgV3VSeyhcUFFEelI5q4D0etCJZ7fbuiamQ=="
|
||||
"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",
|
||||
@@ -127,9 +127,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/user-agents": {
|
||||
"version": "1.0.912",
|
||||
"resolved": "https://registry.npmjs.org/user-agents/-/user-agents-1.0.912.tgz",
|
||||
"integrity": "sha512-vvFMlK3XcaF/ZRF1Ky4IrBASSNXflZys+5ArPRanNeggWtOYeD2k3FD/6wrv9h7lFlvh5KS+X45E/siw26+EJg==",
|
||||
"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"
|
||||
@@ -179,9 +179,9 @@
|
||||
}
|
||||
},
|
||||
"follow-redirects": {
|
||||
"version": "1.14.7",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.7.tgz",
|
||||
"integrity": "sha512-+hbxoLbFMbRKDwohX8GkTataGqO6Jb7jGwpAlwgy2bIz25XtRm7KEzJM76R1WiNT5SwZkX4Y75SwBolkpmE7iQ=="
|
||||
"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",
|
||||
@@ -189,9 +189,9 @@
|
||||
"integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8="
|
||||
},
|
||||
"multiformats": {
|
||||
"version": "9.6.2",
|
||||
"resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.6.2.tgz",
|
||||
"integrity": "sha512-1dKng7RkBelbEZQQD2zvdzYKgUmtggpWl+GXQBYhnEGGkV6VIYfWgV3VSeyhcUFFEelI5q4D0etCJZ7fbuiamQ=="
|
||||
"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",
|
||||
@@ -239,9 +239,9 @@
|
||||
}
|
||||
},
|
||||
"user-agents": {
|
||||
"version": "1.0.912",
|
||||
"resolved": "https://registry.npmjs.org/user-agents/-/user-agents-1.0.912.tgz",
|
||||
"integrity": "sha512-vvFMlK3XcaF/ZRF1Ky4IrBASSNXflZys+5ArPRanNeggWtOYeD2k3FD/6wrv9h7lFlvh5KS+X45E/siw26+EJg==",
|
||||
"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"
|
||||
|
||||
19
package.json
19
package.json
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "youtubei.js",
|
||||
"version": "1.2.9",
|
||||
"version": "1.3.6",
|
||||
"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": {
|
||||
"test": "node test"
|
||||
},
|
||||
"author": "LuanRT",
|
||||
"funding": "https://ko-fi.com/luanrt",
|
||||
"license": "MIT",
|
||||
"directories": {
|
||||
"example": "examples",
|
||||
@@ -23,20 +24,22 @@
|
||||
"url": "git+https//github.com/LuanRT/YouTube.js.git"
|
||||
},
|
||||
"keywords": [
|
||||
"yt",
|
||||
"ytdl",
|
||||
"youtube",
|
||||
"youtube-dl",
|
||||
"youtubedl",
|
||||
"youtube-downloader",
|
||||
"innertube",
|
||||
"innertubeapi",
|
||||
"livechat",
|
||||
"api",
|
||||
"search",
|
||||
"like",
|
||||
"dislike",
|
||||
"comment",
|
||||
"downloader",
|
||||
"comments-section",
|
||||
"youtube-downloader"
|
||||
"dislike",
|
||||
"search",
|
||||
"comment",
|
||||
"like",
|
||||
"api",
|
||||
"dl"
|
||||
],
|
||||
"bugs": {
|
||||
"url": "https://github.com/LuanRT/YouTube.js/issues"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
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',
|
||||
|
||||
@@ -10,7 +10,7 @@ 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);
|
||||
@@ -30,7 +30,7 @@ async function performTests() {
|
||||
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)
|
||||
@@ -58,4 +58,4 @@ function assert(outcome, description, data) {
|
||||
return outcome;
|
||||
}
|
||||
|
||||
performTests();
|
||||
performTests();
|
||||
|
||||
Reference in New Issue
Block a user