Compare commits

...

70 Commits

Author SHA1 Message Date
luan.lrt4@gmail.com
7230a2d927 chore: fix typos 2022-04-13 01:51:03 -03:00
luan.lrt4@gmail.com
924693349c chore: remove unneeded file 2022-04-13 01:48:33 -03:00
luan.lrt4@gmail.com
1ab302319d refactor!: rewrite parser and refactor project structure, closes #19 2022-04-13 01:47:57 -03:00
luan.lrt4@gmail.com
bbc1d0135b deps: add new dependency 2022-04-04 13:59:34 -03:00
luan.lrt4@gmail.com
9c1e34c9ab feat: implement pagination, refactor some methods & better error handling 2022-04-04 13:56:22 -03:00
luan.lrt4@gmail.com
c5eea2b4ff feat: implement pagination for all endpoints 2022-04-04 13:52:59 -03:00
luan.lrt4@gmail.com
60130f4d0f refactor: add utility to access deep object properties 2022-04-04 13:51:27 -03:00
luan.lrt4@gmail.com
5090c572d5 chore(release): v1.3.8 2022-03-30 23:52:28 -03:00
luan.lrt4@gmail.com
c9c72d0f31 feat: add support for comment replies, like and dislike 2022-03-30 23:31:11 -03:00
luan.lrt4@gmail.com
7635f49191 chore: add comment reply/action prototbuf messages 2022-03-30 14:33:22 -03:00
luan.lrt4@gmail.com
c932e65dad chore: simplify livechat logic and fix yt search suggestions 2022-03-28 14:18:49 -03:00
luan.lrt4@gmail.com
23717aab11 chore: rephrase comment 2022-03-26 05:42:53 -03:00
luan.lrt4@gmail.com
85df28a7fb feat: add support for channels (WIP) 2022-03-26 05:35:16 -03:00
luan.lrt4@gmail.com
9f4970b3ee refactor: separate protobuf stuff from utilities 2022-03-26 05:33:49 -03:00
luan.lrt4@gmail.com
82bbc715ff fix: playlists and home feed should work when logged out 2022-03-23 03:18:40 -03:00
luan.lrt4@gmail.com
3ec111212c chore(docs): rephrase 2022-03-23 00:45:59 -03:00
luan.lrt4@gmail.com
7ca4b2bb45 chore(release): v1.3.6 2022-03-23 00:43:09 -03:00
luan.lrt4@gmail.com
8d411f25c8 fix: age restricted videos causing uncaught exceptions when logged out 2022-03-23 00:32:51 -03:00
luan.lrt4@gmail.com
80fe969917 refactor: use axios instances to simplify logic & improve code readability 2022-03-22 23:35:39 -03:00
luan.lrt4@gmail.com
13c94fbb8a chore: rephrase comment 2022-03-22 09:36:00 -03:00
luan.lrt4@gmail.com
60ce869054 fix: welp, let's try again 2022-03-22 09:33:08 -03:00
luan.lrt4@gmail.com
1268ac83a6 chore: use optional chaining, bleh 2022-03-22 09:18:52 -03:00
luan.lrt4@gmail.com
5e588d0db5 refactor: use continuation requests for video data 2022-03-22 09:10:25 -03:00
luan.lrt4@gmail.com
8b37bd99b1 chore: add note regarding getVideoInfo() 2022-03-22 05:51:55 -03:00
luan.lrt4@gmail.com
08741de831 fix: oops, wrong param 2022-03-22 05:50:07 -03:00
luan.lrt4@gmail.com
574a595a01 chore: remove unneeded endpoint var 2022-03-22 04:09:32 -03:00
luan.lrt4@gmail.com
16928ee71b chore: update metadata keys 2022-03-21 22:41:38 -03:00
luan.lrt4@gmail.com
de6283080b feat: return comment count in getDetails() 2022-03-21 22:39:41 -03:00
luan.lrt4@gmail.com
23ab8bca4d chore: improve parsing 2022-03-21 19:13:29 -03:00
luan.lrt4@gmail.com
068b86b410 fix: parsing error if streaming data is not available 2022-03-18 17:13:42 -03:00
LuanRT
0b001c0956 fix: getHomeFeed() should work when logged out 2022-03-09 04:10:03 -03:00
LuanRT
4c14662d42 chore(docs): fix typo 2022-03-09 04:07:56 -03:00
LuanRT
f1a9d5d77b chore(docs): fix typo 2022-03-07 19:56:48 -03:00
LuanRT
398cd8728d 1.3.6 2022-03-07 19:30:14 -03:00
LuanRT
459c30528e fix: decipher n param only if necessary 2022-03-07 19:29:39 -03:00
LuanRT
6e1e96610c docs: fix table of contents 2022-03-07 19:25:09 -03:00
LuanRT
6d30aa3228 docs: oops 2022-03-03 03:37:47 -03:00
LuanRT
d33cb0b576 docs: add unsubscribe() snippet 2022-03-03 03:34:02 -03:00
LuanRT
51af4c3ffe chore: add issue & pull request template 2022-03-03 03:29:08 -03:00
LuanRT
b577a79893 chore: update lock file 2022-03-03 02:40:29 -03:00
LuanRT
da0c5e5887 chore(release): v1.3.5 2022-03-03 02:31:22 -03:00
LuanRT
b47350894d 2.0.0-0 2022-03-03 02:23:22 -03:00
LuanRT
c0387017e3 docs: add more examples 2022-03-03 02:22:48 -03:00
LuanRT
b286bc43df chore: update tests 2022-03-03 02:21:58 -03:00
LuanRT
61028a2ab9 style: format and refactor code 2022-03-03 02:21:32 -03:00
LuanRT
254588da81 feat: add acc settings and alternative to download 2022-03-03 02:18:03 -03:00
LuanRT
ef3e54775c feat: add watch history and playlist support 2022-03-03 02:13:00 -03:00
dependabot[bot]
30cec36660 Merge pull request #12 from LuanRT/dependabot/npm_and_yarn/follow-redirects-1.14.8 2022-02-14 16:26:42 +00:00
dependabot[bot]
427a1bd396 build(deps): bump follow-redirects from 1.14.7 to 1.14.8
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.14.7 to 1.14.8.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.14.7...v1.14.8)

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

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-14 16:24:09 +00:00
LuanRT
cf4901fd3c feat: automatically delete old players 2022-02-13 19:09:40 -03:00
LuanRT
2fd98a021f format: remove white space 2022-02-13 19:08:14 -03:00
LuanRT
cd64e30b69 chore: simplify format selection 2022-02-13 19:06:56 -03:00
LuanRT
2b5027eb06 fix: getLyrics() only working when signed-in 2022-02-06 15:26:16 -03:00
LuanRT
0c9f7135bf docs: oops 2022-02-05 19:25:23 -03:00
LuanRT
ce8a109398 docs: update table of contents 2022-02-05 19:23:07 -03:00
LuanRT
6aaa3360e8 docs: update YouTube Music examples 2022-02-05 19:17:12 -03:00
LuanRT
89c018c431 refactor: move getLyrics to Innertube.js 2022-02-05 19:16:36 -03:00
LuanRT
339a01f3a9 chore(release): v1.3.0 2022-02-05 18:45:30 -03:00
LuanRT
dd3f4c0009 chore: format code & other minor changes 2022-02-05 18:32:25 -03:00
LuanRT
7cd41e1d8a docs: add YouTube Music examples 2022-02-05 18:31:28 -03:00
LuanRT
6ac8561af2 feat: add lyrics support 2022-02-05 18:30:21 -03:00
LuanRT
b4607d531f fix(OAuth): secret not found due to bad regex 2022-02-04 15:20:35 -03:00
LuanRT
b3a1cdc1cd chore: remove ntoken 'translate' func var names 2022-02-03 04:54:37 -03:00
LuanRT
fd662df93d style: remove extra white space 2022-02-02 06:12:38 -03:00
LuanRT
8a1f4b4e55 Merge branch 'main' of https://github.com/LuanRT/YouTube.js 2022-02-02 06:04:49 -03:00
LuanRT
4ff83bdc3f style: add missing semi & rename some variables 2022-02-02 06:04:39 -03:00
LuanRT
c81e8e29ac chore: remove unnecessary condition 2022-02-01 18:49:17 -03:00
LuanRT
d5f884ff9b refactor: move refineNTokenData function to Utils 2022-02-01 16:47:02 -03:00
LuanRT
5517c2f202 fix: ntoken function 'translate2' not being parsed 2022-02-01 16:12:47 -03:00
LuanRT
3493a82765 chore: remove more unused variables 2022-01-31 04:31:45 -03:00
38 changed files with 3339 additions and 1662 deletions

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

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

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

@@ -0,0 +1,38 @@
---
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 have provided sufficient information

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

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

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

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

27
.github/pull_request_template.md vendored Normal file
View File

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

View File

@@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
SOFTWARE.

1124
README.md

File diff suppressed because it is too large Load Diff

View File

@@ -1,92 +1,92 @@
'use strict';
const fs = require('fs');
const Innertube = require('..');
const creds_path = './yt_oauth_creds.json';
const creds = fs.existsSync(creds_path) && JSON.parse(fs.readFileSync(creds_path).toString()) || {};
async function start() {
const youtube = await new Innertube();
youtube.ev.on('auth', (data) => {
if (data.status === 'AUTHORIZATION_PENDING') {
console.info(`Hello!\nOn your phone or computer, go to ${data.verification_url} and enter the code ${data.code}`);
} else if (data.status === 'SUCCESS') {
fs.writeFileSync(creds_path, JSON.stringify(data.credentials));
console.info('Successfully signed-in, enjoy!');
}
});
youtube.ev.on('update-credentials', (data) => {
fs.writeFileSync(creds_path, JSON.stringify(data.credentials));
console.info('Credentials updated!', data);
});
await youtube.signIn(creds);
const search = await youtube.search('Looking for life on Mars - documentary');
console.info('Search results:', search);
const video = await youtube.getDetails(search.videos[0].id).catch((error) => error);
console.info('Video details:', video);
if (youtube.logged_in) {
const myNotifications = await youtube.getNotifications();
console.info('My notifications:', myNotifications);
const like = await video.like();
if (like.success) {
console.info('Video marked as liked!');
}
const dislike = await video.dislike();
if (dislike.success) {
console.info('Video marked as disliked!');
}
const removeDislikeOrLike = await video.removeLike();
if (removeDislikeOrLike.success) {
console.info('Removed the dislike/like!')
}
const myComment = await video.comment('Haha, nice!');
if (myComment.success) {
console.info('Comment successfully posted!')
}
const subscribe = await video.subscribe();
if (subscribe.success) {
console.info('Just subscribed to', video.metadata.channel_name + '!');
}
const unsubscribe = await video.unsubscribe();
if (unsubscribe.success) {
console.info('Just unsubscribed from', video.metadata.channel_name + ' :(');
}
}
// Downloading videos:
const stream = youtube.download(search.videos[0].id, {
format: 'mp4', // Optional, ignored when type is set to audio and defaults to mp4, and I recommend to leave it as it is
quality: '360p', // if a video doesn't have a specific quality it'll fall back to 360p, this is ignored when type is set to audio
type: 'videoandaudio' // can be “video”, “audio” and “videoandaudio”
});
stream.pipe(fs.createWriteStream(`./${search.videos[0].id}.mp4`));
stream.on('start', () => {
console.info('[DOWNLOADER]', 'Starting download now!');
});
stream.on('info', (info) => {
console.info('[DOWNLOADER]', `Downloading ${info.video_details.title} by ${info.video_details.metadata.channel_name}`);
});
stream.on('end', () => {
console.info('[DOWNLOADER]', 'Done!');
});
stream.on('error', (err) => console.error('[ERROR]', err));
}
'use strict';
const fs = require('fs');
const Innertube = require('..');
const creds_path = './yt_oauth_creds.json';
const creds = fs.existsSync(creds_path) && JSON.parse(fs.readFileSync(creds_path).toString()) || {};
async function start() {
const youtube = await new Innertube();
youtube.ev.on('auth', (data) => {
if (data.status === 'AUTHORIZATION_PENDING') {
console.info(`Hello!\nOn your phone or computer, go to ${data.verification_url} and enter the code ${data.code}`);
} else if (data.status === 'SUCCESS') {
fs.writeFileSync(creds_path, JSON.stringify(data.credentials));
console.info('Successfully signed-in, enjoy!');
}
});
youtube.ev.on('update-credentials', (data) => {
fs.writeFileSync(creds_path, JSON.stringify(data.credentials));
console.info('Credentials updated!', data);
});
await youtube.signIn(creds);
const search = await youtube.search('Looking for life on Mars - documentary');
console.info('Search results:', search);
const video = await youtube.getDetails(search.videos[0].id);
console.info('Video details:', video);
if (youtube.logged_in) {
const myNotifications = await youtube.getNotifications();
console.info('My notifications:', myNotifications);
const like = await video.like();
if (like.success) {
console.info('Video marked as liked!');
}
const dislike = await video.dislike();
if (dislike.success) {
console.info('Video marked as disliked!');
}
const removeDislikeOrLike = await video.removeLike();
if (removeDislikeOrLike.success) {
console.info('Removed the dislike/like!')
}
const myComment = await video.comment('Haha, nice!');
if (myComment.success) {
console.info('Comment successfully posted!')
}
const subscribe = await video.subscribe();
if (subscribe.success) {
console.info('Just subscribed to', video.metadata.channel_name + '!');
}
const unsubscribe = await video.unsubscribe();
if (unsubscribe.success) {
console.info('Just unsubscribed from', video.metadata.channel_name + ' :(');
}
}
// Downloading videos:
const stream = youtube.download(search.videos[0].id, {
format: 'mp4', // Optional, ignored when type is set to audio and defaults to mp4, and I recommend to leave it as it is
quality: '360p', // if a video doesn't have a specific quality it'll fall back to 360p, this is ignored when type is set to audio
type: 'videoandaudio' // can be “video”, “audio” and “videoandaudio”
});
stream.pipe(fs.createWriteStream(`./${search.videos[0].id}.mp4`));
stream.on('start', () => {
console.info('[DOWNLOADER]', 'Starting download now!');
});
stream.on('info', (info) => {
console.info('[DOWNLOADER]', `Downloading ${info.video_details.title} by ${info.video_details.metadata.channel_name}`);
});
stream.on('end', () => {
console.info('[DOWNLOADER]', 'Done!');
});
stream.on('error', (err) => console.error('[ERROR]', err));
}
start();

View File

@@ -1,317 +0,0 @@
'use strict';
const Uuid = require('uuid');
const Axios = require('axios');
const Utils = require('./Utils');
const Constants = require('./Constants');
/**
* Performs direct interactions on YouTube.
*
* @param {object} 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 }
*/
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':
case 'like/removelike':
data = {
context: session.context,
target: {
videoId: args.video_id
}
};
break;
case 'subscription/subscribe':
case 'subscription/unsubscribe':
data = {
context: session.context,
channelIds: [args.channel_id]
};
break;
case 'comment/create_comment':
data = {
context: session.context,
commentText: args.text,
createCommentParams: Utils.encodeCommentParams(args.video_id)
};
break;
default:
}
const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/${engagement_type}${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session, id: args.video_id, data })).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
return {
success: true,
status_code: response.status
};
}
/**
* 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 }
*/
async function browse(session, action_type, args) {
if (!session.logged_in) throw new Error('You are not signed-in');
let data;
switch (action_type) { // TODO: Handle more actions
case 'home_feed':
data = {
context: session.context,
browseId: 'FEwhat_to_watch'
};
break;
case 'subscriptions_feed':
data = {
context: session.context,
browseId: 'FEsubscriptions'
};
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}`}`,
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session })).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
return {
success: true,
status_code: response.status,
data: response.data
};
}
/**
* 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 }
*/
async function search(session, client, args = {}) {
if (!args.query) throw new Error('No query was provided');
let data;
switch (client) {
case 'YOUTUBE':
data = {
context: session.context,
params: Utils.encodeFilter(args.options.period, args.options.duration, args.options.order),
query: args.query
};
break;
case 'YTMUSIC':
const yt_music_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';
data = {
context: yt_music_context,
query: args.query
};
break;
default:
break;
}
const response = await Axios.post(`${client === 'YOUTUBE' && Constants.URLS.YT_BASE_URL || Constants.URLS.YT_MUSIC_URL}/youtubei/v1/search${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session, ytmusic: client === 'YTMUSIC' })).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
return {
success: true,
status_code: response.status,
data: response.data
};
}
/**
* 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 }
*/
async function notifications(session, action_type, args = {}) {
if (!session.logged_in) throw new Error('You are not signed-in');
let data;
switch (action_type) {
case 'modify_channel_preference':
let pref_types = { PERSONALIZED: 1, ALL: 2, NONE: 3 };
data = {
context: session.context,
params: Utils.encodeNotificationPref(args.channel_id, pref_types[args.pref.toUpperCase()])
};
break;
case 'get_notification_menu':
data = {
context: session.context,
notificationsMenuRequestType: 'NOTIFICATIONS_MENU_REQUEST_TYPE_INBOX'
};
break;
case 'get_unseen_count':
data = {
context: session.context
};
break;
default:
}
const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/notification/${action_type}${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session })).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
if (action_type === 'modify_channel_preference') return { success: true, status_code: response.status };
return {
success: true,
status_code: response.status,
data: response.data
};
}
/**
* 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 }
*/
async function livechat(session, action_type, args = {}) {
let data;
switch (action_type) {
case 'live_chat/get_live_chat':
data = {
context: session.context,
continuation: args.ctoken
};
break;
case 'live_chat/send_message':
data = {
context: session.context,
params: Utils.encodeMessageParams(args.channel_id, args.video_id),
clientMessageId: `ytjs-${Uuid.v4()}`,
richMessage: {
textSegments: [{ text: args.text }]
}
};
break;
case 'live_chat/get_item_context_menu':
data = {
context: session.context
};
break;
case 'live_chat/moderate':
data = {
context: session.context,
params: args.cmd_params
};
break;
case 'updated_metadata':
data = {
context: session.context,
videoId: args.video_id
};
args.continuation && (data.continuation = args.continuation);
break;
default:
}
const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/${action_type}${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session, params: args.params })).catch((error) => error);
if (response instanceof Error) return { success: false, 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.
*/
async function getVideoInfo(session, args = {}) {
let response;
!args.is_desktop && (response = await Axios.get(`${Constants.URLS.YT_WATCH_PAGE}?v=${args.id}&t=8s&pbj=1`, Constants.INNERTUBE_REQOPTS({ session, id: args.id, desktop: false })).catch((error) => error)) ||
(response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/player${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify(Constants.VIDEO_INFO_REQBODY(args.id, session.sts, session.context)), Constants.INNERTUBE_REQOPTS({ session, id: args.id, desktop: true })).catch((error) => error));
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 }
*/
async function getContinuation(session, args = {}) {
let data = { context: session.context };
args.continuation_token && (data.continuation = args.continuation_token);
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
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';
data.context = yt_music_context;
data.isAudioOnly = true;
data.tunerSettingValue = 'AUTOMIX_SETTING_NORMAL';
} else {
data.racyCheckOk = true;
data.contentCheckOk = false;
data.autonavState = 'STATE_NONE';
data.playbackContext = {
vis: 0,
lactMilliseconds: '-1'
}
data.captionsRequested = false;
}
}
const client_domain = args.ytmusic && Constants.URLS.YT_MUSIC_URL || Constants.URLS.YT_BASE_URL;
const response = await Axios.post(`${client_domain}/youtubei/v1/next${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session, ytmusic: args.ytmusic })).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
return {
success: true,
status_code: response.status,
data: response.data
};
}
module.exports = { engage, browse, search, notifications, livechat, getVideoInfo, getContinuation };

File diff suppressed because it is too large Load Diff

View File

@@ -1,206 +0,0 @@
'use strict';
const Utils = require('./Utils');
const Actions = require('./Actions');
const Constants = require('./Constants');
/**
* Takes raw data from the Innertube API and refines it.
* Mainly used for video data and search results, as those are more complex to parse.
*/
class Parser {
constructor(session, data, args = {}) {
this.session = session;
this.data = data;
this.args = args;
}
parse() {
return this.args.client === 'YOUTUBE' ? ({
SEARCH: () => this.#parseVideoSearch(),
VIDEO_INFO: () => this.#parseVideoInfo()
})[this.args.data_type]() : ({
SEARCH: () => this.#parseMusicSearch(),
SONG_INFO: () => { }
})[this.args.data_type]();
}
#parseVideoSearch() {
const response = {};
const contents = this.data.contents.twoColumnSearchResultsRenderer
.primaryContents.sectionListRenderer.contents[0].itemSectionRenderer
.contents;
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.videos = contents.map((data) => {
if (!data.videoRenderer) return;
const video = data.videoRenderer;
return {
title: video.title.runs[0].text,
description: video.detailedMetadataSnippets && video.detailedMetadataSnippets[0].snippetText.runs.map((item) => item.text).join('') || 'N/A',
author: video.ownerText.runs[0].text,
id: video.videoId,
url: `https://youtu.be/${video.videoId}`,
channel_url: `${Constants.URLS.YT_BASE_URL}${video.ownerText.runs[0].navigationEndpoint.commandMetadata.webCommandMetadata.url}`,
metadata: {
view_count: video.viewCountText && video.viewCountText.simpleText || 'N/A',
short_view_count_text: {
simple_text: video.shortViewCountText && video.shortViewCountText.simpleText || 'N/A',
accessibility_label: video.shortViewCountText && (video.shortViewCountText.accessibility && video.shortViewCountText.accessibility.accessibilityData.label || 'N/A') || 'N/A',
},
thumbnails: video.thumbnail.thumbnails,
duration: {
seconds: Utils.timeToSeconds(video.lengthText && video.lengthText.simpleText || '0'),
simple_text: video.lengthText && video.lengthText.simpleText || 'N/A',
accessibility_label: video.lengthText && video.lengthText.accessibility.accessibilityData.label || 'N/A'
},
published: video.publishedTimeText && video.publishedTimeText.simpleText || 'N/A',
badges: video.badges && video.badges.map((item) => item.metadataBadgeRenderer.label) || 'N/A',
owner_badges: video.ownerBadges && video.ownerBadges.map((item) => item.metadataBadgeRenderer.tooltip) || 'N/A '
}
};
}).filter((video) => video);
return response;
}
#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;
}
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)
};
});
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)
};
});
const albums_ms = contents.find((content) => content.musicShelfRenderer.title.runs[0].text == 'Albums');
const albums = albums_ms.musicShelfRenderer.contents.map((item) => {
const list_item = item.musicResponsiveListItemRenderer;
return {
title: list_item.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text,
author: list_item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[2].text,
year: list_item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs.find((run) => /^[12][0-9]{3}$/.test(run.text)).text,
thumbnail: list_item.thumbnail.musicThumbnailRenderer.thumbnail,
};
});
return { songs, videos, albums };
}
#parseVideoInfo() {
const desktop_v = this.args.desktop_v;
const playability_status = desktop_v && this.data.playabilityStatus ||
this.data[2].playerResponse.playabilityStatus;
if (playability_status.status == 'ERROR')
throw new Error(`Could not retrieve details for this video: ${playability_status.status} - ${playability_status.reason}`);
const details = desktop_v && this.data.videoDetails ||
this.data[2].playerResponse.videoDetails;
const microformat = desktop_v && this.data.microformat.playerMicroformatRenderer ||
this.data[2].playerResponse.microformat.playerMicroformatRenderer;
const streaming_data = desktop_v && this.data.streamingData ||
this.data[2].playerResponse.streamingData;
const response = {
id: '',
title: '',
description: '',
thumbnail: [],
metadata: {}
};
const mf_raw_data = Object.entries(microformat);
const dt_raw_data = Object.entries(details);
mf_raw_data.forEach((entry) => {
const key = Utils.camelToSnake(entry[0]);
if (Constants.METADATA_KEYS.includes(key)) {
key == 'view_count' && (response.metadata[key] = parseInt(entry[1])) ||
key == 'owner_profile_url' && (response.metadata.channel_url = entry[1]) ||
key == 'owner_channel_name' && (response.metadata.channel_name = entry[1]) ||
(response.metadata[key] = entry[1]);
} else {
response[key] = entry[1];
}
});
dt_raw_data.forEach((entry) => {
const key = Utils.camelToSnake(entry[0]);
if (Constants.BLACKLISTED_KEYS.includes(key)) return;
if (Constants.METADATA_KEYS.includes(key)) {
key == 'view_count' && (response.metadata[key] = parseInt(entry[1])) ||
(response.metadata[key] = entry[1]);
} else {
key == 'short_description' && (response.description = entry[1]) ||
key == 'thumbnail' && (response.thumbnail = entry[1].thumbnails.slice(-1)[0]) ||
key == 'video_id' && (response.id = entry[1]) ||
(response[key] = entry[1]);
}
});
if (!desktop_v) {
const dislike_available = this.data[3].response.contents.singleColumnWatchNextResults.results.results.contents[1]
.slimVideoMetadataSectionRenderer.contents[1].slimVideoActionBarRenderer.buttons[1].slimMetadataToggleButtonRenderer
.button.toggleButtonRenderer.defaultText.accessibility && true || false;
response.metadata.likes = parseInt(this.data[3].response.contents.singleColumnWatchNextResults.results.results.contents[1]
.slimVideoMetadataSectionRenderer.contents[1].slimVideoActionBarRenderer.buttons[0].slimMetadataToggleButtonRenderer
.button.toggleButtonRenderer.defaultText.accessibility.accessibilityData.label.replace(/\D/g, ''));
response.metadata.dislikes = dislike_available && parseInt(this.data[3].response.contents.singleColumnWatchNextResults.results.results.contents[1]
.slimVideoMetadataSectionRenderer.contents[1].slimVideoActionBarRenderer.buttons[1].slimMetadataToggleButtonRenderer
.button.toggleButtonRenderer.defaultText.accessibility.accessibilityData.label.replace(/\D/g, '')) || 0;
}
response.metadata.available_qualities = [...new Set(streaming_data.adaptiveFormats.filter(v => v.qualityLabel)
.map(v => v.qualityLabel).sort((a, b) => + a.replace(/\D/gi, '') - + b.replace(/\D/gi, '')))];
return response;
}
}
module.exports = Parser;

View File

@@ -1,174 +0,0 @@
'use strict';
const Fs = require('fs');
const Proto = require('protons');
const Crypto = require('crypto');
const UserAgent = require('user-agents');
/**
* Returns a random user agent.
*
* @param {string} type mobile | desktop
*/
function getRandomUserAgent(type) {
switch (type) {
case 'mobile':
return new UserAgent(/Android/).data;
case 'desktop':
return new UserAgent({ deviceCategory: 'desktop' }).data;
default:
}
}
/**
* Generates an authentication token from a cookies' sid.
*
* @param {string} sid Sid extracted from cookies
*/
function generateSidAuth(sid) {
const youtube = 'https://www.youtube.com';
const timestamp = Math.floor(new Date().getTime() / 1000);
const input = [timestamp, sid, youtube].join(' ');
let hash = Crypto.createHash('sha1');
let data = hash.update(input, 'utf-8');
let gen_hash = data.digest('hex');
return ['SAPISIDHASH', [timestamp, gen_hash].join('_')].join(' ');
}
/**
* Gets a string between two delimiters.
*
* @param {string} data The data.
* @param {string} start_string Start string.
* @param {string} end_string End string.
*/
function getStringBetweenStrings(data, start_string, end_string) {
const regex = new RegExp(`${escapeStringRegexp(start_string)}(.*?)${escapeStringRegexp(end_string)}`, "s");
const match = data.match(regex);
return match ? match[1] : undefined;
}
function escapeStringRegexp(string) {
return string.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&").replace(/-/g, "\\x2d");
}
/**
* Converts time (h:m:s) to seconds.
*
* @param {string} time
* @returns {string} seconds
*/
function timeToSeconds(time) {
let params = time.split(':');
return parseInt(({
3: +params[0] * 3600 + +params[1] * 60 + +params[2],
2: +params[0] * 60 + +params[1],
1: +params[0]
})[params.length]);
}
/**
* Converts strings in camelCase to snake_case.
*
* @param {string} string The string in camelCase.
*/
function camelToSnake(string) {
return string[0].toLowerCase() + string.slice(1, string.length).replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
}
/**
* Encodes notification preferences protobuf.
*
* @param {string} channel_id
* @param {string} index
*/
function encodeNotificationPref(channel_id, index) {
const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/proto/youtube.proto`));
const buf = youtube_proto.NotificationPreferences.encode({
channel_id,
pref_id: {
index
},
number_0: 0,
number_1: 4
});
return encodeURIComponent(Buffer.from(buf).toString('base64'));
}
/**
* Encodes livestream message protobuf.
*
* @param {string} channel_id
* @param {string} video_id
*/
function encodeMessageParams(channel_id, video_id) {
const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/proto/youtube.proto`));
const buf = youtube_proto.LiveMessageParams.encode({
params: {
ids: {
channel_id,
video_id
}
},
number_0: 1,
number_1: 4
});
return Buffer.from(encodeURIComponent(Buffer.from(buf).toString('base64'))).toString('base64');
}
/**
* Encodes comment params protobuf.
*
* @param {string} video_id
*/
function encodeCommentParams(video_id) {
const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/proto/youtube.proto`));
const buf = youtube_proto.CreateCommentParams.encode({
video_id,
params: {
index: 0
},
number: 7
});
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
*/
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 search_filter_buff = youtube_proto.SearchFilter.encode({
number: orders[order],
filter: {
param_0: periods[period],
param_1: (period == 'hour' && order == 'relevance') ? null : 1,
param_2: durations[duration]
}
});
return encodeURIComponent(Buffer.from(search_filter_buff).toString('base64'));
}
module.exports = { getRandomUserAgent, generateSidAuth, getStringBetweenStrings, camelToSnake, timeToSeconds, encodeMessageParams, encodeCommentParams, encodeNotificationPref, encodeFilter };

413
lib/core/Actions.js Normal file
View File

@@ -0,0 +1,413 @@
'use strict';
const Uuid = require('uuid');
const Axios = require('axios');
const Proto = require('../proto');
const Constants = require('../utils/Constants');
/**
* Performs direct interactions on YouTube.
*
* @param {Innertube} session - A valid Innertube session.
* @param {string} engagement_type - Type of engagement.
* @param {object} args - Engagement arguments.
* @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>}
*/
async function engage(session, engagement_type, args = {}) {
if (!session.logged_in) throw new Error('You are not signed in');
const data = { context: session.context };
switch (engagement_type) {
case 'like/like':
case 'like/dislike':
case 'like/removelike':
data.target = {
videoId: args.video_id
}
break;
case 'subscription/subscribe':
case 'subscription/unsubscribe':
data.channelIds = [args.channel_id];
data.params = engagement_type == 'subscription/subscribe' ? 'EgIIAhgA' : 'CgIIAhgA';
break;
case 'comment/create_comment':
data.commentText = args.text;
data.createCommentParams = Proto.encodeCommentParams(args.video_id);
break;
case 'comment/create_comment_reply':
data.createReplyParams = Proto.encodeCommentReplyParams(args.comment_id, args.video_id);
data.commentText = args.text;
break;
case 'comment/perform_comment_action':
const action = ({
like: () => Proto.encodeCommentActionParams(5, args.comment_id, args.video_id, args.channel_id),
dislike: () => Proto.encodeCommentActionParams(4, args.comment_id, args.video_id, args.channel_id),
})[args.comment_action]();
data.actions = [action];
break;
default:
throw new Utils.InnertubeError('Invalid action', action);
}
const response = await session.YTRequester.post(`/${engagement_type}`, JSON.stringify(data)).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response?.status || 0, message: response.message };
return {
success: true,
status_code: response.status
};
}
/**
* Accesses YouTube's various sections.
*
* @param {Innertube} session - A valid Innertube session.
* @param {string} action - Type of action.
* @param {object} args - Action argumenets.
* @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>}
*/
async function browse(session, action, args = {}) {
if (!session.logged_in && action != 'home_feed' &&
action !== 'lyrics' && action !== 'music_playlist' &&
action !== 'playlist')
throw new Error('You are not signed in');
const data = { context: session.context };
switch (action) {
case 'account_notifications':
data.browseId = 'SPaccount_notifications';
break;
case 'account_privacy':
data.browseId = 'SPaccount_privacy';
break;
case 'history':
data.browseId = 'FEhistory';
break;
case 'home_feed':
data.browseId = 'FEwhat_to_watch';
break;
case 'subscriptions_feed':
data.browseId = 'FEsubscriptions';
break;
case 'lyrics':
case 'music_playlist':
const context = JSON.parse(JSON.stringify(session.context)); // deep copy the context obj so we don't accidentally change it
context.client.originalUrl = Constants.URLS.YT_MUSIC;
context.client.clientVersion = Constants.YTMUSIC_VERSION;
context.client.clientName = 'WEB_REMIX';
data.context = context;
data.browseId = args.browse_id;
break;
case 'channel':
case 'playlist':
data.browseId = args.browse_id;
break;
case 'continuation':
data.continuation = args.ctoken;
break;
default:
throw new Utils.InnertubeError('Invalid action', action);
}
const requester = args.ytmusic && session.YTMRequester || session.YTRequester;
const response = await requester.post('/browse', JSON.stringify(data)).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response?.status || 0, message: response.message };
return {
success: true,
status_code: response.status,
data: response.data
};
}
/**
* Account settings endpoints.
*
* @param {Innertube} session - A valid Innertube session.
* @param {string} action - Type of action.
* @param {object} args - Action argumenets.
* @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>}
*/
async function account(session, action, args = {}) {
if (!session.logged_in) throw new Error('You are not signed in');
const data = {};
switch (action) {
case 'account/account_menu':
data.context = session.context;
break;
case 'account/set_setting':
data.context = session.context;
data.newValue = { boolValue: args.new_value };
data.settingItemId = args.setting_item_id;
break;
default:
throw new Utils.InnertubeError('Invalid action', action);
}
const response = await session.YTRequester.post(`/${action}`, JSON.stringify(data)).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response?.status || 0, message: response.message };
return {
success: true,
status_code: response.status,
data: response.data
};
}
/**
* Accesses YouTube Music endpoints (/youtubei/v1/music/).
*
* @param {Innertube} session - A valid Innertube session.
* @param {string} action - Type of action.
* @param {object} args - Action arguments.
* @todo Implement more actions.
* @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>}
*/
async function music(session, action, args) {
const context = JSON.parse(JSON.stringify(session.context)); // deep copy the context obj so we don't accidentally change it
context.client.originalUrl = Constants.URLS.YT_MUSIC;
context.client.clientVersion = Constants.YTMUSIC_VERSION;
context.client.clientName = 'WEB_REMIX';
let data;
switch (action) {
case 'get_search_suggestions':
data.context = context;
data.input = args.input || '';
break;
default:
throw new Utils.InnertubeError('Invalid action', action);
}
const response = await session.YTMRequester.post(`/music/${action}`, JSON.stringify(data)).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response?.status || 0, message: response.message };
return {
success: true,
status_code: response.status,
data: response.data
};
}
/**
* Searches a given query on YouTube/YTMusic.
*
* @param {Innertube} session - A valid Innertube session.
* @param {string} client - YouTube client: YOUTUBE | YTMUSIC
* @param {object} args - Search arguments.
* @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>}
*/
async function search(session, client, args = {}) {
const data = { context: session.context };
switch (client) {
case 'YOUTUBE':
if (args.query) {
data.params = Proto.encodeSearchFilter(args.options.period, args.options.duration, args.options.order);
data.query = args.query;
} else {
data.continuation = args.ctoken;
}
break;
case 'YTMUSIC':
const context = JSON.parse(JSON.stringify(session.context)); // deep copy the context obj so we don't accidentally change it
context.client.originalUrl = Constants.URLS.YT_MUSIC;
context.client.clientVersion = Constants.YTMUSIC_VERSION;
context.client.clientName = 'WEB_REMIX';
data.context = context;
data.query = args.query;
break;
default:
throw new Utils.InnertubeError('Invalid client', action);
}
const requester = client == 'YOUTUBE' && session.YTRequester || session.YTMRequester;
const response = await requester.post('/search', JSON.stringify(data)).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response?.status || 0, message: response.message };
return {
success: true,
status_code: response.status,
data: response.data
};
}
/**
* Interacts with YouTube's notification system.
*
* @param {Innertube} session - A valid Innertube session.
* @param {string} action - Type of action.
* @param {object} args - Action arguments.
* @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>}
*/
async function notifications(session, action, args = {}) {
if (!session.logged_in) throw new Error('You are not signed in');
const data = {};
switch (action) {
case 'modify_channel_preference':
const pref_types = { PERSONALIZED: 1, ALL: 2, NONE: 3 };
data.context = session.context;
data.params = Proto.encodeNotificationPref(args.channel_id, pref_types[args.pref.toUpperCase()]);
break;
case 'get_notification_menu':
data.context = session.context;
data.notificationsMenuRequestType = 'NOTIFICATIONS_MENU_REQUEST_TYPE_INBOX';
args.ctoken && (data.ctoken = args.ctoken);
break;
case 'get_unseen_count':
data.context = session.context;
break;
default:
throw new Utils.InnertubeError('Invalid action', action);
}
const response = await session.YTRequester.post(`/notification/${action}`, JSON.stringify(data)).catch((err) => err);
if (response instanceof Error) return { success: false, status_code: response.response?.status || 0, message: response.message };
if (action === 'modify_channel_preference') return { success: true, status_code: response.status };
return {
success: true,
status_code: response.status,
data: response.data
};
}
/**
* Interacts with YouTube's livechat system.
*
* @param {Innertube} session - A valid Innertube session.
* @param {string} action - Type of action.
* @param {object} args - Action arguments.
* @returns {Promise.<{ success: boolean; data: object; message?: string }>}
*/
async function livechat(session, action, args = {}) {
const data = {};
switch (action) {
case 'live_chat/get_live_chat':
data.context = session.context;
data.continuation = args.ctoken;
break;
case 'live_chat/send_message':
data.context = session.context;
data.params = Proto.encodeMessageParams(args.channel_id, args.video_id);
data.clientMessageId = `ytjs-${Uuid.v4()}`;
data.richMessage = {
textSegments: [{ text: args.text }]
}
break;
case 'live_chat/get_item_context_menu':
data.context = session.context;
break;
case 'live_chat/moderate':
data.context = session.context;
data.params = args.cmd_params;
break;
case 'updated_metadata':
data.context = session.context;
data.videoId = args.video_id;
args.continuation && (data.continuation = args.continuation);
break;
default:
throw new Utils.InnertubeError('Invalid action', action);
}
const response = await session.YTRequester.post(`/${action}`, JSON.stringify(data)).catch((err) => err);
if (response instanceof Error) return { success: false, message: response.message };
return { success: true, data: response.data };
}
/**
* Requests continuation for previously performed actions.
*
* @param {Innertube} session - A valid Innertube session.
* @param {object} args - Continuation arguments.
* @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>}
*/
async function next(session, args = {}) {
let data = { context: session.context };
args.continuation_token && (data.continuation = args.continuation_token);
if (args.video_id) {
data.videoId = args.video_id;
if (args.ytmusic) {
const context = JSON.parse(JSON.stringify(session.context)); // deep copy the context obj so we don't accidentally change it
context.client.originalUrl = Constants.URLS.YT_MUSIC;
context.client.clientVersion = Constants.YTMUSIC_VERSION;
context.client.clientName = 'WEB_REMIX';
data.context = context;
data.isAudioOnly = true;
data.tunerSettingValue = 'AUTOMIX_SETTING_NORMAL';
} else {
data.racyCheckOk = true;
data.contentCheckOk = false;
data.autonavState = 'STATE_NONE';
data.playbackContext = { vis: 0, lactMilliseconds: '-1' };
data.captionsRequested = false;
}
}
const requester = args.ytmusic && session.YTMRequester || session.YTRequester;
const response = await requester.post('/next', JSON.stringify(data)).catch((error) => error);
if (response instanceof Error) return {
success: false,
status_code: response.response?.status || 0,
message: response.message
};
return {
success: true,
status_code: response.status,
data: response.data
};
}
/**
* Retrieves video data.
*
* @param {Innertube} session - A valid Innertube session.
* @param {object} args - Request arguments.
* @returns {Promise.<object>} - Video data.
*/
async function getVideoInfo(session, args = {}) {
const response = await session.YTRequester.post(`/player`, JSON.stringify(Constants.VIDEO_INFO_REQBODY(args.id, session.sts, session.context))).catch((err) => err);
if (response instanceof Error) throw new Error(`Could not get video info: ${response.message}`);
return response.data;
}
/**
* Gets search suggestions.
*
* @param {Innertube} session - A valid innertube session
* @param {string} query - Search query
* @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>}
*/
async function getYTSearchSuggestions(session, query) {
const response = await Axios.get(`${Constants.URLS.YT_SUGGESTIONS}search?client=firefox&ds=yt&q=${encodeURIComponent(query)}`,
Constants.DEFAULT_HEADERS(session)).catch((error) => error);
if (response instanceof Error) return {
success: false,
status_code: 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, next, getYTSearchSuggestions };

View File

@@ -1,13 +1,15 @@
'use strict';
const Axios = require('axios');
const Actions = require('./Actions');
const Constants = require('./Constants');
const EventEmitter = require('events');
class Livechat extends EventEmitter {
constructor(session, token, channel_id, video_id) {
super(session);
if (!token)
throw new Error('Could not retrieve livechat data');
this.ctoken = token;
this.session = session;
this.video_id = video_id;
@@ -119,8 +121,12 @@ class Livechat extends EventEmitter {
};
}
/**
* Blocks a user.
* @todo Implement this method.
* @param {object} msg_params
*/
async blockUser(msg_params) {
/* TODO: Implement this */
throw new Error('Not implemented');
}

View File

@@ -1,7 +1,7 @@
'use strict';
const Axios = require('axios');
const Constants = require('./Constants');
const Constants = require('../utils/Constants');
const EventEmitter = require('events');
const Uuid = require('uuid');
@@ -11,16 +11,15 @@ class OAuth extends EventEmitter {
this.auth_info = auth_info;
this.refresh_interval = 5;
this.oauth_code_url = `${Constants.URLS.YT_BASE_URL}/o/oauth2/device/code`;
this.oauth_token_url = `${Constants.URLS.YT_BASE_URL}/o/oauth2/token`;
this.guide_url = `${Constants.URLS.YT_BASE_URL}/youtubei/v1/guide`;
this.oauth_code_url = `${Constants.URLS.YT_BASE}/o/oauth2/device/code`;
this.oauth_token_url = `${Constants.URLS.YT_BASE}/o/oauth2/token`;
this.model_name = Constants.OAUTH.MODEL_NAME;
this.grant_type = Constants.OAUTH.GRANT_TYPE;
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 +27,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 +66,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 +123,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 {Promise.<{ credentials: { access_token: string; refresh_token: string; expires: Date }; status: string }>}
*/
async refreshAccessToken() {
const identity = await this.#getClientIdentity();
@@ -166,16 +167,16 @@ 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.
const yttv_response = await Axios.get(`${Constants.URLS.YT_BASE_URL}/tv`, Constants.OAUTH.HEADERS).catch((error) => error);
const yttv_response = await Axios.get(`${Constants.URLS.YT_BASE}/tv`, Constants.OAUTH.HEADERS).catch((error) => error);
if (yttv_response instanceof Error) throw new Error(`Could not extract client identity: ${yttv_response.message}`);
// Here we download the script and extract the necessary data to proceed with the auth flow.
const url_body = this.auth_script_regex.exec(yttv_response.data)[1];
const script_url = `${Constants.URLS.YT_BASE_URL}/${url_body}`;
const script_url = `${Constants.URLS.YT_BASE}/${url_body}`;
const response = await Axios.get(script_url, Constants.DEFAULT_HEADERS).catch((error) => error);
if (response instanceof Error) throw new Error(`Could not extract client identity: ${response.message}`);

View File

@@ -2,14 +2,14 @@
const Fs = require('fs');
const Axios = require('axios');
const Utils = require('./Utils');
const Constants = require('./Constants');
const Utils = require('../utils/Utils');
const Constants = require('../utils/Constants');
class Player {
constructor(session) {
this.session = session;
this.player_name = Utils.getStringBetweenStrings(this.session.player_url, '/player/', '/');
this.tmp_cache_dir = __dirname.slice(0, -3) + 'cache';
this.tmp_cache_dir = __dirname.slice(0, -8) + 'cache';
}
async init() {
@@ -18,10 +18,13 @@ class Player {
this.sig_decipher_sc = this.#getSigDecipherCode(player_data);
this.ntoken_sc = this.#getNEncoder(player_data);
} else {
const response = await Axios.get(`${Constants.URLS.YT_BASE_URL}${this.session.player_url}`, { path: this.session.playerUrl, headers: { 'content-type': 'text/javascript', 'user-agent': Utils.getRandomUserAgent('desktop').userAgent } }).catch((error) => error);
const response = await Axios.get(`${Constants.URLS.YT_BASE}${this.session.player_url}`, { path: this.session.playerUrl, headers: { 'content-type': 'text/javascript', 'user-agent': Utils.getRandomUserAgent('desktop').userAgent } }).catch((error) => error);
if (response instanceof Error) throw new Error('Could not download player script: ' + response.message);
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);

View File

@@ -1,23 +1,20 @@
'use strict';
const Utils = require('./Utils');
const Constants = require('./Constants');
const Utils = require('../utils/Utils');
const Constants = require('../utils/Constants');
class NToken {
constructor(raw_code) {
constructor(raw_code, n) {
this.n = n;
this.raw_code = raw_code;
this.null_placeholder_regex = /c\[(.*?)\]=c/g;
this.transformation_calls_regex = /c\[(.*?)\]\((.+?)\)/g;
}
/**
* Solves throttling challange by transforming the n token.
*
* @param {string} n token.
* @returns {string} transformed token.
*/
transform(n) {
let n_token = n.split('');
transform() {
let n_token = this.n.split('');
try {
let transformations = this.#getTransformationData();
@@ -41,22 +38,22 @@ 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(Constants.NTOKEN_REGEX.PLACEHOLDERS)].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(Constants.NTOKEN_REGEX.CALLS)].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)
return n;
console.error(`Could not transform n-token (${this.n}), download may be throttled:`, err.message);
return this.n;
}
return n_token.join('');
}
@@ -71,28 +68,30 @@ 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;
arr.forEach(function (char, index, loc) {
arr.forEach(function(char, index, loc) {
this.push(loc[index] = characters[(characters.indexOf(char) - characters.indexOf(this[index]) + 64) % characters.length]);
}, token.split(''));
}
#translate2(arr, token, characters) {
let chars_length = characters.length;
arr.forEach(function (char, index, loc) {
arr.forEach(function(char, index, loc) {
this.push(loc[index] = characters[(characters.indexOf(char) - characters.indexOf(this[index]) + index + chars_length--) % characters.length]);
}, token.split(''));
}
/**
* 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 +100,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 +111,7 @@ class NToken {
/**
* Rotates elements of the array.
* @returns
*/
#rotate(arr, index) {
index = (index % arr.length + arr.length) % arr.length;
@@ -119,6 +120,7 @@ class NToken {
/**
* Deletes one element at the given index.
* @returns
*/
#splice(arr, index) {
index = (index % arr.length + arr.length) % arr.length;

View File

@@ -1,18 +1,16 @@
'use strict';
const NToken = require('./NToken');
const QueryString = require('querystring');
class SigDecipher {
constructor(url, cver, player) {
constructor(url, player) {
this.url = url;
this.cver = cver;
this.player = player;
this.func_regex = /(.{2}):function\(.*?\){(.*?)}/g;
this.actions_regex = /;.{2}\.(.{2})\(.*?,(.*?)\)/g;
}
/**
/**
* Deciphers signature.
*/
decipher() {
@@ -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();
}

255
lib/parser/index.js Normal file
View File

@@ -0,0 +1,255 @@
'use strict';
const Utils = require('../utils/Utils');
const Actions = require('../core/Actions');
const Constants = require('../utils/Constants');
const YTDataItems = require('./youtube');
const YTMusicDataItems = require('./ytmusic');
class Parser {
constructor(session, data, args = {}) {
this.session = session;
this.data = data;
this.args = args;
}
parse() {
const client = this.args.client;
const data_type = this.args.data_type
let processed_data;
switch (client) {
case 'YOUTUBE':
processed_data = ({
SEARCH: () => this.#processSearch(),
PLAYLIST: () => this.#processPlaylist(),
VIDEO_INFO: () => this.#processVideoInfo()
})[data_type]()
break;
case 'YTMUSIC':
processed_data = ({
SEARCH: () => this.#processMusicSearch(),
PLAYLIST: () => this.#processMusicPlaylist()
})[data_type]();
break;
default:
throw new Utils.InnertubeError('Invalid client');
}
return processed_data;
}
#processSearch() {
const contents = Utils.findNode(this.data, 'contents', 'contents', 5);
const processed_data = {};
const parseItems = (contents) => {
const content = contents[0].itemSectionRenderer.contents;
processed_data.query = content[0]?.showingResultsForRenderer?.originalQuery?.simpleText || this.args.query;
processed_data.corrected_query = content[0]?.showingResultsForRenderer?.correctedQueryEndpoint?.searchEndpoint?.query || 'N/A';
processed_data.estimated_results = parseInt(this.data.estimatedResults);
processed_data.videos = YTDataItems.VideoResultItem.parse(content);
processed_data.getContinuation = async () => {
const citem = contents.find((item) => item.continuationItemRenderer);
const ctoken = citem.continuationItemRenderer.continuationEndpoint.continuationCommand.token;
const response = await Actions.search(this.session, 'YOUTUBE', { ctoken });
if (!response.success) throw new Utils.InnertubeError('Could not get continuation', response);
const continuation_items = Utils.findNode(response.data, 'onResponseReceivedCommands', 'itemSectionRenderer', 4, false);
return parseItems(continuation_items);
};
return processed_data;
}
return parseItems(contents);
}
#processMusicSearch() {
const tabs = Utils.findNode(this.data, 'contents', 'tabs').tabs;
const contents = Utils.findNode(tabs, '0', 'contents', 5);
const did_you_mean_item = contents.find((content) => content.itemSectionRenderer);
const did_you_mean_renderer = did_you_mean_item?.itemSectionRenderer.contents[0].didYouMeanRenderer;
const processed_data = {
query: '',
corrected_query: '',
results: {}
};
processed_data.query = this.args.query;
processed_data.corrected_query = did_you_mean_renderer?.correctedQuery.runs.map((run) => run.text).join('') || 'N/A';
contents.forEach((content) => {
const section = content?.musicShelfRenderer;
if (section) {
const section_title = section.title.runs[0].text;
const section_items = ({
['Top result']: () => YTMusicDataItems.TopResultItem.parse(section.contents), // console.log(JSON.stringify(section.contents, null, 4)),
['Songs']: () => YTMusicDataItems.SongResultItem.parse(section.contents),
['Videos']: () => YTMusicDataItems.VideoResultItem.parse(section.contents),
['Featured playlists']: () => YTMusicDataItems.PlaylistResultItem.parse(section.contents),
['Community playlists']: () => YTMusicDataItems.PlaylistResultItem.parse(section.contents),
['Artists']: () => YTMusicDataItems.ArtistResultItem.parse(section.contents),
['Albums']: () => YTMusicDataItems.AlbumResultItem.parse(section.contents)
})[section_title]();
processed_data.results[section_title.replace(/ /g, '_').toLowerCase()] = section_items;
}
});
return processed_data;
}
#processPlaylist() {
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 list = Utils.findNode(this.data, 'contents', 'contents', 13, false);
const items = YTDataItems.PlaylistItem.parse(list.contents);
return {
...metadata,
items
}
}
#processMusicPlaylist() {
const details = this.data.header.musicDetailHeaderRenderer;
const metadata = {
title: details?.title?.runs[0].text,
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 = YTMusicDataItems.PlaylistItem.parse(playlist_content);
return {
...metadata,
items
}
}
/**
* Video data is parsed dynamically, so if youtube decides to add something new we won't have to change anything here.
*/
#processVideoInfo() {
const playability_status = this.data.playabilityStatus;
if (playability_status.status == 'ERROR')
throw new Error(`Could not retrieve video details: ${playability_status.status} - ${playability_status.reason}`);
const details = this.data.videoDetails;
const microformat = this.data.microformat.playerMicroformatRenderer;
const streaming_data = this.data.streamingData;
const mf_raw_data = Object.entries(microformat);
const dt_raw_data = Object.entries(details);
const processed_data = {
id: '',
title: '',
description: '',
thumbnail: [],
metadata: {}
};
// Extracts most of the metadata
mf_raw_data.forEach((entry) => {
const key = Utils.camelToSnake(entry[0]);
if (Constants.METADATA_KEYS.includes(key)) {
key == 'view_count' && (processed_data.metadata[key] = parseInt(entry[1])) ||
key == 'owner_profile_url' && (processed_data.metadata.channel_url = entry[1]) ||
key == 'owner_channel_name' && (processed_data.metadata.channel_name = entry[1]) ||
(processed_data.metadata[key] = entry[1]);
} else {
processed_data[key] = entry[1];
}
});
// Extracts extra details
dt_raw_data.forEach((entry) => {
const key = Utils.camelToSnake(entry[0]);
if (Constants.BLACKLISTED_KEYS.includes(key)) return;
if (Constants.METADATA_KEYS.includes(key)) {
key == 'view_count' && (processed_data.metadata[key] = parseInt(entry[1])) ||
(processed_data.metadata[key] = entry[1]);
} else {
key == 'short_description' && (processed_data.description = entry[1]) ||
key == 'thumbnail' && (processed_data.thumbnail = entry[1].thumbnails.slice(-1)[0]) ||
key == 'video_id' && (processed_data.id = entry[1]) ||
(processed_data[key] = entry[1]);
}
});
// Data continuation is only required for getDetails()
if (this.data.continuation) {
const primary_info_renderer = this.data.continuation.contents.twoColumnWatchNextResults
.results.results.contents.find((item) => item.videoPrimaryInfoRenderer).videoPrimaryInfoRenderer;
const secondary_info_renderer = this.data.continuation.contents.twoColumnWatchNextResults
.results.results.contents.find((item) => item.videoSecondaryInfoRenderer).videoSecondaryInfoRenderer;
const like_btn = primary_info_renderer.videoActions.menuRenderer
.topLevelButtons.find((item) => item.toggleButtonRenderer.defaultIcon.iconType == 'LIKE');
const dislike_btn = primary_info_renderer.videoActions.menuRenderer
.topLevelButtons.find((item) => item.toggleButtonRenderer.defaultIcon.iconType == 'DISLIKE');
const notification_toggle_btn = secondary_info_renderer.subscribeButton.subscribeButtonRenderer
?.notificationPreferenceButton?.subscriptionNotificationToggleButtonRenderer;
// These will always be false if logged out.
processed_data.metadata.is_liked = like_btn.toggleButtonRenderer.isToggled;
processed_data.metadata.is_disliked = dislike_btn.toggleButtonRenderer.isToggled;
processed_data.metadata.is_subscribed = secondary_info_renderer.subscribeButton.subscribeButtonRenderer?.subscribed || false;
processed_data.metadata.subscriber_count = secondary_info_renderer.owner.videoOwnerRenderer?.subscriberCountText?.simpleText || 'N/A';
processed_data.metadata.current_notification_preference = notification_toggle_btn?.states.find((state) => state.stateId == notification_toggle_btn.currentStateId)
.state.buttonRenderer.icon.iconType || 'N/A';
// Simpler version of publish_date
processed_data.metadata.publish_date_text = primary_info_renderer.dateText.simpleText;
// Only parse like count if it's enabled
if (processed_data.metadata.allow_ratings) {
processed_data.metadata.likes = {
count: parseInt(like_btn.toggleButtonRenderer.defaultText.accessibility.accessibilityData.label.replace(/\D/g, '')),
short_count_text: like_btn.toggleButtonRenderer.defaultText.simpleText
};
}
processed_data.metadata.owner_badges = secondary_info_renderer.owner.videoOwnerRenderer?.badges?.map((badge) => badge.metadataBadgeRenderer.tooltip) || [];
}
streaming_data && streaming_data.adaptiveFormats &&
(processed_data.metadata.available_qualities = [...new Set(streaming_data.adaptiveFormats.filter(v => v.qualityLabel)
.map(v => v.qualityLabel).sort((a, b) => +a.replace(/\D/gi, '') - +b.replace(/\D/gi, '')))]) ||
(processed_data.metadata.available_qualities = []);
return processed_data;
}
}
module.exports = Parser;

View File

@@ -0,0 +1,6 @@
'use strict';
const VideoResultItem = require('./search/VideoResultItem');
const PlaylistItem = require('./others/PlaylistItem');
module.exports = { VideoResultItem, PlaylistItem };

View File

@@ -0,0 +1,26 @@
'use strict';
const Utils = require('../../../utils/Utils');
class PlaylistItem {
static parse(data) {
return data.map((item) => this.parseItem(item)).filter((item) => item);
}
static parseItem(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?.simpleText || '0'),
simple_text: item?.playlistVideoRenderer?.lengthText?.simpleText || 'N/A',
accessibility_label: item?.playlistVideoRenderer?.lengthText?.accessibility?.accessibilityData?.label || 'N/A'
},
thumbnails: item?.playlistVideoRenderer?.thumbnail?.thumbnails,
};
}
}
module.exports = PlaylistItem;

View File

@@ -0,0 +1,43 @@
'use strict';
const Utils = require('../../../utils/Utils');
const Constants = require('../../../utils/Constants');
class VideoResultItem {
static parse(data) {
return data.map((item) => this.parseItem(item)).filter((item) => item);
}
static parseItem(item) {
const renderer = item.videoRenderer || item.compactVideoRenderer;
if (renderer) return {
id: renderer.videoId,
url: `https://youtu.be/${renderer.videoId}`,
title: renderer.title.runs[0].text,
description: renderer?.detailedMetadataSnippets && renderer?.detailedMetadataSnippets[0].snippetText.runs.map((item) => item.text).join('') || 'N/A',
channel: {
id: renderer?.ownerText?.runs[0]?.navigationEndpoint?.browseEndpoint?.browseId,
name: renderer?.ownerText?.runs[0]?.text,
url: `${Constants.URLS.YT_BASE}${renderer.ownerText.runs[0].navigationEndpoint?.browseEndpoint?.canonicalBaseUrl}`
},
metadata: {
view_count: renderer?.viewCountText?.simpleText || 'N/A',
short_view_count_text: {
simple_text: renderer?.shortViewCountText?.simpleText || 'N/A',
accessibility_label: renderer?.shortViewCountText?.accessibility?.accessibilityData?.label || 'N/A',
},
thumbnails: renderer?.thumbnail.thumbnails,
duration: {
seconds: Utils.timeToSeconds(renderer?.lengthText?.simpleText || '0'),
simple_text: renderer?.lengthText?.simpleText || 'N/A',
accessibility_label: renderer?.lengthText?.accessibility?.accessibilityData?.label || 'N/A'
},
published: renderer?.publishedTimeText?.simpleText || 'N/A',
badges: renderer?.badges?.map((item) => item.metadataBadgeRenderer.label) || [],
owner_badges: renderer?.ownerBadges?.map((item) => item.metadataBadgeRenderer.tooltip) || []
}
};
}
}
module.exports = VideoResultItem;

View File

@@ -0,0 +1,11 @@
'use strict';
const SongResultItem = require('./search/SongResultItem');
const VideoResultItem = require('./search/VideoResultItem');
const AlbumResultItem = require('./search/AlbumResultItem');
const ArtistResultItem = require('./search/ArtistResultItem');
const PlaylistResultItem = require('./search/PlaylistResultItem');
const TopResultItem = require('./search/TopResultItem');
const PlaylistItem = require('./others/PlaylistItem');
module.exports = { SongResultItem, VideoResultItem, AlbumResultItem, ArtistResultItem, PlaylistResultItem, TopResultItem, PlaylistItem };

View File

@@ -0,0 +1,28 @@
'use strict';
const Utils = require('../../../utils/Utils');
class PlaylistItem {
static parse(data) {
return data.map((item) => this.parseItem(item)).filter((item) => item.id);
}
static parseItem(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: {
seconds: Utils.timeToSeconds(fixed_columns[0].musicResponsiveListItemFixedColumnRenderer.text.runs[0].text || '0'),
simple_text: fixed_columns[0].musicResponsiveListItemFixedColumnRenderer.text.runs[0].text,
},
thumbnails: item_renderer.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails
}
}
}
module.exports = PlaylistItem;

View File

@@ -0,0 +1,21 @@
'use strict';
class AlbumResultItem {
static parse(data) {
return data.map((item) => this.parseItem(item));
}
static parseItem(item) {
const list_item = item.musicResponsiveListItemRenderer;
return {
id: list_item.navigationEndpoint.browseEndpoint.browseId,
title: list_item.flexColumns[0]?.musicResponsiveListItemFlexColumnRenderer.text.runs[0]?.text,
author: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs[2]?.text,
year: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs
.find((run) => /^[12][0-9]{3}$/.test(run.text)).text,
thumbnails: list_item?.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails,
};
}
}
module.exports = AlbumResultItem;

View File

@@ -0,0 +1,19 @@
'use strict';
class ArtistResultItem {
static parse(data) {
return data.map((item) => this.parseItem(item));
}
static parseItem(item) {
const list_item = item.musicResponsiveListItemRenderer;
return {
id: list_item.navigationEndpoint.browseEndpoint.browseId,
name: list_item.flexColumns[0]?.musicResponsiveListItemFlexColumnRenderer.text.runs[0]?.text,
subscribers: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs[2]?.text,
thumbnails: list_item?.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails,
};
}
}
module.exports = ArtistResultItem;

View File

@@ -0,0 +1,23 @@
'use strict';
class PlaylistResultItem {
static parse(data) {
return data.map((item) => this.parseItem(item));
}
static parseItem(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 || '0',
total_items: parseInt(list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs[4]?.text.match(/\d+/g)),
};
}
}
module.exports = PlaylistResultItem;

View File

@@ -0,0 +1,22 @@
'use strict';
class SongResultItem {
static parse(data) {
return data.map((item) => this.parseItem(item)).filter((item) => item);
}
static parseItem(item) {
const list_item = item.musicResponsiveListItemRenderer;
if (list_item.playlistItemData) return {
id: list_item.playlistItemData.videoId,
title: list_item.flexColumns[0]?.musicResponsiveListItemFlexColumnRenderer.text.runs[0]?.text,
artist: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs[2]?.text,
album: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs[4]?.text,
duration: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs
.find((run) => /^\d+$/.test(run.text.replace(/:/g, ''))).text,
thumbnails: list_item.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails,
};
}
}
module.exports = SongResultItem;

View File

@@ -0,0 +1,32 @@
'use strict';
const SongResultItem = require('./SongResultItem');
const VideoResultItem = require('./VideoResultItem');
const AlbumResultItem = require('./AlbumResultItem');
const ArtistResultItem = require('./ArtistResultItem');
const PlaylistResultItem = require('./PlaylistResultItem');
class TopResultItem {
static parse(data) {
return data.map((item) => {
const list_item = item.musicResponsiveListItemRenderer;
const runs = list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs;
const type = runs[0].text.toLowerCase();
const parsed_item = ({
playlist: () => PlaylistResultItem.parseItem(item),
song: () => SongResultItem.parseItem(item),
video: () => VideoResultItem.parseItem(item),
artist: () => ArtistResultItem.parseItem(item),
album: () => ArtistResultItem.parseItem(item)
}[type])();
parsed_item.type = type;
return parsed_item;
}).filter((item) => item);
}
}
module.exports = TopResultItem;

View File

@@ -0,0 +1,22 @@
'use strict';
class VideoResultItem {
static parse(data) {
return data.map((item) => this.parseItem(item)).filter((item) => item);
}
static parseItem(item) {
const list_item = item.musicResponsiveListItemRenderer;
if (list_item.playlistItemData) return {
id: list_item.playlistItemData.videoId,
title: list_item.flexColumns[0]?.musicResponsiveListItemFlexColumnRenderer.text.runs[0]?.text,
author: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs[2]?.text,
views: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs[4]?.text,
duration: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs
.find((run) => /^\d+$/.test(run.text.replace(/:/g, ''))).text,
thumbnails: list_item?.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails,
};
}
}
module.exports = VideoResultItem;

133
lib/proto/index.js Normal file
View File

@@ -0,0 +1,133 @@
'use strict';
const Fs = require('fs');
const Proto = require('protons');
/**
* Encodes advanced search filters.
*
* @param {string} period - Period in which a video is uploaded: any | hour | day | week | month | year
* @param {string} duration - The duration of a video: any | short | long
* @param {string} order - The order of the search results: relevance | rating | age | views
* @returns {string}
*/
function encodeSearchFilter(period, duration, order) {
const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/youtube.proto`));
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 search_filter_buff = youtube_proto.SearchFilter.encode({
number: orders[order],
filter: {
param_0: periods[period],
param_1: (period == 'hour' && order == 'relevance') ? null : 1,
param_2: durations[duration]
}
});
return encodeURIComponent(Buffer.from(search_filter_buff).toString('base64'));
}
/**
* Encodes livestream message parameters.
*
* @param {string} channel_id - The id of the channel hosting the livestream.
* @param {string} video_id - The id of the livestream.
* @returns {string}
*/
function encodeMessageParams(channel_id, video_id) {
const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/youtube.proto`));
const buf = youtube_proto.LiveMessageParams.encode({
params: {
ids: { channel_id, video_id }
},
number_0: 1,
number_1: 4
});
return Buffer.from(encodeURIComponent(Buffer.from(buf).toString('base64'))).toString('base64');
}
/**
* Encodes comment parameters.
*
* @param {string} video_id - The id of the video you're commenting on.
* @returns {string}
*/
function encodeCommentParams(video_id) {
const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/youtube.proto`));
const buf = youtube_proto.CreateCommentParams.encode({
video_id,
params: { index: 0 },
number: 7
});
return encodeURIComponent(Buffer.from(buf).toString('base64'));
}
/**
* Encodes comment reply parameters.
*
* @param {string} comment_id - The id of the comment.
* @param {string} video_id - The id of the video you're commenting on.
* @returns {string}
*/
function encodeCommentReplyParams(comment_id, video_id) {
const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/youtube.proto`));
const buf = youtube_proto.CreateCommentReplyParams.encode({
video_id, comment_id,
params: { unk_num: 0 },
unk_num: 7
});
return encodeURIComponent(Buffer.from(buf).toString('base64'));
}
/**
* Encodes comment action parameters (liking, disliking, reporting a comment etc).
*
* @param {string} type - Type of action.
* @param {string} comment_id - The id of the comment.
* @param {string} video_id - The id of the video you're commenting on.
* @param {string} channel_id - The id of the channel.
* @returns {string}
*/
function encodeCommentActionParams(type, comment_id, video_id, channel_id) {
const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/youtube.proto`));
const buf = youtube_proto.PeformCommentActionParams.encode({
type, comment_id, channel_id, video_id,
unk_num: 2, unk_num_1: 0, unk_num_2: 0,
unk_num_3: "0", unk_num_4: 0,
unk_num_5: 12, unk_num_6: 0,
});
return encodeURIComponent(Buffer.from(buf).toString('base64'));
}
/**
* Encodes notification preferences.
*
* @param {string} channel_id - The id of the channel.
* @param {string} index - The index of the preference id.
* @returns {string}
*/
function encodeNotificationPref(channel_id, index) {
const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/youtube.proto`));
const buf = youtube_proto.NotificationPreferences.encode({
channel_id,
pref_id: { index },
number_0: 0,
number_1: 4
});
return encodeURIComponent(Buffer.from(buf).toString('base64'));
}
module.exports = { encodeMessageParams, encodeCommentParams, encodeCommentReplyParams, encodeCommentActionParams, encodeNotificationPref, encodeSearchFilter };

View File

@@ -1,44 +1,75 @@
syntax = "proto2";
package proto;
message NotificationPreferences {
string channel_id = 1;
message Preference {
int32 index = 1;
}
Preference pref_id = 2;
int32 number_0 = 3;
int32 number_1 = 4;
}
message LiveMessageParams {
message Params {
message Ids {
string channel_id = 1;
string video_id = 2;
}
Ids ids = 5;
}
Params params = 1;
int32 number_0 = 2;
int32 number_1 = 3;
}
message CreateCommentParams {
string video_id = 2;
message Params {
int32 index = 1;
}
Params params = 5;
int32 number = 10;
}
message SearchFilter {
int32 number = 1;
message Filter {
int32 param_0 = 1;
int32 param_1 = 2;
int32 param_2 = 3;
}
Filter filter = 2;
syntax = "proto2";
package proto;
message NotificationPreferences {
string channel_id = 1;
message Preference {
int32 index = 1;
}
Preference pref_id = 2;
int32 number_0 = 3;
int32 number_1 = 4;
}
message LiveMessageParams {
message Params {
message Ids {
string channel_id = 1;
string video_id = 2;
}
Ids ids = 5;
}
Params params = 1;
int32 number_0 = 2;
int32 number_1 = 3;
}
message CreateCommentParams {
string video_id = 2;
message Params {
int32 index = 1;
}
Params params = 5;
int32 number = 10;
}
message CreateCommentReplyParams {
string video_id = 2;
string comment_id = 4;
message UnknownParams {
int32 unk_num = 1;
}
UnknownParams params = 5;
int32 unk_num = 10;
}
message PeformCommentActionParams {
int32 type = 1;
int32 unk_num = 2;
string comment_id = 3;
string video_id = 5;
int32 unk_num_1 = 6;
int32 unk_num_2 = 7;
string unk_num_3 = 9;
int32 unk_num_4 = 10;
int32 unk_num_5 = 21;
string channel_id = 23;
int32 unk_num_6 = 30;
}
message SearchFilter {
int32 number = 1;
message Filter {
int32 param_0 = 1;
int32 param_1 = 2;
int32 param_2 = 3;
}
Filter filter = 2;
}

View File

@@ -4,10 +4,11 @@ const Utils = require('./Utils');
module.exports = {
URLS: {
YT_BASE_URL: 'https://www.youtube.com',
YT_MUSIC_URL: 'https://music.youtube.com',
YT_MOBILE_URL: 'https://m.youtube.com',
YT_WATCH_PAGE: 'https://m.youtube.com/watch'
YT_BASE: 'https://www.youtube.com',
YT_BASE_API: 'https://www.youtube.com/youtubei/',
YT_SUGGESTIONS: 'https://suggestqueries.google.com/complete/',
YT_MUSIC: 'https://music.youtube.com',
YT_MUSIC_BASE_API: 'https://music.youtube.com/youtubei/'
},
OAUTH: {
SCOPE: 'http://gdata.youtube.com https://www.googleapis.com/auth/youtube-paid-content',
@@ -32,8 +33,7 @@ module.exports = {
'Referer': 'https://www.google.com/',
'Accept': 'text/html',
'Accept-Language': 'en-US,en',
'Accept-Encoding': 'gzip',
'Upgrade-Insecure-Requests': 1
'Accept-Encoding': 'gzip'
}
};
},
@@ -45,36 +45,30 @@ module.exports = {
'Referer': 'https://www.youtube.com',
'DNT': '?1'
},
INNERTUBE_REQOPTS: (info) => {
info.desktop === undefined && (info.desktop = true);
const origin = info.ytmusic && 'https://music.youtube.com' ||
info.desktop && 'https://www.youtube.com' || 'https://m.youtube.com';
let req_opts = {
params: info.params || {},
headers: {
'accept': '*/*',
'user-agent': Utils.getRandomUserAgent(info.desktop ? 'desktop' : 'mobile').userAgent,
'content-type': 'application/json',
'accept-language': 'en-US,en;q=0.9',
'x-goog-authuser': 0,
'x-goog-visitor-id': info.session.context.client.visitorData || '',
'x-youtube-client-name': info.desktop ? 1 : 2,
'x-youtube-client-version': info.session.context.client.clientVersion,
'x-youtube-chrome-connected': 'source=Chrome,mode=0,enable_account_consistency=true,supervised=false,consistency_enabled_by_default=false',
'x-origin': origin,
'origin': origin,
}
INNERTUBE_HEADERS: (info) => {
const origin = info.ytmusic && 'https://music.youtube.com' || 'https://www.youtube.com';
const headers = {
'accept': '*/*',
'user-agent': Utils.getRandomUserAgent('desktop').userAgent,
'content-type': 'application/json',
'accept-language': 'en-US,en;q=0.9',
'x-goog-authuser': 0,
'x-goog-visitor-id': info.session.context.client.visitorData || '',
'x-youtube-client-name': 1,
'x-youtube-client-version': info.session.context.client.clientVersion,
'x-youtube-chrome-connected': 'source=Chrome,mode=0,enable_account_consistency=true,supervised=false,consistency_enabled_by_default=false',
'x-origin': origin,
'origin': origin
};
info.id && (req_opts.headers.referer = (info.desktop ? 'https://www.youtube.com' : 'https://m.youtube.com') + '/watch?v=' + info.id);
if (info.session.logged_in) {
if (info.session.logged_in && info.desktop) {
req_opts.headers.Cookie = info.session.cookie;
req_opts.headers.authorization = info.session.cookie.length < 1 ? `Bearer ${info.session.access_token}` : info.session.auth_apisid;
headers.Cookie = info.session.cookie;
headers.authorization = info.session.cookie.length && info.session.auth_apisid || `Bearer ${info.session.access_token}`;
}
return req_opts;
return headers
},
VIDEO_INFO_REQBODY: (id, sts, context) => {
return {
@@ -95,8 +89,9 @@ module.exports = {
videoId: id
};
},
YTMUSIC_VERSION: '1.20211213.00.00',
METADATA_KEYS: [
'embed', 'view_count', 'average_rating',
'embed', 'view_count', 'average_rating', 'allow_ratings',
'length_seconds', 'channel_id', 'channel_url',
'external_channel_id', 'is_live_content', 'is_family_safe',
'is_unlisted', 'is_private', 'has_ypc_metadata',
@@ -105,14 +100,31 @@ module.exports = {
'owner_profile_url'
],
BLACKLISTED_KEYS: [
'is_owner_viewing', 'is_unplugged_corpus',
'is_crawlable', 'allow_ratings', 'author'
'is_owner_viewing', 'is_unplugged_corpus',
'is_crawlable', '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/,
NTOKEN_REGEX: {
CALLS: /c\[(.*?)\]\((.+?)\)/g,
PLACEHOLDERS: /c\[(.*?)\]=c/g,
},
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 +136,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'
}
};

133
lib/utils/Utils.js Normal file
View File

@@ -0,0 +1,133 @@
'use strict';
const Fs = require('fs');
const Crypto = require('crypto');
const UserAgent = require('user-agents');
const Flatten = require('flat');
function InnertubeError(message, info) {
this.info = info;
this.stack = Error(message).stack;
}
InnertubeError.prototype = Object.create(Error.prototype);
InnertubeError.prototype.constructor = InnertubeError;
class ParsingError extends InnertubeError {};
class DownloadError extends InnertubeError {};
class MissingParamError extends InnertubeError {};
class UnavailableContentError extends InnertubeError {};
class NoStreamingDataError extends InnertubeError {};
/**
* Utility to help access deep properties of an object.
*
* @param {object} obj - The object.
* @param {string} key - Key of the property being accessed.
* @param {string} target - Anything that might be inside of the property.
* @param {number} depth - Maximum number of nested objects to flatten.
* @param {boolean} safe - If set to true arrays will be preserved.
*/
function findNode(obj, key, target, depth, safe = true) {
const flat_obj = Flatten(obj, { safe, maxDepth: depth || 2 });
const result = Object.keys(flat_obj).find((entry) => entry.includes(key) && JSON.stringify(flat_obj[entry] || '{}').includes(target));
if (!result) throw new ParsingError(`Expected to find "${key}" with content "${target}" but got ${result}`, { key, target, data_snippet: `${JSON.stringify(flat_obj).slice(0, 300)}..` });
return flat_obj[result];
}
/**
* Gets a string between two delimiters.
*
* @param {string} data - The data.
* @param {string} start_string - Start string.
* @param {string} end_string - End string.
*/
function getStringBetweenStrings(data, start_string, end_string) {
const regex = new RegExp(`${escapeStringRegexp(start_string)}(.*?)${escapeStringRegexp(end_string)}`, 's');
const match = data.match(regex);
return match ? match[1] : undefined;
}
function escapeStringRegexp(string) {
return string.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&").replace(/-/g, "\\x2d");
}
/**
* Returns a random user agent.
*
* @param {string} type - mobile | desktop
* @returns {object}
*/
function getRandomUserAgent(type) {
switch (type) {
case 'mobile':
return new UserAgent(/Android/).data;
case 'desktop':
return new UserAgent({ deviceCategory: 'desktop' }).data;
default:
}
}
/**
* Generates an authentication token from a cookies' sid.
*
* @param {string} sid - Sid extracted from cookies
* @returns {string}
*/
function generateSidAuth(sid) {
const youtube = 'https://www.youtube.com';
const timestamp = Math.floor(new Date().getTime() / 1000);
const input = [timestamp, sid, youtube].join(' ');
let hash = Crypto.createHash('sha1');
let data = hash.update(input, 'utf-8');
let gen_hash = data.digest('hex');
return ['SAPISIDHASH', [timestamp, gen_hash].join('_')].join(' ');
}
/**
* Converts time (h:m:s) to seconds.
*
* @param {string} time
* @returns {number} seconds
*/
function timeToSeconds(time) {
let params = time.split(':');
return parseInt(({
3: +params[0] * 3600 + +params[1] * 60 + +params[2],
2: +params[0] * 60 + +params[1],
1: +params[0]
})[params.length]);
}
/**
* Converts strings in camelCase to snake_case.
*
* @param {string} string The string in camelCase.
* @returns {string}
*/
function camelToSnake(string) {
return string[0].toLowerCase() + string.slice(1, string.length).replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
}
/**
* 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])}');
}
const errors = { UnavailableContentError, ParsingError, DownloadError, InnertubeError, MissingParamError, NoStreamingDataError };
const functions = { findNode, getRandomUserAgent, generateSidAuth, getStringBetweenStrings, camelToSnake, timeToSeconds, refineNTokenData };
module.exports = { ...functions, ...errors };

60
package-lock.json generated
View File

@@ -1,18 +1,25 @@
{
"name": "youtubei.js",
"version": "1.2.9",
"version": "1.4.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "youtubei.js",
"version": "1.2.9",
"version": "1.4.0",
"license": "MIT",
"dependencies": {
"axios": "^0.21.4",
"flat": "^5.0.2",
"protons": "^2.0.3",
"user-agents": "^1.0.778",
"uuid": "^8.3.2"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://ko-fi.com/luanrt"
}
},
"node_modules/axios": {
@@ -52,10 +59,18 @@
"dot-json": "bin/dot-json.js"
}
},
"node_modules/flat": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz",
"integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==",
"bin": {
"flat": "cli.js"
}
},
"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.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.9.tgz",
"integrity": "sha512-MQDfihBQYMcyy5dhRDJUHcw7lb2Pv/TuE6xP1vyraLukNDHKbDxDNaOE3NbCAdKQApno+GPRyo1YAp89yCjK4w==",
"funding": [
{
"type": "individual",
@@ -77,9 +92,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 +142,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.984",
"resolved": "https://registry.npmjs.org/user-agents/-/user-agents-1.0.984.tgz",
"integrity": "sha512-gFYUg9GRrUA5LPKBa+K2K6jML3VPseVxm2TzhfTMVpLuxYZGm4qM8egSfQ7DV8X4DTNwECHAhlwv6JWZnIsCHQ==",
"dependencies": {
"dot-json": "^1.2.2",
"lodash.clonedeep": "^4.5.0"
@@ -178,10 +193,15 @@
"underscore-keypath": "~0.0.22"
}
},
"flat": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz",
"integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ=="
},
"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.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.9.tgz",
"integrity": "sha512-MQDfihBQYMcyy5dhRDJUHcw7lb2Pv/TuE6xP1vyraLukNDHKbDxDNaOE3NbCAdKQApno+GPRyo1YAp89yCjK4w=="
},
"lodash.clonedeep": {
"version": "4.5.0",
@@ -189,9 +209,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 +259,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.984",
"resolved": "https://registry.npmjs.org/user-agents/-/user-agents-1.0.984.tgz",
"integrity": "sha512-gFYUg9GRrUA5LPKBa+K2K6jML3VPseVxm2TzhfTMVpLuxYZGm4qM8egSfQ7DV8X4DTNwECHAhlwv6JWZnIsCHQ==",
"requires": {
"dot-json": "^1.2.2",
"lodash.clonedeep": "^4.5.0"

View File

@@ -1,19 +1,24 @@
{
"name": "youtubei.js",
"version": "1.2.9",
"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!",
"version": "1.4.0",
"description": "A full-featured library that allows you to get detailed info about any video, subscribe, unsubscribe, like, dislike, comment, search, download videos/music and much more!",
"main": "index.js",
"author": "LuanRT <luan.lrt4@gmail.com> (https://github.com/LuanRT)",
"funding": "https://ko-fi.com/luanrt",
"license": "MIT",
"engines": {
"node": ">=14"
},
"scripts": {
"test": "node test"
},
"author": "LuanRT",
"license": "MIT",
"directories": {
"example": "examples",
"lib": "lib"
},
"dependencies": {
"axios": "^0.21.4",
"flat": "^5.0.2",
"protons": "^2.0.3",
"user-agents": "^1.0.778",
"uuid": "^8.3.2"
@@ -22,24 +27,26 @@
"type": "git",
"url": "git+https//github.com/LuanRT/YouTube.js.git"
},
"keywords": [
"youtube",
"youtube-dl",
"youtubedl",
"innertube",
"innertubeapi",
"livechat",
"api",
"search",
"like",
"dislike",
"comment",
"downloader",
"comments-section",
"youtube-downloader"
],
"bugs": {
"url": "https://github.com/LuanRT/YouTube.js/issues"
},
"homepage": "https://github.com/LuanRT/YouTube.js#readme"
"homepage": "https://github.com/LuanRT/YouTube.js#readme",
"keywords": [
"yt",
"ytdl",
"youtube",
"youtube-dl",
"youtubedl",
"youtube-downloader",
"innertube",
"innertubeapi",
"downloader",
"livechat",
"dislike",
"search",
"comment",
"like",
"api",
"dl"
]
}

View File

@@ -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',

View File

@@ -1,61 +1,75 @@
'use strict';
const Fs = require('fs');
const Innertube = require('..');
const NToken = require('../lib/NToken');
const SigDecipher = require('../lib/Sig');
const Constants = require('./constants');
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);
if (!(youtube instanceof Error)) {
const search = await youtube.search('Carl Sagan - Documentary').catch((error) => error);
assert(!(search instanceof Error) && search.videos.length >= 1, `should search videos`, search);
const details = await youtube.getDetails(Constants.test_video_id).catch((error) => error);
assert(!(details instanceof Error), `should retrieve details for ${Constants.test_video_id}`, details);
const comments = await youtube.getComments(Constants.test_video_id).catch((error) => error);
assert(!(comments instanceof Error), `should retrieve comments for ${Constants.test_video_id}`, comments);
const video = await downloadVideo(Constants.test_video_id, youtube).catch((error) => error);
assert(!(video instanceof Error), `should download video (${Constants.test_video_id})`, video);
}
const n_token = new NToken(Constants.n_scramble_sc).transform(Constants.original_ntoken);
assert(n_token == Constants.expected_ntoken, `should transform n token into ${Constants.expected_ntoken}`, n_token);
const transformed_url = new SigDecipher(Constants.test_url, Constants.client_version, { sig_decipher_sc: Constants.sig_decipher_sc, ntoken_sc: Constants.n_scramble_sc }).decipher();
assert(transformed_url == Constants.expected_url, `should correctly decipher signature`, transformed_url);
if (failed_tests > 0)
throw new Error('Some tests have failed');
}
function downloadVideo(id, youtube) {
return new Promise((resolve, reject) => {
let got_video_info = false;
const stream = youtube.download(id, { type: 'videoandaudio' });
stream.pipe(Fs.createWriteStream(`./${id}.mp4`));
stream.on('end', () => Fs.existsSync(`./${id}.mp4`) && got_video_info && resolve() || reject());
stream.on('info', () => got_video_info = true);
stream.on('error', (err) => reject(err));
});
}
function assert(outcome, description, data) {
const pass_fail = outcome ? 'pass' : 'fail';
console.info(pass_fail, ':', description);
!outcome && (failed_tests += 1);
!outcome && console.error('Error: ', data);
return outcome;
}
performTests();
'use strict';
const Fs = require('fs');
const Innertube = require('..');
const NToken = require('../lib/deciphers/NToken');
const SigDecipher = require('../lib/deciphers/Sig');
const Constants = require('./constants');
let failed_tests = 0;
async function performTests() {
const youtube = await new Innertube().catch((error) => error);
assert(!(youtube instanceof Error), `should retrieve Innertube configuration data`, youtube);
if (!(youtube instanceof Error)) {
const homefeed = await youtube.getHomeFeed();
assert(!(homefeed instanceof Error), `should retrieve recommendations`, homefeed);
const ytsearch = await youtube.search('Carl Sagan - Documentary').catch((error) => error);
assert(!(ytsearch instanceof Error) && ytsearch.videos.length, `should search on YouTube`, ytsearch);
const ytmsearch = await youtube.search('Logic - Obediently Yours', { client: 'YTMUSIC' }).catch((error) => error);
assert(!(ytmsearch instanceof Error), `should search on YouTube Music`, ytmsearch);
const details = await youtube.getDetails(Constants.test_video_id).catch((error) => error);
assert(!(details instanceof Error), `should retrieve details for ${Constants.test_video_id}`, details);
const comments = await youtube.getComments(Constants.test_video_id).catch((error) => error);
assert(!(comments instanceof Error), `should retrieve comments for ${Constants.test_video_id}`, comments);
const ytplaylist = await youtube.getPlaylist(ytmsearch.results.community_playlists[0].id, { client: 'YOUTUBE' });
assert(!(ytplaylist instanceof Error), `should retrieve and parse playlist with YouTube`, ytplaylist);
const ytmplaylist = await youtube.getPlaylist(ytmsearch.results.community_playlists[0].id, { client: 'YTMUSIC' });
assert(!(ytmplaylist instanceof Error), `should retrieve and parse playlist with YouTube Music`, ytmplaylist);
const lyrics = await youtube.getLyrics(ytmsearch.results.songs[0].id);
assert(!(lyrics instanceof Error), `should retrieve song lyrics`, lyrics);
const video = await downloadVideo(Constants.test_video_id, youtube).catch((error) => error);
assert(!(video instanceof Error), `should download video (${Constants.test_video_id})`, video);
}
const n_token = new NToken(Constants.n_scramble_sc, Constants.original_ntoken).transform();
assert(n_token == Constants.expected_ntoken, `should transform n token into ${Constants.expected_ntoken}`, n_token);
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)
throw new Error('Some tests have failed');
}
function downloadVideo(id, youtube) {
return new Promise((resolve, reject) => {
let got_video_info = false;
const stream = youtube.download(id, { type: 'videoandaudio' });
stream.pipe(Fs.createWriteStream(`./${id}.mp4`));
stream.on('end', () => Fs.existsSync(`./${id}.mp4`) && got_video_info && resolve() || reject());
stream.on('info', () => got_video_info = true);
stream.on('error', (err) => reject(err));
});
}
function assert(outcome, description, data) {
const pass_fail = outcome ? 'pass' : 'fail';
console.info(pass_fail, ':', description);
!outcome && (failed_tests += 1);
!outcome && console.error('Error: ', data);
return outcome;
}
performTests();