mirror of
https://github.com/LuanRT/YouTube.js.git
synced 2026-06-13 09:32:12 +00:00
Compare commits
139 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7230a2d927 | ||
|
|
924693349c | ||
|
|
1ab302319d | ||
|
|
bbc1d0135b | ||
|
|
9c1e34c9ab | ||
|
|
c5eea2b4ff | ||
|
|
60130f4d0f | ||
|
|
5090c572d5 | ||
|
|
c9c72d0f31 | ||
|
|
7635f49191 | ||
|
|
c932e65dad | ||
|
|
23717aab11 | ||
|
|
85df28a7fb | ||
|
|
9f4970b3ee | ||
|
|
82bbc715ff | ||
|
|
3ec111212c | ||
|
|
7ca4b2bb45 | ||
|
|
8d411f25c8 | ||
|
|
80fe969917 | ||
|
|
13c94fbb8a | ||
|
|
60ce869054 | ||
|
|
1268ac83a6 | ||
|
|
5e588d0db5 | ||
|
|
8b37bd99b1 | ||
|
|
08741de831 | ||
|
|
574a595a01 | ||
|
|
16928ee71b | ||
|
|
de6283080b | ||
|
|
23ab8bca4d | ||
|
|
068b86b410 | ||
|
|
0b001c0956 | ||
|
|
4c14662d42 | ||
|
|
f1a9d5d77b | ||
|
|
398cd8728d | ||
|
|
459c30528e | ||
|
|
6e1e96610c | ||
|
|
6d30aa3228 | ||
|
|
d33cb0b576 | ||
|
|
51af4c3ffe | ||
|
|
b577a79893 | ||
|
|
da0c5e5887 | ||
|
|
b47350894d | ||
|
|
c0387017e3 | ||
|
|
b286bc43df | ||
|
|
61028a2ab9 | ||
|
|
254588da81 | ||
|
|
ef3e54775c | ||
|
|
30cec36660 | ||
|
|
427a1bd396 | ||
|
|
cf4901fd3c | ||
|
|
2fd98a021f | ||
|
|
cd64e30b69 | ||
|
|
2b5027eb06 | ||
|
|
0c9f7135bf | ||
|
|
ce8a109398 | ||
|
|
6aaa3360e8 | ||
|
|
89c018c431 | ||
|
|
339a01f3a9 | ||
|
|
dd3f4c0009 | ||
|
|
7cd41e1d8a | ||
|
|
6ac8561af2 | ||
|
|
b4607d531f | ||
|
|
b3a1cdc1cd | ||
|
|
fd662df93d | ||
|
|
8a1f4b4e55 | ||
|
|
4ff83bdc3f | ||
|
|
c81e8e29ac | ||
|
|
d5f884ff9b | ||
|
|
5517c2f202 | ||
|
|
3493a82765 | ||
|
|
07f02d0dc1 | ||
|
|
b2afa86744 | ||
|
|
a1caa60750 | ||
|
|
e1dd718832 | ||
|
|
222bf1e61f | ||
|
|
3b48de20dd | ||
|
|
348d901935 | ||
|
|
94b12002ff | ||
|
|
2720e8f251 | ||
|
|
a8a1ec2182 | ||
|
|
ee0d1bef40 | ||
|
|
5cad39ee44 | ||
|
|
e8ca248919 | ||
|
|
44d09026b5 | ||
|
|
ff044f4216 | ||
|
|
8153e6178c | ||
|
|
ee3f1b4638 | ||
|
|
86c8a7e0d2 | ||
|
|
b375ae2f06 | ||
|
|
2ff4b2ea95 | ||
|
|
599ab69107 | ||
|
|
c6c6dc24bd | ||
|
|
fa2e0724c6 | ||
|
|
6af689ada6 | ||
|
|
9997c0d939 | ||
|
|
3dee7fc12f | ||
|
|
4dff129b74 | ||
|
|
7e86bb15e0 | ||
|
|
d0de164722 | ||
|
|
5d165ebb61 | ||
|
|
2ad19adbe4 | ||
|
|
cabbdc9f50 | ||
|
|
fe84f31432 | ||
|
|
22c605f528 | ||
|
|
6777b59116 | ||
|
|
de70d851d8 | ||
|
|
e20e671d16 | ||
|
|
d0e1140029 | ||
|
|
bf483256fe | ||
|
|
d4c32d47e1 | ||
|
|
70feab80da | ||
|
|
c006f49dc1 | ||
|
|
aeff0c3fdc | ||
|
|
00d67ed417 | ||
|
|
78f93c7118 | ||
|
|
6db3f0ad91 | ||
|
|
cf48385f72 | ||
|
|
e70eab2416 | ||
|
|
771c6050c4 | ||
|
|
5670228a4f | ||
|
|
62ae384f27 | ||
|
|
185cdbd6ce | ||
|
|
5dd6ef9e24 | ||
|
|
309942090d | ||
|
|
af4a4b8b82 | ||
|
|
b9c9d40077 | ||
|
|
62fbc166c5 | ||
|
|
c3991dda32 | ||
|
|
95e804e8ea | ||
|
|
67a8435421 | ||
|
|
1847558d50 | ||
|
|
bde915bce3 | ||
|
|
a9ad3a31b5 | ||
|
|
e52e6138bd | ||
|
|
76248ad143 | ||
|
|
94f441a4e2 | ||
|
|
685e14fcc1 | ||
|
|
3cd115461f | ||
|
|
6da4ee8fd4 |
19
.github/ISSUE_TEMPLATE/FEATURE.md
vendored
Normal file
19
.github/ISSUE_TEMPLATE/FEATURE.md
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Use this template for requesting new features
|
||||
title: "[FEATURE NAME]"
|
||||
labels: enhancement
|
||||
assignees:
|
||||
---
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
Please describe the behavior you are expecting
|
||||
|
||||
## Current Behavior
|
||||
|
||||
What is the current behavior?
|
||||
|
||||
## Sample Code
|
||||
|
||||
If applicable, provide a sample code snippet that demonstrates the gist of the feature you're proposing. This can be either from a usage standpoint, or an implementation standpoint.
|
||||
38
.github/ISSUE_TEMPLATE/ISSUE.md
vendored
Normal file
38
.github/ISSUE_TEMPLATE/ISSUE.md
vendored
Normal 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
15
.github/ISSUE_TEMPLATE/QUESTION.md
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
---
|
||||
name: Question
|
||||
about: Use this template to ask a question about the project
|
||||
title: "[QUESTION SUMMARY]"
|
||||
labels: question
|
||||
assignees:
|
||||
---
|
||||
|
||||
## Question
|
||||
|
||||
State your question
|
||||
|
||||
## Sample Code
|
||||
|
||||
Please include relevant code snippets or files that provide context for your question.
|
||||
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
27
.github/pull_request_template.md
vendored
Normal file
27
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
# Pull Request Template
|
||||
|
||||
## Description
|
||||
|
||||
Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change.
|
||||
|
||||
Fixes # (issue)
|
||||
|
||||
## Type of change
|
||||
|
||||
Please delete options that are not relevant.
|
||||
|
||||
- [ ] Bug fix (non-breaking change which fixes an issue)
|
||||
- [ ] New feature (non-breaking change which adds functionality)
|
||||
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||
- [ ] This change requires a documentation update
|
||||
|
||||
## Checklist:
|
||||
|
||||
- [ ] My code follows the style guidelines of this project
|
||||
- [ ] I have performed a self-review of my own code
|
||||
- [ ] I have commented my code, particularly in hard-to-understand areas
|
||||
- [ ] I have made corresponding changes to the documentation
|
||||
- [ ] My changes generate no new warnings
|
||||
- [ ] I have added tests that prove my fix is effective or that my feature works
|
||||
- [ ] New and existing unit tests pass locally with my changes
|
||||
- [ ] I have checked my code and corrected any misspellings
|
||||
5
.github/workflows/node.js.yml
vendored
5
.github/workflows/node.js.yml
vendored
@@ -1,6 +1,5 @@
|
||||
# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
|
||||
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
|
||||
# TODO: Make actual tests instead of just running the examples file
|
||||
|
||||
name: Build
|
||||
|
||||
@@ -17,7 +16,7 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [ 12.x, 14.x, 15.x]
|
||||
node-version: [ 14.x, 15.x, 16.x ]
|
||||
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
|
||||
|
||||
steps:
|
||||
@@ -27,4 +26,4 @@ jobs:
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
- run: npm install
|
||||
- run: node ./examples
|
||||
- run: npm test
|
||||
|
||||
2
LICENSE
2
LICENSE
@@ -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.
|
||||
@@ -1,81 +1,92 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const Innertube = require('..');
|
||||
|
||||
async function start() {
|
||||
const youtube = await new Innertube();
|
||||
|
||||
// Searching, getting details about videos & making interactions:
|
||||
const search = await youtube.search('Looking for life on Mars - documentary');
|
||||
console.info('Search results:', search);
|
||||
|
||||
if (search.videos.length === 0)
|
||||
return console.error('Could not find any video about that on YouTube.');
|
||||
|
||||
const video = await youtube.getDetails(search.videos[0].id).catch((error) => error);
|
||||
console.info('Video details:', video);
|
||||
|
||||
if (video instanceof Error)
|
||||
return console.error('Could not get details for ' + search.videos[0].title);
|
||||
|
||||
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();
|
||||
165
lib/Actions.js
165
lib/Actions.js
@@ -1,165 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const Uuid = require('uuid');
|
||||
const Axios = require('axios');
|
||||
const Utils = require('./Utils');
|
||||
const Constants = require('./Constants');
|
||||
|
||||
async function engage(session, engagement_type, args = {}) {
|
||||
if (!session.logged_in) throw new Error('You are not logged 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.generateCommentParams(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_request_opts({ session, id: args.video_id, data })).catch((error) => error);
|
||||
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
|
||||
return {
|
||||
success: true,
|
||||
status_code: response.status
|
||||
};
|
||||
}
|
||||
|
||||
async function browse(session, action_type) {
|
||||
if (!session.logged_in) throw new Error('You are not logged in');
|
||||
let data;
|
||||
switch (action_type) {
|
||||
case 'subscriptions_feed':
|
||||
data = {
|
||||
context: session.context,
|
||||
browseId: 'FEsubscriptions'
|
||||
};
|
||||
break;
|
||||
default:
|
||||
}
|
||||
|
||||
const response = await Axios.post(`${Constants.urls.YT_BASE_URL}/youtubei/v1/browse${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`, JSON.stringify(data), Constants.innertube_request_opts({ 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
|
||||
};
|
||||
}
|
||||
|
||||
async function notifications(session, action_type, args = {}) {
|
||||
if (!session.logged_in) throw new Error('You are not logged 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_request_opts({ 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
|
||||
};
|
||||
}
|
||||
|
||||
async function livechat(session, action_type, args = {}) {
|
||||
let data;
|
||||
switch (action_type) {
|
||||
case 'live_chat/send_message':
|
||||
data = {
|
||||
context: session.context,
|
||||
params: Utils.generateMessageParams(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;
|
||||
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_request_opts({ session, params: args.params })).catch((error) => error);
|
||||
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
|
||||
return {
|
||||
success: true,
|
||||
status_code: response.status,
|
||||
data: response.data
|
||||
};
|
||||
}
|
||||
|
||||
async function getContinuation(session, info = {}) {
|
||||
let data = { context: session.context };
|
||||
info.continuation_token && (data.continuation = info.continuation_token);
|
||||
|
||||
if (info.video_id) {
|
||||
data.videoId = info.video_id;
|
||||
data.racyCheckOk = true;
|
||||
data.contentCheckOk = false;
|
||||
data.autonavState = 'STATE_NONE';
|
||||
data.playbackContext = {
|
||||
vis: 0,
|
||||
lactMilliseconds: '-1'
|
||||
};
|
||||
data.captionsRequested = false;
|
||||
}
|
||||
|
||||
const response = await Axios.post(`${Constants.urls.YT_BASE_URL}/youtubei/v1/next${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`, JSON.stringify(data), Constants.innertube_request_opts({ session })).catch((error) => error);
|
||||
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
|
||||
return {
|
||||
success: true,
|
||||
status_code: response.status,
|
||||
data: response.data
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { engage, browse, notifications, livechat, getContinuation };
|
||||
329
lib/Constants.js
329
lib/Constants.js
@@ -1,329 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const Utils = require('./Utils');
|
||||
|
||||
const urls = {
|
||||
YT_BASE_URL: 'https://www.youtube.com',
|
||||
YT_MOBILE_URL: 'https://m.youtube.com',
|
||||
YT_WATCH_PAGE: 'https://m.youtube.com/watch',
|
||||
};
|
||||
|
||||
const oauth = {
|
||||
scope: 'http://gdata.youtube.com https://www.googleapis.com/auth/youtube-paid-content',
|
||||
grant_type: 'http://oauth.net/grant_type/device/1.0',
|
||||
model_name: 'ytlr::'
|
||||
};
|
||||
|
||||
const oauth_reqopts = {
|
||||
headers: {
|
||||
'accept': '*/*',
|
||||
'origin': urls.YT_BASE_URL,
|
||||
'user-agent': 'Mozilla/5.0 (ChromiumStylePlatform) Cobalt/Version',
|
||||
'content-type': 'application/json',
|
||||
'referer': `${urls.YT_BASE_URL}/tv`,
|
||||
'accept-language': 'en-US'
|
||||
}
|
||||
};
|
||||
|
||||
const default_headers = (session) => {
|
||||
return {
|
||||
headers: {
|
||||
'Cookie': session.cookie,
|
||||
'user-agent': Utils.getRandomUserAgent('desktop').userAgent,
|
||||
'Referer': 'https://www.google.com/',
|
||||
'Accept': 'text/html',
|
||||
'Accept-Language': 'en-US,en',
|
||||
'Accept-Encoding': 'gzip',
|
||||
'Upgrade-Insecure-Requests': 1
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const innertube_request_opts = (info) => {
|
||||
info.desktop === undefined && (info.desktop = true);
|
||||
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': info.desktop ? urls.YT_BASE_URL : urls.YT_MOBILE_URL,
|
||||
'origin': info.desktop ? urls.YT_BASE_URL : urls.YT_MOBILE_URL,
|
||||
}
|
||||
};
|
||||
|
||||
info.id && (req_opts.headers.referer = (info.desktop ? urls.YT_BASE_URL : urls.YT_MOBILE_URL) + '/watch?v=' + info.id);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
return req_opts;
|
||||
};
|
||||
|
||||
const video_details_reqbody = (id, sts, context) => {
|
||||
return {
|
||||
playbackContext: {
|
||||
contentPlaybackContext: {
|
||||
'currentUrl': '/watch?v=' + id,
|
||||
'vis': 0,
|
||||
'splay': false,
|
||||
'autoCaptionsDefaultOn': false,
|
||||
'autonavState': 'STATE_OFF',
|
||||
'html5Preference': 'HTML5_PREF_WANTS',
|
||||
'signatureTimestamp': sts,
|
||||
'referer': urls.YT_BASE_URL,
|
||||
'lactMilliseconds': '-1'
|
||||
}
|
||||
},
|
||||
context: context,
|
||||
videoId: id
|
||||
};
|
||||
};
|
||||
|
||||
const stream_headers = (range) => {
|
||||
let headers = {
|
||||
'Accept': '*/*',
|
||||
'User-Agent': Utils.getRandomUserAgent('desktop').userAgent,
|
||||
'Connection': 'keep-alive',
|
||||
'Origin': urls.YT_BASE_URL,
|
||||
'Referer': urls.YT_BASE_URL,
|
||||
'DNT': '?1'
|
||||
};
|
||||
range && (headers.Range = range);
|
||||
return headers;
|
||||
};
|
||||
|
||||
const formatVideoData = (data, context, desktop) => {
|
||||
let video_details = {};
|
||||
let metadata = {};
|
||||
|
||||
if (desktop) {
|
||||
metadata.embed = data.microformat.playerMicroformatRenderer.embed;
|
||||
metadata.view_count = parseInt(data.videoDetails.viewCount);
|
||||
metadata.average_rating = data.videoDetails.averageRating;
|
||||
metadata.length_seconds = data.microformat.playerMicroformatRenderer.lengthSeconds;
|
||||
metadata.channel_id = data.videoDetails.channelId;
|
||||
metadata.channel_url = data.microformat.playerMicroformatRenderer.ownerProfileUrl;
|
||||
metadata.external_channel_id = data.microformat.playerMicroformatRenderer.externalChannelId;
|
||||
metadata.is_live_content = data.videoDetails.isLiveContent;
|
||||
metadata.is_family_safe = data.microformat.playerMicroformatRenderer.isFamilySafe;
|
||||
metadata.is_unlisted = data.microformat.playerMicroformatRenderer.isUnlisted;
|
||||
metadata.is_private = data.videoDetails.isPrivate;
|
||||
metadata.has_ypc_metadata = data.microformat.playerMicroformatRenderer.hasYpcMetadata;
|
||||
metadata.category = data.microformat.playerMicroformatRenderer.category;
|
||||
metadata.channel_name = data.microformat.playerMicroformatRenderer.ownerChannelName;
|
||||
metadata.publish_date = data.microformat.playerMicroformatRenderer.publishDate || 'N/A';
|
||||
metadata.upload_date = data.microformat.playerMicroformatRenderer.uploadDate || 'N/A';
|
||||
metadata.keywords = data.videoDetails.keywords || [];
|
||||
|
||||
video_details.title = data.videoDetails.title;
|
||||
video_details.description = data.videoDetails.shortDescription;
|
||||
video_details.thumbnail = data.videoDetails.thumbnail.thumbnails.slice(-1)[0];
|
||||
video_details.metadata = metadata;
|
||||
} else {
|
||||
metadata.embed = data[2].playerResponse.microformat.playerMicroformatRenderer.embed;
|
||||
metadata.likes = parseInt(data[3].response.contents.singleColumnWatchNextResults.results.results.contents[1].slimVideoMetadataSectionRenderer.contents[1].slimVideoActionBarRenderer.buttons[0].slimMetadataToggleButtonRenderer.button.toggleButtonRenderer.defaultText.accessibility.accessibilityData.label.replace(/\D/g, ''));
|
||||
metadata.dislikes = parseInt(data[3].response.contents.singleColumnWatchNextResults.results.results.contents[1].slimVideoMetadataSectionRenderer.contents[1].slimVideoActionBarRenderer.buttons[1].slimMetadataToggleButtonRenderer.button.toggleButtonRenderer.defaultText.accessibility.accessibilityData.label.replace(/\D/g, ''));
|
||||
metadata.view_count = parseInt(data[2].playerResponse.videoDetails.viewCount);
|
||||
metadata.average_rating = data[2].playerResponse.videoDetails.averageRating;
|
||||
metadata.length_seconds = data[2].playerResponse.microformat.playerMicroformatRenderer.lengthSeconds;
|
||||
metadata.channel_id = data[2].playerResponse.videoDetails.channelId;
|
||||
metadata.channel_url = data[2].playerResponse.microformat.playerMicroformatRenderer.ownerProfileUrl;
|
||||
metadata.external_channel_id = data[2].playerResponse.microformat.playerMicroformatRenderer.externalChannelId;
|
||||
metadata.is_live_content = data[2].playerResponse.videoDetails.isLiveContent;
|
||||
metadata.is_family_safe = data[2].playerResponse.microformat.playerMicroformatRenderer.isFamilySafe;
|
||||
metadata.is_unlisted = data[2].playerResponse.microformat.playerMicroformatRenderer.isUnlisted;
|
||||
metadata.is_private = data[2].playerResponse.videoDetails.isPrivate;
|
||||
metadata.has_ypc_metadata = data[2].playerResponse.microformat.playerMicroformatRenderer.hasYpcMetadata;
|
||||
metadata.category = data[2].playerResponse.microformat.playerMicroformatRenderer.category;
|
||||
metadata.channel_name = data[2].playerResponse.microformat.playerMicroformatRenderer.ownerChannelName;
|
||||
metadata.publish_date = data[2].playerResponse.microformat.playerMicroformatRenderer.publishDate;
|
||||
metadata.upload_date = data[2].playerResponse.microformat.playerMicroformatRenderer.uploadDate;
|
||||
metadata.keywords = data[2].playerResponse.videoDetails.keywords;
|
||||
|
||||
video_details.title = data[2].playerResponse.videoDetails.title;
|
||||
video_details.description = data[2].playerResponse.videoDetails.shortDescription;
|
||||
video_details.thumbnail = data[2].playerResponse.videoDetails.thumbnail.thumbnails.slice(-1)[0];
|
||||
|
||||
// Functions
|
||||
video_details.like = () => {};
|
||||
video_details.dislike = () => {};
|
||||
video_details.removeLike = () => {};
|
||||
video_details.subscribe = () => {};
|
||||
video_details.unsubscribe = () => {};
|
||||
video_details.comment = () => {};
|
||||
video_details.getComments = () => {};
|
||||
video_details.setNotificationPref = () => {};
|
||||
video_details.getLivechat = () => {};
|
||||
|
||||
// Additional metadata
|
||||
video_details.metadata = metadata;
|
||||
}
|
||||
return video_details;
|
||||
};
|
||||
|
||||
const base64_alphabet = {
|
||||
normal: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'.split(''),
|
||||
reverse: '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_'.split('')
|
||||
};
|
||||
|
||||
const filters = (order) => {
|
||||
// It seems like all of these are just proto buffers, so I think it'll be easy to refactor
|
||||
switch (order) {
|
||||
case 'any,any,relevance':
|
||||
return 'EgIQAQ%3D%3D';
|
||||
case 'hour,any,relevance':
|
||||
return 'EgIIAQ%3D%3D';
|
||||
case 'day,any,relevance':
|
||||
return 'EgQIAhAB';
|
||||
case 'week,any,relevance':
|
||||
return 'EgQIAxAB';
|
||||
case 'month,any,relevance':
|
||||
return 'EgQIBBAB';
|
||||
case 'year,any,relevance':
|
||||
return 'EgQIBRAB';
|
||||
case 'any,short,relevance':
|
||||
return 'EgQQARgB';
|
||||
case 'hour,short,relevance':
|
||||
return 'EgYIARABGAE%3D';
|
||||
case 'day,short,relevance':
|
||||
return 'EgYIAhABGAE%3D';
|
||||
case 'week,short,relevance':
|
||||
return 'EgYIAxABGAE%3D';
|
||||
case 'month,short,relevance':
|
||||
return 'EgYIBBABGAE%3D';
|
||||
case 'year,short,relevance':
|
||||
return 'EgYIBRABGAE%3D';
|
||||
case 'any,long,relevance':
|
||||
return 'EgQQARgC';
|
||||
case 'hour,long,relevance':
|
||||
return 'EgYIARABGAI%3D';
|
||||
case 'day,long,relevance':
|
||||
return 'EgYIAhABGAI%3D';
|
||||
case 'week,long,relevance':
|
||||
return 'EgYIAxABGAI%3D';
|
||||
case 'month,long,relevance':
|
||||
return 'EgYIBBABGAI%3D';
|
||||
case 'year,long,relevance':
|
||||
return 'EgYIBRABGAI%3D';
|
||||
case 'any,any,age':
|
||||
return 'CAISAhAB';
|
||||
case 'hour,any,age':
|
||||
return 'CAISBAgBEAE%3D';
|
||||
case 'day,any,age':
|
||||
return 'CAISBAgCEAE%3D';
|
||||
case 'week,any,age':
|
||||
return 'CAISBAgDEAE%3D';
|
||||
case 'month,any,age':
|
||||
return 'CAISBAgEEAE%3D';
|
||||
case 'year,any,age':
|
||||
return 'CAISBAgFEAE%3D';
|
||||
case 'any,short,age':
|
||||
return 'CAISBBABGAE%3D';
|
||||
case 'hour,short,age':
|
||||
return 'CAISBggBEAEYAQ%3D%3D';
|
||||
case 'day,short,age':
|
||||
return 'CAISBggCEAEYAQ%3D%3D';
|
||||
case 'week,short,age':
|
||||
return 'CAISBggDEAEYAQ%3D%3D';
|
||||
case 'month,short,age':
|
||||
return 'CAISBggEEAEYAQ%3D%3D';
|
||||
case 'year,short,age':
|
||||
return 'CAISBggFEAEYAQ%3D%3D';
|
||||
case 'any,long,age':
|
||||
return 'CAISBBABGAI%3D';
|
||||
case 'hour,long,age':
|
||||
return 'CAISBggBEAEYAg%3D%3D';
|
||||
case 'day,long,age':
|
||||
return 'CAISBggCEAEYAg%3D%3D';
|
||||
case 'week,long,age':
|
||||
return 'CAISBggDEAEYAg%3D%3D';
|
||||
case 'month,long,age':
|
||||
return 'CAISBggEEAEYAg%3D%3D';
|
||||
case 'year,long,age':
|
||||
return 'CAISBggFEAEYAg%3D%3D';
|
||||
case 'any,any,views':
|
||||
return 'CAMSAhAB';
|
||||
case 'hour,any,views':
|
||||
return 'CAMSBAgBEAE%3D';
|
||||
case 'day,any,views':
|
||||
return 'CAMSBAgCEAE%3D';
|
||||
case 'week,any,views':
|
||||
return 'CAMSBAgDEAE%3D';
|
||||
case 'month,any,views':
|
||||
return 'CAMSBAgEEAE%3D';
|
||||
case 'year,any,views':
|
||||
return 'CAMSBAgFEAE%3D';
|
||||
case 'any,short,views':
|
||||
return 'CAMSBBABGAE%3D';
|
||||
case 'hour,short,views':
|
||||
return 'CAMSBggBEAEYAQ%3D%3D';
|
||||
case 'day,short,views':
|
||||
return 'CAMSBggCEAEYAQ%3D%3D';
|
||||
case 'week,short,views':
|
||||
return 'CAMSBggDEAEYAQ%3D%3D';
|
||||
case 'month,short,views':
|
||||
return 'CAMSBggEEAEYAQ%3D%3D';
|
||||
case 'year,short,views':
|
||||
return 'CAMSBggFEAEYAQ%3D%3D';
|
||||
case 'any,long,views':
|
||||
return 'CAMSBBABGAI%3D';
|
||||
case 'hour,long,views':
|
||||
return 'CAMSBggBEAEYAg%3D%3D';
|
||||
case 'day,long,views':
|
||||
return 'CAMSBggCEAEYAg%3D%3D';
|
||||
case 'week,long,views':
|
||||
return 'CAMSBggDEAEYAg%3D%3D';
|
||||
case 'month,long,views':
|
||||
return 'CAMSBggEEAEYAg%3D%3D';
|
||||
case 'year,long,views':
|
||||
return 'CAMSBggFEAEYAg%3D%3D';
|
||||
case 'any,any,rating':
|
||||
return 'CAESAhAB';
|
||||
case 'hour,any,rating':
|
||||
return 'CAESBAgBEAE%3D';
|
||||
case 'day,any,rating':
|
||||
return 'CAESBAgCEAE%3D';
|
||||
case 'week,any,rating':
|
||||
return 'CAESBAgDEAE%3D';
|
||||
case 'month,any,rating':
|
||||
return 'CAESBAgEEAE%3D';
|
||||
case 'year,any,rating':
|
||||
return 'CAESBAgFEAE%3D';
|
||||
case 'any,short,rating':
|
||||
return 'CAESBBABGAE%3D';
|
||||
case 'hour,short,rating':
|
||||
return 'CAESBggBEAEYAQ%3D%3D';
|
||||
case 'day,short,rating':
|
||||
return 'CAESBggCEAEYAQ%3D%3D';
|
||||
case 'week,short,rating':
|
||||
return 'CAESBggDEAEYAQ%3D%3D';
|
||||
case 'month,short,rating':
|
||||
return 'CAESBggEEAEYAQ%3D%3D';
|
||||
case 'year,short,rating':
|
||||
return 'CAESBggFEAEYAQ%3D%3D';
|
||||
case 'any,long,rating':
|
||||
return 'CAESBBABGAI%3D';
|
||||
case 'hour,long,rating':
|
||||
return 'CAESBggBEAEYAg%3D%3D';
|
||||
case 'day,long,rating':
|
||||
return 'CAESBggCEAEYAg%3D%3D';
|
||||
case 'week,long,rating':
|
||||
return 'CAESBggDEAEYAg%3D%3D';
|
||||
case 'month,long,rating':
|
||||
return 'CAESBggEEAEYAg%3D%3D';
|
||||
case 'year,long,rating':
|
||||
return 'CAESBggFEAEYAg%3D%3D';
|
||||
default:
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { urls, oauth, oauth_reqopts, default_headers, innertube_request_opts, video_details_reqbody, stream_headers, formatVideoData, base64_alphabet, filters };
|
||||
1260
lib/Innertube.js
1260
lib/Innertube.js
File diff suppressed because it is too large
Load Diff
135
lib/NToken.js
135
lib/NToken.js
@@ -1,135 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const Utils = require('./Utils');
|
||||
const Constants = require('./Constants');
|
||||
|
||||
class NToken {
|
||||
constructor(raw_code) {
|
||||
this.raw_code = raw_code;
|
||||
this.null_placeholder_regex = /c\[(.*?)\]=c/g;
|
||||
this.transformation_calls_regex = /c\[(.*?)\]\((.+?)\)/g;
|
||||
}
|
||||
|
||||
transform(n) {
|
||||
let n_token = n.split('');
|
||||
|
||||
try {
|
||||
let transformations = this.getTransformationData(this.raw_code);
|
||||
|
||||
// Identifies the necessary transformation data and emulates them accordingly.
|
||||
transformations = transformations.map((el) => {
|
||||
if (el != null && typeof el != 'number') {
|
||||
const is_reverse_base64 = el.includes('case 65:');
|
||||
if (el.includes('function(d){for(var')) {
|
||||
el = (arr) => this.pushSplice(arr);
|
||||
} else if (el.includes('d.push(e)')) {
|
||||
el = (arr, item) => this.push(arr, item);
|
||||
} else if (el.includes('d.reverse()')) {
|
||||
el = (arr) => this.reverse(arr);
|
||||
} else if (el.includes('d.length;d.splice(e,1)')) {
|
||||
el = (arr, index) => this.spliceOnce(arr, index);
|
||||
} else if (el.includes('d[0])[0])')) {
|
||||
el = (arr, index) => this.spliceTwice(arr, index);
|
||||
} else if (el.includes('reverse().forEach')) {
|
||||
el = (arr, index) => this.spliceReverseUnshift(arr, index);
|
||||
} else if (el.includes('f=d[0];d[0]')) {
|
||||
el = (arr, index) => this.swapFirstItem(arr, index);
|
||||
} else if (el.includes('unshift(d.pop())')) {
|
||||
el = (arr, index) => this.unshiftPop(arr, index);
|
||||
} else if (el.includes('switch')) {
|
||||
el = (arr, e) => this.translateAB(arr, e, is_reverse_base64);
|
||||
} else if (el === 'b') {
|
||||
el = n_token;
|
||||
}
|
||||
}
|
||||
return el;
|
||||
});
|
||||
|
||||
// Fills the null placeholders with a copy of the transformations array.
|
||||
let null_placeholder_positions = [...this.raw_code.matchAll(this.null_placeholder_regex)].map((item) => parseInt(item[1]));
|
||||
null_placeholder_positions.forEach((pos) => transformations[pos] = transformations);
|
||||
|
||||
// Parses and emulates calls to functions of the transformations array.
|
||||
let transformation_calls = [...Utils.getStringBetweenStrings(this.raw_code.replace(/\n/g, ''), 'try{', '}catch').matchAll(this.transformation_calls_regex)].map((params) => ({ index: params[1], params: params[2] }));
|
||||
transformation_calls.forEach((data) => {
|
||||
const index = data.index;
|
||||
const param_index = data.params.split(',').map((param) => param.match(/c\[(.*?)\]/)[1]);
|
||||
transformations[index](transformations[param_index[0]], transformations[param_index[1]]);
|
||||
});
|
||||
} catch (err) {
|
||||
return n;
|
||||
}
|
||||
|
||||
return n_token.join('');
|
||||
}
|
||||
|
||||
getTransformationData() {
|
||||
// These variable names have always been the same since earlier player versions, so it should not be a problem for now.
|
||||
let transformation_data = `[${Utils.getStringBetweenStrings(this.raw_code.replace(/\n/g, ''), 'c=[', '];c')}]`;
|
||||
transformation_data = transformation_data
|
||||
.replace(/function\(d,e\)/g, '"function(d,e)')
|
||||
.replace(/function\(d\)/g, '"function(d)')
|
||||
.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])}');
|
||||
|
||||
return JSON.parse(transformation_data);
|
||||
}
|
||||
|
||||
translateAB(arr, index, is_reverse_base64) {
|
||||
let characters = is_reverse_base64 && Constants.base64_alphabet.reverse || Constants.base64_alphabet.normal;
|
||||
arr.forEach(function(char, index, loc) {
|
||||
this.push(loc[index] = characters[(characters.indexOf(char) - characters.indexOf(this[index]) + 64) % characters.length]);
|
||||
}, index.split(''));
|
||||
}
|
||||
|
||||
unshiftPop(arr, index) {
|
||||
index = (index % arr.length + arr.length) % arr.length;
|
||||
for (; index--;) {
|
||||
arr.unshift(arr.pop());
|
||||
}
|
||||
}
|
||||
|
||||
swapFirstItem(arr, index) {
|
||||
let oldValue = arr[0];
|
||||
index = (index % arr.length + arr.length) % arr.length;
|
||||
arr[0] = arr[index];
|
||||
arr[index] = oldValue;
|
||||
}
|
||||
|
||||
spliceReverseUnshift(arr, index) {
|
||||
index = (index % arr.length + arr.length) % arr.length;
|
||||
arr.splice(-index).reverse().forEach((f) => arr.unshift(f));
|
||||
}
|
||||
|
||||
spliceOnce(arr, index) {
|
||||
index = (index % arr.length + arr.length) % arr.length;
|
||||
arr.splice(index, 1);
|
||||
}
|
||||
|
||||
spliceTwice(arr, index) {
|
||||
index = (index % arr.length + arr.length) % arr.length;
|
||||
arr.splice(0, 1, arr.splice(index, 1, arr[0])[0]);
|
||||
}
|
||||
|
||||
pushSplice(arr) {
|
||||
for (let index = arr.length; index;)
|
||||
arr.push(arr.splice(--index, 1)[0]);
|
||||
}
|
||||
|
||||
push(arr, item) {
|
||||
arr.push(item);
|
||||
}
|
||||
|
||||
reverse(arr) {
|
||||
arr.reverse();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = NToken;
|
||||
171
lib/OAuth.js
171
lib/OAuth.js
@@ -1,171 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const Axios = require('axios');
|
||||
const Utils = require('./Utils');
|
||||
const Constants = require('./Constants');
|
||||
const EventEmitter = require('events');
|
||||
const Uuid = require("uuid");
|
||||
|
||||
class OAuth extends EventEmitter {
|
||||
constructor(creds) {
|
||||
super();
|
||||
// Default interval between requests when waiting for authorization.
|
||||
this.refresh_interval = 5;
|
||||
|
||||
// OAuth URLs:
|
||||
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`;
|
||||
|
||||
// Used to check whether an access token is valid or not.
|
||||
this.guide_url = `${Constants.urls.YT_BASE_URL}/youtubei/v1/guide`;
|
||||
|
||||
// These are always the same, so we shouldn't have any problems for now.
|
||||
this.model_name = Constants.oauth.model_name;
|
||||
this.grant_type = Constants.oauth.grant_type;
|
||||
this.scope = Constants.oauth.scope;
|
||||
|
||||
// Script that contains important information such as client id and client secret.
|
||||
this.auth_script_regex = /<script id=\"base-js\" src=\"(.*?)\" nonce=".*?"><\/script>/;
|
||||
|
||||
// Used to find the credentials inside the script.
|
||||
this.identity_regex = /var .+?=\"(?<id>.+?)\",.?=\"(?<secret>.+?)\"/;
|
||||
|
||||
if (creds.access_token != undefined && creds.refresh_token != undefined) return;
|
||||
this.requestAuthCode();
|
||||
}
|
||||
|
||||
waitForAuth(device_code) {
|
||||
const data = {
|
||||
client_id: this.client_id,
|
||||
client_secret: this.client_secret,
|
||||
code: device_code,
|
||||
grant_type: this.grant_type
|
||||
};
|
||||
|
||||
setTimeout(async () => {
|
||||
const response = await Axios.post(this.oauth_token_url, JSON.stringify(data), Constants.oauth_reqopts).catch((error) => error);
|
||||
if (response instanceof Error)
|
||||
return this.emit('auth', {
|
||||
error: 'Could not get auth token.',
|
||||
status: 'FAILED'
|
||||
});
|
||||
|
||||
if (response.data.error) {
|
||||
switch (response.data.error) {
|
||||
case 'slow_down':
|
||||
case 'authorization_pending':
|
||||
this.waitForAuth(device_code);
|
||||
break;
|
||||
case 'access_denied':
|
||||
this.emit('auth', {
|
||||
error: 'The access was denied.',
|
||||
status: 'ACCESS_DENIED'
|
||||
});
|
||||
break;
|
||||
case 'expired_token':
|
||||
this.emit('auth', {
|
||||
error: 'The device code has expired, requesting a new one.',
|
||||
status: 'DEVICE_CODE_EXPIRED'
|
||||
});
|
||||
this.requestAuthCode();
|
||||
break;
|
||||
default:
|
||||
}
|
||||
} else {
|
||||
this.emit('auth', {
|
||||
access_token: response.data.access_token,
|
||||
refresh_token: response.data.refresh_token,
|
||||
token_type: response.data.token_type,
|
||||
expires: response.data.expires_in,
|
||||
scope: response.data.scope,
|
||||
status: 'SUCCESS'
|
||||
});
|
||||
}
|
||||
}, 1000 * this.refresh_interval);
|
||||
}
|
||||
|
||||
async requestAuthCode() {
|
||||
const identity = await this.getClientIdentity();
|
||||
this.client_id = identity.id;
|
||||
this.client_secret = identity.secret;
|
||||
|
||||
const data = {
|
||||
client_id: this.client_id,
|
||||
scope: this.scope,
|
||||
device_id: Uuid.v4(),
|
||||
model_name: this.model_name
|
||||
};
|
||||
|
||||
const response = await Axios.post(this.oauth_code_url, JSON.stringify(data), Constants.oauth_reqopts).catch((error) => error);
|
||||
if (response instanceof Error)
|
||||
return this.emit('auth', {
|
||||
error: 'Could not get auth code.',
|
||||
status: 'FAILED'
|
||||
});
|
||||
|
||||
this.emit('auth', {
|
||||
code: response.data.user_code,
|
||||
status: 'AUTHORIZATION_PENDING',
|
||||
expires_in: response.data.expires_in,
|
||||
verification_url: response.data.verification_url
|
||||
});
|
||||
|
||||
this.refresh_interval = response.data.interval;
|
||||
|
||||
// Keeps requesting at a specific rate until the authorization is granted or denied.
|
||||
this.waitForAuth(response.data.device_code);
|
||||
}
|
||||
|
||||
async getClientIdentity() {
|
||||
// The first 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_reqopts).catch((error) => error);
|
||||
if (yttv_response instanceof Error) throw new Error(`Could not extract client identify: ${yttv_response.message}`);
|
||||
|
||||
// Here we get the script and extract the necessary data to proceed with the auth flow.
|
||||
const url_body = this.auth_script_regex.exec(yttv_response.data)[1];
|
||||
const script_url = `${Constants.urls.YT_BASE_URL}/${url_body}`;
|
||||
const response = await Axios.get(script_url, Constants.default_headers).catch((error) => error);
|
||||
if (response instanceof Error) throw new Error(`Could not extract client identify: ${response.message}`);
|
||||
|
||||
const identity_function = Utils.getStringBetweenStrings(response.data, 'YTLR_STORAGE_NAMESPACE",', 'reloadAppFlushLogsMaxTimeoutMs:');
|
||||
const client_identity = identity_function.replace(/\n/g, '').match(this.identity_regex);
|
||||
return client_identity.groups;
|
||||
}
|
||||
|
||||
async refreshAccessToken(refresh_token) {
|
||||
const identity = await this.getClientIdentity();
|
||||
|
||||
const data = {
|
||||
client_id: identity.id,
|
||||
client_secret: identity.secret,
|
||||
refresh_token,
|
||||
grant_type: 'refresh_token',
|
||||
};
|
||||
|
||||
const response = await Axios.post(this.oauth_token_url, JSON.stringify(data), Constants.oauth_reqopts).catch((error) => error);
|
||||
if (response instanceof Error)
|
||||
return this.emit('refresh-token', {
|
||||
error: 'Could not refresh access token.',
|
||||
status: 'FAILED'
|
||||
});
|
||||
|
||||
this.emit('refresh-token', {
|
||||
access_token: response.data.access_token,
|
||||
token_type: response.data.token_type,
|
||||
expires: response.data.expires_in,
|
||||
scope: response.data.scope,
|
||||
status: 'SUCCESS'
|
||||
});
|
||||
}
|
||||
|
||||
async checkTokenValidity(access_token, session) {
|
||||
let headers = Constants.innertube_request_opts({ session }).headers;
|
||||
headers.authorization = `Bearer ${access_token}`;
|
||||
|
||||
const response = await Axios.post(this.guide_url, JSON.stringify({ context: session.context }), { headers }).catch((error) => error);
|
||||
if (response instanceof Error) return 'INVALID';
|
||||
return 'VALID';
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = OAuth;
|
||||
@@ -1,46 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const Axios = require('axios');
|
||||
const Utils = require('./Utils');
|
||||
const Constants = require('./Constants');
|
||||
|
||||
class Player {
|
||||
constructor(innertube_session) {
|
||||
this.session = innertube_session;
|
||||
this.player_name = Utils.getStringBetweenStrings(this.session.player_url, '/player/', '/');
|
||||
this.tmp_cache_dir = __dirname.slice(0, -3) + 'cache';
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (fs.existsSync(`${this.tmp_cache_dir}/${this.player_name}.js`)) {
|
||||
const player_data = fs.readFileSync(`${this.tmp_cache_dir}/${this.player_name}.js`).toString();
|
||||
this.getSigDecipherCode(player_data);
|
||||
this.getNEncoder(player_data);
|
||||
} else {
|
||||
const response = await Axios.get(`${Constants.urls.YT_BASE_URL}${this.session.player_url}`, { path: this.session.playerUrl, headers: { 'content-type': 'text/javascript', 'user-agent': Utils.getRandomUserAgent('desktop').userAgent } }).catch((error) => error);
|
||||
if (response instanceof Error) throw new Error('Could not download player script: ' + response.message);
|
||||
|
||||
try {
|
||||
// Caches the current player so we don't have to download it all the time
|
||||
fs.mkdirSync(this.tmp_cache_dir, { recursive: true });
|
||||
fs.writeFileSync(`${this.tmp_cache_dir}/${this.player_name}.js`, response.data);
|
||||
} catch (err) {}
|
||||
|
||||
this.getSigDecipherCode(response.data);
|
||||
this.getNEncoder(response.data);
|
||||
}
|
||||
}
|
||||
|
||||
getSigDecipherCode(data) {
|
||||
const manipulation_algorithm_code = Utils.getStringBetweenStrings(data, 'this.audioTracks};var', '};');
|
||||
const manipulation_sequence_code = Utils.getStringBetweenStrings(data, 'function(a){a=a.split("")', 'return a.join("")}');
|
||||
this.sig_decipher_sc = manipulation_algorithm_code + manipulation_sequence_code;
|
||||
}
|
||||
|
||||
getNEncoder(data) {
|
||||
this.ntoken_sc = `var b=a.split("")${Utils.getStringBetweenStrings(data, 'b=a.split("")', '}return b.join("")}')}} return b.join("");`;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Player;
|
||||
86
lib/Utils.js
86
lib/Utils.js
@@ -1,86 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const Fs = require('fs');
|
||||
const Proto = require('protons');
|
||||
const Crypto = require('crypto');
|
||||
const UserAgent = require('user-agents');
|
||||
|
||||
function getRandomUserAgent(type) {
|
||||
switch (type) {
|
||||
case 'mobile':
|
||||
return new UserAgent(/Android/).data;
|
||||
case 'desktop':
|
||||
return new UserAgent({ deviceCategory: 'desktop' }).data;
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
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(' ');
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
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'));
|
||||
}
|
||||
|
||||
function generateMessageParams(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');
|
||||
}
|
||||
|
||||
function generateCommentParams(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'));
|
||||
}
|
||||
|
||||
module.exports = { getRandomUserAgent, generateSidAuth, getStringBetweenStrings, generateMessageParams, generateCommentParams, encodeNotificationPref };
|
||||
413
lib/core/Actions.js
Normal file
413
lib/core/Actions.js
Normal 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 };
|
||||
@@ -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;
|
||||
@@ -19,12 +21,55 @@ class Livechat extends EventEmitter {
|
||||
this.poll_intervals_ms = 1000;
|
||||
this.running = true;
|
||||
|
||||
this.poll();
|
||||
this.#poll();
|
||||
}
|
||||
|
||||
enqueueActionGroup(group) {
|
||||
async #poll() {
|
||||
if (!this.running) return;
|
||||
|
||||
const livechat = await Actions.livechat(this.session, 'live_chat/get_live_chat', { ctoken: this.ctoken });
|
||||
if (!livechat.success) {
|
||||
this.emit('error', { message: `Failed polling livechat: ${livechat.message}. Retrying...` });
|
||||
return await this.#poll();
|
||||
}
|
||||
|
||||
const continuation_contents = livechat.data.continuationContents;
|
||||
const action_group = continuation_contents.liveChatContinuation.actions;
|
||||
this.#enqueueActionGroup(action_group);
|
||||
|
||||
this.message_queue.forEach((message) => {
|
||||
if (this.id_cache.includes(message.id)) return;
|
||||
setTimeout(() => this.emit('chat-update', message), message.timestamp / 1000 - new Date().getTime());
|
||||
this.id_cache.push(message.id);
|
||||
});
|
||||
|
||||
this.message_queue = [];
|
||||
|
||||
const data = { video_id: this.video_id };
|
||||
if (this.metadata_ctoken) data.continuation = this.metadata_ctoken;
|
||||
|
||||
const updated_metadata = await Actions.livechat(this.session, 'updated_metadata', data);
|
||||
if (!updated_metadata.success) {
|
||||
this.emit('error', { message: `Failed polling livechat metadata: ${livechat.message}.` });
|
||||
}
|
||||
|
||||
this.metadata_ctoken = updated_metadata.data.continuation.timedContinuationData.continuation;
|
||||
|
||||
const metadata = updated_metadata.data.actions;
|
||||
this.emit('update-metadata', {
|
||||
likes: metadata[1].updateToggleButtonTextAction.defaultText.simpleText,
|
||||
view_count: {
|
||||
simple_text: metadata[0].updateViewershipAction.viewCount.videoViewCountRenderer.viewCount.simpleText,
|
||||
short_view_count: metadata[0].updateViewershipAction.viewCount.videoViewCountRenderer.extraShortViewCount.simpleText
|
||||
}
|
||||
});
|
||||
|
||||
this.livechat_poller = setTimeout(async () => await this.#poll(), this.poll_intervals_ms);
|
||||
}
|
||||
|
||||
#enqueueActionGroup(group) {
|
||||
group.forEach((action) => {
|
||||
if (!action.addChatItemAction) return; //TODO: handle different action types
|
||||
if (!action.addChatItemAction) return; //TODO: handle different action types
|
||||
const message_content = action.addChatItemAction.item.liveChatTextMessageRenderer;
|
||||
if (!message_content) return;
|
||||
|
||||
@@ -43,48 +88,6 @@ class Livechat extends EventEmitter {
|
||||
});
|
||||
}
|
||||
|
||||
async poll() {
|
||||
if (!this.running) return;
|
||||
|
||||
let data;
|
||||
|
||||
data = { context: this.session.context, continuation: this.ctoken };
|
||||
const livechat = await Axios.post(`${Constants.urls.YT_BASE_URL}/youtubei/v1/live_chat/get_live_chat${this.session.logged_in && this.session.cookie.length < 1 ? '' : `?key=${this.session.key}`}`, JSON.stringify(data), Constants.innertube_request_opts({ session: this.session, data, desktop: true }));
|
||||
if (livechat instanceof Error) throw new Error(`Error polling livechat: ${livechat.message}`);
|
||||
|
||||
const continuation_contents = livechat.data.continuationContents;
|
||||
const action_group = continuation_contents.liveChatContinuation.actions;
|
||||
this.enqueueActionGroup(action_group);
|
||||
|
||||
// Why don't we just emit the message directly? Well, enqueueing the messages is necessary so they are not emitted in a “messy” way, funny enough that's exactly how YouTube does it in its livechat js player.
|
||||
this.message_queue.forEach((message, index) => {
|
||||
if (this.id_cache.includes(message.id)) return;
|
||||
setTimeout(() => this.emit('chat-update', message), message.timestamp / 1000 - new Date().getTime());
|
||||
this.id_cache.push(message.id);
|
||||
});
|
||||
|
||||
this.message_queue = [];
|
||||
|
||||
data = { context: this.session.context, videoId: this.video_id };
|
||||
if (this.metadata_ctoken) data.continuation = this.metadata_ctoken;
|
||||
|
||||
const updated_metadata = await Axios.post(`${Constants.urls.YT_BASE_URL}/youtubei/v1/updated_metadata${this.session.logged_in && this.session.cookie.length < 1 ? '' : `?key=${this.session.key}`}`, JSON.stringify(data), Constants.innertube_request_opts({ session: this.session, data, desktop: true }));
|
||||
if (updated_metadata instanceof Error) throw new Error(`Error polling updated metadata: ${updated_metadata.message}`);
|
||||
this.metadata_ctoken = updated_metadata.data.continuation.timedContinuationData.continuation;
|
||||
|
||||
const metadata = updated_metadata.data.actions;
|
||||
this.emit('update-metadata', {
|
||||
likes: metadata[1].updateToggleButtonTextAction.defaultText.simpleText,
|
||||
dislikes: metadata[2].updateToggleButtonTextAction.defaultText.simpleText,
|
||||
view_count: {
|
||||
simple_text: metadata[0].updateViewershipAction.viewCount.videoViewCountRenderer.viewCount.simpleText,
|
||||
short_view_count: metadata[0].updateViewershipAction.viewCount.videoViewCountRenderer.extraShortViewCount.simpleText
|
||||
}
|
||||
});
|
||||
|
||||
this.livechat_poller = setTimeout(async () => await this.poll(), this.poll_intervals_ms);
|
||||
}
|
||||
|
||||
async sendMessage(text) {
|
||||
const message = await Actions.livechat(this.session, 'live_chat/send_message', { text, channel_id: this.channel_id, video_id: this.video_id });
|
||||
if (!message.success) return message;
|
||||
@@ -104,7 +107,7 @@ class Livechat extends EventEmitter {
|
||||
return {
|
||||
success: true,
|
||||
status_code: message.status_code,
|
||||
deleteMessage,
|
||||
deleteMessage: deleteMessage,
|
||||
message_data: {
|
||||
text: message.data.actions[0].addChatItemAction.item.liveChatTextMessageRenderer.message.runs.map((item) => item.text).join(' '),
|
||||
author: {
|
||||
@@ -118,8 +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');
|
||||
}
|
||||
|
||||
199
lib/core/OAuth.js
Normal file
199
lib/core/OAuth.js
Normal file
@@ -0,0 +1,199 @@
|
||||
'use strict';
|
||||
|
||||
const Axios = require('axios');
|
||||
const Constants = require('../utils/Constants');
|
||||
const EventEmitter = require('events');
|
||||
const Uuid = require('uuid');
|
||||
|
||||
class OAuth extends EventEmitter {
|
||||
constructor(auth_info) {
|
||||
super();
|
||||
this.auth_info = auth_info;
|
||||
this.refresh_interval = 5;
|
||||
|
||||
this.oauth_code_url = `${Constants.URLS.YT_BASE}/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>.+?)\",.+?:\"(?<secret>.+?)\"},/;
|
||||
|
||||
if (auth_info.access_token) return;
|
||||
this.#requestAuthCode();
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the OAuth server for an auth code.
|
||||
* @returns {Promise.<void>}
|
||||
*/
|
||||
async #requestAuthCode() {
|
||||
const identity = await this.#getClientIdentity();
|
||||
|
||||
this.client_id = identity.id;
|
||||
this.client_secret = identity.secret;
|
||||
|
||||
const data = {
|
||||
client_id: this.client_id,
|
||||
scope: this.scope,
|
||||
device_id: Uuid.v4(),
|
||||
model_name: this.model_name
|
||||
};
|
||||
|
||||
const response = await Axios.post(this.oauth_code_url, JSON.stringify(data), Constants.OAUTH.HEADERS).catch((error) => error);
|
||||
|
||||
if (response instanceof Error)
|
||||
return this.emit('auth', {
|
||||
error: 'Could not get auth code.',
|
||||
status: 'FAILED'
|
||||
});
|
||||
|
||||
this.emit('auth', {
|
||||
code: response.data.user_code,
|
||||
status: 'AUTHORIZATION_PENDING',
|
||||
expires_in: response.data.expires_in,
|
||||
verification_url: response.data.verification_url
|
||||
});
|
||||
|
||||
this.refresh_interval = response.data.interval;
|
||||
|
||||
this.#waitForAuth(response.data.device_code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for sign-in authorization.
|
||||
*
|
||||
* @param {string} device_code Client's device code.
|
||||
* @returns
|
||||
*/
|
||||
#waitForAuth(device_code) {
|
||||
const data = {
|
||||
client_id: this.client_id,
|
||||
client_secret: this.client_secret,
|
||||
code: device_code,
|
||||
grant_type: this.grant_type
|
||||
};
|
||||
|
||||
setTimeout(async () => {
|
||||
const response = await Axios.post(this.oauth_token_url, JSON.stringify(data), Constants.OAUTH.HEADERS).catch((error) => error);
|
||||
if (response instanceof Error)
|
||||
return this.emit('auth', {
|
||||
error: 'Could not get authentication token.',
|
||||
status: 'FAILED'
|
||||
});
|
||||
|
||||
if (response.data.error) {
|
||||
switch (response.data.error) {
|
||||
case 'slow_down':
|
||||
case 'authorization_pending':
|
||||
this.#waitForAuth(device_code);
|
||||
break;
|
||||
case 'access_denied':
|
||||
this.emit('auth', {
|
||||
error: 'Access was denied.',
|
||||
status: 'ACCESS_DENIED'
|
||||
});
|
||||
break;
|
||||
case 'expired_token':
|
||||
this.emit('auth', {
|
||||
error: 'The device code has expired, requesting a new one.',
|
||||
status: 'DEVICE_CODE_EXPIRED'
|
||||
});
|
||||
this.#requestAuthCode();
|
||||
break;
|
||||
default:
|
||||
}
|
||||
} else {
|
||||
const expiration_date = new Date(new Date().getTime() + response.data.expires_in * 1000);
|
||||
|
||||
this.emit('auth', {
|
||||
credentials: {
|
||||
access_token: response.data.access_token,
|
||||
refresh_token: response.data.refresh_token,
|
||||
expires: expiration_date,
|
||||
},
|
||||
token_type: response.data.token_type,
|
||||
status: 'SUCCESS'
|
||||
});
|
||||
}
|
||||
}, 1000 * this.refresh_interval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a new access token using a refresh token.
|
||||
* @returns {Promise.<{ credentials: { access_token: string; refresh_token: string; expires: Date }; status: string }>}
|
||||
*/
|
||||
async refreshAccessToken() {
|
||||
const identity = await this.#getClientIdentity();
|
||||
|
||||
const data = {
|
||||
client_id: identity.id,
|
||||
client_secret: identity.secret,
|
||||
refresh_token: this.auth_info.refresh_token,
|
||||
grant_type: 'refresh_token',
|
||||
};
|
||||
|
||||
const response = await Axios.post(this.oauth_token_url, JSON.stringify(data), Constants.OAUTH.HEADERS).catch((error) => error);
|
||||
if (response instanceof Error) {
|
||||
this.emit('auth', {
|
||||
error: 'Could not refresh access token.',
|
||||
status: 'FAILED'
|
||||
});
|
||||
|
||||
return {
|
||||
credentials: {
|
||||
access_token: this.auth_info.access_token,
|
||||
refresh_token: this.auth_info.refresh_token,
|
||||
expires: this.auth_info.expires
|
||||
},
|
||||
status: 'FAILED'
|
||||
};
|
||||
}
|
||||
|
||||
const expiration_date = new Date(new Date().getTime() + response.data.expires_in * 1000);
|
||||
|
||||
return {
|
||||
credentials: {
|
||||
refresh_token: this.auth_info.refresh_token,
|
||||
access_token: response.data.access_token,
|
||||
expires: expiration_date
|
||||
},
|
||||
token_type: response.data.token_type,
|
||||
status: 'SUCCESS'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets client identity data.
|
||||
* @returns {Promise.<{ id: string; secret: string }>}
|
||||
*/
|
||||
async #getClientIdentity() {
|
||||
// This request is made to get the auth script url, hard-coding it isn't viable as it changes overtime.
|
||||
const yttv_response = await Axios.get(`${Constants.URLS.YT_BASE}/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_body}`;
|
||||
|
||||
const response = await Axios.get(script_url, Constants.DEFAULT_HEADERS).catch((error) => error);
|
||||
if (response instanceof Error) throw new Error(`Could not extract client identity: ${response.message}`);
|
||||
|
||||
const client_identity = response.data.replace(/\n/g, '').match(this.identity_regex);
|
||||
return client_identity.groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks access token validity.
|
||||
* @returns {boolean} true | false
|
||||
*/
|
||||
isTokenValid() {
|
||||
const timestamp = new Date(this.auth_info.expires).getTime();
|
||||
const is_valid = new Date().getTime() < timestamp;
|
||||
return is_valid;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = OAuth;
|
||||
49
lib/core/Player.js
Normal file
49
lib/core/Player.js
Normal file
@@ -0,0 +1,49 @@
|
||||
'use strict';
|
||||
|
||||
const Fs = require('fs');
|
||||
const Axios = require('axios');
|
||||
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, -8) + 'cache';
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (Fs.existsSync(`${this.tmp_cache_dir}/${this.player_name}.js`)) {
|
||||
const player_data = Fs.readFileSync(`${this.tmp_cache_dir}/${this.player_name}.js`).toString();
|
||||
this.sig_decipher_sc = this.#getSigDecipherCode(player_data);
|
||||
this.ntoken_sc = this.#getNEncoder(player_data);
|
||||
} else {
|
||||
const response = await Axios.get(`${Constants.URLS.YT_BASE}${this.session.player_url}`, { path: this.session.playerUrl, headers: { 'content-type': 'text/javascript', 'user-agent': Utils.getRandomUserAgent('desktop').userAgent } }).catch((error) => error);
|
||||
if (response instanceof Error) throw new Error('Could not download player script: ' + response.message);
|
||||
|
||||
try {
|
||||
// Deletes old players
|
||||
Fs.existsSync(this.tmp_cache_dir) && Fs.rmSync(this.tmp_cache_dir, { recursive: true });
|
||||
|
||||
// Caches the current player so we don't have to download it all the time.
|
||||
Fs.mkdirSync(this.tmp_cache_dir, { recursive: true });
|
||||
Fs.writeFileSync(`${this.tmp_cache_dir}/${this.player_name}.js`, response.data);
|
||||
} catch (err) {}
|
||||
|
||||
this.sig_decipher_sc = this.#getSigDecipherCode(response.data);
|
||||
this.ntoken_sc = this.#getNEncoder(response.data);
|
||||
}
|
||||
}
|
||||
|
||||
#getSigDecipherCode(data) {
|
||||
const sig_alg_sc = Utils.getStringBetweenStrings(data, 'this.audioTracks};var', '};');
|
||||
const sig_data = Utils.getStringBetweenStrings(data, 'function(a){a=a.split("")', 'return a.join("")}');
|
||||
return sig_alg_sc + sig_data;
|
||||
}
|
||||
|
||||
#getNEncoder(data) {
|
||||
return `var b=a.split("")${Utils.getStringBetweenStrings(data, 'b=a.split("")', '}return b.join("")}')}} return b.join("");`;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Player;
|
||||
139
lib/deciphers/NToken.js
Normal file
139
lib/deciphers/NToken.js
Normal file
@@ -0,0 +1,139 @@
|
||||
'use strict';
|
||||
|
||||
const Utils = require('../utils/Utils');
|
||||
const Constants = require('../utils/Constants');
|
||||
|
||||
class NToken {
|
||||
constructor(raw_code, n) {
|
||||
this.n = n;
|
||||
this.raw_code = raw_code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Solves throttling challange by transforming the n token.
|
||||
* @returns {string} transformed token.
|
||||
*/
|
||||
transform() {
|
||||
let n_token = this.n.split('');
|
||||
|
||||
try {
|
||||
let transformations = this.#getTransformationData();
|
||||
transformations = transformations.map((el) => {
|
||||
if (el != null && typeof el != 'number') {
|
||||
const is_reverse_base64 = el.includes('case 65:');
|
||||
(({ // Identifies the transformation functions
|
||||
[Constants.FUNCS.PUSH]: () => el = (arr, i) => this.#push(arr, i),
|
||||
[Constants.FUNCS.SPLICE]: () => el = (arr, i) => this.#splice(arr, i),
|
||||
[Constants.FUNCS.SWAP0_1]: () => el = (arr, i) => this.#swap0(arr, i),
|
||||
[Constants.FUNCS.SWAP0_2]: () => el = (arr, i) => this.#swap0(arr, i),
|
||||
[Constants.FUNCS.ROTATE_1]: () => el = (arr, i) => this.#rotate(arr, i),
|
||||
[Constants.FUNCS.ROTATE_2]: () => el = (arr, i) => this.#rotate(arr, i),
|
||||
[Constants.FUNCS.REVERSE_1]: () => el = (arr) => this.#reverse(arr),
|
||||
[Constants.FUNCS.REVERSE_2]: () => el = (arr) => this.#reverse(arr),
|
||||
[Constants.FUNCS.BASE64_DIA]: () => el = () => this.#getBase64Dia(is_reverse_base64),
|
||||
[Constants.FUNCS.TRANSLATE_1]: () => el = (arr, token) => this.#translate1(arr, token, is_reverse_base64),
|
||||
[Constants.FUNCS.TRANSLATE_2]: () => el = (arr, token, base64_dic) => this.#translate2(arr, token, base64_dic)
|
||||
})[this.#getFunc(el)] || (() => el === 'b' && (el = n_token)))();
|
||||
}
|
||||
return el;
|
||||
});
|
||||
|
||||
// Fills 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 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] }));
|
||||
|
||||
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 (${this.n}), download may be throttled:`, err.message);
|
||||
return this.n;
|
||||
}
|
||||
return n_token.join('');
|
||||
}
|
||||
|
||||
#getFunc(el) {
|
||||
return el.match(Constants.FUNCS_REGEX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes the n-transform data, refines it, and then returns a readable json array.
|
||||
* @returns {object}
|
||||
*/
|
||||
#getTransformationData() {
|
||||
const data = `[${Utils.getStringBetweenStrings(this.raw_code.replace(/\n/g, ''), 'c=[', '];c')}]`;
|
||||
return JSON.parse(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) {
|
||||
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) {
|
||||
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;
|
||||
return characters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Swaps the first element with the one at the given index.
|
||||
* @returns
|
||||
*/
|
||||
#swap0(arr, index) {
|
||||
const old_elem = arr[0];
|
||||
index = (index % arr.length + arr.length) % arr.length;
|
||||
arr[0] = arr[index];
|
||||
arr[index] = old_elem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotates elements of the array.
|
||||
* @returns
|
||||
*/
|
||||
#rotate(arr, index) {
|
||||
index = (index % arr.length + arr.length) % arr.length;
|
||||
arr.splice(-index).reverse().forEach((el) => arr.unshift(el));
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes one element at the given index.
|
||||
* @returns
|
||||
*/
|
||||
#splice(arr, index) {
|
||||
index = (index % arr.length + arr.length) % arr.length;
|
||||
arr.splice(index, 1);
|
||||
}
|
||||
|
||||
#reverse(arr) {
|
||||
arr.reverse();
|
||||
}
|
||||
|
||||
#push(arr, item) {
|
||||
arr.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = NToken;
|
||||
@@ -1,20 +1,21 @@
|
||||
'use strict';
|
||||
|
||||
const NToken = require('./NToken');
|
||||
const QueryString = require('querystring');
|
||||
|
||||
class SigDecipher {
|
||||
constructor(url, cver, player) {
|
||||
constructor(url, player) {
|
||||
this.url = url;
|
||||
this.cver = cver;
|
||||
this.player = player;
|
||||
this.func_regex = /(.{2}):function\(.*?\){(.*?)}/g;
|
||||
this.actions_regex = /;.{2}\.(.{2})\(.*?,(.*?)\)/g;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deciphers signature.
|
||||
*/
|
||||
decipher() {
|
||||
const args = QueryString.parse(this.url);
|
||||
const functions = this.getFunctions();
|
||||
const functions = this.#getFunctions();
|
||||
|
||||
function splice(arr, end) {
|
||||
arr.splice(0, end);
|
||||
@@ -49,15 +50,11 @@ class SigDecipher {
|
||||
}
|
||||
|
||||
const url_components = new URL(args.url);
|
||||
|
||||
args.sp !== undefined ? url_components.searchParams.set(args.sp, signature.join('')) : url_components.searchParams.set('signature', signature.join(''));
|
||||
url_components.searchParams.set('cver', this.cver);
|
||||
url_components.searchParams.set('ratebypass', 'yes');
|
||||
url_components.searchParams.set('n', new NToken(this.player.ntoken_sc).transform(url_components.searchParams.get('n')));
|
||||
args.sp ? url_components.searchParams.set(args.sp, signature.join('')) : url_components.searchParams.set('signature', signature.join(''));
|
||||
return url_components.toString();
|
||||
}
|
||||
|
||||
getFunctions() {
|
||||
#getFunctions() {
|
||||
let func;
|
||||
let func_name = [];
|
||||
|
||||
255
lib/parser/index.js
Normal file
255
lib/parser/index.js
Normal 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;
|
||||
6
lib/parser/youtube/index.js
Normal file
6
lib/parser/youtube/index.js
Normal file
@@ -0,0 +1,6 @@
|
||||
'use strict';
|
||||
|
||||
const VideoResultItem = require('./search/VideoResultItem');
|
||||
const PlaylistItem = require('./others/PlaylistItem');
|
||||
|
||||
module.exports = { VideoResultItem, PlaylistItem };
|
||||
26
lib/parser/youtube/others/PlaylistItem.js
Normal file
26
lib/parser/youtube/others/PlaylistItem.js
Normal 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;
|
||||
43
lib/parser/youtube/search/VideoResultItem.js
Normal file
43
lib/parser/youtube/search/VideoResultItem.js
Normal 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;
|
||||
11
lib/parser/ytmusic/index.js
Normal file
11
lib/parser/ytmusic/index.js
Normal 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 };
|
||||
28
lib/parser/ytmusic/others/PlaylistItem.js
Normal file
28
lib/parser/ytmusic/others/PlaylistItem.js
Normal 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;
|
||||
21
lib/parser/ytmusic/search/AlbumResultItem.js
Normal file
21
lib/parser/ytmusic/search/AlbumResultItem.js
Normal 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;
|
||||
19
lib/parser/ytmusic/search/ArtistResultItem.js
Normal file
19
lib/parser/ytmusic/search/ArtistResultItem.js
Normal 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;
|
||||
23
lib/parser/ytmusic/search/PlaylistResultItem.js
Normal file
23
lib/parser/ytmusic/search/PlaylistResultItem.js
Normal 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;
|
||||
22
lib/parser/ytmusic/search/SongResultItem.js
Normal file
22
lib/parser/ytmusic/search/SongResultItem.js
Normal 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;
|
||||
32
lib/parser/ytmusic/search/TopResultItem.js
Normal file
32
lib/parser/ytmusic/search/TopResultItem.js
Normal 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;
|
||||
22
lib/parser/ytmusic/search/VideoResultItem.js
Normal file
22
lib/parser/ytmusic/search/VideoResultItem.js
Normal 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
133
lib/proto/index.js
Normal 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 };
|
||||
@@ -1,34 +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;
|
||||
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;
|
||||
}
|
||||
141
lib/utils/Constants.js
Normal file
141
lib/utils/Constants.js
Normal file
@@ -0,0 +1,141 @@
|
||||
'use strict';
|
||||
|
||||
const Utils = require('./Utils');
|
||||
|
||||
module.exports = {
|
||||
URLS: {
|
||||
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',
|
||||
GRANT_TYPE: 'http://oauth.net/grant_type/device/1.0',
|
||||
MODEL_NAME: 'ytlr::',
|
||||
HEADERS: {
|
||||
headers: {
|
||||
'accept': '*/*',
|
||||
'origin': 'https://www.youtube.com',
|
||||
'user-agent': 'Mozilla/5.0 (ChromiumStylePlatform) Cobalt/Version',
|
||||
'content-type': 'application/json',
|
||||
'referer': `https://www.youtube.com/tv`,
|
||||
'accept-language': 'en-US'
|
||||
}
|
||||
}
|
||||
},
|
||||
DEFAULT_HEADERS: (session) => {
|
||||
return {
|
||||
headers: {
|
||||
'Cookie': session.cookie,
|
||||
'user-agent': Utils.getRandomUserAgent('desktop').userAgent,
|
||||
'Referer': 'https://www.google.com/',
|
||||
'Accept': 'text/html',
|
||||
'Accept-Language': 'en-US,en',
|
||||
'Accept-Encoding': 'gzip'
|
||||
}
|
||||
};
|
||||
},
|
||||
STREAM_HEADERS: {
|
||||
'Accept': '*/*',
|
||||
'User-Agent': Utils.getRandomUserAgent('desktop').userAgent,
|
||||
'Connection': 'keep-alive',
|
||||
'Origin': 'https://www.youtube.com',
|
||||
'Referer': 'https://www.youtube.com',
|
||||
'DNT': '?1'
|
||||
},
|
||||
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
|
||||
};
|
||||
|
||||
if (info.session.logged_in) {
|
||||
|
||||
headers.Cookie = info.session.cookie;
|
||||
headers.authorization = info.session.cookie.length && info.session.auth_apisid || `Bearer ${info.session.access_token}`;
|
||||
}
|
||||
|
||||
return headers
|
||||
},
|
||||
VIDEO_INFO_REQBODY: (id, sts, context) => {
|
||||
return {
|
||||
playbackContext: {
|
||||
contentPlaybackContext: {
|
||||
'currentUrl': '/watch?v=' + id,
|
||||
'vis': 0,
|
||||
'splay': false,
|
||||
'autoCaptionsDefaultOn': false,
|
||||
'autonavState': 'STATE_OFF',
|
||||
'html5Preference': 'HTML5_PREF_WANTS',
|
||||
'signatureTimestamp': sts,
|
||||
'referer': 'https://www.youtube.com',
|
||||
'lactMilliseconds': '-1'
|
||||
}
|
||||
},
|
||||
context: context,
|
||||
videoId: id
|
||||
};
|
||||
},
|
||||
YTMUSIC_VERSION: '1.20211213.00.00',
|
||||
METADATA_KEYS: [
|
||||
'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',
|
||||
'category', 'owner_channel_name', 'publish_date',
|
||||
'upload_date', 'keywords', 'available_countries',
|
||||
'owner_profile_url'
|
||||
],
|
||||
BLACKLISTED_KEYS: [
|
||||
'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('')
|
||||
},
|
||||
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()',
|
||||
REVERSE_2: 'function(d){for(var',
|
||||
SPLICE: 'd.length;d.splice(e,1)',
|
||||
SWAP0_1: 'd[0])[0])',
|
||||
SWAP0_2: 'f=d[0];d[0]',
|
||||
ROTATE_1: 'reverse().forEach',
|
||||
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'
|
||||
}
|
||||
};
|
||||
133
lib/utils/Utils.js
Normal file
133
lib/utils/Utils.js
Normal 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 };
|
||||
89
package-lock.json
generated
89
package-lock.json
generated
@@ -1,19 +1,25 @@
|
||||
{
|
||||
"name": "youtubei.js",
|
||||
"version": "1.2.4",
|
||||
"version": "1.4.0",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "youtubei.js",
|
||||
"version": "1.2.4",
|
||||
"version": "1.4.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^0.21.4",
|
||||
"flat": "^5.0.2",
|
||||
"protons": "^2.0.3",
|
||||
"time-to-seconds": "^1.1.5",
|
||||
"user-agents": "^1.0.778",
|
||||
"uuid": "^8.3.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://ko-fi.com/luanrt"
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
@@ -53,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.4",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.4.tgz",
|
||||
"integrity": "sha512-zwGkiSXC1MUJG/qmeIFH2HBJx9u0V46QGUe3YR1fXG8bXQxq7fLj0RjLZQ5nubr9qNJUZrH+xUcwXEoXNpfS+g==",
|
||||
"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",
|
||||
@@ -78,9 +92,9 @@
|
||||
"integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8="
|
||||
},
|
||||
"node_modules/multiformats": {
|
||||
"version": "9.4.9",
|
||||
"resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.4.9.tgz",
|
||||
"integrity": "sha512-zA84TTJcRfRMpjvYqy63piBbSEdqlIGqNNSpP6kspqtougqjo60PRhIFo+oAxrjkof14WMCImvr7acK6rPpXLw=="
|
||||
"version": "9.6.4",
|
||||
"resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.6.4.tgz",
|
||||
"integrity": "sha512-fCCB6XMrr6CqJiHNjfFNGT0v//dxOBMrOMqUIzpPc/mmITweLEyhvMpY9bF+jZ9z3vaMAau5E8B68DW77QMXkg=="
|
||||
},
|
||||
"node_modules/protocol-buffers-schema": {
|
||||
"version": "3.6.0",
|
||||
@@ -106,15 +120,6 @@
|
||||
"varint": "~5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/time-to-seconds": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/time-to-seconds/-/time-to-seconds-1.1.5.tgz",
|
||||
"integrity": "sha512-mpzJDHGF4VdhiahyusCUSy+BWJdN3q8Cluzfy0n7GMU9IIj+HJDX9bbbr7wVSUiqmRn1vqhhfECgdfj+SByu2A==",
|
||||
"engines": {
|
||||
"node": ">=15.0.1",
|
||||
"vscode": "^1.22.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uint8arrays": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.0.0.tgz",
|
||||
@@ -124,9 +129,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/underscore": {
|
||||
"version": "1.13.1",
|
||||
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.1.tgz",
|
||||
"integrity": "sha512-hzSoAVtJF+3ZtiFX0VgfFPHEDRm7Y/QPjGyNo4TVdnDTdft3tr8hEkD25a1jC+TjTuE7tkHGKkhwCgs9dgBB2g=="
|
||||
"version": "1.13.2",
|
||||
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.2.tgz",
|
||||
"integrity": "sha512-ekY1NhRzq0B08g4bGuX4wd2jZx5GnKz6mKSqFL4nqBlfyMGiG10gDFhDTMEfYmDL6Jy0FUIZp7wiRB+0BP7J2g=="
|
||||
},
|
||||
"node_modules/underscore-keypath": {
|
||||
"version": "0.0.22",
|
||||
@@ -137,9 +142,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/user-agents": {
|
||||
"version": "1.0.814",
|
||||
"resolved": "https://registry.npmjs.org/user-agents/-/user-agents-1.0.814.tgz",
|
||||
"integrity": "sha512-OU3lCkkaUCghnZiZuN2edSJ+//ituJihs3P1FCjCDwe1tMBS8eni68CQ0TBbPoVPDtoAqxM3Z7qbP7EWGlhBxA==",
|
||||
"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"
|
||||
@@ -188,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.4",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.4.tgz",
|
||||
"integrity": "sha512-zwGkiSXC1MUJG/qmeIFH2HBJx9u0V46QGUe3YR1fXG8bXQxq7fLj0RjLZQ5nubr9qNJUZrH+xUcwXEoXNpfS+g=="
|
||||
"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",
|
||||
@@ -199,9 +209,9 @@
|
||||
"integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8="
|
||||
},
|
||||
"multiformats": {
|
||||
"version": "9.4.9",
|
||||
"resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.4.9.tgz",
|
||||
"integrity": "sha512-zA84TTJcRfRMpjvYqy63piBbSEdqlIGqNNSpP6kspqtougqjo60PRhIFo+oAxrjkof14WMCImvr7acK6rPpXLw=="
|
||||
"version": "9.6.4",
|
||||
"resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.6.4.tgz",
|
||||
"integrity": "sha512-fCCB6XMrr6CqJiHNjfFNGT0v//dxOBMrOMqUIzpPc/mmITweLEyhvMpY9bF+jZ9z3vaMAau5E8B68DW77QMXkg=="
|
||||
},
|
||||
"protocol-buffers-schema": {
|
||||
"version": "3.6.0",
|
||||
@@ -227,11 +237,6 @@
|
||||
"varint": "~5.0.0"
|
||||
}
|
||||
},
|
||||
"time-to-seconds": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/time-to-seconds/-/time-to-seconds-1.1.5.tgz",
|
||||
"integrity": "sha512-mpzJDHGF4VdhiahyusCUSy+BWJdN3q8Cluzfy0n7GMU9IIj+HJDX9bbbr7wVSUiqmRn1vqhhfECgdfj+SByu2A=="
|
||||
},
|
||||
"uint8arrays": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.0.0.tgz",
|
||||
@@ -241,9 +246,9 @@
|
||||
}
|
||||
},
|
||||
"underscore": {
|
||||
"version": "1.13.1",
|
||||
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.1.tgz",
|
||||
"integrity": "sha512-hzSoAVtJF+3ZtiFX0VgfFPHEDRm7Y/QPjGyNo4TVdnDTdft3tr8hEkD25a1jC+TjTuE7tkHGKkhwCgs9dgBB2g=="
|
||||
"version": "1.13.2",
|
||||
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.2.tgz",
|
||||
"integrity": "sha512-ekY1NhRzq0B08g4bGuX4wd2jZx5GnKz6mKSqFL4nqBlfyMGiG10gDFhDTMEfYmDL6Jy0FUIZp7wiRB+0BP7J2g=="
|
||||
},
|
||||
"underscore-keypath": {
|
||||
"version": "0.0.22",
|
||||
@@ -254,9 +259,9 @@
|
||||
}
|
||||
},
|
||||
"user-agents": {
|
||||
"version": "1.0.814",
|
||||
"resolved": "https://registry.npmjs.org/user-agents/-/user-agents-1.0.814.tgz",
|
||||
"integrity": "sha512-OU3lCkkaUCghnZiZuN2edSJ+//ituJihs3P1FCjCDwe1tMBS8eni68CQ0TBbPoVPDtoAqxM3Z7qbP7EWGlhBxA==",
|
||||
"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"
|
||||
@@ -273,4 +278,4 @@
|
||||
"integrity": "sha512-lKxKYG6H03yCZUpAGOPOsMcGxd1RHCu1iKvEHYDPmTyq2HueGhD73ssNBqqQWfvYs04G9iUFRvmAVLW20Jw6ow=="
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
56
package.json
56
package.json
@@ -1,21 +1,25 @@
|
||||
{
|
||||
"name": "youtubei.js",
|
||||
"version": "1.2.4",
|
||||
"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",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"author": "LuanRT",
|
||||
"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"
|
||||
},
|
||||
"directories": {
|
||||
"example": "examples",
|
||||
"lib": "lib"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^0.21.4",
|
||||
"flat": "^5.0.2",
|
||||
"protons": "^2.0.3",
|
||||
"time-to-seconds": "^1.1.5",
|
||||
"user-agents": "^1.0.778",
|
||||
"uuid": "^8.3.2"
|
||||
},
|
||||
@@ -23,24 +27,26 @@
|
||||
"type": "git",
|
||||
"url": "git+https//github.com/LuanRT/YouTube.js.git"
|
||||
},
|
||||
"keywords": [
|
||||
"youtube",
|
||||
"youtube-dl",
|
||||
"innertube",
|
||||
"innertubeapi",
|
||||
"livechat",
|
||||
"api",
|
||||
"search",
|
||||
"like",
|
||||
"dislike",
|
||||
"comment",
|
||||
"automation",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
|
||||
24
test/constants.js
Normal file
24
test/constants.js
Normal file
@@ -0,0 +1,24 @@
|
||||
'use strict';
|
||||
|
||||
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=D8yGa-DC5m2Dwv--&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cratebypass%2Cdur%2Clmt&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgdS6ux5rh5ulfwh8c6_Kt2cOdyS51OxPlxSUoB5k5x9YCICOgRiuFsZwAqJmxvBrCuq3CKk1S4YeAxEq3zPLvzAvX&sig=AOq0QJ8wRQIgdRvdKLMqva85sJOfykj1OX7HKH23Ettz2_WTVTzVm64CIQDOalGxpK1V2Ht6dNXsr1Sg17v7j4psm5VFQbs2JLT2vA%3D%3D',
|
||||
original_ntoken: 'PqjqqJjdB9K821VIisj',
|
||||
expected_ntoken: 'AxwyS-osUl1WhMUd1',
|
||||
client_version: '2.20211101.01.00',
|
||||
test_video_id: 'dQw4w9WgXcQ',
|
||||
sig_decipher_sc: `fB={RP:function(a,b){a.splice(0,b)},
|
||||
Td:function(a){a.reverse()},
|
||||
kq:function(a,b){var c=a[0];a[0]=a[b%a.length];a[b%a.length]=c};fB.kq(a,35);fB.RP(a,2);fB.kq(a,46);fB.Td(a,6);`,
|
||||
n_scramble_sc: `var b=a.split(""),c=[-470482026,-691770757,function(d,e){e=(e%d.length+d.length)%d.length;d.splice(-e).reverse().forEach(function(f){d.unshift(f)})},
|
||||
b,258876269,-1426380890,318754300,-68090711,-2064438462,-1886316521,1913911047,1635047330,function(d,e){e=(e%d.length+d.length)%d.length;d.splice(-e).reverse().forEach(function(f){d.unshift(f)})},
|
||||
-1815897225,1940621629,-714586149,-1723898467,null,778601498,2145333248,1245726977,1952270083,268207944,244274044,null,function(d,e){e=(e%d.length+d.length)%d.length;d.splice(0,1,d.splice(e,1,d[0])[0])},
|
||||
null,-762271981,604636391,1087224318,-931565987,-338396815,function(d,e){for(e=(e%d.length+d.length)%d.length;e--;)d.unshift(d.pop())},
|
||||
2126741474,function(d){for(var e=d.length;e;)d.push(d.splice(--e,1)[0])},
|
||||
-1874551858,-1238260579,498106911,1913911047,-1951114300,-504396507,b,344510945,905306344,b,function(d,e){e=(e%d.length+d.length)%d.length;var f=d[0];d[0]=d[e];d[e]=f},
|
||||
909033134,1027812119,1686673079,function(d,e){d.push(e)},
|
||||
-1902376100,function(d,e){e=(e%d.length+d.length)%d.length;d.splice(e,1)},
|
||||
"push",function(d,e){for(var f=64,h=[];++f-h.length-32;)switch(f){case 58:f=96;continue;case 91:f=44;break;case 65:f=47;continue;case 46:f=153;case 123:f-=58;default:h.push(String.fromCharCode(f))}d.forEach(function(l,m,n){this.push(n[m]=h[(h.indexOf(l)-h.indexOf(this[m])+m-32+f--)%h.length])},e.split(""))}];
|
||||
c[17]=c;c[24]=c;c[26]=c;try{c[45](c[17],c[38]),c[12](c[44],c[29]),c[45](c[26],c[0]),c[51](c[41],c[13]),c[12](c[41],c[27]),c[12](c[26],c[11]),c[39](c[17],c[49]),c[9](c[38],c[47]),c[26](c[40],c[0]),c[7](c[8],c[44]),c[14](c[54],c[0]),c[18](c[3],c[25]),c[7](c[33],c[36]),c[15](c[19],c[14]),c[7](c[19],c[9]),c[7](c[6],c[12]),c[41](c[33],c[35]),c[7](c[40],c[5]),c[50](c[42]),c[13](c[14],c[17]),c[6](c[35],c[51]),c[26](c[48],c[50]),c[26](c[35],c[0]),c[6](c[21],c[46]),c[15](c[21],c[42]),c[1](c[2],c[43]),c[15](c[2],
|
||||
c[31]),c[1](c[21],c[25]),c[22](c[30],c[17]),c[15](c[44],c[46]),c[22](c[44],c[11]),c[22](c[23],c[38]),c[1](c[23],c[14]),c[35](c[23],c[44]),c[11](c[53],c[20]),c[9](c[51]),c[31](c[51],c[28]),c[18](c[51],c[35]),c[46](c[53],c[6]),c[52](c[51],c[49]),c[11](c[53],c[15])}catch(d){return"enhanced_except_75MBkOz-_w8_"+a} return b.join("");`
|
||||
};
|
||||
75
test/index.js
Normal file
75
test/index.js
Normal file
@@ -0,0 +1,75 @@
|
||||
'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();
|
||||
Reference in New Issue
Block a user