Compare commits

...

131 Commits

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-14 16:24:09 +00:00
LuanRT
cf4901fd3c feat: automatically delete old players 2022-02-13 19:09:40 -03:00
LuanRT
2fd98a021f format: remove white space 2022-02-13 19:08:14 -03:00
LuanRT
cd64e30b69 chore: simplify format selection 2022-02-13 19:06:56 -03:00
LuanRT
2b5027eb06 fix: getLyrics() only working when signed-in 2022-02-06 15:26:16 -03:00
LuanRT
0c9f7135bf docs: oops 2022-02-05 19:25:23 -03:00
LuanRT
ce8a109398 docs: update table of contents 2022-02-05 19:23:07 -03:00
LuanRT
6aaa3360e8 docs: update YouTube Music examples 2022-02-05 19:17:12 -03:00
LuanRT
89c018c431 refactor: move getLyrics to Innertube.js 2022-02-05 19:16:36 -03:00
LuanRT
339a01f3a9 chore(release): v1.3.0 2022-02-05 18:45:30 -03:00
LuanRT
dd3f4c0009 chore: format code & other minor changes 2022-02-05 18:32:25 -03:00
LuanRT
7cd41e1d8a docs: add YouTube Music examples 2022-02-05 18:31:28 -03:00
LuanRT
6ac8561af2 feat: add lyrics support 2022-02-05 18:30:21 -03:00
LuanRT
b4607d531f fix(OAuth): secret not found due to bad regex 2022-02-04 15:20:35 -03:00
LuanRT
b3a1cdc1cd chore: remove ntoken 'translate' func var names 2022-02-03 04:54:37 -03:00
LuanRT
fd662df93d style: remove extra white space 2022-02-02 06:12:38 -03:00
LuanRT
8a1f4b4e55 Merge branch 'main' of https://github.com/LuanRT/YouTube.js 2022-02-02 06:04:49 -03:00
LuanRT
4ff83bdc3f style: add missing semi & rename some variables 2022-02-02 06:04:39 -03:00
LuanRT
c81e8e29ac chore: remove unnecessary condition 2022-02-01 18:49:17 -03:00
LuanRT
d5f884ff9b refactor: move refineNTokenData function to Utils 2022-02-01 16:47:02 -03:00
LuanRT
5517c2f202 fix: ntoken function 'translate2' not being parsed 2022-02-01 16:12:47 -03:00
LuanRT
3493a82765 chore: remove more unused variables 2022-01-31 04:31:45 -03:00
LuanRT
07f02d0dc1 chore: remove unnecessary variable 2022-01-30 20:47:26 -03:00
LuanRT
b2afa86744 chore: update deps 2022-01-30 20:43:29 -03:00
LuanRT
a1caa60750 1.2.9 2022-01-30 20:34:18 -03:00
LuanRT
e1dd718832 chore: format code and other minor changes 2022-01-30 20:28:00 -03:00
LuanRT
222bf1e61f fix: ntoken 'translate2' function not being parsed 2022-01-30 20:26:02 -03:00
LuanRT
3b48de20dd fix: oauth identity creds regex no longer working 2022-01-30 20:20:52 -03:00
LuanRT
348d901935 chore: update tests 2022-01-30 20:19:11 -03:00
LuanRT
94b12002ff feat: implement continuation requests for YTMusic 2022-01-18 15:55:07 -03:00
LuanRT
2720e8f251 chore(livechat): remove console.log 2022-01-18 15:52:35 -03:00
LuanRT
a8a1ec2182 fix (tests): video used for tests is no longer available 2022-01-18 15:50:43 -03:00
LuanRT
ee0d1bef40 deps: remove time-to-seconds dependency 2022-01-18 15:46:54 -03:00
LuanRT
5cad39ee44 fix: polling interval missing 2022-01-07 18:59:17 -03:00
LuanRT
e8ca248919 feat: add home feed support 2022-01-07 18:50:00 -03:00
LuanRT
44d09026b5 chore: simplify video details parser 2022-01-07 18:46:29 -03:00
LuanRT
ff044f4216 fix: error polling livechat due to dislikes 2022-01-07 18:45:30 -03:00
LuanRT
8153e6178c fix: subsfeed sections placeholders missing 2022-01-05 16:36:48 -03:00
LuanRT
ee3f1b4638 chore: update examples 2022-01-05 16:31:52 -03:00
LuanRT
86c8a7e0d2 fix: filter out undefined search results 2021-12-31 04:05:38 -03:00
LuanRT
b375ae2f06 chore: fix typo 2021-12-31 03:35:05 -03:00
LuanRT
2ff4b2ea95 test: remove node 12 build 2021-12-31 03:27:54 -03:00
LuanRT
599ab69107 refactor: rewrite inefficient code and add docs 2021-12-31 03:19:58 -03:00
LuanRT
c6c6dc24bd feat: add support for music search 2021-12-31 03:15:59 -03:00
LuanRT
fa2e0724c6 docs: fix a typo 2021-12-22 15:27:26 -03:00
LuanRT
6af689ada6 docs: improve documentation & add unseen notifications example 2021-12-22 15:22:10 -03:00
LuanRT
9997c0d939 build (package): release v1.2.8 2021-12-18 19:14:15 -03:00
LuanRT
3dee7fc12f fix: forgot to export getVideoInfo :v 2021-12-18 12:20:17 -03:00
LuanRT
4dff129b74 chore: update tests 2021-12-18 12:17:00 -03:00
LuanRT
7e86bb15e0 refactor (OAuth): a simpler & more efficient auth system 2021-12-18 12:03:44 -03:00
LuanRT
d0de164722 chore: update examples & format code 2021-12-18 00:24:57 -03:00
LuanRT
5d165ebb61 refactor: move all internal actions to Actions.js for better maintainability 2021-12-18 00:16:47 -03:00
LuanRT
2ad19adbe4 refactor: move search request code to Actions.js for better maintainability & organization 2021-12-17 23:55:39 -03:00
LuanRT
cabbdc9f50 chore: encode search filters correctly 2021-12-17 23:08:35 -03:00
LuanRT
fe84f31432 chore: add search filter protobuf message 2021-12-17 21:12:14 -03:00
LuanRT
22c605f528 perf (OAuth): check access token validity in a more efficient way 2021-12-13 21:58:02 -03:00
LuanRT
6777b59116 feat: include available stream quality in the metadata 2021-12-13 21:38:31 -03:00
stranothus
de70d851d8 Desktop version compatible
The desktop version is sent a different resopnse by the Innertube API
and streamingData needs to be accessed from data, rather than the third
index of data and through playerResponse.
2021-12-13 15:40:44 -06:00
stranothus
e20e671d16 Include available video qualities to metadata
The playerResponse streamingData adaptiveFormats are filter to include only those which
include a qualityLabel. This array is then mapped to an array of qualityLabels and sorted
from lowest to highest quality.
2021-12-13 09:29:45 -06:00
LuanRT
d0e1140029 chore: yes, more code formatting 2021-12-09 23:24:50 -03:00
LuanRT
bf483256fe chore: remove useless comments & format code 2021-12-09 22:45:18 -03:00
LuanRT
d4c32d47e1 build (package): release v1.2.7 2021-11-24 12:14:46 -03:00
LuanRT
70feab80da fix: check if dislike count is available to avoid unexpected errors 2021-11-23 07:17:17 -03:00
LuanRT
c006f49dc1 chore: remove unnecessary param 2021-11-23 06:09:12 -03:00
50 changed files with 4395 additions and 1767 deletions

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

1237
README.md

File diff suppressed because it is too large Load Diff

View File

@@ -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();

View File

@@ -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_REQOPTS({ session, id: args.video_id, data })).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
return {
success: true,
status_code: response.status
};
}
async function browse(session, action_type) {
if (!session.logged_in) throw new Error('You are not 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_REQOPTS({ session })).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
return {
success: true,
status_code: response.status,
data: response.data
};
}
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_REQOPTS({ session })).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
if (action_type === 'modify_channel_preference') return { success: true, status_code: response.status };
return {
success: true,
status_code: response.status,
data: response.data
};
}
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_REQOPTS({ session, params: args.params })).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
return {
success: true,
status_code: response.status,
data: response.data
};
}
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_REQOPTS({ session })).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
return {
success: true,
status_code: response.status,
data: response.data
};
}
module.exports = { engage, browse, notifications, livechat, getContinuation };

View File

@@ -1,266 +0,0 @@
'use strict';
const Utils = require('./Utils');
module.exports = {
URLS: {
YT_BASE_URL: 'https://www.youtube.com',
YT_MOBILE_URL: 'https://m.youtube.com',
YT_WATCH_PAGE: 'https://m.youtube.com/watch'
},
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',
'Upgrade-Insecure-Requests': 1
}
};
},
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_REQOPTS: (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 ? 'https://www.youtube.com' : 'https://m.youtube.com',
'origin': info.desktop ? 'https://www.youtube.com' : 'https://m.youtube.com',
}
};
info.id && (req_opts.headers.referer = (info.desktop ? 'https://www.youtube.com' : 'https://m.youtube.com') + '/watch?v=' + info.id);
if (info.session.logged_in && 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;
},
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
};
},
BASE64_DIALECT: {
NORMAL: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'.split(''),
REVERSE: '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_'.split('')
},
FUNCS_REGEX: /d\.push\(e\)|d\.reverse\(\)|d\[0\]\)\[0\]\)|f=d\[0];d\[0\]|d\.length;d\.splice\(e,1\)|function\(\){for\(var|function\(d,e,f\){var h=f|function\(d\){for\(var|reverse\(\)\.forEach|unshift\(d\.pop\(\)\)|function\(d,e\){for\(var f/,
FUNCS: {
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 h=f'
},
// Helper functions, felt like Utils.js wasn't the right place for them:
formatNTransformData: (data) => {
return data
.replace(/function\(d,e\)/g, '"function(d,e)').replace(/function\(d\)/g, '"function(d)')
.replace(/function\(\)/g, '"function()').replace(/function\(d,e,f\)/g, '"function(d,e,f)')
.replace(/\[function\(d,e,f\)/g, '["function(d,e,f)')
.replace(/,b,/g, ',"b",').replace(/,b/g, ',"b"').replace(/b,/g, '"b",').replace(/b]/g, '"b"]')
.replace(/\[b/g, '["b"').replace(/}]/g, '"]').replace(/},/g, '}",').replace(/""/g, '')
.replace(/length]\)}"/g, 'length])}');
},
formatVideoData: (data, context, is_desktop) => {
let video_details = {};
let metadata = {};
if (is_desktop) {
metadata.embed = data.microformat.playerMicroformatRenderer.embed;
metadata.view_count = parseInt(data.videoDetails.viewCount);
metadata.average_rating = data.videoDetails.averageRating;
metadata.length_seconds = data.microformat.playerMicroformatRenderer.lengthSeconds;
metadata.channel_id = data.videoDetails.channelId;
metadata.channel_url = data.microformat.playerMicroformatRenderer.ownerProfileUrl;
metadata.external_channel_id = data.microformat.playerMicroformatRenderer.externalChannelId;
metadata.is_live_content = data.videoDetails.isLiveContent;
metadata.is_family_safe = data.microformat.playerMicroformatRenderer.isFamilySafe;
metadata.is_unlisted = data.microformat.playerMicroformatRenderer.isUnlisted;
metadata.is_private = data.videoDetails.isPrivate;
metadata.has_ypc_metadata = data.microformat.playerMicroformatRenderer.hasYpcMetadata;
metadata.category = data.microformat.playerMicroformatRenderer.category;
metadata.channel_name = data.microformat.playerMicroformatRenderer.ownerChannelName;
metadata.publish_date = data.microformat.playerMicroformatRenderer.publishDate || 'N/A';
metadata.upload_date = data.microformat.playerMicroformatRenderer.uploadDate || 'N/A';
metadata.keywords = data.videoDetails.keywords || [];
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;
},
filters: (order) => {
return (({
'any,any,relevance': 'EgIQAQ%3D%3D',
'hour,any,relevance': 'EgIIAQ%3D%3D',
'day,any,relevance': 'EgQIAhAB',
'week,any,relevance': 'EgQIAxAB',
'month,any,relevance': 'EgQIBBAB',
'year,any,relevance': 'EgQIBRAB',
'any,short,relevance': 'EgQQARgB',
'hour,short,relevance': 'EgYIARABGAE%3D',
'day,short,relevance': 'EgYIAhABGAE%3D',
'week,short,relevance': 'EgYIAxABGAE%3D',
'month,short,relevance': 'EgYIBBABGAE%3D',
'year,short,relevance': 'EgYIBRABGAE%3D',
'any,long,relevance': 'EgQQARgC',
'hour,long,relevance': 'EgYIARABGAI%3D',
'day,long,relevance': 'EgYIAhABGAI%3D',
'week,long,relevance': 'EgYIAxABGAI%3D',
'month,long,relevance': 'EgYIBBABGAI%3D',
'year,long,relevance': 'EgYIBRABGAI%3D',
'any,any,age': 'CAISAhAB',
'hour,any,age': 'CAISBAgBEAE%3D',
'day,any,age': 'CAISBAgCEAE%3D',
'week,any,age': 'CAISBAgDEAE%3D',
'month,any,age': 'CAISBAgEEAE%3D',
'year,any,age': 'CAISBAgFEAE%3D',
'any,short,age': 'CAISBBABGAE%3D',
'hour,short,age': 'CAISBggBEAEYAQ%3D%3D',
'day,short,age': 'CAISBggCEAEYAQ%3D%3D',
'week,short,age': 'CAISBggDEAEYAQ%3D%3D',
'month,short,age': 'CAISBggEEAEYAQ%3D%3D',
'year,short,age': 'CAISBggFEAEYAQ%3D%3D',
'any,long,age': 'CAISBBABGAI%3D',
'hour,long,age': 'CAISBggBEAEYAg%3D%3D',
'day,long,age': 'CAISBggCEAEYAg%3D%3D',
'week,long,age': 'CAISBggDEAEYAg%3D%3D',
'month,long,age': 'CAISBggEEAEYAg%3D%3D',
'year,long,age': 'CAISBggFEAEYAg%3D%3D',
'any,any,views': 'CAMSAhAB',
'hour,any,views': 'CAMSBAgBEAE%3D',
'day,any,views': 'CAMSBAgCEAE%3D',
'week,any,views': 'CAMSBAgDEAE%3D',
'month,any,views': 'CAMSBAgEEAE%3D',
'year,any,views': 'CAMSBAgFEAE%3D',
'any,short,views': 'CAMSBBABGAE%3D',
'hour,short,views': 'CAMSBggBEAEYAQ%3D%3D',
'day,short,views': 'CAMSBggCEAEYAQ%3D%3D',
'week,short,views': 'CAMSBggDEAEYAQ%3D%3D',
'month,short,views': 'CAMSBggEEAEYAQ%3D%3D',
'year,short,views': 'CAMSBggFEAEYAQ%3D%3D',
'any,long,views': 'CAMSBBABGAI%3D',
'hour,long,views': 'CAMSBggBEAEYAg%3D%3D',
'day,long,views': 'CAMSBggCEAEYAg%3D%3D',
'week,long,views': 'CAMSBggDEAEYAg%3D%3D',
'month,long,views': 'CAMSBggEEAEYAg%3D%3D',
'year,long,views': 'CAMSBggFEAEYAg%3D%3D',
'any,any,rating': 'CAESAhAB',
'hour,any,rating': 'CAESBAgBEAE%3D',
'day,any,rating': 'CAESBAgCEAE%3D',
'week,any,rating': 'CAESBAgDEAE%3D',
'month,any,rating': 'CAESBAgEEAE%3D',
'year,any,rating': 'CAESBAgFEAE%3D',
'any,short,rating': 'CAESBBABGAE%3D',
'hour,short,rating': 'CAESBggBEAEYAQ%3D%3D',
'day,short,rating': 'CAESBggCEAEYAQ%3D%3D',
'week,short,rating': 'CAESBggDEAEYAQ%3D%3D',
'month,short,rating': 'CAESBggEEAEYAQ%3D%3D',
'year,short,rating': 'CAESBggFEAEYAQ%3D%3D',
'any,long,rating': 'CAESBBABGAI%3D',
'hour,long,rating': 'CAESBggBEAEYAg%3D%3D',
'day,long,rating': 'CAESBggCEAEYAg%3D%3D',
'week,long,rating': 'CAESBggDEAEYAg%3D%3D',
'month,long,rating': 'CAESBggEEAEYAg%3D%3D',
'year,long,rating': 'CAESBggFEAEYAg%3D%3D'
})[order] || 'EgIQAQ%3D%3D');
}
};

File diff suppressed because it is too large Load Diff

View File

@@ -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();
}
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;
// 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.HEADERS).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, 'setQuery("");', '{useGaiaSandbox:');
const client_identity = identity_function.replace(/\n/g, '').match(this.identity_regex);
return client_identity.groups;
}
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 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 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.HEADERS).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_REQOPTS({ 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;

View File

@@ -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;

View File

@@ -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 };

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

@@ -0,0 +1,437 @@
'use strict';
const Uuid = require('uuid');
const Axios = require('axios');
const Proto = require('../proto');
const Utils = require('../utils/Utils');
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 Utils.InnertubeError('You are not signed in');
const data = { context: session.context };
switch (engagement_type) {
case 'like/like':
case 'like/dislike':
case 'like/removelike':
data.target = {
videoId: args.video_id
}
break;
case 'subscription/subscribe':
case 'subscription/unsubscribe':
data.channelIds = [args.channel_id];
data.params = engagement_type == 'subscription/subscribe' ? 'EgIIAhgA' : 'CgIIAhgA';
break;
case 'comment/create_comment':
data.commentText = args.text;
data.createCommentParams = Proto.encodeCommentParams(args.video_id);
break;
case 'comment/create_comment_reply':
data.createReplyParams = Proto.encodeCommentReplyParams(args.comment_id, args.video_id);
data.commentText = args.text;
break;
case 'comment/perform_comment_action':
const action = ({
like: () => Proto.encodeCommentActionParams(5, args.comment_id, args.video_id, args.channel_id),
dislike: () => Proto.encodeCommentActionParams(4, args.comment_id, args.video_id, args.channel_id),
})[args.comment_action]();
data.actions = [ action ];
break;
case 'playlist/create':
data.title = args.title;
data.videoIds = [args.video_id];
break;
case 'playlist/delete':
data.playlistId = args.playlist_id;
break;
case 'browse/edit_playlist':
data.playlistId = args.playlist_id;
data.actions = args.video_ids.map((id) => ({
action: args.action,
addedVideoId: id
}));
break;
default:
throw new Utils.InnertubeError('Invalid action', engagement_type);
}
const response = await session.YTRequester.post(`/${engagement_type}`, JSON.stringify(data)).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response?.status || 0, message: response.message };
return {
success: true,
status_code: response.status
};
}
/**
* Accesses YouTube's various sections.
*
* @param {Innertube} session - A valid Innertube session.
* @param {string} action - Type of action.
* @param {object} args - Action argumenets.
* @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>}
*/
async function browse(session, action, args = {}) {
if (!session.logged_in && ![ 'home_feed', 'lyrics',
'music_playlist', 'playlist', 'trending' ].includes(action))
throw new Utils.InnertubeError('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 'trending':
data.browseId = 'FEtrending';
args.params && (data.params = args.params);
break;
case 'subscriptions_feed':
data.browseId = 'FEsubscriptions';
break;
case 'lyrics':
case 'music_playlist':
const context = JSON.parse(JSON.stringify(session.context)); // deep copy the context obj so we don't accidentally change it
context.client.originalUrl = Constants.URLS.YT_MUSIC;
context.client.clientVersion = Constants.YTMUSIC_VERSION;
context.client.clientName = 'WEB_REMIX';
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 Utils.InnertubeError('You are not signed in');
const data = {};
switch (action) {
case 'account/account_menu':
data.context = session.context;
break;
case 'account/set_setting':
data.context = session.context;
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', client);
}
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 Utils.InnertubeError('You are not signed in');
const data = {};
switch (action) {
case 'modify_channel_preference':
const pref_types = { PERSONALIZED: 1, ALL: 2, NONE: 3 };
data.context = session.context;
data.params = Proto.encodeNotificationPref(args.channel_id, pref_types[args.pref.toUpperCase()]);
break;
case 'get_notification_menu':
data.context = session.context;
data.notificationsMenuRequestType = 'NOTIFICATIONS_MENU_REQUEST_TYPE_INBOX';
args.ctoken && (data.ctoken = args.ctoken);
break;
case 'get_unseen_count':
data.context = session.context;
break;
default:
throw new Utils.InnertubeError('Invalid action', action);
}
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 Utils.InnertubeError(`Could not get video info: ${response.message}`);
return response.data;
}
/**
* Gets search suggestions.
*
* @param {Innertube} session - A valid innertube session
* @param {string} query - Search query
* @returns {Promise.<{ success: boolean; status_code: number; data: object; message?: string }>}
*/
async function getSearchSuggestions(session, client, input) {
if (!['YOUTUBE', 'YTMUSIC'].includes(client))
throw new Utils.InnertubeError('Invalid client', client);
const response = await ({
'YOUTUBE': async () => {
const response = await Axios.get(`${Constants.URLS.YT_SUGGESTIONS}search?client=firefox&ds=yt&q=${encodeURIComponent(input)}`,
Constants.DEFAULT_HEADERS(session)).catch((error) => error);
return {
success: !(response instanceof Error),
status_code: response.status,
data: response?.data
};
},
'YTMUSIC': async () => {
const response = await music(session, 'get_search_suggestions', { input });
return response;
}
}[client])();
return response;
}
module.exports = { engage, browse, account, music, search, notifications, livechat, getVideoInfo, next, getSearchSuggestions };

View File

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

235
lib/core/OAuth.js Normal file
View File

@@ -0,0 +1,235 @@
'use strict';
const Axios = require('axios');
const Constants = require('../utils/Constants');
const Uuid = require('uuid');
class OAuth {
#scope = Constants.OAUTH.SCOPE;
#model_name = Constants.OAUTH.MODEL_NAME;
#grant_type = Constants.OAUTH.GRANT_TYPE;
#oauth_code_url = `${Constants.URLS.YT_BASE}/o/oauth2/device/code`;
#oauth_token_url = `${Constants.URLS.YT_BASE}/o/oauth2/token`;
#oauth_revoke_url = `${Constants.URLS.YT_BASE}/o/oauth2/revoke`;
#auth_info = {};
#refresh_interval = 5;
#ev = null;
constructor(ev) {
this.#ev = ev;
}
/**
* Starts the auth flow in case no valid credentials are available.
* @returns {Promise.<void>}
*/
async init(auth_info) {
this.#auth_info = auth_info;
if (!auth_info.access_token) {
this.#requestUserCode();
}
}
/**
* Asks the OAuth server for a user code
* and verification URL.
*
* @returns {Promise.<void>}
*/
async #requestUserCode() {
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.#ev.emit('auth', { error: 'Could not obtain user code.', status: 'FAILED' });
this.#ev.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.#ev.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.#ev.emit('auth', {
error: 'Access was denied.',
status: 'ACCESS_DENIED'
});
break;
case 'expired_token':
this.#ev.emit('auth', {
error: 'The user code has expired, requesting a new one.',
status: 'DEVICE_CODE_EXPIRED'
});
this.#requestUserCode();
break;
default:
}
} else {
const expiration_date = new Date(new Date().getTime() + response.data.expires_in * 1000);
const credentials = {
access_token: response.data.access_token,
refresh_token: response.data.refresh_token,
expires: expiration_date,
};
this.#auth_info = credentials;
this.#ev.emit('auth', {
credentials,
status: 'SUCCESS'
});
}
}, 1000 * this.#refresh_interval);
}
/**
* Refreshes the access token if necessary.
* @returns {Promise.<void>}
*/
async checkTokenValidity() {
if (this.shouldRefreshToken()) {
await this.#refreshAccessToken();
}
}
/**
* Gets a new access token using a refresh token.
* @returns {Promise.<void>}
*/
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)
return this.#ev.emit('update-credentials', {
error: 'Could not refresh access token.',
status: 'FAILED'
});
const expiration_date = new Date(new Date().getTime() + response.data.expires_in * 1000);
const credentials = {
access_token: response.data.access_token,
refresh_token: response.data.refresh_token || this.#auth_info.refresh_token,
expires: expiration_date,
};
this.#auth_info = credentials;
this.#ev.emit('update-credentials', {
credentials,
status: 'SUCCESS'
});
}
/**
* Revokes access token (note that the refresh token will also be revoked).
* @returns {Promise.<void>}
*/
async revokeAccessToken() {
const response = await Axios.post(`${this.#oauth_revoke_url}?token=${this.getAccessToken()}`, Constants.OAUTH.HEADERS).catch((error) => error);
return {
success: !(response instanceof Error),
status_code: response.status || 0
}
}
/**
* 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 = Constants.OAUTH.REGEX.AUTH_SCRIPT.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(Constants.OAUTH.REGEX.CLIENT_IDENTITY);
return client_identity.groups;
}
getAccessToken() {
return this.#auth_info.access_token;
}
getRefreshToken() {
return this.#auth_info.refresh_token;
}
/**
* Checks if the auth info is valid.
* @returns {boolean} true | false
*/
isValidAuthInfo() {
return this.#auth_info.hasOwnProperty('access_token')
&& this.#auth_info.hasOwnProperty('refresh_token')
&& this.#auth_info.hasOwnProperty('expires');
}
/**
* Checks access token validity.
* @returns {boolean} true | false
*/
shouldRefreshToken() {
const timestamp = new Date(this.#auth_info.expires).getTime();
return new Date().getTime() > timestamp;
}
}
module.exports = OAuth;

49
lib/core/Player.js Normal file
View 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;

View File

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

View File

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

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

@@ -0,0 +1,485 @@
'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.data = data;
this.session = session;
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(),
CHANNEL: () => this.#processChannel(),
PLAYLIST: () => this.#processPlaylist(),
SUBSFEED: () => this.#processSubscriptionFeed(),
HOMEFEED: () => this.#processHomeFeed(),
TRENDING: () => this.#processTrending(),
HISTORY: () => this.#processHistory(),
VIDEO_INFO: () => this.#processVideoInfo(),
NOTIFICATIONS: () => this.#processNotifications(),
SEARCH_SUGGESTIONS: () => this.#processSearchSuggestions(),
})[data_type]()
break;
case 'YTMUSIC':
processed_data = ({
SEARCH: () => this.#processMusicSearch(),
PLAYLIST: () => this.#processMusicPlaylist(),
SEARCH_SUGGESTIONS: () => this.#processMusicSearchSuggestions(),
})[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;
}
#processSearchSuggestions() {
return YTDataItems.SearchSuggestionItem.parse(this.data[1], this.data[0]);
}
#processMusicSearchSuggestions() {
const contents = this.data.contents[0].searchSuggestionsSectionRenderer.contents;
return YTMusicDataItems.MusicSearchSuggestionItem.parse(contents);
}
#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;
}
#processChannel() {
const tabs = this.data.contents.twoColumnBrowseResultsRenderer.tabs;
const metadata = this.data.metadata;
const home_tab = tabs.find((tab) => tab.tabRenderer.title == 'Home');
const home_contents = home_tab.tabRenderer.content.sectionListRenderer.contents;
const home_shelves = [];
home_contents.forEach((content) => {
if (content.itemSectionRenderer) {
const contents = content.itemSectionRenderer.contents[0];
const list = contents?.shelfRenderer?.content.horizontalListRenderer;
if (!list) return; // Ignores featured channels (for now only videos & playlists are supported)
const shelf = {
title: contents.shelfRenderer.title.runs[0].text,
content: []
};
shelf.content = list.items.map((item) => {
if (item.gridVideoRenderer) {
return YTDataItems.GridVideoItem.parseItem(item);
} else if (item.gridPlaylistRenderer) {
return YTDataItems.GridPlaylistItem.parseItem(item);
}
});
home_shelves.push(shelf);
}
});
const ch_info = YTDataItems.ChannelMetadata.parse(metadata);
return {
...ch_info,
content: {
// Home page of the channel, always available in the first request.
home_page: home_shelves,
// TODO: Implement these (note: they require additional requests)
getVideos: () => {},
getPlaylists: () => {},
getCommunity: () => {},
getChannels: () => {},
getAbout: () => {}
}
}
}
#processNotifications() {
const contents = this.data.actions[0].openPopupAction.popup.multiPageMenuRenderer.sections[0];
if (!contents.multiPageMenuNotificationSectionRenderer) throw new Utils.InnertubeError('No notifications', response);
const parseItems = (items) => {
const parsed_items = YTDataItems.NotificationItem.parse(items);
const getContinuation = async () => {
const citem = items.find((item) => item.continuationItemRenderer);
const ctoken = citem?.continuationItemRenderer?.continuationEndpoint?.getNotificationMenuEndpoint?.ctoken;
const response = await Actions.notifications(this.session, 'get_notification_menu', { ctoken });
if (!response.success) throw new Utils.InnertubeError('Could not retrieve continuation', response);
return parseItems(response.data.actions[0].appendContinuationItemsAction.continuationItems);
}
return { items: parsed_items, getContinuation };
}
return parseItems(contents.multiPageMenuNotificationSectionRenderer.items);
}
#processTrending() {
const tabs = Utils.findNode(this.data, 'contents', 'tabRenderer', 4, false);
const categories = {};
const trending = tabs.map((tab) => {
const tab_renderer = tab.tabRenderer;
const tab_content = tab_renderer?.content;
const category_title = tab_renderer.title.toLowerCase();
categories[category_title] = {};
if (tab_content) { // The “Now” category is always available
const contents = tab_content.sectionListRenderer.contents;
categories[category_title].content = contents.map((content) => {
const shelf = content.itemSectionRenderer.contents[0].shelfRenderer;
const parsed_shelf = YTDataItems.ShelfRenderer.parse(shelf);
return parsed_shelf;
});
} else { // The rest can only be fetched with additional calls
const params = tab_renderer.endpoint.browseEndpoint.params;
categories[category_title].getVideos = async () => {
const response = await Actions.browse(this.session, 'trending', { params });
if (!response.success) throw new Utils.InnertubeError('Could not retrieve category videos', response);
const tabs = Utils.findNode(response, 'contents', 'tabRenderer', 4, false);
const tab = tabs.find((tab) => tab.tabRenderer.title === tab_renderer.title);
const contents = tab.tabRenderer.content.sectionListRenderer.contents;
const items = Utils.findNode(contents, 'itemSectionRenderer', 'items', 8, false);
return YTDataItems.VideoItem.parse(items);
};
}
});
return categories;
}
#processHistory() {
const contents = Utils.findNode(this.data, 'contents', 'videoRenderer', 9, false)
const history = { items: [] };
const parseItems = (contents) => {
contents.forEach((section) => {
if (!section.itemSectionRenderer) return;
const header = section.itemSectionRenderer.header.itemSectionHeaderRenderer.title;
const section_title = header?.simpleText || header?.runs.map((run) => run.text).join('');
const contents = section.itemSectionRenderer.contents;
const section_items = YTDataItems.VideoItem.parse(contents);
history.items.push({
date: section_title,
videos: section_items
});
});
history.getContinuation = async () => {
const citem = contents.find((item) => item.continuationItemRenderer);
const ctoken = citem.continuationItemRenderer.continuationEndpoint.continuationCommand.token;
const response = await Actions.browse(this.session, 'continuation', { ctoken });
if (!response.success) throw new Utils.InnertubeError('Could not retrieve continuation', response);
history.items = [];
return parseItems(response.data.onResponseReceivedActions[0].appendContinuationItemsAction.continuationItems);
}
return history;
}
return parseItems(contents);
}
#processHomeFeed() {
const contents = Utils.findNode(this.data, 'contents', 'videoRenderer', 9, false)
const parseItems = (contents) => {
const videos = YTDataItems.VideoItem.parse(contents);
const getContinuation = async () => {
const citem = contents.find((item) => item.continuationItemRenderer);
const ctoken = citem.continuationItemRenderer.continuationEndpoint.continuationCommand.token;
const response = await Actions.browse(this.session, 'continuation', { ctoken });
if (!response.success) throw new Utils.InnertubeError('Could not retrieve continuation', response);
return parseItems(response.data.onResponseReceivedActions[0].appendContinuationItemsAction.continuationItems);
}
return { videos, getContinuation };
}
return parseItems(contents);
}
#processSubscriptionFeed() {
const contents = Utils.findNode(this.data, 'contents', 'contents', 9, false);
const subsfeed = { items: [] };
const parseItems = (contents) => {
contents.forEach((section) => {
if (!section.itemSectionRenderer) return;
const section_contents = section.itemSectionRenderer.contents[0];
const section_title = section_contents.shelfRenderer.title.runs[0].text;
const section_items = section_contents.shelfRenderer.content.gridRenderer.items;
const items = YTDataItems.GridVideoItem.parse(section_items);
subsfeed.items.push({
date: section_title,
videos: items
});
});
subsfeed.getContinuation = async () => {
const citem = contents.find((item) => item.continuationItemRenderer);
const ctoken = citem.continuationItemRenderer.continuationEndpoint.continuationCommand.token;
const response = await Actions.browse(this.session, 'continuation', { ctoken });
if (!response.success) throw new Utils.InnertubeError('Could not retrieve continuation', response);
const ccontents = Utils.findNode(response.data, 'onResponseReceivedActions', 'itemSectionRenderer', 4, false);
subsfeed.items = [];
return parseItems(ccontents);
}
return subsfeed;
};
return parseItems(contents);
}
}
module.exports = Parser;

View File

@@ -0,0 +1,13 @@
'use strict';
const VideoResultItem = require('./search/VideoResultItem');
const SearchSuggestionItem = require('./search/SearchSuggestionItem');
const PlaylistItem = require('./others/PlaylistItem');
const NotificationItem = require('./others/NotificationItem');
const VideoItem = require('./others/VideoItem');
const GridVideoItem = require('./others/GridVideoItem');
const GridPlaylistItem = require('./others/GridPlaylistItem');
const ChannelMetadata = require('./others/ChannelMetadata');
const ShelfRenderer = require('./others/ShelfRenderer');
module.exports = { VideoResultItem, SearchSuggestionItem, PlaylistItem, NotificationItem, VideoItem, GridVideoItem, GridPlaylistItem, ChannelMetadata, ShelfRenderer };

View File

@@ -0,0 +1,20 @@
'use strict';
class ChannelMetadata {
static parse(data) {
return {
title: data.channelMetadataRenderer.title,
description: data.channelMetadataRenderer.description,
metadata: {
url: data.channelMetadataRenderer?.channelUrl,
rss_urls: data.channelMetadataRenderer?.rssUrl,
vanity_channel_url: data.channelMetadataRenderer?.vanityChannelUrl,
external_id: data.channelMetadataRenderer?.externalId,
is_family_safe: data.channelMetadataRenderer?.isFamilySafe,
keywords: data.channelMetadataRenderer?.keywords
}
}
}
}
module.exports = ChannelMetadata;

View File

@@ -0,0 +1,20 @@
'use strict';
class GridPlaylistItem {
static parse(data) {
return data.map((item) => this.parseItem(item)).filter((item) => item);
}
static parseItem(item) {
return {
id: item?.gridPlaylistRenderer.playlistId,
title: item?.gridPlaylistRenderer.title?.runs?.map((run) => run.text).join(''),
metadata: {
thumbnail: item?.gridPlaylistRenderer.thumbnail?.thumbnails?.slice(-1)[0] || {},
video_count: item?.gridPlaylistRenderer.videoCountShortText?.simpleText || 'N/A',
}
};
}
}
module.exports = GridPlaylistItem;

View File

@@ -0,0 +1,35 @@
'use strict';
const Constants = require('../../../utils/Constants');
class GridVideoItem {
static parse(data) {
return data.map((item) => this.parseItem(item)).filter((item) => item);
}
static parseItem(item) {
return {
id: item.gridVideoRenderer.videoId,
title: item?.gridVideoRenderer?.title?.runs?.map((run) => run.text).join(' '),
channel: {
id: item?.gridVideoRenderer?.shortBylineText?.runs[0]?.navigationEndpoint?.browseEndpoint?.browseId,
name: item?.gridVideoRenderer?.shortBylineText?.runs[0]?.text || 'N/A',
url: `${Constants.URLS.YT_BASE}${item?.gridVideoRenderer?.shortBylineText?.runs[0]?.navigationEndpoint?.browseEndpoint?.canonicalBaseUrl}`
},
metadata: {
view_count: item?.gridVideoRenderer?.viewCountText?.simpleText || 'N/A',
short_view_count_text: {
simple_text: item?.gridVideoRenderer?.shortViewCountText?.simpleText || 'N/A',
accessibility_label: item?.gridVideoRenderer?.shortViewCountText?.accessibility?.accessibilityData?.label || 'N/A',
},
thumbnail: item?.gridVideoRenderer?.thumbnail?.thumbnails.slice(-1)[0] || [],
moving_thumbnail: item?.gridVideoRenderer?.richThumbnail?.movingThumbnailRenderer?.movingThumbnailDetails?.thumbnails[0] || {},
published: item?.gridVideoRenderer?.publishedTimeText?.simpleText || 'N/A',
badges: item?.gridVideoRenderer?.badges?.map((badge) => badge.metadataBadgeRenderer.label) || [],
owner_badges: item?.gridVideoRenderer?.ownerBadges?.map((badge) => badge.metadataBadgeRenderer.tooltip) || []
}
};
}
}
module.exports = GridVideoItem;

View File

@@ -0,0 +1,25 @@
'use strict';
class NotificationItem {
static parse(data) {
return data.map((item) => this.parseItem(item)).filter((item) => item);
}
static parseItem(item) {
if (item.notificationRenderer) {
const notification = item.notificationRenderer;
return {
title: notification?.shortMessage?.simpleText,
sent_time: notification?.sentTimeText?.simpleText,
channel_name: notification?.contextualMenu?.menuRenderer?.items[1]?.menuServiceItemRenderer?.text?.runs[1]?.text || 'N/A',
channel_thumbnail: notification?.thumbnail?.thumbnails[0],
video_thumbnail: notification?.videoThumbnail?.thumbnails[0],
video_url: notification.navigationEndpoint.watchEndpoint && `https://youtu.be/${notification.navigationEndpoint.watchEndpoint.videoId}` || 'N/A',
read: notification.read,
notification_id: notification.notificationId,
};
}
}
}
module.exports = NotificationItem;

View File

@@ -0,0 +1,26 @@
'use strict';
const Utils = require('../../../utils/Utils');
class PlaylistItem {
static parse(data) {
return data.map((item) => this.parseItem(item)).filter((item) => item);
}
static parseItem(item) {
if (item.playlistVideoRenderer)
return {
id: item?.playlistVideoRenderer?.videoId,
title: item?.playlistVideoRenderer?.title?.runs[0]?.text,
author: item?.playlistVideoRenderer?.shortBylineText?.runs[0]?.text,
duration: {
seconds: Utils.timeToSeconds(item?.playlistVideoRenderer?.lengthText?.simpleText || '0'),
simple_text: item?.playlistVideoRenderer?.lengthText?.simpleText || 'N/A',
accessibility_label: item?.playlistVideoRenderer?.lengthText?.accessibility?.accessibilityData?.label || 'N/A'
},
thumbnails: item?.playlistVideoRenderer?.thumbnail?.thumbnails,
};
}
}
module.exports = PlaylistItem;

View File

@@ -0,0 +1,41 @@
'use strict';
const VideoItem = require('./VideoItem');
const GridVideoItem = require('./GridVideoItem');
class ShelfRenderer {
static parse(data) {
return {
title: this.getTitle(data.title),
videos: this.parseItems(data.content)
}
}
static getTitle(data) {
if ('runs' in (data || {})) {
return data.runs.map((run) => run.text).join('');
} else if ('simpleText' in (data || {})) {
return data.simpleText;
} else {
return 'Others';
}
}
static parseItems(data) {
let items;
if ('expandedShelfContentsRenderer' in data) {
items = data.expandedShelfContentsRenderer.items;
} else if ('horizontalListRenderer' in data) {
items = data.horizontalListRenderer.items;
}
const videos = ('gridVideoRenderer' in items[0])
&& GridVideoItem.parse(items)
|| VideoItem.parse(items);
return videos;
}
}
module.exports = ShelfRenderer;

View File

@@ -0,0 +1,46 @@
'use strict';
const Utils = require('../../../utils/Utils');
const Constants = require('../../../utils/Constants');
class VideoItem {
static parse(data) {
return data.map((item) => this.parseItem(item)).filter((item) => item);
}
static parseItem(item) {
item = (item.richItemRenderer && item.richItemRenderer.content.videoRenderer)
&& item.richItemRenderer.content
|| item;
if (item.videoRenderer) return {
id: item.videoRenderer.videoId,
title: item.videoRenderer.title.runs.map((run) => run.text).join(' '),
description: item?.videoRenderer?.descriptionSnippet?.runs[0]?.text || 'N/A',
channel: {
id: item?.videoRenderer?.shortBylineText?.runs[0]?.navigationEndpoint?.browseEndpoint?.browseId,
name: item?.videoRenderer?.shortBylineText?.runs[0]?.text || 'N/A',
url: `${Constants.URLS.YT_BASE}${item?.videoRenderer?.shortBylineText?.runs[0]?.navigationEndpoint?.browseEndpoint?.canonicalBaseUrl}`
},
metadata: {
view_count: item?.videoRenderer?.viewCountText?.simpleText || 'N/A',
short_view_count_text: {
simple_text: item?.videoRenderer?.shortViewCountText?.simpleText || 'N/A',
accessibility_label: item?.videoRenderer?.shortViewCountText?.accessibility?.accessibilityData?.label || 'N/A',
},
thumbnail: item?.videoRenderer?.thumbnail?.thumbnails.slice(-1)[0] || {},
moving_thumbnail: item?.videoRenderer?.richThumbnail?.movingThumbnailRenderer?.movingThumbnailDetails?.thumbnails[0] || {},
published: item?.videoRenderer?.publishedTimeText?.simpleText || 'N/A',
duration: {
seconds: Utils.timeToSeconds(item?.videoRenderer?.lengthText?.simpleText || '0'),
simple_text: item?.videoRenderer?.lengthText?.simpleText || 'N/A',
accessibility_label: item?.videoRenderer?.lengthText?.accessibility?.accessibilityData?.label || 'N/A'
},
badges: item?.videoRenderer?.badges?.map((badge) => badge.metadataBadgeRenderer.label) || [],
owner_badges: item?.videoRenderer?.ownerBadges?.map((badge) => badge.metadataBadgeRenderer.tooltip) || []
}
}
}
}
module.exports = VideoItem;

View File

@@ -0,0 +1,12 @@
'use strict';
class SearchSuggestionItem {
static parse(data, bold_text) {
return data.map((item) => ({
text: item.trim(),
bold_text: bold_text.trim().toLowerCase()
}));
}
}
module.exports = SearchSuggestionItem;

View File

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

View File

@@ -0,0 +1,12 @@
'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 MusicSearchSuggestionItem = require('./search/MusicSearchSuggestionItem');
const TopResultItem = require('./search/TopResultItem');
const PlaylistItem = require('./others/PlaylistItem');
module.exports = { SongResultItem, VideoResultItem, AlbumResultItem, ArtistResultItem, PlaylistResultItem, MusicSearchSuggestionItem, TopResultItem, PlaylistItem };

View File

@@ -0,0 +1,28 @@
'use strict';
const Utils = require('../../../utils/Utils');
class PlaylistItem {
static parse(data) {
return data.map((item) => this.parseItem(item)).filter((item) => item.id);
}
static parseItem(item) {
const item_renderer = item.musicResponsiveListItemRenderer;
const fixed_columns = item_renderer.fixedColumns;
const flex_columns = item_renderer.flexColumns;
return {
id: item_renderer.playlistItemData && item_renderer.playlistItemData.videoId,
title: flex_columns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text,
author: flex_columns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text,
duration: {
seconds: Utils.timeToSeconds(fixed_columns[0].musicResponsiveListItemFixedColumnRenderer.text.runs[0].text || '0'),
simple_text: fixed_columns[0].musicResponsiveListItemFixedColumnRenderer.text.runs[0].text,
},
thumbnails: item_renderer.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails
}
}
}
module.exports = PlaylistItem;

View File

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

View File

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

View File

@@ -0,0 +1,22 @@
'use strict';
class MusicSearchSuggestionItem {
static parse(data) {
return data.map((item) => this.parseItem(item));
}
static parseItem(item) {
let suggestion;
item.historySuggestionRenderer &&
(suggestion = item.historySuggestionRenderer.suggestion) ||
(suggestion = item.searchSuggestionRenderer.suggestion);
return {
text: suggestion.runs.map((run) => run.text).join('').trim(),
bold_text: suggestion.runs[0].text.trim()
};
}
}
module.exports = MusicSearchSuggestionItem;

View File

@@ -0,0 +1,23 @@
'use strict';
class PlaylistResultItem {
static parse(data) {
return data.map((item) => this.parseItem(item));
}
static parseItem(item) {
const list_item = item.musicResponsiveListItemRenderer;
const watch_playlist_endpoint = list_item.overlay.musicItemThumbnailOverlayRenderer
.content.musicPlayButtonRenderer.playNavigationEndpoint.watchPlaylistEndpoint;
return {
id: watch_playlist_endpoint?.playlistId,
title: list_item.flexColumns[0]?.musicResponsiveListItemFlexColumnRenderer.text.runs[0]?.text,
author: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs[2]?.text,
channel_id: list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs[2]?.navigationEndpoint?.browseEndpoint.browseId || '0',
total_items: parseInt(list_item.flexColumns[1]?.musicResponsiveListItemFlexColumnRenderer.text.runs[4]?.text.match(/\d+/g)),
};
}
}
module.exports = PlaylistResultItem;

View File

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

View File

@@ -0,0 +1,33 @@
'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: () => AlbumResultItem.parseItem(item),
single: () => AlbumResultItem.parseItem(item)
}[type])();
parsed_item && (parsed_item.type = type);
return parsed_item;
}).filter((item) => item);
}
}
module.exports = TopResultItem;

View File

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

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

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

View File

@@ -1,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;
}

147
lib/utils/Constants.js Normal file
View File

@@ -0,0 +1,147 @@
'use strict';
const Utils = require('./Utils');
module.exports = {
URLS: {
YT_BASE: 'https://www.youtube.com',
YT_BASE_API: 'https://www.youtube.com/youtubei/',
YT_STUDIO_BASE_API: 'https://studio.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'
}
},
REGEX: {
AUTH_SCRIPT: /<script id=\"base-js\" src=\"(.*?)\" nonce=".*?"><\/script>/,
CLIENT_IDENTITY: /.+?={};var .+?={clientId:\"(?<id>.+?)\",.+?:\"(?<secret>.+?)\"},/
}
},
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
};
const auth_creds = info.session.cookie.length && info.session.auth_apisid || `Bearer ${info.session.access_token}`
if (info.session.logged_in) {
headers.Cookie = info.session.cookie;
headers.authorization = auth_creds;
}
return headers;
},
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
View File

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

89
package-lock.json generated
View File

@@ -1,19 +1,25 @@
{
"name": "youtubei.js",
"version": "1.2.6",
"version": "1.4.1",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "youtubei.js",
"version": "1.2.6",
"version": "1.4.1",
"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.5",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.5.tgz",
"integrity": "sha512-wtphSXy7d4/OR+MvIFbCVBDzZ5520qV8XfPklSN5QtxuMUJZ+b0Wnst1e1lCDocfzuCkHqj8k0FpZqO+UIaKNA==",
"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.10",
"resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.4.10.tgz",
"integrity": "sha512-BwWGvgqB/5J/cnWaOA0sXzJ+UGl+kyFAw3Sw1L6TN4oad34C9OpW+GCpYTYPDp4pUaXDC1EjvB3yv9Iodo1EhA=="
"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.840",
"resolved": "https://registry.npmjs.org/user-agents/-/user-agents-1.0.840.tgz",
"integrity": "sha512-pdXizW5mvcHRHsJH50STUA+zywDvUKbxouQ2523FEmfYJCkLMVGavBSOL7u/VVmneVXM2EX20Ikru9lCnbNDeQ==",
"version": "1.0.989",
"resolved": "https://registry.npmjs.org/user-agents/-/user-agents-1.0.989.tgz",
"integrity": "sha512-HiQ6M3NWbil5gb00J3r8mITIbOzmFmWZJCPcw3l5Dm5tfjl6x/yrHNFhZKLTZJQ6K4Gz4UPmJUtN6T2XSCYJhQ==",
"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.5",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.5.tgz",
"integrity": "sha512-wtphSXy7d4/OR+MvIFbCVBDzZ5520qV8XfPklSN5QtxuMUJZ+b0Wnst1e1lCDocfzuCkHqj8k0FpZqO+UIaKNA=="
"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.10",
"resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.4.10.tgz",
"integrity": "sha512-BwWGvgqB/5J/cnWaOA0sXzJ+UGl+kyFAw3Sw1L6TN4oad34C9OpW+GCpYTYPDp4pUaXDC1EjvB3yv9Iodo1EhA=="
"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.840",
"resolved": "https://registry.npmjs.org/user-agents/-/user-agents-1.0.840.tgz",
"integrity": "sha512-pdXizW5mvcHRHsJH50STUA+zywDvUKbxouQ2523FEmfYJCkLMVGavBSOL7u/VVmneVXM2EX20Ikru9lCnbNDeQ==",
"version": "1.0.989",
"resolved": "https://registry.npmjs.org/user-agents/-/user-agents-1.0.989.tgz",
"integrity": "sha512-HiQ6M3NWbil5gb00J3r8mITIbOzmFmWZJCPcw3l5Dm5tfjl6x/yrHNFhZKLTZJQ6K4Gz4UPmJUtN6T2XSCYJhQ==",
"requires": {
"dot-json": "^1.2.2",
"lodash.clonedeep": "^4.5.0"
@@ -273,4 +278,4 @@
"integrity": "sha512-lKxKYG6H03yCZUpAGOPOsMcGxd1RHCu1iKvEHYDPmTyq2HueGhD73ssNBqqQWfvYs04G9iUFRvmAVLW20Jw6ow=="
}
}
}
}

View File

@@ -1,21 +1,28 @@
{
"name": "youtubei.js",
"version": "1.2.6",
"description": "An object-oriented library that allows you to search, get detailed info about videos, subscribe, unsubscribe, like, dislike, comment, download videos and much more!",
"version": "1.4.1",
"description": "A full-featured library that allows you to get detailed info about any video, subscribe, unsubscribe, like, dislike, comment, search, download videos/music and much more!",
"main": "index.js",
"author": "LuanRT <luan.lrt4@gmail.com> (https://github.com/LuanRT)",
"funding": "https://ko-fi.com/luanrt",
"license": "MIT",
"engines": {
"node": ">=14"
},
"scripts": {
"test": "node test"
},
"author": "LuanRT",
"license": "MIT",
"types": "./typings/index.d.ts",
"directories": {
"example": "examples",
"lib": "lib"
"test": "./test",
"typings": "./typings",
"examples": "./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 +30,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"
]
}

View File

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

View File

@@ -2,38 +2,59 @@
const Fs = require('fs');
const Innertube = require('..');
const NToken = require('../lib/NToken');
const SigDecipher = require('../lib/Sig');
const NToken = require('../lib/deciphers/NToken');
const SigDecipher = require('../lib/deciphers/Sig');
const Constants = require('./constants');
let failed_tests = 0;
let failed_tests_count = 0;
async function performTests() {
const youtube = await new Innertube().catch((error) => error);
assert(youtube instanceof Error ? false : true, `should retrieve Innertube configuration data`);
assert(!(youtube instanceof Error), `should retrieve Innertube configuration data`, youtube);
if (!(youtube instanceof Error)) {
const search = await youtube.search('Carl Sagan - Documentary').catch((error) => error);
assert((search instanceof Error ? false : true) && search.videos.length >= 1, `should search videos`);
const homefeed = await youtube.getHomeFeed().catch((error) => error);
assert(!(homefeed instanceof Error), `should retrieve recommendations`, homefeed);
const ytsearch = await youtube.search('Carl Sagan - Documentary').catch((error) => error);
assert(!(ytsearch instanceof Error), `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 ytsearch_suggestions = await youtube.getSearchSuggestions('test', { client: 'YOUTUBE' }).catch((error) => error);
assert(!(ytsearch_suggestions instanceof Error), `should retrieve YouTube search suggestions`, ytsearch_suggestions);
const ytmsearch_suggestions = await youtube.getSearchSuggestions('test', { client: 'YTMUSIC' });
assert(!(ytmsearch_suggestions instanceof Error), `should retrieve YouTube Music search suggestions`);
const details = await youtube.getDetails(Constants.test_video_id).catch((error) => error);
assert(details instanceof Error ? false : true, `should retrieve details for ${Constants.test_video_id}`);
assert(!(details instanceof Error), `should retrieve details for ${Constants.test_video_id}`, details);
const comments = await youtube.getComments(Constants.test_video_id).catch((error) => error);
assert(comments instanceof Error ? false : true, `should retrieve comments for ${Constants.test_video_id}`);
const video = await downloadVideo(Constants.test_video_id_1, youtube).catch((error) => error);
assert(video instanceof Error ? false : true, `should download video (${Constants.test_video_id_1})`);
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' }).catch((error) => error);
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' }).catch((error) => error);
assert(!(ytmplaylist instanceof Error), `should retrieve and parse playlist with YouTube Music`, ytmplaylist);
const lyrics = await youtube.getLyrics(Constants.test_song_id).catch((error) => error);
assert(!(lyrics instanceof Error), `should retrieve song lyrics`, lyrics);
const video = await downloadVideo(Constants.test_video_id, youtube).catch((error) => error);
assert(!(video instanceof Error), `should download video (${Constants.test_video_id})`, video);
}
const n_token = new NToken(Constants.n_scramble_sc).transform(Constants.original_ntoken);
assert(n_token == Constants.expected_ntoken, `should transform n token into ${Constants.expected_ntoken}`);
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, Constants.client_version, { sig_decipher_sc: Constants.sig_decipher_sc, ntoken_sc: Constants.n_scramble_sc }).decipher();
assert(transformed_url == Constants.expected_url, `should correctly decipher signature`);
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');
if (failed_tests_count > 0)
throw new Error(`${failed_tests_count} tests have failed`);
}
function downloadVideo(id, youtube) {
@@ -43,15 +64,18 @@ function downloadVideo(id, youtube) {
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', () => reject());
stream.on('error', (err) => reject(err));
});
}
function assert(outcome, description) {
function assert(outcome, description, data) {
const pass_fail = outcome ? 'pass' : 'fail';
!outcome && (failed_tests += 1);
console.info(pass_fail, ':', description);
return outcome;
};
performTests();
console.info(pass_fail, ':', description);
!outcome && (failed_tests_count += 1);
!outcome && console.error('Error: ', data);
return outcome;
}
performTests();

181
typings/index.d.ts vendored Normal file
View File

@@ -0,0 +1,181 @@
interface AuthInfo {
access_token: string;
refresh_token: string;
expires: Date;
}
interface AccountInfo {
name: string;
photo: Record<string, any>[];
country: string;
language: string;
}
interface SearchOptions {
client: 'YTMUSIC' | 'YOUTUBE';
period: 'any' | 'hour' | 'day' | 'week' | 'month' | 'year';
order: 'relevance' | 'rating' | 'age' | 'views';
duration: 'any' | 'short' | 'long';
}
interface YouTubeSearch {
query: string;
corrected_query: string;
estimated_results: number;
videos: any[];
}
interface YouTubeMusicSearch {
query: string;
corrected_query: string;
results: {
top_result?: any[];
songs?: any[];
albums?: any[];
videos?: any[];
community_playlists?: any[];
artists?: any[];
}
}
type SearchResults = YouTubeSearch | YouTubeMusicSearch;
type ClientOption = Pick<SearchOptions, 'client'>;
interface Suggestion {
text: string;
bold_text: string;
}
interface ApiStatus {
success: boolean;
status_code: number;
data: object;
message?: string;
}
interface Comments {
comments: any[];
comment_count?: string;
}
interface Video {
title: string;
description: string;
thumbnail: object;
metadata: Record<any, any>;
like: () => Promise<ApiStatus>;
dislike: () => Promise<ApiStatus>;
removeLike: () => Promise<ApiStatus>;
subscribe: () => Promise<ApiStatus>;
unsubscribe: () => Promise<ApiStatus>;
comment: (text: string) => Promise<ApiStatus>;
getComments: () => Promise<Comments>;
getLivechat: () => any; // TODO type LiveChat
changeNotificationPreferences: () => Promise<ApiStatus>;
}
interface Channel {
title: string;
description: string;
metadata: object;
content: object;
}
interface PlayList {
description: string;
items: any[];
title: string;
total_items: string | number;
duration?: string;
last_updated?: string;
views?: string;
year?: string;
}
interface CommentData {
token: string;
channel_id: string;
}
interface History {
items: {
date: string;
videos: any[];
}[];
}
interface SubscriptionFeed {
items: {
date: string;
videos: any[];
}[];
}
interface HomeFeed {
videos: {
id: string;
title: string;
description: string;
channel: string;
metadata: Record<string, any>;
}[];
}
interface Trending {
now: {
content: {
title: string;
videos: [];
}[];
};
music: { getVideos: () => Promise<Array>; };
gaming: { getVideos: () => Promise<Array>; };
movies: { getVideos: () => Promise<Array>; };
}
interface Notifications {
items: {
title: string;
sent_time: string;
channel_name: string;
channel_thumbnail: Record<string, any>;
video_thumbnail: Record<string, any>;
video_url: string;
read: boolean;
notification_id: string;
}[];
}
interface StreamingData {
selected_format: Record<string, any>;
formats: any[];
}
interface StreamingOptions {
quality?: string;
type?: string;
format?: string;
}
export default class Innertube {
constructor(cookie?: string)
public signIn(auth_info: AuthInfo): Promise<void>;
public signOut(): Promise<ApiStatus>;
public getAccountInfo(): Promise<AccountInfo>;
public search(query: string, options: SearchOptions): Promise<SearchResults>;
public getSearchSuggestions(options: ClientOption): Promise<Suggestion>;
public getDetails(video_id: string): Promise<ApiStatus>;
public getChannel(id: string): Promise<Channel>;
public getLyrics(video_id: string): Promise<string>;
public getPlaylist(playlist_id: string, options?: ClientOption): Promise<PlayList>;
public getComments(video_id: string, options?: CommentData): Promise<Comments[]>;
public getHistory(): Promise<History>;
public getHomeFeed(): Promise<HomeFeed>;
public getTrending(): Promise<Trending>;
public getSubscriptionsFeed(): Promise<SubscriptionFeed>;
public getNotifications(): Promise<Notifications>;
public getUnseenNotificationsCount(): Promise<number>;
public getStreamingData(id: string, options?: StreamingOptions): Promise<StreamingData>;
public download(id: string, options?: StreamingOptions): ReadableStream;
}