mirror of
https://github.com/LuanRT/YouTube.js.git
synced 2026-06-14 01:52:11 +00:00
Compare commits
119 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07f02d0dc1 | ||
|
|
b2afa86744 | ||
|
|
a1caa60750 | ||
|
|
e1dd718832 | ||
|
|
222bf1e61f | ||
|
|
3b48de20dd | ||
|
|
348d901935 | ||
|
|
94b12002ff | ||
|
|
2720e8f251 | ||
|
|
a8a1ec2182 | ||
|
|
ee0d1bef40 | ||
|
|
5cad39ee44 | ||
|
|
e8ca248919 | ||
|
|
44d09026b5 | ||
|
|
ff044f4216 | ||
|
|
8153e6178c | ||
|
|
ee3f1b4638 | ||
|
|
86c8a7e0d2 | ||
|
|
b375ae2f06 | ||
|
|
2ff4b2ea95 | ||
|
|
599ab69107 | ||
|
|
c6c6dc24bd | ||
|
|
fa2e0724c6 | ||
|
|
6af689ada6 | ||
|
|
9997c0d939 | ||
|
|
3dee7fc12f | ||
|
|
4dff129b74 | ||
|
|
7e86bb15e0 | ||
|
|
d0de164722 | ||
|
|
5d165ebb61 | ||
|
|
2ad19adbe4 | ||
|
|
cabbdc9f50 | ||
|
|
fe84f31432 | ||
|
|
22c605f528 | ||
|
|
6777b59116 | ||
|
|
de70d851d8 | ||
|
|
e20e671d16 | ||
|
|
d0e1140029 | ||
|
|
bf483256fe | ||
|
|
d4c32d47e1 | ||
|
|
70feab80da | ||
|
|
c006f49dc1 | ||
|
|
aeff0c3fdc | ||
|
|
00d67ed417 | ||
|
|
78f93c7118 | ||
|
|
6db3f0ad91 | ||
|
|
cf48385f72 | ||
|
|
e70eab2416 | ||
|
|
771c6050c4 | ||
|
|
5670228a4f | ||
|
|
62ae384f27 | ||
|
|
185cdbd6ce | ||
|
|
5dd6ef9e24 | ||
|
|
309942090d | ||
|
|
af4a4b8b82 | ||
|
|
b9c9d40077 | ||
|
|
62fbc166c5 | ||
|
|
c3991dda32 | ||
|
|
95e804e8ea | ||
|
|
67a8435421 | ||
|
|
1847558d50 | ||
|
|
bde915bce3 | ||
|
|
a9ad3a31b5 | ||
|
|
e52e6138bd | ||
|
|
76248ad143 | ||
|
|
94f441a4e2 | ||
|
|
685e14fcc1 | ||
|
|
3cd115461f | ||
|
|
6da4ee8fd4 | ||
|
|
b095044baa | ||
|
|
ba2b757fdb | ||
|
|
9d7d0d83e1 | ||
|
|
b893e46634 | ||
|
|
d8ab6f3887 | ||
|
|
eea5ebfd04 | ||
|
|
b2117f11b9 | ||
|
|
389b0f362f | ||
|
|
6ce4a89766 | ||
|
|
4d7573c46f | ||
|
|
445de3546d | ||
|
|
3b265119d6 | ||
|
|
933ec50a1c | ||
|
|
922ab51a8d | ||
|
|
330943cbc0 | ||
|
|
368dfc4ea3 | ||
|
|
3a9d8a411e | ||
|
|
47ea630329 | ||
|
|
c8e8f34a83 | ||
|
|
14a5b0f0e8 | ||
|
|
e817eb46d4 | ||
|
|
6ca6e22fea | ||
|
|
d11d03bd92 | ||
|
|
27962242b7 | ||
|
|
f54993e3b7 | ||
|
|
d2ec5ebe9c | ||
|
|
10abf386b4 | ||
|
|
f0360cac69 | ||
|
|
24c11f2c06 | ||
|
|
ff2ca5ad3b | ||
|
|
391a4300c1 | ||
|
|
530e310da6 | ||
|
|
73bac32886 | ||
|
|
8023e74adb | ||
|
|
09ce31061d | ||
|
|
7072485782 | ||
|
|
4bd79e5903 | ||
|
|
028e723226 | ||
|
|
1a68875aad | ||
|
|
de7d52a62c | ||
|
|
b8a2fd01cc | ||
|
|
b9ea6e36c8 | ||
|
|
783e6d2435 | ||
|
|
0b11e441ff | ||
|
|
d7db0d2304 | ||
|
|
d674eef530 | ||
|
|
17aa4edb66 | ||
|
|
656c0fb7b8 | ||
|
|
d4dce16be0 | ||
|
|
80a9ece314 |
5
.github/workflows/node.js.yml
vendored
5
.github/workflows/node.js.yml
vendored
@@ -1,6 +1,5 @@
|
||||
# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
|
||||
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
|
||||
# TODO: Make actual tests instead of just running the examples file
|
||||
|
||||
name: Build
|
||||
|
||||
@@ -17,7 +16,7 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [10.x, 12.x, 14.x, 15.x]
|
||||
node-version: [ 14.x, 15.x, 16.x ]
|
||||
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
|
||||
|
||||
steps:
|
||||
@@ -27,4 +26,4 @@ jobs:
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
- run: npm install
|
||||
- run: node ./examples
|
||||
- run: npm test
|
||||
|
||||
531
README.md
531
README.md
@@ -1,22 +1,36 @@
|
||||
# YouTube.js
|
||||
<h1 align="center">YouTube.js</h1>
|
||||
<p align="center"><i>An object-oriented wrapper around the Innertube API, which is what YouTube itself uses.</i><p>
|
||||
|
||||
[](https://github.com/LuanRT/YouTube.js/actions/workflows/node.js.yml)
|
||||
<p align="center">
|
||||
<img src="https://github.com/LuanRT/YouTube.js/actions/workflows/node.js.yml/badge.svg">
|
||||
<img src="https://img.shields.io/npm/v/youtubei.js?color=%2335C757">
|
||||
<img src="https://www.codefactor.io/repository/github/luanrt/youtube.js/badge">
|
||||
</p>
|
||||
|
||||
Innertube is an API used across all YouTube clients, it was [made to simplify](https://gizmodo.com/how-project-innertube-helped-pull-youtube-out-of-the-gu-1704946491) the internal structure of the platform and make it easy to push updates. This library takes advantage of that API, therefore providing a simple & efficient way to interact with YouTube programmatically.
|
||||
|
||||
And big thanks to [@gatecrasher777](https://github.com/gatecrasher777/ytcog) for his research on the workings of the Innertube API!
|
||||
|
||||
An object-oriented wrapper around the Innertube API, which is what YouTube itself uses. This makes YouTube.js fast, simple & efficient. And big thanks to [@gatecrasher777](https://github.com/gatecrasher777/ytcog) for his research on the workings of the Innertube API!
|
||||
|
||||
#### What can it do?
|
||||
|
||||
As of now, this is one of the most advanced & stable YouTube libraries out there, and it can:
|
||||
As of now, this is one of the most advanced & stable YouTube libraries out there, here's a short summary of its features:
|
||||
|
||||
- Search.
|
||||
- Get detailed info about videos.
|
||||
- Fetch notifications (sign-in required).
|
||||
- Subscribe/Unsubscribe/Like/Dislike/Comment (sign-in required).
|
||||
- Last but not least, you can also download videos!
|
||||
- Search videos
|
||||
- Get detailed info about any video
|
||||
- Fetch live chat & live stats in real time
|
||||
- Get notifications
|
||||
- Get subscriptions feed
|
||||
- Change notification preferences for a channel
|
||||
- Subscribe/Unsubscribe/Like/Dislike/Comment etc
|
||||
- Easily sign in to any Google Account
|
||||
- Download videos
|
||||
|
||||
Do note that you must be signed-in to perform actions that involve an account, such as commenting, subscribing, sending messages to a live chat, etc.
|
||||
|
||||
#### Do I need an API key to use this?
|
||||
|
||||
No, since it's basically what YouTube itself uses to populate its app/website no API keys are required.
|
||||
No, YouTube.js does not use any official API so no API keys are required.
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -30,27 +44,21 @@ npm install youtubei.js
|
||||
|
||||
[2. Interactions](https://github.com/LuanRT/YouTube.js#interactions)
|
||||
|
||||
[3. Downloading Videos](https://github.com/LuanRT/YouTube.js#downloading-videos)
|
||||
[3. Live chats](https://github.com/LuanRT/YouTube.js#fetching-live-chats)
|
||||
|
||||
[4. Signing-in](https://github.com/LuanRT/YouTube.js#signing-in)
|
||||
[4. Downloading videos](https://github.com/LuanRT/YouTube.js#downloading-videos)
|
||||
|
||||
[5. Disclaimer](https://github.com/LuanRT/YouTube.js#disclaimer)
|
||||
[5. Signing-in](https://github.com/LuanRT/YouTube.js#signing-in)
|
||||
|
||||
First of all we're gonna start by initializing the Innertube class:
|
||||
[6. Disclaimer](https://github.com/LuanRT/YouTube.js#disclaimer)
|
||||
|
||||
First of all we're gonna start by initializing the Innertube instance.
|
||||
And to make things faster, you should do this only once and reuse the Innertube object when needed.
|
||||
|
||||
```js
|
||||
const Innertube = require('youtubei.js');
|
||||
|
||||
async function start() {
|
||||
const youtube = await new Innertube();
|
||||
//...
|
||||
}
|
||||
|
||||
start();
|
||||
const youtube = await new Innertube();
|
||||
```
|
||||
|
||||
After this you should be good to go, so let's dive into it!
|
||||
|
||||
Doing a simple search:
|
||||
|
||||
```js
|
||||
@@ -118,7 +126,7 @@ const search = await youtube.search('Looking for life on Mars - Documentary');
|
||||
|
||||
|
||||
|
||||
Getting details about a specific video:
|
||||
Get details about a specific video:
|
||||
|
||||
```js
|
||||
const video = await youtube.getDetails(search.videos[0].id);
|
||||
@@ -178,8 +186,271 @@ const video = await youtube.getDetails(search.videos[0].id);
|
||||
</p>
|
||||
</details>
|
||||
|
||||
Get comments:
|
||||
|
||||
Fetching notifications:
|
||||
```js
|
||||
const video = await youtube.getDetails(VIDEO_ID_HERE);
|
||||
const comments = await video.getComments();
|
||||
|
||||
// If you want to load more comments simply call:
|
||||
const comments_continuation = await comments.getContinuation();
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Output</summary>
|
||||
<p>
|
||||
|
||||
```js
|
||||
{
|
||||
"comments":[
|
||||
{
|
||||
"text":"The amazing thing to me is the engineering. It's truly remarkable that we can build machines like these.",
|
||||
"author":{
|
||||
"name":"Mark B",
|
||||
"thumbnail":[
|
||||
{
|
||||
"url":"https://yt3.ggpht.com/ytc/AKedOLTKxmup9YqNEMvf-nSdOe7F6CwWhUtu4mpUsg=s48-c-k-c0x00ffffff-no-rj",
|
||||
"width":48,
|
||||
"height":48
|
||||
},
|
||||
{
|
||||
"url":"https://yt3.ggpht.com/ytc/AKedOLTKxmup9YqNEMvf-nSdOe7F6CwWhUtu4mpUsg=s88-c-k-c0x00ffffff-no-rj",
|
||||
"width":88,
|
||||
"height":88
|
||||
},
|
||||
{
|
||||
"url":"https://yt3.ggpht.com/ytc/AKedOLTKxmup9YqNEMvf-nSdOe7F6CwWhUtu4mpUsg=s176-c-k-c0x00ffffff-no-rj",
|
||||
"width":176,
|
||||
"height":176
|
||||
}
|
||||
],
|
||||
"channel_id":"UClnPXUOtCLnKsbS2reuN7wg"
|
||||
},
|
||||
"metadata":{
|
||||
"published":"2 months ago",
|
||||
"is_liked":false,
|
||||
"is_channel_owner":false,
|
||||
"like_count":"54",
|
||||
"reply_count":3,
|
||||
"id":"Ugy-bGGepYil_2dAQUp4AaABAg"
|
||||
}
|
||||
},
|
||||
{
|
||||
"text":"May 25th, 2021 and everything has gone perfectly! Ingenuity, moxy and perseverance all working to plan. Unbelievable accomplishments!!!",
|
||||
"author":{
|
||||
"name":"cliff luebke",
|
||||
"thumbnail":[
|
||||
{
|
||||
"url":"https://yt3.ggpht.com/ytc/AKedOLR1_6jvPZa_ycrkUEVxVxo0Alo25e7O8fOcm5v9ww=s48-c-k-c0x00ffffff-no-rj",
|
||||
"width":48,
|
||||
"height":48
|
||||
},
|
||||
{
|
||||
"url":"https://yt3.ggpht.com/ytc/AKedOLR1_6jvPZa_ycrkUEVxVxo0Alo25e7O8fOcm5v9ww=s88-c-k-c0x00ffffff-no-rj",
|
||||
"width":88,
|
||||
"height":88
|
||||
},
|
||||
{
|
||||
"url":"https://yt3.ggpht.com/ytc/AKedOLR1_6jvPZa_ycrkUEVxVxo0Alo25e7O8fOcm5v9ww=s176-c-k-c0x00ffffff-no-rj",
|
||||
"width":176,
|
||||
"height":176
|
||||
}
|
||||
],
|
||||
"channel_id":"UCeVFeX4jCgaJpvNJ4f_I4RA"
|
||||
},
|
||||
"metadata":{
|
||||
"published":"4 months ago",
|
||||
"is_liked":false,
|
||||
"is_channel_owner":false,
|
||||
"like_count":"54",
|
||||
"reply_count":0,
|
||||
"id":"UgylkZHOe7v78hxHPpl4AaABAg"
|
||||
}
|
||||
},
|
||||
//...
|
||||
],
|
||||
"comment_count":"3,231" // not available in continuations
|
||||
}
|
||||
```
|
||||
|
||||
</p>
|
||||
</details>
|
||||
|
||||
Get subscriptions feed:
|
||||
```js
|
||||
const mysubfeed = await youtube.getSubscriptionsFeed();
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Output</summary>
|
||||
<p>
|
||||
|
||||
```js
|
||||
{
|
||||
"today":[
|
||||
{
|
||||
"title":"Life As My P*nis",
|
||||
"id":"udDINILQH10",
|
||||
"channel":"penguinz0",
|
||||
"metadata":{
|
||||
"view_count":"220,432 views",
|
||||
"thumbnail":[
|
||||
{
|
||||
"url":"https://i.ytimg.com/vi/udDINILQH10/hqdefault.jpg?sqp=-oaymwEbCNIBEHZIVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAQ7bbeUhCxRSg-g-CPek-soixUMQ",
|
||||
"width":210,
|
||||
"height":118
|
||||
},
|
||||
{
|
||||
"url":"https://i.ytimg.com/vi/udDINILQH10/hqdefault.jpg?sqp=-oaymwEcCPYBEIoBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLB6fuvBJMeLtkM0TLkZwharsyojjA",
|
||||
"width":246,
|
||||
"height":138
|
||||
},
|
||||
{
|
||||
"url":"https://i.ytimg.com/vi/udDINILQH10/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDd7BncH1QuZD-Hada_n6dAVRTnmg",
|
||||
"width":336,
|
||||
"height":188
|
||||
}
|
||||
],
|
||||
"published":"2 hours ago",
|
||||
"badges":"N/A",
|
||||
"owner_badges":[
|
||||
"Verified"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"title":"Perseverance and Ingenuity went two weeks without contacting Earth",
|
||||
"id":"VsmYZMVCHuc",
|
||||
"channel":"Mars Guy",
|
||||
"metadata":{
|
||||
"view_count":"2,633 views",
|
||||
"thumbnail":[
|
||||
{
|
||||
"url":"https://i.ytimg.com/vi/VsmYZMVCHuc/hqdefault.jpg?sqp=-oaymwEbCNIBEHZIVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLA0-vwAgpFbMO1zG4HTzdHZey1kZQ",
|
||||
"width":210,
|
||||
"height":118
|
||||
},
|
||||
{
|
||||
"url":"https://i.ytimg.com/vi/VsmYZMVCHuc/hqdefault.jpg?sqp=-oaymwEcCPYBEIoBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBtM3W-RXsCfPnxgnrktaBkiL9zzg",
|
||||
"width":246,
|
||||
"height":138
|
||||
},
|
||||
{
|
||||
"url":"https://i.ytimg.com/vi/VsmYZMVCHuc/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDTil7At4FUVYeSNySOoFoKlPXWSA",
|
||||
"width":336,
|
||||
"height":188
|
||||
}
|
||||
],
|
||||
"published":"15 hours ago",
|
||||
"badges":"N/A",
|
||||
"owner_badges":"N/A"
|
||||
}
|
||||
}
|
||||
//...
|
||||
],
|
||||
"yesterday":[
|
||||
{
|
||||
"title":"Fortnite - S.T.A.R.S (Resident Evil) | PS5, PS4",
|
||||
"id":"-ZLEQOVbWD4",
|
||||
"channel":"PlayStation",
|
||||
"metadata":{
|
||||
"view_count":"157,197 views",
|
||||
"thumbnail":[
|
||||
{
|
||||
"url":"https://i.ytimg.com/vi/-ZLEQOVbWD4/hqdefault.jpg?sqp=-oaymwEbCNIBEHZIVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAeA1fLzsEA0ZIouNJuMDJOqOc9Ng",
|
||||
"width":210,
|
||||
"height":118
|
||||
},
|
||||
{
|
||||
"url":"https://i.ytimg.com/vi/-ZLEQOVbWD4/hqdefault.jpg?sqp=-oaymwEcCPYBEIoBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDzvhxJ6m2ztykk2ezNH2Din33hEw",
|
||||
"width":246,
|
||||
"height":138
|
||||
},
|
||||
{
|
||||
"url":"https://i.ytimg.com/vi/-ZLEQOVbWD4/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLDo67DvnaiKzOVfNm9lJg_Edd1UDQ",
|
||||
"width":336,
|
||||
"height":188
|
||||
}
|
||||
],
|
||||
"published":"1 day ago",
|
||||
"badges":"N/A",
|
||||
"owner_badges":[
|
||||
"Verified"
|
||||
]
|
||||
}
|
||||
},
|
||||
//...
|
||||
],
|
||||
"this_week":[
|
||||
{
|
||||
"title":"Horrible $100 Million Gold Mansion",
|
||||
"id":"F-d3CEYJyrg",
|
||||
"channel":"penguinz0",
|
||||
"metadata":{
|
||||
"view_count":"693,041 views",
|
||||
"thumbnail":[
|
||||
{
|
||||
"url":"https://i.ytimg.com/vi/F-d3CEYJyrg/hqdefault.jpg?sqp=-oaymwEbCNIBEHZIVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDdMMGG-50O4U5uIcoZ2FoiO6Mopg",
|
||||
"width":210,
|
||||
"height":118
|
||||
},
|
||||
{
|
||||
"url":"https://i.ytimg.com/vi/F-d3CEYJyrg/hqdefault.jpg?sqp=-oaymwEcCPYBEIoBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAwqMy1ekLaxWGnjWwCJl7z7Nw2aQ",
|
||||
"width":246,
|
||||
"height":138
|
||||
},
|
||||
{
|
||||
"url":"https://i.ytimg.com/vi/F-d3CEYJyrg/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLADaPBg0vh52e6clHvUf5otBZO9HA",
|
||||
"width":336,
|
||||
"height":188
|
||||
}
|
||||
],
|
||||
"published":"2 days ago",
|
||||
"badges":"N/A",
|
||||
"owner_badges":[
|
||||
"Verified"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"title":"OOopsieeee",
|
||||
"id":"mJ2WOIhEPm8",
|
||||
"channel":"PewDiePie",
|
||||
"metadata":{
|
||||
"view_count":"1,953,970 views",
|
||||
"thumbnail":[
|
||||
{
|
||||
"url":"https://i.ytimg.com/vi/mJ2WOIhEPm8/hqdefault.jpg?sqp=-oaymwEbCNIBEHZIVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLCrJe2a6rasJICj_jchMquZ2YGVrQ",
|
||||
"width":210,
|
||||
"height":118
|
||||
},
|
||||
{
|
||||
"url":"https://i.ytimg.com/vi/mJ2WOIhEPm8/hqdefault.jpg?sqp=-oaymwEcCPYBEIoBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLCpxERsiLJayuKegeb5mHw3Ok6wGA",
|
||||
"width":246,
|
||||
"height":138
|
||||
},
|
||||
{
|
||||
"url":"https://i.ytimg.com/vi/mJ2WOIhEPm8/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAlRLjypimzrE3GD_iGYDGwTlCGvA",
|
||||
"width":336,
|
||||
"height":188
|
||||
}
|
||||
],
|
||||
"published":"2 days ago",
|
||||
"badges":"N/A",
|
||||
"owner_badges":[
|
||||
"Verified"
|
||||
]
|
||||
}
|
||||
},
|
||||
//...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
</p>
|
||||
</details>
|
||||
|
||||
Get notifications:
|
||||
|
||||
```js
|
||||
const notifications = await youtube.getNotifications();
|
||||
@@ -216,67 +487,185 @@ const notifications = await youtube.getNotifications();
|
||||
</p>
|
||||
</details>
|
||||
|
||||
Get unseen notifications count:
|
||||
|
||||
```js
|
||||
const notifications = await youtube.getUnseenNotificationsCount();
|
||||
```
|
||||
|
||||
### Interactions:
|
||||
|
||||
---
|
||||
|
||||
* Subscribing/Unsubscribing to channels:
|
||||
* Subscribe/Unsubscribe:
|
||||
```js
|
||||
const video = await youtube.getDetails(VIDEO_ID_HERE); // this is equivalent to opening the watch page on YouTube
|
||||
const video = await youtube.getDetails(VIDEO_ID_HERE);
|
||||
|
||||
await video.subscribe();
|
||||
await video.unsubscribe();
|
||||
```
|
||||
|
||||
* Liking/Disliking:
|
||||
* Like/Dislike:
|
||||
```js
|
||||
const video = await youtube.getDetails(VIDEO_ID_HERE); // this is equivalent to opening the watch page on YouTube
|
||||
const video = await youtube.getDetails(VIDEO_ID_HERE);
|
||||
|
||||
await video.like();
|
||||
await video.dislike();
|
||||
await video.removeLike(); // removes either a like or dislike
|
||||
```
|
||||
|
||||
* Commenting:
|
||||
* Comment:
|
||||
```js
|
||||
const video = await youtube.getDetails(VIDEO_ID_HERE);
|
||||
await video.comment('Haha, nice!');
|
||||
```
|
||||
|
||||
All of the interactions above will return ```{ success: true, status_code: 200 }``` if everything goes alright.
|
||||
* Change notification preferences:
|
||||
```js
|
||||
const video = await youtube.getDetails(VIDEO_ID_HERE);
|
||||
|
||||
### Signing-in:
|
||||
// Options: ALL | NONE | PERSONALIZED
|
||||
await video.setNotificationPref('ALL');
|
||||
```
|
||||
|
||||
All of the above interactions will return ```{ success: true, status_code: 200 }``` if everything goes alright.
|
||||
|
||||
### Fetching live chats:
|
||||
---
|
||||
YouTube.js isn't able to download live content yet, but it does allow you to fetch live chats plus you can also send messages!
|
||||
```js
|
||||
const Innertube = require('youtubei.js');
|
||||
|
||||
async function start() {
|
||||
const youtube = await new Innertube();
|
||||
|
||||
const search = await youtube.search('Some random livestream');
|
||||
const video = await youtube.getDetails(search.videos[0].id);
|
||||
|
||||
const livechat = video.getLivechat();
|
||||
|
||||
// Updated stats about the livestream
|
||||
livechat.on('update-metadata', (data) => {
|
||||
console.info('Info:', data);
|
||||
});
|
||||
|
||||
// Fired whenever there is a new message or other chat events
|
||||
livechat.on('chat-update', (message) => {
|
||||
console.info(`- ${message.author.name}\n${message.text}\n\n`);
|
||||
|
||||
if(message.text == '!info') {
|
||||
livechat.sendMessage('Hello! This message was sent from YouTube.js');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
start();
|
||||
```
|
||||
Stop fetching the live chat:
|
||||
```js
|
||||
livechat.stop();
|
||||
```
|
||||
|
||||
Delete a message:
|
||||
```js
|
||||
const msg = await livechat.sendMessage('Nice livestream!');
|
||||
await msg.deleteMessage();
|
||||
```
|
||||
|
||||
### Downloading videos:
|
||||
---
|
||||
|
||||
This library allows you to sign-in in two different ways:
|
||||
|
||||
- Using OAuth 2.0, easy, simple & reliable.
|
||||
- Cookies, usually more complicated to get and unreliable.
|
||||
|
||||
OAuth 2.0:
|
||||
YouTube.js provides an easy-to-use and simple downloader:
|
||||
|
||||
```js
|
||||
const fs = require('fs');
|
||||
const Innertube = require('youtubei.js');
|
||||
const creds_path = './yt_oauth_creds.json';
|
||||
|
||||
async function start() {
|
||||
const youtube = await new Innertube();
|
||||
|
||||
const search = await youtube.search('Looking for life on Mars - documentary');
|
||||
|
||||
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, also ignored when type is set to audio
|
||||
type: 'videoandaudio' // can be “video”, “audio” and “videoandaudio”
|
||||
});
|
||||
|
||||
stream.pipe(fs.createWriteStream(`./${search.videos[0].title}.mp4`));
|
||||
|
||||
stream.on('start', () => {
|
||||
console.info('[DOWNLOADER]', 'Starting download now!');
|
||||
});
|
||||
|
||||
stream.on('info', (info) => {
|
||||
// { video_details: {..}, selected_format: {..}, formats: {..} }
|
||||
console.info('[DOWNLOADER]', `Downloading ${info.video_details.title} by ${info.video_details.metadata.channel_name}`);
|
||||
});
|
||||
|
||||
stream.on('progress', (info) => {
|
||||
process.stdout.clearLine();
|
||||
process.stdout.cursorTo(0);
|
||||
process.stdout.write(`[DOWNLOADER] Downloaded ${info.percentage}% (${info.downloaded_size}MB) of ${info.size}MB`);
|
||||
});
|
||||
|
||||
stream.on('end', () => {
|
||||
process.stdout.clearLine();
|
||||
process.stdout.cursorTo(0);
|
||||
console.info('[DOWNLOADER]', 'Done!');
|
||||
});
|
||||
|
||||
stream.on('error', (err) => console.error('[ERROR]', err));
|
||||
}
|
||||
|
||||
start();
|
||||
```
|
||||
|
||||
You can also specify a range:
|
||||
```js
|
||||
const stream = youtube.download(VIDEO_ID, {
|
||||
//...
|
||||
type: 'videoandaudio',
|
||||
range: { start: 0, end: 1048576 * 5 }
|
||||
});
|
||||
|
||||
```
|
||||
|
||||
Cancelling a download:
|
||||
```js
|
||||
stream.cancel();
|
||||
```
|
||||
|
||||
### Signing-in:
|
||||
---
|
||||
|
||||
When signing in to your account, you have two options:
|
||||
|
||||
- Use OAuth 2.0; easy, simple & reliable.
|
||||
- Cookies; usually more complicated to get and unreliable.
|
||||
|
||||
OAuth:
|
||||
|
||||
```js
|
||||
const fs = require('fs');
|
||||
const Innertube = require('youtubei.js');
|
||||
const creds_path = './yt_oauth_creds.json';
|
||||
|
||||
async function start() {
|
||||
const creds = fs.existsSync(creds_path) && JSON.parse(fs.readFileSync(creds_path).toString()) || {};
|
||||
const youtube = await new Innertube();
|
||||
|
||||
// Only triggered when signing-in.
|
||||
youtube.on('auth', (data) => {
|
||||
youtube.ev.on('auth', (data) => {
|
||||
if (data.status === 'AUTHORIZATION_PENDING') {
|
||||
console.info(`Hello!\nOn your phone or computer, go to ${data.verification_url} and enter the code ${data.code}`);
|
||||
} else if (data.status === 'SUCCESS') {
|
||||
fs.writeFileSync(creds_path, JSON.stringify({ access_token: data.access_token, refresh_token: data.refresh_token }));
|
||||
fs.writeFileSync(creds_path, JSON.stringify(data.credentials));
|
||||
console.info('Successfully signed-in, enjoy!');
|
||||
}
|
||||
});
|
||||
|
||||
// Triggered whenever the access token is refreshed.
|
||||
youtube.on('update-credentials', (data) => {
|
||||
fs.writeFileSync(creds_path, JSON.stringify({ access_token: data.access_token, refresh_token: data.refresh_token }));
|
||||
youtube.ev.on('update-credentials', (data) => {
|
||||
fs.writeFileSync(creds_path, JSON.stringify(data.credentials));
|
||||
console.info('Credentials updated!', data);
|
||||
});
|
||||
|
||||
@@ -301,62 +690,16 @@ async function start() {
|
||||
start();
|
||||
```
|
||||
|
||||
### Downloading videos:
|
||||
|
||||
```js
|
||||
const fs = require('fs');
|
||||
const Innertube = require('youtubei.js');
|
||||
|
||||
async function start() {
|
||||
const youtube = await new Innertube();
|
||||
const search = await youtube.search('Looking for life on Mars - documentary');
|
||||
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].title}.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('progress', (info) => {
|
||||
process.stdout.clearLine();
|
||||
process.stdout.cursorTo(0);
|
||||
process.stdout.write(`[DOWNLOADER] Downloaded ${info.percentage}% (${info.downloaded_size}MB) of ${info.size}MB`);
|
||||
});
|
||||
|
||||
stream.on('end', () => {
|
||||
process.stdout.clearLine();
|
||||
process.stdout.cursorTo(0);
|
||||
console.info('[DOWNLOADER]', 'Done!');
|
||||
});
|
||||
|
||||
stream.on('error', (err) => console.error('[ERROR]', err));
|
||||
}
|
||||
|
||||
start();
|
||||
```
|
||||
|
||||
Cancelling a download:
|
||||
```js
|
||||
stream.cancel();
|
||||
```
|
||||
|
||||
## Contributing
|
||||
Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change.
|
||||
|
||||
Please make sure to update tests as appropriate.
|
||||
|
||||
## Disclaimer
|
||||
This project is not affiliated with, endorsed, or sponsored by YouTube or any of their affiliates or subsidiaries.
|
||||
All trademarks, logos and brand names are the property of their respective owners.
|
||||
|
||||
Should you have any questions or concerns please contact me directly via email.
|
||||
|
||||
## License
|
||||
[MIT](https://choosealicense.com/licenses/mit/)
|
||||
@@ -1,15 +1,33 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const Innertube = require('..');
|
||||
const COOKIE = 'YT_COOKIE_HERE';
|
||||
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(COOKIE);
|
||||
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);
|
||||
|
||||
// Searching, getting details about videos & making interactions:
|
||||
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);
|
||||
const video = await youtube.getDetails(search.videos[0].id).catch((error) => error);
|
||||
console.info('Video details:', video);
|
||||
|
||||
if (youtube.logged_in) {
|
||||
@@ -54,7 +72,7 @@ async function start() {
|
||||
type: 'videoandaudio' // can be “video”, “audio” and “videoandaudio”
|
||||
});
|
||||
|
||||
stream.pipe(fs.createWriteStream(`./${search.videos[0].title}.mp4`));
|
||||
stream.pipe(fs.createWriteStream(`./${search.videos[0].id}.mp4`));
|
||||
|
||||
stream.on('start', () => {
|
||||
console.info('[DOWNLOADER]', 'Starting download now!');
|
||||
|
||||
452
lib/Actions.js
452
lib/Actions.js
@@ -1,171 +1,317 @@
|
||||
'use strict';
|
||||
|
||||
const Uuid = require('uuid');
|
||||
const Axios = require('axios');
|
||||
const Utils = require('./Utils');
|
||||
const Constants = require('./Constants');
|
||||
|
||||
async function subscribe(session, video_id, channel_id) {
|
||||
if (!session.logged_in) throw new Error('You must be logged in to subscribe to a channel');
|
||||
let data = {
|
||||
context: session.context,
|
||||
channelIds: [channel_id]
|
||||
};
|
||||
/**
|
||||
* Performs direct interactions on YouTube.
|
||||
*
|
||||
* @param {object} session A valid Innertube session.
|
||||
* @param {string} engagement_type Type of engagement.
|
||||
* @param {object} args Engagement arguments.
|
||||
* @returns {object} { success: boolean, status_code: number } | { success: boolean, status_code: number, message: string }
|
||||
*/
|
||||
async function engage(session, engagement_type, args = {}) {
|
||||
if (!session.logged_in) throw new Error('You are not signed-in');
|
||||
|
||||
const response = await Axios.post(`${Constants.urls.YT_BASE_URL}/youtubei/v1/subscription/subscribe${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`, JSON.stringify(data), Constants.innertube_request_opts({ session, id: video_id, data })).catch((error) => error);
|
||||
if (response instanceof Error) {
|
||||
return {
|
||||
success: false,
|
||||
status_code: response.response.status,
|
||||
message: response.message
|
||||
};
|
||||
} else if (response.data.responseContext) {
|
||||
return {
|
||||
success: true,
|
||||
status_code: response.status,
|
||||
};
|
||||
let data;
|
||||
switch (engagement_type) {
|
||||
case 'like/like':
|
||||
case 'like/dislike':
|
||||
case 'like/removelike':
|
||||
data = {
|
||||
context: session.context,
|
||||
target: {
|
||||
videoId: args.video_id
|
||||
}
|
||||
};
|
||||
break;
|
||||
case 'subscription/subscribe':
|
||||
case 'subscription/unsubscribe':
|
||||
data = {
|
||||
context: session.context,
|
||||
channelIds: [args.channel_id]
|
||||
};
|
||||
break;
|
||||
case 'comment/create_comment':
|
||||
data = {
|
||||
context: session.context,
|
||||
commentText: args.text,
|
||||
createCommentParams: Utils.encodeCommentParams(args.video_id)
|
||||
};
|
||||
break;
|
||||
default:
|
||||
}
|
||||
|
||||
const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/${engagement_type}${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
|
||||
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session, id: args.video_id, data })).catch((error) => error);
|
||||
|
||||
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status_code: response.status
|
||||
};
|
||||
}
|
||||
|
||||
async function unsubscribe(session, video_id, channel_id) {
|
||||
if (!session.logged_in) throw new Error('You must be logged in to unsubscribe from a channel');
|
||||
let data = {
|
||||
context: session.context,
|
||||
channelIds: [channel_id]
|
||||
};
|
||||
/**
|
||||
* Accesses YouTube's various sections.
|
||||
*
|
||||
* @param {object} session A valid Innertube session.
|
||||
* @param {string} action_type Type of action.
|
||||
* @returns {object} { success: boolean, status_code: number, data: object } | { success: boolean, status_code: number, message: string }
|
||||
*/
|
||||
async function browse(session, action_type, args) {
|
||||
if (!session.logged_in) throw new Error('You are not signed-in');
|
||||
|
||||
const response = await Axios.post(`${Constants.urls.YT_BASE_URL}/youtubei/v1/subscription/unsubscribe${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`, JSON.stringify(data), Constants.innertube_request_opts({ session, id: video_id, data })).catch((error) => error);
|
||||
if (response instanceof Error) {
|
||||
return {
|
||||
success: false,
|
||||
status_code: response.response.status,
|
||||
message: response.message
|
||||
};
|
||||
} else if (response.data.responseContext) {
|
||||
return {
|
||||
success: true,
|
||||
status_code: response.status,
|
||||
};
|
||||
let data;
|
||||
switch (action_type) { // TODO: Handle more actions
|
||||
case 'home_feed':
|
||||
data = {
|
||||
context: session.context,
|
||||
browseId: 'FEwhat_to_watch'
|
||||
};
|
||||
break;
|
||||
case 'subscriptions_feed':
|
||||
data = {
|
||||
context: session.context,
|
||||
browseId: 'FEsubscriptions'
|
||||
};
|
||||
break;
|
||||
default:
|
||||
}
|
||||
|
||||
const client_domain = args.ytmusic && Constants.URLS.YT_MUSIC_URL || Constants.URLS.YT_BASE_URL;
|
||||
const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/browse${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
|
||||
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session })).catch((error) => error);
|
||||
|
||||
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status_code: response.status,
|
||||
data: response.data
|
||||
};
|
||||
}
|
||||
|
||||
async function likeVideo(session, video_id) {
|
||||
if (!session.logged_in) throw new Error('You must be logged in to like a video');
|
||||
let data = {
|
||||
context: session.context,
|
||||
target: {
|
||||
videoId: video_id
|
||||
/**
|
||||
* Performs searches on YouTube.
|
||||
*
|
||||
* @param {object} session A valid Innertube session.
|
||||
* @param {string} client YouTube client: YOUTUBE | YTMUSIC
|
||||
* @param {object} args Search arguments.
|
||||
* @returns {object} { success: boolean, status_code: number, data: object } | { success: boolean, status_code: number, message: string }
|
||||
*/
|
||||
async function search(session, client, args = {}) {
|
||||
if (!args.query) throw new Error('No query was provided');
|
||||
|
||||
let data;
|
||||
switch (client) {
|
||||
case 'YOUTUBE':
|
||||
data = {
|
||||
context: session.context,
|
||||
params: Utils.encodeFilter(args.options.period, args.options.duration, args.options.order),
|
||||
query: args.query
|
||||
};
|
||||
break;
|
||||
case 'YTMUSIC':
|
||||
const yt_music_context = JSON.parse(JSON.stringify(session.context)); // deep copy the context obj so we don't accidentally change it
|
||||
|
||||
yt_music_context.client.originalUrl = Constants.URLS.YT_MUSIC_URL;
|
||||
yt_music_context.client.clientVersion = '1.20211213.00.00';
|
||||
yt_music_context.client.clientName = 'WEB_REMIX';
|
||||
|
||||
data = {
|
||||
context: yt_music_context,
|
||||
query: args.query
|
||||
};
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
const response = await Axios.post(`${client === 'YOUTUBE' && Constants.URLS.YT_BASE_URL || Constants.URLS.YT_MUSIC_URL}/youtubei/v1/search${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
|
||||
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session, ytmusic: client === 'YTMUSIC' })).catch((error) => error);
|
||||
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status_code: response.status,
|
||||
data: response.data
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Interacts with YouTube's notification system.
|
||||
*
|
||||
* @param {object} session A valid Innertube session.
|
||||
* @param {string} action_type Type of action.
|
||||
* @param {object} args Action arguments.
|
||||
* @returns {object} { success: boolean, status_code: number, data: object } | { success: boolean, status_code: number, message: string }
|
||||
*/
|
||||
async function notifications(session, action_type, args = {}) {
|
||||
if (!session.logged_in) throw new Error('You are not signed-in');
|
||||
|
||||
let data;
|
||||
|
||||
switch (action_type) {
|
||||
case 'modify_channel_preference':
|
||||
let pref_types = { PERSONALIZED: 1, ALL: 2, NONE: 3 };
|
||||
data = {
|
||||
context: session.context,
|
||||
params: Utils.encodeNotificationPref(args.channel_id, pref_types[args.pref.toUpperCase()])
|
||||
};
|
||||
break;
|
||||
case 'get_notification_menu':
|
||||
data = {
|
||||
context: session.context,
|
||||
notificationsMenuRequestType: 'NOTIFICATIONS_MENU_REQUEST_TYPE_INBOX'
|
||||
};
|
||||
break;
|
||||
case 'get_unseen_count':
|
||||
data = {
|
||||
context: session.context
|
||||
};
|
||||
break;
|
||||
default:
|
||||
}
|
||||
|
||||
const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/notification/${action_type}${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
|
||||
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session })).catch((error) => error);
|
||||
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
|
||||
if (action_type === 'modify_channel_preference') return { success: true, status_code: response.status };
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status_code: response.status,
|
||||
data: response.data
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Interacts with YouTube's livechat system.
|
||||
*
|
||||
* @param {object} session A valid Innertube session.
|
||||
* @param {string} action_type Type of action.
|
||||
* @param {object} args Action arguments.
|
||||
* @returns {object} { success: boolean, status_code: number, data: object } | { success: boolean, status_code: number, message: string }
|
||||
*/
|
||||
async function livechat(session, action_type, args = {}) {
|
||||
let data;
|
||||
switch (action_type) {
|
||||
case 'live_chat/get_live_chat':
|
||||
data = {
|
||||
context: session.context,
|
||||
continuation: args.ctoken
|
||||
};
|
||||
break;
|
||||
case 'live_chat/send_message':
|
||||
data = {
|
||||
context: session.context,
|
||||
params: Utils.encodeMessageParams(args.channel_id, args.video_id),
|
||||
clientMessageId: `ytjs-${Uuid.v4()}`,
|
||||
richMessage: {
|
||||
textSegments: [{ text: args.text }]
|
||||
}
|
||||
};
|
||||
break;
|
||||
case 'live_chat/get_item_context_menu':
|
||||
data = {
|
||||
context: session.context
|
||||
};
|
||||
break;
|
||||
case 'live_chat/moderate':
|
||||
data = {
|
||||
context: session.context,
|
||||
params: args.cmd_params
|
||||
};
|
||||
break;
|
||||
case 'updated_metadata':
|
||||
data = {
|
||||
context: session.context,
|
||||
videoId: args.video_id
|
||||
};
|
||||
args.continuation && (data.continuation = args.continuation);
|
||||
break;
|
||||
default:
|
||||
}
|
||||
|
||||
const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/${action_type}${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
|
||||
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session, params: args.params })).catch((error) => error);
|
||||
if (response instanceof Error) return { success: false, message: response.message };
|
||||
|
||||
return { success: true, data: response.data };
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Gets detailed data for a video.
|
||||
*
|
||||
* @param {object} session A valid Innertube session.
|
||||
* @param {object} args Request arguments.
|
||||
* @returns {object} Video data.
|
||||
*/
|
||||
async function getVideoInfo(session, args = {}) {
|
||||
let response;
|
||||
|
||||
!args.is_desktop && (response = await Axios.get(`${Constants.URLS.YT_WATCH_PAGE}?v=${args.id}&t=8s&pbj=1`, Constants.INNERTUBE_REQOPTS({ session, id: args.id, desktop: false })).catch((error) => error)) ||
|
||||
(response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/player${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
|
||||
JSON.stringify(Constants.VIDEO_INFO_REQBODY(args.id, session.sts, session.context)), Constants.INNERTUBE_REQOPTS({ session, id: args.id, desktop: true })).catch((error) => error));
|
||||
if (response instanceof Error) throw new Error(`Could not get video info: ${response.message}`);
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Requests continuation for previously performed actions.
|
||||
*
|
||||
* @param {object} session A valid Innertube session.
|
||||
* @param {object} args Continuation arguments.
|
||||
* @returns {object} { success: boolean, status_code: number, data: object } | { success: boolean, status_code: number, message: string }
|
||||
*/
|
||||
async function getContinuation(session, args = {}) {
|
||||
let data = { context: session.context };
|
||||
args.continuation_token && (data.continuation = args.continuation_token);
|
||||
|
||||
if (args.video_id) {
|
||||
data.videoId = args.video_id;
|
||||
if (args.ytmusic) {
|
||||
const yt_music_context = JSON.parse(JSON.stringify(session.context)); // deep copy the context obj so we don't accidentally change it
|
||||
|
||||
yt_music_context.client.originalUrl = Constants.URLS.YT_MUSIC_URL;
|
||||
yt_music_context.client.clientVersion = '1.20211213.00.00';
|
||||
yt_music_context.client.clientName = 'WEB_REMIX';
|
||||
|
||||
data.context = yt_music_context;
|
||||
data.isAudioOnly = true;
|
||||
data.tunerSettingValue = 'AUTOMIX_SETTING_NORMAL';
|
||||
} else {
|
||||
data.racyCheckOk = true;
|
||||
data.contentCheckOk = false;
|
||||
data.autonavState = 'STATE_NONE';
|
||||
data.playbackContext = {
|
||||
vis: 0,
|
||||
lactMilliseconds: '-1'
|
||||
}
|
||||
data.captionsRequested = false;
|
||||
}
|
||||
};
|
||||
|
||||
const response = await Axios.post(`${Constants.urls.YT_BASE_URL}/youtubei/v1/like/like${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`, JSON.stringify(data), Constants.innertube_request_opts({ session, id: video_id, data })).catch((error) => error);
|
||||
if (response instanceof Error) {
|
||||
return {
|
||||
success: false,
|
||||
status_code: response.response.status,
|
||||
message: response.message
|
||||
};
|
||||
} else if (response.data.responseContext) {
|
||||
return {
|
||||
success: true,
|
||||
status_code: response.status,
|
||||
};
|
||||
}
|
||||
|
||||
const client_domain = args.ytmusic && Constants.URLS.YT_MUSIC_URL || Constants.URLS.YT_BASE_URL;
|
||||
const response = await Axios.post(`${client_domain}/youtubei/v1/next${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
|
||||
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session, ytmusic: args.ytmusic })).catch((error) => error);
|
||||
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status_code: response.status,
|
||||
data: response.data
|
||||
};
|
||||
}
|
||||
|
||||
async function dislikeVideo(session, video_id) {
|
||||
if (!session.logged_in) throw new Error('You must be logged in to like a video');
|
||||
let data = {
|
||||
context: session.context,
|
||||
target: {
|
||||
videoId: video_id
|
||||
}
|
||||
};
|
||||
|
||||
const response = await Axios.post(`${Constants.urls.YT_BASE_URL}/youtubei/v1/like/dislike${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`, JSON.stringify(data), Constants.innertube_request_opts({ session, id: video_id, data })).catch((error) => error);
|
||||
if (response instanceof Error) {
|
||||
return {
|
||||
success: false,
|
||||
status_code: response.response.status,
|
||||
message: response.message
|
||||
};
|
||||
} else if (response.data.responseContext) {
|
||||
return {
|
||||
success: true,
|
||||
status_code: response.status,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function removeLike(session, video_id) {
|
||||
if (!session.logged_in) throw new Error('You must be logged in to remove a like/dislike.');
|
||||
let data = {
|
||||
context: session.context,
|
||||
target: {
|
||||
videoId: video_id
|
||||
}
|
||||
};
|
||||
|
||||
const response = await Axios.post(`${Constants.urls.YT_BASE_URL}/youtubei/v1/like/removelike${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`, JSON.stringify(data), Constants.innertube_request_opts({ session, id: video_id, data })).catch((error) => error);
|
||||
if (response instanceof Error) {
|
||||
return {
|
||||
success: false,
|
||||
status_code: response.response.status,
|
||||
message: response.message
|
||||
};
|
||||
} else if (response.data.responseContext) {
|
||||
return {
|
||||
success: true,
|
||||
status_code: response.status,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function commentVideo(session, video_id, text) {
|
||||
if (!text) throw new Error('No text was provided');
|
||||
if (!session.logged_in) throw new Error('You must be logged in to post a comment.');
|
||||
|
||||
let data = {
|
||||
context: session.context,
|
||||
commentText: text,
|
||||
createCommentParams: Utils.encodeId(video_id)
|
||||
};
|
||||
|
||||
const response = await Axios.post(`${Constants.urls.YT_BASE_URL}/youtubei/v1/comment/create_comment${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`, JSON.stringify(data), Constants.innertube_request_opts({ session, id: video_id, data })).catch((error) => error);
|
||||
if (response instanceof Error) {
|
||||
return {
|
||||
success: false,
|
||||
status_code: response.response.status,
|
||||
message: response.message
|
||||
};
|
||||
} else if (response.data.responseContext) {
|
||||
return {
|
||||
success: true,
|
||||
status_code: response.status,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function getNotifications(session) {
|
||||
if (!session.logged_in) throw new Error('You must be logged in to fetch notifications');
|
||||
let data = {
|
||||
context: session.context,
|
||||
notificationsMenuRequestType: 'NOTIFICATIONS_MENU_REQUEST_TYPE_INBOX'
|
||||
};
|
||||
|
||||
const response = await Axios.post(`${Constants.urls.YT_BASE_URL}/youtubei/v1/notification/get_notification_menu${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`, JSON.stringify(data), Constants.innertube_request_opts({ session, data, desktop: true })).catch((error) => error);
|
||||
if (response instanceof Error) {
|
||||
return {
|
||||
success: false,
|
||||
status_code: response.response.status,
|
||||
message: response.message
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: true,
|
||||
status_code: response.status,
|
||||
data: response.data
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { subscribe, unsubscribe, likeVideo, dislikeVideo, removeLike, commentVideo, getNotifications };
|
||||
module.exports = { engage, browse, search, notifications, livechat, getVideoInfo, getContinuation };
|
||||
447
lib/Constants.js
447
lib/Constants.js
@@ -2,327 +2,138 @@
|
||||
|
||||
const Utils = require('./Utils');
|
||||
|
||||
const urls = {
|
||||
YT_BASE_URL: 'https://www.youtube.com',
|
||||
YT_MOBILE_URL: 'https://m.youtube.com',
|
||||
YT_WATCH_PAGE: 'https://m.youtube.com/watch',
|
||||
};
|
||||
|
||||
const oauth = {
|
||||
scope: 'http://gdata.youtube.com https://www.googleapis.com/auth/youtube-paid-content',
|
||||
grant_type: 'http://oauth.net/grant_type/device/1.0',
|
||||
model_name: 'ytlr::'
|
||||
};
|
||||
|
||||
const oauth_reqopts = {
|
||||
headers: {
|
||||
'accept': '*/*',
|
||||
'origin': urls.YT_BASE_URL,
|
||||
'user-agent': 'Mozilla/5.0 (ChromiumStylePlatform) Cobalt/Version',
|
||||
'content-type': 'application/json',
|
||||
'x-requested-with': 'mark.via.gp',
|
||||
'referer': `${urls.YT_BASE_URL}/tv`,
|
||||
'accept-language': 'en-US'
|
||||
}
|
||||
};
|
||||
|
||||
const default_headers = (session) => {
|
||||
return {
|
||||
headers: {
|
||||
'Cookie': session.cookie,
|
||||
'user-agent': Utils.getRandomUserAgent('desktop').userAgent,
|
||||
'Referer': 'https://www.google.com/',
|
||||
'Accept': 'text/html',
|
||||
'Accept-Language': 'en-US,en',
|
||||
'Accept-Encoding': 'gzip',
|
||||
'Upgrade-Insecure-Requests': 1
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const innertube_request_opts = (info) => {
|
||||
if (info.desktop === undefined) info.desktop = true;
|
||||
let req_opts = {
|
||||
headers: {
|
||||
'accept': '*/*',
|
||||
'user-agent': Utils.getRandomUserAgent(info.desktop ? 'desktop' : 'mobile').userAgent,
|
||||
'content-type': 'application/json',
|
||||
'accept-language': 'en-US,en;q=0.9',
|
||||
'x-goog-authuser': 0,
|
||||
'x-goog-visitor-id': info.session.context.client.visitorData,
|
||||
'x-youtube-client-name': info.desktop ? 1 : 2,
|
||||
'x-youtube-client-version': info.session.context.client.clientVersion,
|
||||
'x-youtube-chrome-connected': 'source=Chrome,mode=0,enable_account_consistency=true,supervised=false,consistency_enabled_by_default=false',
|
||||
'x-origin': info.desktop ? urls.YT_BASE_URL : urls.YT_MOBILE_URL,
|
||||
'origin': info.desktop ? urls.YT_BASE_URL : urls.YT_MOBILE_URL,
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (info.data) {
|
||||
req_opts.headers['content-length'] = Buffer.byteLength(JSON.stringify(info.data), 'utf8');
|
||||
}
|
||||
|
||||
if (info.id) {
|
||||
req_opts.headers.referer = (info.desktop ? urls.YT_BASE_URL : urls.YT_MOBILE_URL) + '/watch?v=' + info.id;
|
||||
}
|
||||
|
||||
return req_opts;
|
||||
};
|
||||
|
||||
const video_details_reqbody = (id, sts, context) => {
|
||||
return {
|
||||
playbackContext: {
|
||||
contentPlaybackContext: {
|
||||
'currentUrl': '/watch?v=' + id,
|
||||
'vis': 0,
|
||||
'splay': false,
|
||||
'autoCaptionsDefaultOn': false,
|
||||
'autonavState': 'STATE_OFF',
|
||||
'html5Preference': 'HTML5_PREF_WANTS',
|
||||
'signatureTimestamp': sts,
|
||||
'referer': urls.YT_BASE_URL,
|
||||
'lactMilliseconds': '-1'
|
||||
module.exports = {
|
||||
URLS: {
|
||||
YT_BASE_URL: 'https://www.youtube.com',
|
||||
YT_MUSIC_URL: 'https://music.youtube.com',
|
||||
YT_MOBILE_URL: 'https://m.youtube.com',
|
||||
YT_WATCH_PAGE: 'https://m.youtube.com/watch'
|
||||
},
|
||||
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'
|
||||
}
|
||||
},
|
||||
context: context,
|
||||
videoId: id
|
||||
};
|
||||
};
|
||||
|
||||
const stream_headers = (range) => {
|
||||
let headers = {
|
||||
}
|
||||
},
|
||||
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': urls.YT_BASE_URL,
|
||||
'Referer': urls.YT_BASE_URL,
|
||||
'Origin': 'https://www.youtube.com',
|
||||
'Referer': 'https://www.youtube.com',
|
||||
'DNT': '?1'
|
||||
};
|
||||
if (range) {
|
||||
headers.Range = range;
|
||||
},
|
||||
INNERTUBE_REQOPTS: (info) => {
|
||||
info.desktop === undefined && (info.desktop = true);
|
||||
const origin = info.ytmusic && 'https://music.youtube.com' ||
|
||||
info.desktop && 'https://www.youtube.com' || 'https://m.youtube.com';
|
||||
|
||||
let req_opts = {
|
||||
params: info.params || {},
|
||||
headers: {
|
||||
'accept': '*/*',
|
||||
'user-agent': Utils.getRandomUserAgent(info.desktop ? 'desktop' : 'mobile').userAgent,
|
||||
'content-type': 'application/json',
|
||||
'accept-language': 'en-US,en;q=0.9',
|
||||
'x-goog-authuser': 0,
|
||||
'x-goog-visitor-id': info.session.context.client.visitorData || '',
|
||||
'x-youtube-client-name': info.desktop ? 1 : 2,
|
||||
'x-youtube-client-version': info.session.context.client.clientVersion,
|
||||
'x-youtube-chrome-connected': 'source=Chrome,mode=0,enable_account_consistency=true,supervised=false,consistency_enabled_by_default=false',
|
||||
'x-origin': origin,
|
||||
'origin': origin,
|
||||
}
|
||||
};
|
||||
|
||||
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
|
||||
};
|
||||
},
|
||||
METADATA_KEYS: [
|
||||
'embed', 'view_count', 'average_rating',
|
||||
'length_seconds', 'channel_id', 'channel_url',
|
||||
'external_channel_id', 'is_live_content', 'is_family_safe',
|
||||
'is_unlisted', 'is_private', 'has_ypc_metadata',
|
||||
'category', 'owner_channel_name', 'publish_date',
|
||||
'upload_date', 'keywords', 'available_countries',
|
||||
'owner_profile_url'
|
||||
],
|
||||
BLACKLISTED_KEYS: [
|
||||
'is_owner_viewing', 'is_unplugged_corpus',
|
||||
'is_crawlable', 'allow_ratings', 'author'
|
||||
],
|
||||
BASE64_DIALECT: {
|
||||
NORMAL: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'.split(''),
|
||||
REVERSE: '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_'.split('')
|
||||
},
|
||||
FUNCS_REGEX: /d\.push\(e\)|d\.reverse\(\)|d\[0\]\)\[0\]\)|f=d\[0];d\[0\]|d\.length;d\.splice\(e,1\)|function\(\){for\(var|function\(d,e,f\){var k=f|function\(d\){for\(var|reverse\(\)\.forEach|unshift\(d\.pop\(\)\)|function\(d,e\){for\(var f/,
|
||||
FUNCS: {
|
||||
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 k=f'
|
||||
},
|
||||
// Just a helper function, felt like Utils.js wasn't the right place for it:
|
||||
formatNTransformData: (data) => {
|
||||
return data
|
||||
.replace(/function\(d,e\)/g, '"function(d,e)').replace(/function\(d\)/g, '"function(d)')
|
||||
.replace(/function\(\)/g, '"function()').replace(/function\(d,e,f\)/g, '"function(d,e,f)')
|
||||
.replace(/\[function\(d,e,f\)/g, '["function(d,e,f)').replace(/,b,/g, ',"b",')
|
||||
.replace(/,b/g, ',"b"').replace(/b,/g, '"b",').replace(/b]/g, '"b"]')
|
||||
.replace(/\[b/g, '["b"').replace(/}]/g, '"]').replace(/},/g, '}",')
|
||||
.replace(/""/g, '').replace(/length]\)}"/g, 'length])}');
|
||||
}
|
||||
return headers;
|
||||
};
|
||||
|
||||
const formatVideoData = (data, context, desktop) => {
|
||||
let video_details = {};
|
||||
let metadata = {};
|
||||
|
||||
if (desktop) {
|
||||
metadata.embed = data.microformat.playerMicroformatRenderer.embed;
|
||||
metadata.view_count = parseInt(data.videoDetails.viewCount);
|
||||
metadata.average_rating = data.videoDetails.averageRating;
|
||||
metadata.length_seconds = data.microformat.playerMicroformatRenderer.lengthSeconds;
|
||||
metadata.channel_id = data.videoDetails.channelId;
|
||||
metadata.channel_url = data.microformat.playerMicroformatRenderer.ownerProfileUrl;
|
||||
metadata.external_channel_id = data.microformat.playerMicroformatRenderer.externalChannelId;
|
||||
metadata.is_live_content = data.videoDetails.isLiveContent;
|
||||
metadata.is_family_safe = data.microformat.playerMicroformatRenderer.isFamilySafe;
|
||||
metadata.is_unlisted = data.microformat.playerMicroformatRenderer.isUnlisted;
|
||||
metadata.is_private = data.videoDetails.isPrivate;
|
||||
metadata.has_ypc_metadata = data.microformat.playerMicroformatRenderer.hasYpcMetadata;
|
||||
metadata.category = data.microformat.playerMicroformatRenderer.category;
|
||||
metadata.channel_name = data.microformat.playerMicroformatRenderer.ownerChannelName;
|
||||
metadata.publish_date = data.microformat.playerMicroformatRenderer.publishDate || 'N/A';
|
||||
metadata.upload_date = data.microformat.playerMicroformatRenderer.uploadDate || 'N/A';
|
||||
metadata.keywords = data.videoDetails.keywords || [];
|
||||
|
||||
video_details.title = data.videoDetails.title;
|
||||
video_details.description = data.videoDetails.shortDescription;
|
||||
video_details.thumbnail = data.videoDetails.thumbnail.thumbnails.slice(-1)[0];
|
||||
video_details.metadata = metadata;
|
||||
} else {
|
||||
metadata.embed = data[2].playerResponse.microformat.playerMicroformatRenderer.embed;
|
||||
metadata.likes = parseInt(data[3].response.contents.singleColumnWatchNextResults.results.results.contents[1].slimVideoMetadataSectionRenderer.contents[1].slimVideoActionBarRenderer.buttons[0].slimMetadataToggleButtonRenderer.button.toggleButtonRenderer.defaultText.accessibility.accessibilityData.label.replace(/\D/g, ''));
|
||||
metadata.dislikes = parseInt(data[3].response.contents.singleColumnWatchNextResults.results.results.contents[1].slimVideoMetadataSectionRenderer.contents[1].slimVideoActionBarRenderer.buttons[1].slimMetadataToggleButtonRenderer.button.toggleButtonRenderer.defaultText.accessibility.accessibilityData.label.replace(/\D/g, ''));
|
||||
metadata.view_count = parseInt(data[2].playerResponse.videoDetails.viewCount);
|
||||
metadata.average_rating = data[2].playerResponse.videoDetails.averageRating;
|
||||
metadata.length_seconds = data[2].playerResponse.microformat.playerMicroformatRenderer.lengthSeconds;
|
||||
metadata.channel_id = data[2].playerResponse.videoDetails.channelId;
|
||||
metadata.channel_url = data[2].playerResponse.microformat.playerMicroformatRenderer.ownerProfileUrl;
|
||||
metadata.external_channel_id = data[2].playerResponse.microformat.playerMicroformatRenderer.externalChannelId;
|
||||
metadata.is_live_content = data[2].playerResponse.videoDetails.isLiveContent;
|
||||
metadata.is_family_safe = data[2].playerResponse.microformat.playerMicroformatRenderer.isFamilySafe;
|
||||
metadata.is_unlisted = data[2].playerResponse.microformat.playerMicroformatRenderer.isUnlisted;
|
||||
metadata.is_private = data[2].playerResponse.videoDetails.isPrivate;
|
||||
metadata.has_ypc_metadata = data[2].playerResponse.microformat.playerMicroformatRenderer.hasYpcMetadata;
|
||||
metadata.category = data[2].playerResponse.microformat.playerMicroformatRenderer.category;
|
||||
metadata.channel_name = data[2].playerResponse.microformat.playerMicroformatRenderer.ownerChannelName;
|
||||
metadata.publish_date = data[2].playerResponse.microformat.playerMicroformatRenderer.publishDate;
|
||||
metadata.upload_date = data[2].playerResponse.microformat.playerMicroformatRenderer.uploadDate;
|
||||
metadata.keywords = data[2].playerResponse.videoDetails.keywords;
|
||||
|
||||
video_details.title = data[2].playerResponse.videoDetails.title;
|
||||
video_details.description = data[2].playerResponse.videoDetails.shortDescription;
|
||||
video_details.thumbnail = data[2].playerResponse.videoDetails.thumbnail.thumbnails.slice(-1)[0];
|
||||
|
||||
// actions
|
||||
video_details.like = like => {};
|
||||
video_details.dislike = dislike => {};
|
||||
video_details.removeLike = remove_like => {};
|
||||
video_details.subscribe = subscribe => {};
|
||||
video_details.unsubscribe = unsubscribe => {};
|
||||
video_details.comment = comment => {};
|
||||
|
||||
// additional metadata
|
||||
video_details.metadata = metadata;
|
||||
}
|
||||
return video_details;
|
||||
};
|
||||
|
||||
const filters = (order) => {
|
||||
switch (order) {
|
||||
case 'any,any,relevance':
|
||||
return 'EgIQAQ%3D%3D';
|
||||
case 'hour,any,relevance':
|
||||
return 'EgIIAQ%3D%3D';
|
||||
case 'day,any,relevance':
|
||||
return 'EgQIAhAB';
|
||||
case 'week,any,relevance':
|
||||
return 'EgQIAxAB';
|
||||
case 'month,any,relevance':
|
||||
return 'EgQIBBAB';
|
||||
case 'year,any,relevance':
|
||||
return 'EgQIBRAB';
|
||||
case 'any,short,relevance':
|
||||
return 'EgQQARgB';
|
||||
case 'hour,short,relevance':
|
||||
return 'EgYIARABGAE%3D';
|
||||
case 'day,short,relevance':
|
||||
return 'EgYIAhABGAE%3D';
|
||||
case 'week,short,relevance':
|
||||
return 'EgYIAxABGAE%3D';
|
||||
case 'month,short,relevance':
|
||||
return 'EgYIBBABGAE%3D';
|
||||
case 'year,short,relevance':
|
||||
return 'EgYIBRABGAE%3D';
|
||||
case 'any,long,relevance':
|
||||
return 'EgQQARgC';
|
||||
case 'hour,long,relevance':
|
||||
return 'EgYIARABGAI%3D';
|
||||
case 'day,long,relevance':
|
||||
return 'EgYIAhABGAI%3D';
|
||||
case 'week,long,relevance':
|
||||
return 'EgYIAxABGAI%3D';
|
||||
case 'month,long,relevance':
|
||||
return 'EgYIBBABGAI%3D';
|
||||
case 'year,long,relevance':
|
||||
return 'EgYIBRABGAI%3D';
|
||||
case 'any,any,age':
|
||||
return 'CAISAhAB';
|
||||
case 'hour,any,age':
|
||||
return 'CAISBAgBEAE%3D';
|
||||
case 'day,any,age':
|
||||
return 'CAISBAgCEAE%3D';
|
||||
case 'week,any,age':
|
||||
return 'CAISBAgDEAE%3D';
|
||||
case 'month,any,age':
|
||||
return 'CAISBAgEEAE%3D';
|
||||
case 'year,any,age':
|
||||
return 'CAISBAgFEAE%3D';
|
||||
case 'any,short,age':
|
||||
return 'CAISBBABGAE%3D';
|
||||
case 'hour,short,age':
|
||||
return 'CAISBggBEAEYAQ%3D%3D';
|
||||
case 'day,short,age':
|
||||
return 'CAISBggCEAEYAQ%3D%3D';
|
||||
case 'week,short,age':
|
||||
return 'CAISBggDEAEYAQ%3D%3D';
|
||||
case 'month,short,age':
|
||||
return 'CAISBggEEAEYAQ%3D%3D';
|
||||
case 'year,short,age':
|
||||
return 'CAISBggFEAEYAQ%3D%3D';
|
||||
case 'any,long,age':
|
||||
return 'CAISBBABGAI%3D';
|
||||
case 'hour,long,age':
|
||||
return 'CAISBggBEAEYAg%3D%3D';
|
||||
case 'day,long,age':
|
||||
return 'CAISBggCEAEYAg%3D%3D';
|
||||
case 'week,long,age':
|
||||
return 'CAISBggDEAEYAg%3D%3D';
|
||||
case 'month,long,age':
|
||||
return 'CAISBggEEAEYAg%3D%3D';
|
||||
case 'year,long,age':
|
||||
return 'CAISBggFEAEYAg%3D%3D';
|
||||
case 'any,any,views':
|
||||
return 'CAMSAhAB';
|
||||
case 'hour,any,views':
|
||||
return 'CAMSBAgBEAE%3D';
|
||||
case 'day,any,views':
|
||||
return 'CAMSBAgCEAE%3D';
|
||||
case 'week,any,views':
|
||||
return 'CAMSBAgDEAE%3D';
|
||||
case 'month,any,views':
|
||||
return 'CAMSBAgEEAE%3D';
|
||||
case 'year,any,views':
|
||||
return 'CAMSBAgFEAE%3D';
|
||||
case 'any,short,views':
|
||||
return 'CAMSBBABGAE%3D';
|
||||
case 'hour,short,views':
|
||||
return 'CAMSBggBEAEYAQ%3D%3D';
|
||||
case 'day,short,views':
|
||||
return 'CAMSBggCEAEYAQ%3D%3D';
|
||||
case 'week,short,views':
|
||||
return 'CAMSBggDEAEYAQ%3D%3D';
|
||||
case 'month,short,views':
|
||||
return 'CAMSBggEEAEYAQ%3D%3D';
|
||||
case 'year,short,views':
|
||||
return 'CAMSBggFEAEYAQ%3D%3D';
|
||||
case 'any,long,views':
|
||||
return 'CAMSBBABGAI%3D';
|
||||
case 'hour,long,views':
|
||||
return 'CAMSBggBEAEYAg%3D%3D';
|
||||
case 'day,long,views':
|
||||
return 'CAMSBggCEAEYAg%3D%3D';
|
||||
case 'week,long,views':
|
||||
return 'CAMSBggDEAEYAg%3D%3D';
|
||||
case 'month,long,views':
|
||||
return 'CAMSBggEEAEYAg%3D%3D';
|
||||
case 'year,long,views':
|
||||
return 'CAMSBggFEAEYAg%3D%3D';
|
||||
case 'any,any,rating':
|
||||
return 'CAESAhAB';
|
||||
case 'hour,any,rating':
|
||||
return 'CAESBAgBEAE%3D';
|
||||
case 'day,any,rating':
|
||||
return 'CAESBAgCEAE%3D';
|
||||
case 'week,any,rating':
|
||||
return 'CAESBAgDEAE%3D';
|
||||
case 'month,any,rating':
|
||||
return 'CAESBAgEEAE%3D';
|
||||
case 'year,any,rating':
|
||||
return 'CAESBAgFEAE%3D';
|
||||
case 'any,short,rating':
|
||||
return 'CAESBBABGAE%3D';
|
||||
case 'hour,short,rating':
|
||||
return 'CAESBggBEAEYAQ%3D%3D';
|
||||
case 'day,short,rating':
|
||||
return 'CAESBggCEAEYAQ%3D%3D';
|
||||
case 'week,short,rating':
|
||||
return 'CAESBggDEAEYAQ%3D%3D';
|
||||
case 'month,short,rating':
|
||||
return 'CAESBggEEAEYAQ%3D%3D';
|
||||
case 'year,short,rating':
|
||||
return 'CAESBggFEAEYAQ%3D%3D';
|
||||
case 'any,long,rating':
|
||||
return 'CAESBBABGAI%3D';
|
||||
case 'hour,long,rating':
|
||||
return 'CAESBggBEAEYAg%3D%3D';
|
||||
case 'day,long,rating':
|
||||
return 'CAESBggCEAEYAg%3D%3D';
|
||||
case 'week,long,rating':
|
||||
return 'CAESBggDEAEYAg%3D%3D';
|
||||
case 'month,long,rating':
|
||||
return 'CAESBggEEAEYAg%3D%3D';
|
||||
case 'year,long,rating':
|
||||
return 'CAESBggFEAEYAg%3D%3D';
|
||||
default:
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { urls, oauth, oauth_reqopts, default_headers, innertube_request_opts, video_details_reqbody, stream_headers, formatVideoData, filters };
|
||||
};
|
||||
451
lib/Innertube.js
451
lib/Innertube.js
@@ -5,152 +5,288 @@ const Stream = require('stream');
|
||||
const OAuth = require('./OAuth');
|
||||
const Utils = require('./Utils');
|
||||
const Player = require('./Player');
|
||||
const Parser = require('./Parser');
|
||||
const NToken = require('./NToken');
|
||||
const Actions = require('./Actions');
|
||||
const Livechat = require('./Livechat');
|
||||
const Constants = require('./Constants');
|
||||
const SigDecipher = require('./SigDecipher');
|
||||
const SigDecipher = require('./Sig');
|
||||
const EventEmitter = require('events');
|
||||
const TimeToSeconds = require('time-to-seconds');
|
||||
const CancelToken = Axios.CancelToken;
|
||||
|
||||
class Innertube extends EventEmitter {
|
||||
constructor(cookie, sign_in) {
|
||||
super();
|
||||
class Innertube {
|
||||
constructor(cookie) {
|
||||
this.cookie = cookie || '';
|
||||
return this.init();
|
||||
this.retry_count = 0;
|
||||
return this.#init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
const response = await Axios.get(Constants.urls.YT_BASE_URL, Constants.default_headers(this)).catch((error) => error);
|
||||
if (response instanceof Error) throw new Error('Could not retrieve Innertube configuration data: ' + response.message);
|
||||
let innertube_data = JSON.parse('{' + Utils.getStringBetweenStrings(response.data, 'ytcfg.set({', '});') + '}');
|
||||
if (innertube_data.INNERTUBE_CONTEXT) {
|
||||
this.context = innertube_data.INNERTUBE_CONTEXT;
|
||||
this.key = innertube_data.INNERTUBE_API_KEY;
|
||||
this.id_token = innertube_data.ID_TOKEN;
|
||||
this.session_token = innertube_data.XSRF_TOKEN;
|
||||
this.player_url = innertube_data.PLAYER_JS_URL;
|
||||
this.logged_in = innertube_data.LOGGED_IN;
|
||||
this.sts = innertube_data.STS;
|
||||
this.context.client.hl = 'en';
|
||||
this.context.client.gl = 'US';
|
||||
async #init() {
|
||||
const response = await Axios.get(Constants.URLS.YT_BASE_URL, Constants.DEFAULT_HEADERS(this)).catch((error) => error);
|
||||
if (response instanceof Error) throw new Error(`Could not retrieve Innertube session: ${response.message}`);
|
||||
|
||||
this.player = new Player(this);
|
||||
await this.player.init();
|
||||
try {
|
||||
const data = JSON.parse(`{${Utils.getStringBetweenStrings(response.data, 'ytcfg.set({', '});')}}`);
|
||||
if (data.INNERTUBE_CONTEXT) {
|
||||
this.context = data.INNERTUBE_CONTEXT;
|
||||
this.key = data.INNERTUBE_API_KEY;
|
||||
this.id_token = data.ID_TOKEN;
|
||||
this.session_token = data.XSRF_TOKEN;
|
||||
this.player_url = data.PLAYER_JS_URL;
|
||||
this.logged_in = data.LOGGED_IN;
|
||||
this.sts = data.STS;
|
||||
this.context.client.hl = 'en';
|
||||
this.context.client.gl = 'US';
|
||||
|
||||
if (this.logged_in && this.cookie.length > 1) {
|
||||
this.auth_apisid = Utils.getStringBetweenStrings(this.cookie, 'PAPISID=', ';');
|
||||
this.auth_apisid = Utils.generateSidAuth(this.auth_apisid);
|
||||
this.ev = new EventEmitter();
|
||||
|
||||
this.player = new Player(this);
|
||||
await this.player.init();
|
||||
|
||||
if (this.logged_in && this.cookie.length > 1) {
|
||||
this.auth_apisid = Utils.getStringBetweenStrings(this.cookie, 'PAPISID=', ';');
|
||||
this.auth_apisid = Utils.generateSidAuth(this.auth_apisid);
|
||||
}
|
||||
} else {
|
||||
throw new Error('Could not retrieve Innertube session due to unknown reasons');
|
||||
}
|
||||
|
||||
this.initialized = true;
|
||||
} else {
|
||||
this.initialized = false;
|
||||
} catch (err) {
|
||||
this.retry_count += 1;
|
||||
if (this.retry_count >= 10) throw new Error(`Could not retrieve Innertube session: ${err.message}`);
|
||||
return this.#init();
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
signIn(credentials = {}) {
|
||||
/**
|
||||
* Signs-in to a google account.
|
||||
*
|
||||
* @param {object} auth_info { refresh_token: string, access_token: string, expires: string }
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
signIn(auth_info = {}) {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const oauth = new OAuth(credentials);
|
||||
if (credentials.access_token && credentials.refresh_token) {
|
||||
let token_validity = await oauth.checkTokenValidity(credentials.access_token, this);
|
||||
if (token_validity === 'VALID') {
|
||||
this.access_token = credentials.access_token;
|
||||
this.refresh_token = credentials.refresh_token;
|
||||
this.logged_in = true;
|
||||
resolve();
|
||||
} else {
|
||||
oauth.refreshAccessToken(credentials.refresh_token);
|
||||
oauth.on('refresh-token', (data) => {
|
||||
this.access_token = data.access_token;
|
||||
this.refresh_token = credentials.refresh_token;
|
||||
this.logged_in = true;
|
||||
this.emit('update-credentials', {
|
||||
access_token: data.access_token,
|
||||
refresh_token: credentials.refresh_token,
|
||||
status: data.status
|
||||
});
|
||||
resolve();
|
||||
});
|
||||
const oauth = new OAuth(auth_info);
|
||||
if (auth_info.access_token) {
|
||||
if (!oauth.isTokenValid()) {
|
||||
const tokens = await oauth.refreshAccessToken();
|
||||
auth_info.refresh_token = tokens.credentials.refresh_token;
|
||||
auth_info.access_token = tokens.credentials.access_token;
|
||||
this.ev.emit('update-credentials', { credentials: tokens.credentials, status: tokens.status });
|
||||
}
|
||||
|
||||
this.access_token = auth_info.access_token;
|
||||
this.refresh_token = auth_info.refresh_token;
|
||||
this.logged_in = true;
|
||||
|
||||
resolve();
|
||||
} else {
|
||||
oauth.on('auth', (data) => {
|
||||
if (data.status === 'SUCCESS') {
|
||||
this.emit('auth', data);
|
||||
this.access_token = data.access_token;
|
||||
this.refresh_token = data.refresh_token;
|
||||
this.ev.emit('auth', { credentials: data.credentials, status: data.status });
|
||||
this.access_token = data.credentials.access_token;
|
||||
this.refresh_token = data.credentials.refresh_token;
|
||||
this.logged_in = true;
|
||||
resolve();
|
||||
} else {
|
||||
this.emit('auth', data);
|
||||
this.ev.emit('auth', data);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async search(query, options = { period: 'any', order: 'relevance', duration: 'any' }) {
|
||||
if (!this.initialized) throw new Error('Missing Innertube data.');
|
||||
/**
|
||||
* Searches on YouTube.
|
||||
*
|
||||
* @param {string} query Search query.
|
||||
* @param {object} options { client: YOUTUBE | YTMUSIC, period: any | hour | day | week | month | year , order: relevance | rating | age | views, duration: any | short | long }
|
||||
* @returns {Promise<object>} Search results.
|
||||
*/
|
||||
async search(query, options = { client: 'YOUTUBE', period: 'any', order: 'relevance', duration: 'any' }) {
|
||||
const response = await Actions.search(this, options.client, { query, options });
|
||||
if (!response.success) throw new Error(`Could not search on YouTube: ${response.message}`);
|
||||
|
||||
const yt_response = await Axios.post(`${Constants.urls.YT_BASE_URL}/youtubei/v1/search${this.logged_in && this.cookie.length < 1 ? '' : `?key=${this.key}`}`, JSON.stringify({ context: this.context, params: Constants.filters(options.period + ',' + options.duration + ',' + options.order), query }), Constants.innertube_request_opts({ session: this })).catch((error) => error);
|
||||
if (yt_response instanceof Error) throw new Error('Could not search on YouTube: ' + yt_response.message);
|
||||
const refined_data = new Parser(this, response.data, {
|
||||
client: options.client,
|
||||
data_type: 'SEARCH',
|
||||
query
|
||||
}).parse();
|
||||
|
||||
let content = yt_response.data.contents.twoColumnSearchResultsRenderer.primaryContents.sectionListRenderer.contents[0].itemSectionRenderer.contents;
|
||||
let search_response = {};
|
||||
return refined_data;
|
||||
}
|
||||
|
||||
search_response.search_metadata = {};
|
||||
search_response.search_metadata.query = content[0].showingResultsForRenderer ? content[0].showingResultsForRenderer.originalQuery.simpleText : query;
|
||||
search_response.search_metadata.corrected_query = content[0].showingResultsForRenderer ? content[0].showingResultsForRenderer.correctedQueryEndpoint.searchEndpoint.query : query;
|
||||
search_response.search_metadata.estimated_results = parseInt(yt_response.data.estimatedResults);
|
||||
search_response.videos = content.map((data) => {
|
||||
if (!data.videoRenderer) return;
|
||||
let video = data.videoRenderer;
|
||||
return {
|
||||
title: video.title.runs[0].text,
|
||||
description: video.detailedMetadataSnippets ? video.detailedMetadataSnippets[0].snippetText.runs.map((item) => item.text).join('') : 'N/A',
|
||||
author: video.ownerText.runs[0].text,
|
||||
id: video.videoId,
|
||||
url: 'https://youtu.be/' + video.videoId,
|
||||
channel_url: Constants.urls.YT_BASE_URL + video.ownerText.runs[0].navigationEndpoint.commandMetadata.webCommandMetadata.url,
|
||||
/**
|
||||
* Gets details for a video.
|
||||
*
|
||||
* @param {string} id The id of the video.
|
||||
*/
|
||||
async getDetails(id) {
|
||||
if (!id) throw new Error('You must provide a video id');
|
||||
|
||||
const data = await Actions.getVideoInfo(this, { id, is_desktop: false });
|
||||
const refined_data = new Parser(this, data, { client: 'YOUTUBE', data_type: 'VIDEO_INFO', desktop_v: false }).parse();
|
||||
|
||||
if (refined_data.metadata.is_live_content) {
|
||||
const data_continuation = await Actions.getContinuation(this, { video_id: id });
|
||||
if (data_continuation.data.contents.twoColumnWatchNextResults.conversationBar) {
|
||||
refined_data.getLivechat = () => new Livechat(this, data_continuation.data.contents.twoColumnWatchNextResults.conversationBar.liveChatRenderer.continuations[0].reloadContinuationData.continuation, refined_data.metadata.channel_id, id);
|
||||
} else {
|
||||
refined_data.getLivechat = () => { };
|
||||
}
|
||||
} else {
|
||||
refined_data.getLivechat = () => { };
|
||||
}
|
||||
|
||||
refined_data.like = () => Actions.engage(this, 'like/like', { video_id: id });
|
||||
refined_data.dislike = () => Actions.engage(this, 'like/dislike', { video_id: id });
|
||||
refined_data.removeLike = () => Actions.engage(this, 'like/removelike', { video_id: id });
|
||||
refined_data.subscribe = () => Actions.engage(this, 'subscription/subscribe', { video_id: id, channel_id: refined_data.metadata.channel_id });
|
||||
refined_data.unsubscribe = () => Actions.engage(this, 'subscription/unsubscribe', { video_id: id, channel_id: refined_data.metadata.channel_id });
|
||||
refined_data.comment = text => Actions.engage(this, 'comment/create_comment', { video_id: id, text });
|
||||
refined_data.getComments = () => this.getComments(id);
|
||||
refined_data.setNotificationPref = pref => Actions.notifications(this, 'modify_channel_preference', { channel_id: refined_data.metadata.channel_id, pref: pref || 'NONE' });
|
||||
|
||||
return refined_data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the comments section of a video.
|
||||
*
|
||||
* @param {string} video_id The id of the video.
|
||||
* @param {string} token Continuation token (optional).
|
||||
*/
|
||||
async getComments(video_id, token) {
|
||||
let comment_section_token;
|
||||
|
||||
if (!token) {
|
||||
const data_continuation = await Actions.getContinuation(this, { video_id });
|
||||
const item_section_renderer = data_continuation.data.contents.twoColumnWatchNextResults.results.results.contents.find((item) => item.itemSectionRenderer);
|
||||
comment_section_token = item_section_renderer.itemSectionRenderer.contents[0].continuationItemRenderer.continuationEndpoint.continuationCommand.token;
|
||||
}
|
||||
|
||||
const response = await Actions.getContinuation(this, { continuation_token: comment_section_token || token });
|
||||
if (!response.success) throw new Error('Could not fetch comments section');
|
||||
|
||||
const comments_section = { comments: [] };
|
||||
!token && (comments_section.comment_count = response.data.onResponseReceivedEndpoints[0].reloadContinuationItemsCommand.continuationItems && response.data.onResponseReceivedEndpoints[0].reloadContinuationItemsCommand.continuationItems[0].commentsHeaderRenderer.countText.runs[0].text || 'N/A');
|
||||
|
||||
let continuation_token;
|
||||
!token && (continuation_token = response.data.onResponseReceivedEndpoints[1].reloadContinuationItemsCommand.continuationItems.find((item) => item.continuationItemRenderer).continuationItemRenderer.continuationEndpoint.continuationCommand.token) ||
|
||||
(continuation_token = response.data.onResponseReceivedEndpoints[0].appendContinuationItemsAction.continuationItems.find((item) => item.continuationItemRenderer).continuationItemRenderer.continuationEndpoint.continuationCommand.token);
|
||||
|
||||
comments_section.getContinuation = () => this.getComments(video_id, continuation_token);
|
||||
|
||||
let contents;
|
||||
!token && (contents = response.data.onResponseReceivedEndpoints[1].reloadContinuationItemsCommand.continuationItems) ||
|
||||
(contents = response.data.onResponseReceivedEndpoints[0].appendContinuationItemsAction.continuationItems);
|
||||
|
||||
contents.forEach((thread) => {
|
||||
if (!thread.commentThreadRenderer) return;
|
||||
const comment = {
|
||||
text: thread.commentThreadRenderer.comment.commentRenderer.contentText.runs.map((t) => t.text).join(' '),
|
||||
author: {
|
||||
name: thread.commentThreadRenderer.comment.commentRenderer.authorText.simpleText,
|
||||
thumbnail: thread.commentThreadRenderer.comment.commentRenderer.authorThumbnail.thumbnails,
|
||||
channel_id: thread.commentThreadRenderer.comment.commentRenderer.authorEndpoint.browseEndpoint.browseId
|
||||
},
|
||||
metadata: {
|
||||
view_count: video.viewCountText ? video.viewCountText.simpleText : 'N/A',
|
||||
short_view_count_text: {
|
||||
simple_text: video.shortViewCountText ? video.shortViewCountText.simpleText : 'N/A',
|
||||
accessibility_label: video.shortViewCountText ? (video.shortViewCountText.accessibility ? video.shortViewCountText.accessibility.accessibilityData.label : 'N/A') : 'N/A',
|
||||
},
|
||||
thumbnails: video.thumbnail.thumbnails,
|
||||
duration: {
|
||||
seconds: TimeToSeconds(video.lengthText ? video.lengthText.simpleText : '0'),
|
||||
simple_text: video.lengthText ? video.lengthText.simpleText : 'N/A',
|
||||
accessibility_label: video.lengthText ? video.lengthText.accessibility.accessibilityData.label : 'N/A'
|
||||
},
|
||||
published: video.publishedTimeText ? video.publishedTimeText.simpleText : 'N/A',
|
||||
badges: video.badges ? video.badges.map((item) => item.metadataBadgeRenderer.label) : 'N/A',
|
||||
owner_badges: video.ownerBadges ? video.ownerBadges.map((item) => item.metadataBadgeRenderer.tooltip) : 'N/A '
|
||||
published: thread.commentThreadRenderer.comment.commentRenderer.publishedTimeText.runs[0].text,
|
||||
is_liked: thread.commentThreadRenderer.comment.commentRenderer.isLiked,
|
||||
is_channel_owner: thread.commentThreadRenderer.comment.commentRenderer.authorIsChannelOwner,
|
||||
like_count: thread.commentThreadRenderer.comment.commentRenderer.voteCount && thread.commentThreadRenderer.comment.commentRenderer.voteCount.simpleText || 'N/A',
|
||||
reply_count: thread.commentThreadRenderer.comment.commentRenderer.replyCount || 0,
|
||||
id: thread.commentThreadRenderer.comment.commentRenderer.commentId,
|
||||
}
|
||||
};
|
||||
}).filter((video_block) => video_block !== undefined);
|
||||
return search_response;
|
||||
comments_section.comments.push(comment);
|
||||
});
|
||||
|
||||
return comments_section;
|
||||
}
|
||||
|
||||
async getDetails(id) {
|
||||
const data = await this.requestVideoInfo(id, false);
|
||||
const video_data = Constants.formatVideoData(data, this, false);
|
||||
/**
|
||||
* Returns YouTube's home feed.
|
||||
* @returns {Promise<object>} home feed.
|
||||
*/
|
||||
async getHomeFeed() {
|
||||
const response = await Actions.browse(this, 'home_feed');
|
||||
if (!response.success) throw new Error('Could not get home feed');
|
||||
|
||||
video_data.like = like => Actions.likeVideo(this, id);
|
||||
video_data.dislike = dislike => Actions.dislikeVideo(this, id);
|
||||
video_data.removeLike = remove_like => Actions.removeLike(this, id);
|
||||
video_data.subscribe = subscribe => Actions.subscribe(this, id, video_data.metadata.channel_id);
|
||||
video_data.unsubscribe = unsubscribe => Actions.unsubscribe(this, id, video_data.metadata.channel_id);
|
||||
video_data.comment = comment => Actions.commentVideo(this, id, comment);
|
||||
const contents = response.data.contents.twoColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.richGridRenderer.contents;
|
||||
|
||||
return video_data;
|
||||
return contents.map((item) => {
|
||||
const content = item.richItemRenderer && item.richItemRenderer.content.videoRenderer &&
|
||||
item.richItemRenderer.content || undefined;
|
||||
|
||||
if (content) return {
|
||||
id: content.videoRenderer.videoId,
|
||||
title: content.videoRenderer.title.runs.map((run) => run.text).join(' '),
|
||||
channel: content.videoRenderer.shortBylineText && content.videoRenderer.shortBylineText.runs[0].text || 'N/A',
|
||||
metadata: {
|
||||
view_count: content.videoRenderer.viewCountText && content.videoRenderer.viewCountText.simpleText || 'N/A',
|
||||
thumbnail: content.videoRenderer.thumbnail && content.videoRenderer.thumbnail.thumbnails.slice(-1)[0] || [],
|
||||
moving_thumbnail: content.videoRenderer.richThumbnail && content.videoRenderer.richThumbnail.movingThumbnailRenderer.movingThumbnailDetails.thumbnails[0] || [],
|
||||
published: content.videoRenderer.publishedTimeText && content.videoRenderer.publishedTimeText.simpleText || 'N/A',
|
||||
badges: content.videoRenderer.badges && content.videoRenderer.badges.map((badge) => badge.metadataBadgeRenderer.label) || 'N/A',
|
||||
owner_badges: content.videoRenderer.ownerBadges && content.videoRenderer.ownerBadges.map((badge) => badge.metadataBadgeRenderer.tooltip) || 'N/A'
|
||||
}
|
||||
}
|
||||
}).filter((video) => video);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns your subscription feed.
|
||||
* @returns {Promise<object>} subs feed.
|
||||
*/
|
||||
async getSubscriptionsFeed() {
|
||||
const response = await Actions.browse(this, 'subscriptions_feed');
|
||||
if (!response.success) throw new Error('Could not get subscriptions feed');
|
||||
|
||||
const contents = response.data.contents.twoColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents;
|
||||
const subscriptions_feed = {};
|
||||
|
||||
contents.forEach((section) => {
|
||||
if (!section.itemSectionRenderer) return;
|
||||
|
||||
const section_contents = section.itemSectionRenderer.contents[0];
|
||||
const section_items = section_contents.shelfRenderer.content.gridRenderer.items;
|
||||
|
||||
const key = section_contents.shelfRenderer.title.runs[0].text;
|
||||
subscriptions_feed[key.toLowerCase().replace(/ +/g, '_')] = [];
|
||||
|
||||
section_items.forEach((item) => {
|
||||
const content = {
|
||||
id: item.gridVideoRenderer.videoId,
|
||||
title: item.gridVideoRenderer.title.runs.map((run) => run.text).join(' '),
|
||||
channel: item.gridVideoRenderer.shortBylineText && item.gridVideoRenderer.shortBylineText.runs[0].text || 'N/A',
|
||||
metadata: {
|
||||
view_count: item.gridVideoRenderer.viewCountText && item.gridVideoRenderer.viewCountText.simpleText || 'N/A',
|
||||
thumbnail: item.gridVideoRenderer.thumbnail && item.gridVideoRenderer.thumbnail.thumbnails.slice(-1)[0] || [],
|
||||
published: item.gridVideoRenderer.publishedTimeText && item.gridVideoRenderer.publishedTimeText.simpleText || 'N/A',
|
||||
badges: item.gridVideoRenderer.badges && item.gridVideoRenderer.badges.map((badge) => badge.metadataBadgeRenderer.label) || 'N/A',
|
||||
owner_badges: item.gridVideoRenderer.ownerBadges && item.gridVideoRenderer.ownerBadges.map((badge) => badge.metadataBadgeRenderer.tooltip) || 'N/A'
|
||||
}
|
||||
};
|
||||
|
||||
subscriptions_feed[key.toLowerCase().replace(/ +/g, '_')].push(content);
|
||||
});
|
||||
});
|
||||
|
||||
return subscriptions_feed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns your notifications.
|
||||
* @returns {Promise<object>} notifications.
|
||||
*/
|
||||
async getNotifications() {
|
||||
const response = await Actions.getNotifications(this);
|
||||
const contents = response.data.actions[0].openPopupAction.popup.multiPageMenuRenderer.sections[0].multiPageMenuNotificationSectionRenderer.items;
|
||||
return contents.map((notification) => {
|
||||
const response = await Actions.notifications(this, 'get_notification_menu');
|
||||
if (!response.success) throw new Error('Could not fetch notifications');
|
||||
|
||||
const contents = response.data.actions[0].openPopupAction.popup.multiPageMenuRenderer.sections[0];
|
||||
if (!contents.multiPageMenuNotificationSectionRenderer) return { error: 'You don\'t have any notification.' };
|
||||
return contents.multiPageMenuNotificationSectionRenderer.items.map((notification) => {
|
||||
if (!notification.notificationRenderer) return;
|
||||
notification = notification.notificationRenderer;
|
||||
return {
|
||||
@@ -159,25 +295,32 @@ class Innertube extends EventEmitter {
|
||||
channel_name: notification.contextualMenu.menuRenderer.items[1].menuServiceItemRenderer.text.runs[1].text,
|
||||
channel_thumbnail: notification.thumbnail.thumbnails[0],
|
||||
video_thumbnail: notification.videoThumbnail.thumbnails[0],
|
||||
video_url: 'https://youtu.be/' + notification.navigationEndpoint.watchEndpoint.videoId,
|
||||
video_url: `https://youtu.be/${notification.navigationEndpoint.watchEndpoint.videoId}`,
|
||||
read: notification.read,
|
||||
notification_id: notification.notificationId,
|
||||
};
|
||||
}).filter((notification_block) => notification_block);
|
||||
}
|
||||
|
||||
async requestVideoInfo(id, desktop) {
|
||||
let response;
|
||||
if (!desktop) {
|
||||
response = await Axios.get(Constants.urls.YT_WATCH_PAGE + '?v=' + id + 't=8s&pbj=1&bpctr=9999999999&has_verified=1&', Constants.innertube_request_opts({ session: this, id, desktop: false })).catch((error) => error);
|
||||
} else {
|
||||
response = await Axios.post(`${Constants.urls.YT_BASE_URL}/youtubei/v1/player${this.logged_in && this.cookie.length < 1 ? '' : `?key=${this.key}`}`, JSON.stringify(Constants.video_details_reqbody(id, this.sts, this.context)), Constants.innertube_request_opts({ session: this, id, desktop: true })).catch((error) => error);
|
||||
}
|
||||
if (response instanceof Error) throw new Error('Could not retrieve watch page info: ' + response.message);
|
||||
return response.data;
|
||||
/**
|
||||
* Returns the amount of notifications you haven't seen.
|
||||
* @returns {Promise<number>} unseen notifications count.
|
||||
*/
|
||||
async getUnseenNotificationsCount() {
|
||||
const response = await Actions.notifications(this, 'get_unseen_count');
|
||||
if (!response.success) throw new Error('Could not get unseen notifications count');
|
||||
return response.data.unseenCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads a video from YouTube.
|
||||
*
|
||||
* @param {string} id The id of the video.
|
||||
* @param {object} options Download options: { quality?: string, type?: string, format?: string }
|
||||
*/
|
||||
download(id, options = {}) {
|
||||
if (!id) throw new Error('Missing video id');
|
||||
|
||||
options.quality = options.quality || '360p';
|
||||
options.type = options.type || 'videoandaudio';
|
||||
options.format = options.format || 'mp4';
|
||||
@@ -186,7 +329,7 @@ class Innertube extends EventEmitter {
|
||||
let cancelled = false;
|
||||
|
||||
const stream = new Stream.PassThrough();
|
||||
this.requestVideoInfo(id, true).then(async (video_data) => {
|
||||
Actions.getVideoInfo(this, { id, is_desktop: true }).then(async (video_data) => {
|
||||
let formats = [];
|
||||
|
||||
if (video_data.playabilityStatus.status === 'LOGIN_REQUIRED') return stream.emit('error', { message: 'You must login to download age-restricted videos.', error_type: 'LOGIN_REQUIRED', playability_status: video_data.playabilityStatus.status });
|
||||
@@ -197,12 +340,12 @@ class Innertube extends EventEmitter {
|
||||
format.url = format.url || format.signatureCipher || format.cipher;
|
||||
|
||||
if (format.signatureCipher || format.cipher) {
|
||||
format.url = new SigDecipher(format.url, this.context.client.clientVersion, this.player.sig_decipher_sc, this.player.encodeN).decipher();
|
||||
format.url = new SigDecipher(format.url, this.context.client.clientVersion, this.player).decipher();
|
||||
} else {
|
||||
const url_components = new URL(format.url);
|
||||
url_components.searchParams.set('cver', this.context.client.clientVersion);
|
||||
url_components.searchParams.set('ratebypass', 'yes');
|
||||
url_components.searchParams.set('n', this.player.encodeN(url_components.searchParams.get('n')));
|
||||
url_components.searchParams.set('n', new NToken(this.player.ntoken_sc).transform(url_components.searchParams.get('n')));
|
||||
format.url = url_components.toString();
|
||||
}
|
||||
|
||||
@@ -216,8 +359,6 @@ class Innertube extends EventEmitter {
|
||||
formats.hls_manifest_url = video_data.streamingData.hlsManifestUrl || undefined;
|
||||
formats.dash_manifest_url = video_data.streamingData.dashManifestUrl || undefined;
|
||||
|
||||
const video_details = Constants.formatVideoData(video_data, this, true);
|
||||
|
||||
let url;
|
||||
let bitrates;
|
||||
let filtered_streams;
|
||||
@@ -240,15 +381,10 @@ class Innertube extends EventEmitter {
|
||||
if (options.type != 'videoandaudio') {
|
||||
let streams;
|
||||
|
||||
if (options.type != 'audio') {
|
||||
streams = filtered_streams.filter((format) => format.mimeType.includes(options.format || 'mp4') && format.qualityLabel == options.quality);
|
||||
} else {
|
||||
streams = filtered_streams.filter((format) => format.mimeType.includes(options.format || 'mp4'));
|
||||
}
|
||||
options.type != 'audio' && (streams = filtered_streams.filter((format) => format.mimeType.includes(options.format || 'mp4') && format.qualityLabel == options.quality)) ||
|
||||
(streams = filtered_streams.filter((format) => format.mimeType.includes(options.format || 'mp4')));
|
||||
|
||||
if (streams == undefined || streams.length == 0) {
|
||||
streams = filtered_streams.filter((format) => format.quality == 'medium');
|
||||
}
|
||||
streams == undefined || streams.length == 0 && (streams = filtered_streams.filter((format) => format.quality == 'medium'));
|
||||
|
||||
bitrates = streams.map((format) => format.bitrate);
|
||||
url = streams.filter((format) => format.bitrate === Math.max(...bitrates))[0];
|
||||
@@ -256,13 +392,19 @@ class Innertube extends EventEmitter {
|
||||
|
||||
const selected_format = options.type == 'videoandaudio' ? filtered_streams[0] : url;
|
||||
if (!selected_format) {
|
||||
return stream.emit('error', { message: 'Could not find any suitable format.', type: 'FORMATS_UNAVAILABLE' });
|
||||
return stream.emit('error', { message: 'Could not find any suitable format.', type: 'FORMAT_UNAVAILABLE' });
|
||||
} else {
|
||||
stream.emit('info', { video_details, selected_format, formats });
|
||||
const refined_data = new Parser(this, video_data, { client: 'YOUTUBE', data_type: 'VIDEO_INFO', desktop_v: true }).parse();
|
||||
stream.emit('info', { video_details: refined_data, selected_format, formats });
|
||||
}
|
||||
|
||||
if (options.type == 'videoandaudio') {
|
||||
const response = await Axios.get(selected_format.url, { cancelToken: new CancelToken(function executor(c) { cancel = c; }), responseType: 'stream', reponseEncoding: 'binary', headers: Constants.stream_headers() }).catch((error) => error);
|
||||
if (options.type == 'videoandaudio' && !options.range) {
|
||||
const response = await Axios.get(selected_format.url, {
|
||||
responseType: 'stream',
|
||||
cancelToken: new CancelToken(function executor(c) { cancel = c; }),
|
||||
headers: Constants.STREAM_HEADERS
|
||||
}).catch((error) => error);
|
||||
|
||||
if (response instanceof Error) {
|
||||
stream.emit('error', { message: response.message, type: 'REQUEST_FAILED' });
|
||||
return stream;
|
||||
@@ -271,6 +413,7 @@ class Innertube extends EventEmitter {
|
||||
}
|
||||
|
||||
let downloaded_size = 0;
|
||||
|
||||
response.data.on('data', (chunk) => {
|
||||
downloaded_size += chunk.length;
|
||||
let size = (response.headers['content-length'] / 1024 / 1024).toFixed(2);
|
||||
@@ -279,25 +422,31 @@ class Innertube extends EventEmitter {
|
||||
});
|
||||
|
||||
response.data.on('error', (err) => {
|
||||
if (cancelled) {
|
||||
stream.emit('error', { message: 'The download was cancelled.', type: 'DOWNLOAD_CANCELLED' });
|
||||
} else {
|
||||
stream.emit('error', { message: err.message, type: 'DOWNLOAD_ABORTED' });
|
||||
}
|
||||
cancelled && stream.emit('error', { message: 'The download was cancelled.', type: 'DOWNLOAD_CANCELLED' })
|
||||
|| stream.emit('error', { message: err.message, type: 'DOWNLOAD_ABORTED' });
|
||||
});
|
||||
|
||||
response.data.pipe(stream, true);
|
||||
response.data.pipe(stream, { end: true });
|
||||
} else {
|
||||
const chunk_size = 1048576 * 10; // 10MB
|
||||
|
||||
let chunk_start = 0;
|
||||
let chunk_end = chunk_size;
|
||||
let chunk_start = (options.range && options.range.start || 0);
|
||||
let chunk_end = (options.range && options.range.end || chunk_size);
|
||||
let downloaded_size = 0;
|
||||
let must_end = false;
|
||||
|
||||
stream.emit('start');
|
||||
|
||||
const downloadChunk = async () => {
|
||||
const response = await Axios.get(selected_format.url, { cancelToken: new CancelToken(function executor(c) { cancel = c; }), responseType: 'stream', headers: Constants.stream_headers(`bytes=${chunk_start}-${chunk_end || ''}`) }).catch((error) => error);
|
||||
(chunk_end >= selected_format.contentLength || options.range) && (must_end = true);
|
||||
options.range && (selected_format.contentLength = options.range.end);
|
||||
|
||||
const response = await Axios.get(`${selected_format.url}&range=${chunk_start}-${chunk_end || ''}`, {
|
||||
responseType: 'stream',
|
||||
cancelToken: new CancelToken(function executor(c) { cancel = c; }),
|
||||
headers: Constants.STREAM_HEADERS
|
||||
}).catch((error) => error);
|
||||
|
||||
if (response instanceof Error) {
|
||||
stream.emit('error', { message: response.message, type: 'REQUEST_FAILED' });
|
||||
return stream;
|
||||
@@ -310,17 +459,6 @@ class Innertube extends EventEmitter {
|
||||
stream.emit('progress', { chunk_size: chunk.length, downloaded_size: (downloaded_size / 1024 / 1024).toFixed(2), percentage, size, raw_data: { chunk_size: chunk.length, downloaded: downloaded_size, size: response.headers['content-length'] } });
|
||||
});
|
||||
|
||||
response.data.on('end', () => {
|
||||
chunk_start = chunk_end + 1;
|
||||
chunk_end += chunk_size;
|
||||
|
||||
if (downloaded_size < selected_format.contentLength) {
|
||||
downloadChunk();
|
||||
} else {
|
||||
stream.emit('end');
|
||||
}
|
||||
});
|
||||
|
||||
response.data.on('error', (err) => {
|
||||
if (cancelled) {
|
||||
stream.emit('error', { message: 'The download was cancelled.', type: 'DOWNLOAD_CANCELLED' });
|
||||
@@ -329,7 +467,15 @@ class Innertube extends EventEmitter {
|
||||
}
|
||||
});
|
||||
|
||||
response.data.pipe(stream, { end: false });
|
||||
response.data.on('end', () => {
|
||||
if (!must_end && !options.range) {
|
||||
chunk_start = chunk_end + 1;
|
||||
chunk_end += chunk_size;
|
||||
downloadChunk();
|
||||
}
|
||||
});
|
||||
|
||||
response.data.pipe(stream, { end: must_end });
|
||||
};
|
||||
downloadChunk();
|
||||
}
|
||||
@@ -339,6 +485,7 @@ class Innertube extends EventEmitter {
|
||||
cancelled = true;
|
||||
cancel();
|
||||
};
|
||||
|
||||
return stream;
|
||||
}
|
||||
}
|
||||
|
||||
133
lib/Livechat.js
Normal file
133
lib/Livechat.js
Normal file
@@ -0,0 +1,133 @@
|
||||
'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);
|
||||
this.ctoken = token;
|
||||
this.session = session;
|
||||
this.video_id = video_id;
|
||||
this.channel_id = channel_id;
|
||||
|
||||
this.message_queue = [];
|
||||
this.id_cache = [];
|
||||
|
||||
this.poll_intervals_ms = 1000;
|
||||
this.running = true;
|
||||
|
||||
this.#poll();
|
||||
}
|
||||
|
||||
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;
|
||||
if (!message_content) return;
|
||||
|
||||
const message = {
|
||||
text: message_content.message.runs.map((item) => item.text).join(' '),
|
||||
author: {
|
||||
name: message_content.authorName && message_content.authorName.simpleText || 'N/',
|
||||
channel_id: message_content.authorExternalChannelId,
|
||||
profile_picture: message_content.authorPhoto.thumbnails
|
||||
},
|
||||
timestamp: message_content.timestampUsec,
|
||||
id: message_content.id
|
||||
};
|
||||
|
||||
this.message_queue.push(message);
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
const deleteMessage = async () => {
|
||||
const menu = await Actions.livechat(this.session, 'live_chat/get_item_context_menu', { params: { params: message.data.actions[0].addChatItemAction.item.liveChatTextMessageRenderer.contextMenuEndpoint.liveChatItemContextMenuEndpoint.params, pbj: 1 } });
|
||||
if (!menu.success) return menu;
|
||||
|
||||
const chat_item_menu = menu.data.liveChatItemContextMenuSupportedRenderers.menuRenderer.items[0];
|
||||
|
||||
const cmd = await Actions.livechat(this.session, 'live_chat/moderate', { cmd_params: chat_item_menu.menuServiceItemRenderer.serviceEndpoint.moderateLiveChatEndpoint.params });
|
||||
if (!cmd.success) return cmd;
|
||||
|
||||
return { success: true, status_code: cmd.status_code };
|
||||
};
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status_code: message.status_code,
|
||||
deleteMessage: deleteMessage,
|
||||
message_data: {
|
||||
text: message.data.actions[0].addChatItemAction.item.liveChatTextMessageRenderer.message.runs.map((item) => item.text).join(' '),
|
||||
author: {
|
||||
name: message.data.actions[0].addChatItemAction.item.liveChatTextMessageRenderer.authorName && message.data.actions[0].addChatItemAction.item.liveChatTextMessageRenderer.authorName.simpleText || 'N/',
|
||||
channel_id: message.data.actions[0].addChatItemAction.item.liveChatTextMessageRenderer.authorExternalChannelId,
|
||||
profile_picture: message.data.actions[0].addChatItemAction.item.liveChatTextMessageRenderer.authorPhoto.thumbnails
|
||||
},
|
||||
timestamp: message.data.actions[0].addChatItemAction.item.liveChatTextMessageRenderer.timestampUsec,
|
||||
id: message.data.actions[0].addChatItemAction.item.liveChatTextMessageRenderer.id
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async blockUser(msg_params) {
|
||||
/* TODO: Implement this */
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.running = false;
|
||||
clearTimeout(this.livechat_poller);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Livechat;
|
||||
137
lib/NToken.js
Normal file
137
lib/NToken.js
Normal file
@@ -0,0 +1,137 @@
|
||||
'use strict';
|
||||
|
||||
const Utils = require('./Utils');
|
||||
const Constants = require('./Constants');
|
||||
|
||||
class NToken {
|
||||
constructor(raw_code) {
|
||||
this.raw_code = raw_code;
|
||||
this.null_placeholder_regex = /c\[(.*?)\]=c/g;
|
||||
this.transformation_calls_regex = /c\[(.*?)\]\((.+?)\)/g;
|
||||
}
|
||||
|
||||
/**
|
||||
* Solves throttling challange by transforming the n token.
|
||||
*
|
||||
* @param {string} n token.
|
||||
* @returns {string} transformed token.
|
||||
*/
|
||||
transform(n) {
|
||||
let n_token = n.split('');
|
||||
|
||||
try {
|
||||
let transformations = this.#getTransformationData();
|
||||
transformations = transformations.map((el) => {
|
||||
if (el != null && typeof el != 'number') {
|
||||
const is_reverse_base64 = el.includes('case 65:');
|
||||
(({ // Identifies the transformation functions
|
||||
[Constants.FUNCS.PUSH]: () => el = (arr, i) => this.#push(arr, i),
|
||||
[Constants.FUNCS.SPLICE]: () => el = (arr, i) => this.#splice(arr, i),
|
||||
[Constants.FUNCS.SWAP0_1]: () => el = (arr, i) => this.#swap0(arr, i),
|
||||
[Constants.FUNCS.SWAP0_2]: () => el = (arr, i) => this.#swap0(arr, i),
|
||||
[Constants.FUNCS.ROTATE_1]: () => el = (arr, i) => this.#rotate(arr, i),
|
||||
[Constants.FUNCS.ROTATE_2]: () => el = (arr, i) => this.#rotate(arr, i),
|
||||
[Constants.FUNCS.REVERSE_1]: () => el = (arr) => this.#reverse(arr),
|
||||
[Constants.FUNCS.REVERSE_2]: () => el = (arr) => this.#reverse(arr),
|
||||
[Constants.FUNCS.BASE64_DIA]: () => el = () => this.#getBase64Dia(is_reverse_base64),
|
||||
[Constants.FUNCS.TRANSLATE_1]: () => el = (arr, token) => this.#translate1(arr, token, is_reverse_base64),
|
||||
[Constants.FUNCS.TRANSLATE_2]: () => el = (arr, token, base64_dic) => this.#translate2(arr, token, base64_dic)
|
||||
})[this.#getFunc(el)] || (() => el === 'b' && (el = n_token)))();
|
||||
}
|
||||
return el;
|
||||
});
|
||||
|
||||
// Fills the null placeholders with a copy of the transformations array
|
||||
const null_placeholder_positions = [...this.raw_code.matchAll(this.null_placeholder_regex)].map((item) => parseInt(item[1]));
|
||||
null_placeholder_positions.forEach((pos) => transformations[pos] = transformations);
|
||||
|
||||
// Parses and emulates calls to the functions of the transformations array
|
||||
const transformation_calls = [...Utils.getStringBetweenStrings(this.raw_code.replace(/\n/g, ''), 'try{', '}catch')
|
||||
.matchAll(this.transformation_calls_regex)].map((params) => ({ index: params[1], params: params[2] }));
|
||||
|
||||
transformation_calls.forEach((data) => {
|
||||
const param_index = data.params.split(',').map((param) => param.match(/c\[(.*?)\]/)[1]);
|
||||
const base64_dia = (param_index[2] && transformations[param_index[2]]());
|
||||
transformations[data.index](transformations[param_index[0]], transformations[param_index[1]], base64_dia);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`Could not transform n-token (${n}), download may be throttled:`, err)
|
||||
return n;
|
||||
}
|
||||
return n_token.join('');
|
||||
}
|
||||
|
||||
#getFunc(el) {
|
||||
return el.match(Constants.FUNCS_REGEX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes the n-transform data, refines it, and then returns a readable json array.
|
||||
* @returns {object}
|
||||
*/
|
||||
#getTransformationData() {
|
||||
const data = `[${Utils.getStringBetweenStrings(this.raw_code.replace(/\n/g, ''), 'c=[', '];c')}]`;
|
||||
return JSON.parse(Constants.formatNTransformData(data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a base64 alphabet and uses it as a lookup table to modify n.
|
||||
*/
|
||||
#translate1(arr, token, is_reverse_base64) {
|
||||
const characters = is_reverse_base64 && Constants.BASE64_DIALECT.REVERSE || Constants.BASE64_DIALECT.NORMAL;
|
||||
arr.forEach(function (char, index, loc) {
|
||||
this.push(loc[index] = characters[(characters.indexOf(char) - characters.indexOf(this[index]) + 64) % characters.length]);
|
||||
}, token.split(''));
|
||||
}
|
||||
|
||||
#translate2(arr, token, characters) {
|
||||
let chars_length = characters.length;
|
||||
arr.forEach(function (char, index, loc) {
|
||||
this.push(loc[index] = characters[(characters.indexOf(char) - characters.indexOf(this[index]) + index + chars_length--) % characters.length]);
|
||||
}, token.split(''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the requested base64 dialect, currently this is only used by 'translate2'.
|
||||
*/
|
||||
#getBase64Dia(is_reverse_base64) {
|
||||
const characters = is_reverse_base64 && Constants.BASE64_DIALECT.REVERSE || Constants.BASE64_DIALECT.NORMAL;
|
||||
return characters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Swaps the first element with the one at the given index.
|
||||
*/
|
||||
#swap0(arr, index) {
|
||||
const old_elem = arr[0];
|
||||
index = (index % arr.length + arr.length) % arr.length;
|
||||
arr[0] = arr[index];
|
||||
arr[index] = old_elem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotates elements of the array.
|
||||
*/
|
||||
#rotate(arr, index) {
|
||||
index = (index % arr.length + arr.length) % arr.length;
|
||||
arr.splice(-index).reverse().forEach((el) => arr.unshift(el));
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes one element at the given index.
|
||||
*/
|
||||
#splice(arr, index) {
|
||||
index = (index % arr.length + arr.length) % arr.length;
|
||||
arr.splice(index, 1);
|
||||
}
|
||||
|
||||
#reverse(arr) {
|
||||
arr.reverse();
|
||||
}
|
||||
|
||||
#push(arr, item) {
|
||||
arr.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = NToken;
|
||||
249
lib/OAuth.js
249
lib/OAuth.js
@@ -1,64 +1,97 @@
|
||||
'use strict';
|
||||
|
||||
const Axios = require('axios');
|
||||
const Utils = require('./Utils');
|
||||
const Constants = require('./Constants');
|
||||
const EventEmitter = require('events');
|
||||
const Uuid = require("uuid");
|
||||
const Uuid = require('uuid');
|
||||
|
||||
class OAuth extends EventEmitter {
|
||||
constructor (creds) {
|
||||
constructor(auth_info) {
|
||||
super();
|
||||
// Default interval between requests when waiting for authorization.
|
||||
this.auth_info = auth_info;
|
||||
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.oauth_code_url = `${Constants.URLS.YT_BASE_URL}/o/oauth2/device/code`;
|
||||
this.oauth_token_url = `${Constants.URLS.YT_BASE_URL}/o/oauth2/token`;
|
||||
this.guide_url = `${Constants.URLS.YT_BASE_URL}/youtubei/v1/guide`;
|
||||
|
||||
this.model_name = Constants.OAUTH.MODEL_NAME;
|
||||
this.grant_type = Constants.OAUTH.GRANT_TYPE;
|
||||
this.scope = Constants.OAUTH.SCOPE;
|
||||
|
||||
this.auth_script_regex = /<script id=\"base-js\" src=\"(.*?)\" nonce=".*?"><\/script>/;
|
||||
|
||||
// Used to find the credentials inside the script.
|
||||
this.identity_regex = /var .+?=\"(?<id>.+?)\",[.|\s].?=\"(?<secret>.+?)\"/;
|
||||
|
||||
if (creds.access_token != undefined && creds.refresh_token != undefined) return;
|
||||
this.requestAuthCode();
|
||||
this.identity_regex = /.+?={};var .+?={clientId:\"(?<id>.+?)\",si:\"(?<secret>.+?)\"},/;
|
||||
|
||||
if (auth_info.access_token) return;
|
||||
this.#requestAuthCode();
|
||||
}
|
||||
|
||||
async waitForAuth(device_code) {
|
||||
|
||||
/**
|
||||
* Asks the OAuth server for an auth code.
|
||||
*/
|
||||
async #requestAuthCode() {
|
||||
const identity = await this.#getClientIdentity();
|
||||
|
||||
this.client_id = identity.id;
|
||||
this.client_secret = identity.secret;
|
||||
|
||||
const data = {
|
||||
client_id: this.client_id,
|
||||
scope: this.scope,
|
||||
device_id: Uuid.v4(),
|
||||
model_name: this.model_name
|
||||
};
|
||||
|
||||
const response = await Axios.post(this.oauth_code_url, JSON.stringify(data), Constants.OAUTH.HEADERS).catch((error) => error);
|
||||
|
||||
if (response instanceof Error)
|
||||
return this.emit('auth', {
|
||||
error: 'Could not get auth code.',
|
||||
status: 'FAILED'
|
||||
});
|
||||
|
||||
this.emit('auth', {
|
||||
code: response.data.user_code,
|
||||
status: 'AUTHORIZATION_PENDING',
|
||||
expires_in: response.data.expires_in,
|
||||
verification_url: response.data.verification_url
|
||||
});
|
||||
|
||||
this.refresh_interval = response.data.interval;
|
||||
|
||||
this.#waitForAuth(response.data.device_code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for sign-in authorization.
|
||||
*
|
||||
* @param {string} device_code Client's device code.
|
||||
*/
|
||||
#waitForAuth(device_code) {
|
||||
const data = {
|
||||
client_id: this.client_id,
|
||||
client_secret: this.client_secret,
|
||||
code: device_code,
|
||||
grant_type: this.grant_type
|
||||
};
|
||||
|
||||
|
||||
setTimeout(async () => {
|
||||
const response = await Axios.post(this.oauth_token_url, JSON.stringify(data), Constants.oauth_reqopts).catch((error) => error);
|
||||
if (response instanceof Error)
|
||||
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.',
|
||||
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);
|
||||
this.#waitForAuth(device_code);
|
||||
break;
|
||||
case 'access_denied':
|
||||
this.emit('auth', {
|
||||
error: 'The access was denied.',
|
||||
error: 'Access was denied.',
|
||||
status: 'ACCESS_DENIED'
|
||||
});
|
||||
break;
|
||||
@@ -67,104 +100,98 @@ class OAuth extends EventEmitter {
|
||||
error: 'The device code has expired, requesting a new one.',
|
||||
status: 'DEVICE_CODE_EXPIRED'
|
||||
});
|
||||
this.requestAuthCode();
|
||||
this.#requestAuthCode();
|
||||
break;
|
||||
default:
|
||||
}
|
||||
} else {
|
||||
const expiration_date = new Date(new Date().getTime() + response.data.expires_in * 1000);
|
||||
|
||||
this.emit('auth', {
|
||||
access_token: response.data.access_token,
|
||||
refresh_token: response.data.refresh_token,
|
||||
credentials: {
|
||||
access_token: response.data.access_token,
|
||||
refresh_token: response.data.refresh_token,
|
||||
expires: expiration_date,
|
||||
},
|
||||
token_type: response.data.token_type,
|
||||
expires: response.data.expires_in,
|
||||
scope: response.data.scope,
|
||||
status: 'SUCCESS'
|
||||
});
|
||||
}
|
||||
}, 1000 * this.refresh_interval);
|
||||
}
|
||||
|
||||
async requestAuthCode() {
|
||||
const identity = await this.getClientIdentity();
|
||||
this.client_id = identity.id;
|
||||
this.client_secret = identity.secret;
|
||||
|
||||
const data = {
|
||||
client_id: this.client_id,
|
||||
scope: this.scope,
|
||||
device_id : Uuid.v4(),
|
||||
model_name: this.model_name
|
||||
};
|
||||
|
||||
const response = await Axios.post(this.oauth_code_url, JSON.stringify(data), Constants.oauth_reqopts).catch((error) => error);
|
||||
if (response instanceof Error)
|
||||
return this.emit('auth', {
|
||||
error: 'Could not get auth code.',
|
||||
status: 'FAILED'
|
||||
});
|
||||
|
||||
this.emit('auth', {
|
||||
code: response.data.user_code,
|
||||
status: 'AUTHORIZATION_PENDING',
|
||||
expires_in: response.data.expires_in,
|
||||
verification_url: response.data.verification_url
|
||||
});
|
||||
|
||||
this.refresh_interval = response.data.interval;
|
||||
|
||||
// Keeps requesting at a specific rate until the authorization is granted or denied.
|
||||
this.waitForAuth(response.data.device_code);
|
||||
}
|
||||
|
||||
async getClientIdentity() {
|
||||
// The first request is made to get the auth script url, hard-coding it isn't viable as it changes overtime.
|
||||
const yttv_response = await Axios.get(`${Constants.urls.YT_BASE_URL}/tv`, Constants.oauth_reqopts).catch((error) => error);
|
||||
if (yttv_response instanceof Error) throw new Error(`Could not get 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 fetch data from auth script: ${response.message}`);
|
||||
|
||||
const identify_function = Utils.getStringBetweenStrings(response.data, '=function(){var a=window.environment', '(function()');
|
||||
const client_identity = identify_function.match(this.identity_regex).groups;
|
||||
return client_identity;
|
||||
}
|
||||
|
||||
async refreshAccessToken (refresh_token) {
|
||||
const identity = await this.getClientIdentity();
|
||||
|
||||
|
||||
/**
|
||||
* Gets a new access token using a refresh token.
|
||||
* @returns {object} { credentials: { access_token: string, refresh_token: string, expires: string }, status: 'FAILED' | 'SUCCESS' }
|
||||
*/
|
||||
async refreshAccessToken() {
|
||||
const identity = await this.#getClientIdentity();
|
||||
|
||||
const data = {
|
||||
client_id: identity.id,
|
||||
client_secret: identity.secret,
|
||||
refresh_token,
|
||||
grant_type : 'refresh_token',
|
||||
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_reqopts).catch((error) => error);
|
||||
if (response instanceof Error)
|
||||
return this.emit('refresh-token', {
|
||||
error: 'Could not refresh token.',
|
||||
|
||||
const response = await Axios.post(this.oauth_token_url, JSON.stringify(data), Constants.OAUTH.HEADERS).catch((error) => error);
|
||||
if (response instanceof Error) {
|
||||
this.emit('auth', {
|
||||
error: 'Could not refresh access token.',
|
||||
status: 'FAILED'
|
||||
});
|
||||
|
||||
this.emit('refresh-token', {
|
||||
access_token: response.data.access_token,
|
||||
|
||||
return {
|
||||
credentials: {
|
||||
access_token: this.auth_info.access_token,
|
||||
refresh_token: this.auth_info.refresh_token,
|
||||
expires: this.auth_info.expires
|
||||
},
|
||||
status: 'FAILED'
|
||||
};
|
||||
}
|
||||
|
||||
const expiration_date = new Date(new Date().getTime() + response.data.expires_in * 1000);
|
||||
|
||||
return {
|
||||
credentials: {
|
||||
refresh_token: this.auth_info.refresh_token,
|
||||
access_token: response.data.access_token,
|
||||
expires: expiration_date
|
||||
},
|
||||
token_type: response.data.token_type,
|
||||
expires: response.data.expires_in,
|
||||
scope: response.data.scope,
|
||||
status: 'SUCCESS'
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
async checkTokenValidity(access_token, session) {
|
||||
let headers = Constants.innertube_request_opts({ session }).headers;
|
||||
headers.authorization = `Bearer ${access_token}`;
|
||||
|
||||
const response = await Axios.post(this.guide_url, JSON.stringify({ context : session.context }), { headers }).catch((error) => error);
|
||||
if (response instanceof Error) return 'INVALID';
|
||||
return 'VALID';
|
||||
|
||||
/**
|
||||
* Gets client identity data.
|
||||
* @returns {object} { id: string, secret: string }
|
||||
*/
|
||||
async #getClientIdentity() {
|
||||
// This request is made to get the auth script url, hard-coding it isn't viable as it changes overtime.
|
||||
const yttv_response = await Axios.get(`${Constants.URLS.YT_BASE_URL}/tv`, Constants.OAUTH.HEADERS).catch((error) => error);
|
||||
if (yttv_response instanceof Error) throw new Error(`Could not extract client identity: ${yttv_response.message}`);
|
||||
|
||||
// Here we download the script and extract the necessary data to proceed with the auth flow.
|
||||
const url_body = this.auth_script_regex.exec(yttv_response.data)[1];
|
||||
const script_url = `${Constants.URLS.YT_BASE_URL}/${url_body}`;
|
||||
|
||||
const response = await Axios.get(script_url, Constants.DEFAULT_HEADERS).catch((error) => error);
|
||||
if (response instanceof Error) throw new Error(`Could not extract client identity: ${response.message}`);
|
||||
|
||||
const client_identity = response.data.replace(/\n/g, '').match(this.identity_regex);
|
||||
return client_identity.groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks access token validity.
|
||||
* @returns {boolean} true | false
|
||||
*/
|
||||
isTokenValid() {
|
||||
const timestamp = new Date(this.auth_info.expires).getTime();
|
||||
const is_valid = new Date().getTime() < timestamp;
|
||||
return is_valid;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
206
lib/Parser.js
Normal file
206
lib/Parser.js
Normal file
@@ -0,0 +1,206 @@
|
||||
'use strict';
|
||||
|
||||
const Utils = require('./Utils');
|
||||
const Actions = require('./Actions');
|
||||
const Constants = require('./Constants');
|
||||
|
||||
/**
|
||||
* Takes raw data from the Innertube API and refines it.
|
||||
* Mainly used for video data and search results, as those are more complex to parse.
|
||||
*/
|
||||
class Parser {
|
||||
constructor(session, data, args = {}) {
|
||||
this.session = session;
|
||||
this.data = data;
|
||||
this.args = args;
|
||||
}
|
||||
|
||||
parse() {
|
||||
return this.args.client === 'YOUTUBE' ? ({
|
||||
SEARCH: () => this.#parseVideoSearch(),
|
||||
VIDEO_INFO: () => this.#parseVideoInfo()
|
||||
})[this.args.data_type]() : ({
|
||||
SEARCH: () => this.#parseMusicSearch(),
|
||||
SONG_INFO: () => { }
|
||||
})[this.args.data_type]();
|
||||
}
|
||||
|
||||
#parseVideoSearch() {
|
||||
const response = {};
|
||||
|
||||
const contents = this.data.contents.twoColumnSearchResultsRenderer
|
||||
.primaryContents.sectionListRenderer.contents[0].itemSectionRenderer
|
||||
.contents;
|
||||
|
||||
const continuation_token = this.data.contents.twoColumnSearchResultsRenderer
|
||||
.primaryContents.sectionListRenderer.contents[1].continuationItemRenderer
|
||||
.continuationEndpoint.continuationCommand.token;
|
||||
|
||||
response.search_metadata = {};
|
||||
response.search_metadata.query = contents[0].showingResultsForRenderer && contents[0].showingResultsForRenderer.originalQuery.simpleText || this.args.query;
|
||||
response.search_metadata.corrected_query = contents[0].showingResultsForRenderer && contents[0].showingResultsForRenderer.correctedQueryEndpoint.searchEndpoint.query || this.args.query;
|
||||
response.search_metadata.estimated_results = parseInt(this.data.estimatedResults);
|
||||
|
||||
response.videos = contents.map((data) => {
|
||||
if (!data.videoRenderer) return;
|
||||
const video = data.videoRenderer;
|
||||
return {
|
||||
title: video.title.runs[0].text,
|
||||
description: video.detailedMetadataSnippets && video.detailedMetadataSnippets[0].snippetText.runs.map((item) => item.text).join('') || 'N/A',
|
||||
author: video.ownerText.runs[0].text,
|
||||
id: video.videoId,
|
||||
url: `https://youtu.be/${video.videoId}`,
|
||||
channel_url: `${Constants.URLS.YT_BASE_URL}${video.ownerText.runs[0].navigationEndpoint.commandMetadata.webCommandMetadata.url}`,
|
||||
metadata: {
|
||||
view_count: video.viewCountText && video.viewCountText.simpleText || 'N/A',
|
||||
short_view_count_text: {
|
||||
simple_text: video.shortViewCountText && video.shortViewCountText.simpleText || 'N/A',
|
||||
accessibility_label: video.shortViewCountText && (video.shortViewCountText.accessibility && video.shortViewCountText.accessibility.accessibilityData.label || 'N/A') || 'N/A',
|
||||
},
|
||||
thumbnails: video.thumbnail.thumbnails,
|
||||
duration: {
|
||||
seconds: Utils.timeToSeconds(video.lengthText && video.lengthText.simpleText || '0'),
|
||||
simple_text: video.lengthText && video.lengthText.simpleText || 'N/A',
|
||||
accessibility_label: video.lengthText && video.lengthText.accessibility.accessibilityData.label || 'N/A'
|
||||
},
|
||||
published: video.publishedTimeText && video.publishedTimeText.simpleText || 'N/A',
|
||||
badges: video.badges && video.badges.map((item) => item.metadataBadgeRenderer.label) || 'N/A',
|
||||
owner_badges: video.ownerBadges && video.ownerBadges.map((item) => item.metadataBadgeRenderer.tooltip) || 'N/A '
|
||||
}
|
||||
};
|
||||
}).filter((video) => video);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
#parseMusicSearch() {
|
||||
const tabs = this.data.contents.tabbedSearchResultsRenderer.tabs;
|
||||
const contents = tabs[0].tabRenderer.content.sectionListRenderer.contents;
|
||||
|
||||
/**
|
||||
* WIP
|
||||
**/
|
||||
const getLyrics = async (id) => {
|
||||
// const data_continuation = await Actions.getContinuation(this.session, { video_id: id, ytmusic: true });
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const songs_ms = contents.find((content) => content.musicShelfRenderer.title.runs[0].text == 'Songs');
|
||||
const songs = songs_ms.musicShelfRenderer.contents.map((item) => {
|
||||
const list_item = item.musicResponsiveListItemRenderer;
|
||||
return {
|
||||
id: list_item.playlistItemData.videoId,
|
||||
title: list_item.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text,
|
||||
artist: list_item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[2].text,
|
||||
album: list_item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[4].text,
|
||||
duration: list_item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[6].text,
|
||||
thumbnail: list_item.thumbnail.musicThumbnailRenderer.thumbnail,
|
||||
getLyrics: () => getLyrics(list_item.playlistItemData.videoId)
|
||||
};
|
||||
});
|
||||
|
||||
const videos_ms = contents.find((content) => content.musicShelfRenderer.title.runs[0].text == 'Videos');
|
||||
const videos = videos_ms.musicShelfRenderer.contents.map((item) => {
|
||||
const list_item = item.musicResponsiveListItemRenderer;
|
||||
return {
|
||||
id: list_item.playlistItemData.videoId,
|
||||
title: list_item.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text,
|
||||
author: list_item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[2].text,
|
||||
views: list_item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[4].text,
|
||||
duration: list_item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[6].text,
|
||||
thumbnail: list_item.thumbnail.musicThumbnailRenderer.thumbnail,
|
||||
getLyrics: () => getLyrics(list_item.playlistItemData.videoId)
|
||||
};
|
||||
});
|
||||
|
||||
const albums_ms = contents.find((content) => content.musicShelfRenderer.title.runs[0].text == 'Albums');
|
||||
const albums = albums_ms.musicShelfRenderer.contents.map((item) => {
|
||||
const list_item = item.musicResponsiveListItemRenderer;
|
||||
return {
|
||||
title: list_item.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text,
|
||||
author: list_item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[2].text,
|
||||
year: list_item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs.find((run) => /^[12][0-9]{3}$/.test(run.text)).text,
|
||||
thumbnail: list_item.thumbnail.musicThumbnailRenderer.thumbnail,
|
||||
};
|
||||
});
|
||||
|
||||
return { songs, videos, albums };
|
||||
}
|
||||
|
||||
#parseVideoInfo() {
|
||||
const desktop_v = this.args.desktop_v;
|
||||
|
||||
const playability_status = desktop_v && this.data.playabilityStatus ||
|
||||
this.data[2].playerResponse.playabilityStatus;
|
||||
|
||||
if (playability_status.status == 'ERROR')
|
||||
throw new Error(`Could not retrieve details for this video: ${playability_status.status} - ${playability_status.reason}`);
|
||||
|
||||
const details = desktop_v && this.data.videoDetails ||
|
||||
this.data[2].playerResponse.videoDetails;
|
||||
|
||||
const microformat = desktop_v && this.data.microformat.playerMicroformatRenderer ||
|
||||
this.data[2].playerResponse.microformat.playerMicroformatRenderer;
|
||||
|
||||
const streaming_data = desktop_v && this.data.streamingData ||
|
||||
this.data[2].playerResponse.streamingData;
|
||||
|
||||
const response = {
|
||||
id: '',
|
||||
title: '',
|
||||
description: '',
|
||||
thumbnail: [],
|
||||
metadata: {}
|
||||
};
|
||||
|
||||
const mf_raw_data = Object.entries(microformat);
|
||||
const dt_raw_data = Object.entries(details);
|
||||
|
||||
mf_raw_data.forEach((entry) => {
|
||||
const key = Utils.camelToSnake(entry[0]);
|
||||
if (Constants.METADATA_KEYS.includes(key)) {
|
||||
key == 'view_count' && (response.metadata[key] = parseInt(entry[1])) ||
|
||||
key == 'owner_profile_url' && (response.metadata.channel_url = entry[1]) ||
|
||||
key == 'owner_channel_name' && (response.metadata.channel_name = entry[1]) ||
|
||||
(response.metadata[key] = entry[1]);
|
||||
} else {
|
||||
response[key] = entry[1];
|
||||
}
|
||||
});
|
||||
|
||||
dt_raw_data.forEach((entry) => {
|
||||
const key = Utils.camelToSnake(entry[0]);
|
||||
if (Constants.BLACKLISTED_KEYS.includes(key)) return;
|
||||
if (Constants.METADATA_KEYS.includes(key)) {
|
||||
key == 'view_count' && (response.metadata[key] = parseInt(entry[1])) ||
|
||||
(response.metadata[key] = entry[1]);
|
||||
} else {
|
||||
key == 'short_description' && (response.description = entry[1]) ||
|
||||
key == 'thumbnail' && (response.thumbnail = entry[1].thumbnails.slice(-1)[0]) ||
|
||||
key == 'video_id' && (response.id = entry[1]) ||
|
||||
(response[key] = entry[1]);
|
||||
}
|
||||
});
|
||||
|
||||
if (!desktop_v) {
|
||||
const dislike_available = this.data[3].response.contents.singleColumnWatchNextResults.results.results.contents[1]
|
||||
.slimVideoMetadataSectionRenderer.contents[1].slimVideoActionBarRenderer.buttons[1].slimMetadataToggleButtonRenderer
|
||||
.button.toggleButtonRenderer.defaultText.accessibility && true || false;
|
||||
|
||||
response.metadata.likes = parseInt(this.data[3].response.contents.singleColumnWatchNextResults.results.results.contents[1]
|
||||
.slimVideoMetadataSectionRenderer.contents[1].slimVideoActionBarRenderer.buttons[0].slimMetadataToggleButtonRenderer
|
||||
.button.toggleButtonRenderer.defaultText.accessibility.accessibilityData.label.replace(/\D/g, ''));
|
||||
|
||||
response.metadata.dislikes = dislike_available && parseInt(this.data[3].response.contents.singleColumnWatchNextResults.results.results.contents[1]
|
||||
.slimVideoMetadataSectionRenderer.contents[1].slimVideoActionBarRenderer.buttons[1].slimMetadataToggleButtonRenderer
|
||||
.button.toggleButtonRenderer.defaultText.accessibility.accessibilityData.label.replace(/\D/g, '')) || 0;
|
||||
}
|
||||
|
||||
response.metadata.available_qualities = [...new Set(streaming_data.adaptiveFormats.filter(v => v.qualityLabel)
|
||||
.map(v => v.qualityLabel).sort((a, b) => + a.replace(/\D/gi, '') - + b.replace(/\D/gi, '')))];
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Parser;
|
||||
@@ -1,43 +1,45 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const Fs = require('fs');
|
||||
const Axios = require('axios');
|
||||
const Utils = require('./Utils');
|
||||
const Constants = require('./Constants');
|
||||
|
||||
class Player {
|
||||
constructor(innertube_session) {
|
||||
this.session = innertube_session;
|
||||
constructor(session) {
|
||||
this.session = session;
|
||||
this.player_name = Utils.getStringBetweenStrings(this.session.player_url, '/player/', '/');
|
||||
this.tmp_cache_dir = __dirname.slice(0, -3) + 'cache';
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (fs.existsSync(this.tmp_cache_dir + '/' + this.player_name + '.js')) {
|
||||
const player_data = fs.readFileSync(this.tmp_cache_dir + '/' + this.player_name + '.js').toString();
|
||||
this.getSigDecipherCode(player_data);
|
||||
this.getNEncoder(player_data);
|
||||
if (Fs.existsSync(`${this.tmp_cache_dir}/${this.player_name}.js`)) {
|
||||
const player_data = Fs.readFileSync(`${this.tmp_cache_dir}/${this.player_name}.js`).toString();
|
||||
this.sig_decipher_sc = this.#getSigDecipherCode(player_data);
|
||||
this.ntoken_sc = this.#getNEncoder(player_data);
|
||||
} else {
|
||||
const response = await Axios.get(Constants.urls.YT_BASE_URL + this.session.player_url, { path: this.session.playerUrl, headers: { 'content-type': 'text/javascript', 'user-agent': Utils.getRandomUserAgent('desktop').userAgent } }).catch((error) => error);
|
||||
if (response instanceof Error) throw new Error('Could not get player data: ' + response.message);
|
||||
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);
|
||||
|
||||
fs.mkdirSync(this.tmp_cache_dir, { recursive: true });
|
||||
fs.writeFileSync(this.tmp_cache_dir + '/' + this.player_name + '.js', response.data);
|
||||
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);
|
||||
this.sig_decipher_sc = this.#getSigDecipherCode(response.data);
|
||||
this.ntoken_sc = this.#getNEncoder(response.data);
|
||||
}
|
||||
}
|
||||
|
||||
getSigDecipherCode(data) {
|
||||
const actions_algorithm_code = Utils.getStringBetweenStrings(data, 'this.audioTracks};var', '};');
|
||||
const actions_sequence_code = Utils.getStringBetweenStrings(data, 'function(a){a=a.split("")', 'return a.join("")}');
|
||||
this.sig_decipher_sc = actions_algorithm_code + actions_sequence_code;
|
||||
#getSigDecipherCode(data) {
|
||||
const sig_alg_sc = Utils.getStringBetweenStrings(data, 'this.audioTracks};var', '};');
|
||||
const sig_data = Utils.getStringBetweenStrings(data, 'function(a){a=a.split("")', 'return a.join("")}');
|
||||
return sig_alg_sc + sig_data;
|
||||
}
|
||||
|
||||
getNEncoder(data) {
|
||||
const raw_code = 'var b=a.split("")' + Utils.getStringBetweenStrings(data, 'b=a.split("")', '}return b.join("")}') + '} return b.join("");';
|
||||
this.encodeN = Utils.createFunction('a', raw_code);
|
||||
#getNEncoder(data) {
|
||||
return `var b=a.split("")${Utils.getStringBetweenStrings(data, 'b=a.split("")', '}return b.join("")}')}} return b.join("");`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,29 +1,32 @@
|
||||
'use strict';
|
||||
|
||||
const NToken = require('./NToken');
|
||||
const QueryString = require('querystring');
|
||||
|
||||
class SigDecipher {
|
||||
constructor(url, cver, func_code, encode_n) {
|
||||
constructor(url, cver, player) {
|
||||
this.url = url;
|
||||
this.cver = cver;
|
||||
this.func_code = func_code;
|
||||
this.encode_n = encode_n;
|
||||
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);
|
||||
}
|
||||
|
||||
function swap(arr, position) {
|
||||
function swap(arr, index) {
|
||||
let origArrI = arr[0];
|
||||
arr[0] = arr[position % arr.length];
|
||||
arr[position % arr.length] = origArrI;
|
||||
arr[0] = arr[index % arr.length];
|
||||
arr[index % arr.length] = origArrI;
|
||||
}
|
||||
|
||||
function reverse(arr) {
|
||||
@@ -33,7 +36,7 @@ class SigDecipher {
|
||||
let actions;
|
||||
let signature = args.s.split('');
|
||||
|
||||
while ((actions = this.actions_regex.exec(this.func_code)) !== null) {
|
||||
while ((actions = this.actions_regex.exec(this.player.sig_decipher_sc)) !== null) {
|
||||
switch (actions[1]) {
|
||||
case functions[0]:
|
||||
reverse(signature, actions[2]);
|
||||
@@ -52,15 +55,15 @@ class SigDecipher {
|
||||
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', this.encode_n(url_components.searchParams.get('n')));
|
||||
url_components.searchParams.set('n', new NToken(this.player.ntoken_sc).transform(url_components.searchParams.get('n')));
|
||||
return url_components.toString();
|
||||
}
|
||||
|
||||
getFunctions() {
|
||||
#getFunctions() {
|
||||
let func;
|
||||
let func_name = [];
|
||||
|
||||
while ((func = this.func_regex.exec(this.func_code)) !== null) {
|
||||
while ((func = this.func_regex.exec(this.player.sig_decipher_sc)) !== null) {
|
||||
if (func[0].includes('reverse()')) {
|
||||
func_name[0] = func[1];
|
||||
} else if (func[0].includes('splice')) {
|
||||
138
lib/Utils.js
138
lib/Utils.js
@@ -1,8 +1,15 @@
|
||||
'use strict';
|
||||
|
||||
const Fs = require('fs');
|
||||
const Proto = require('protons');
|
||||
const Crypto = require('crypto');
|
||||
const UserAgent = require('user-agents');
|
||||
|
||||
/**
|
||||
* Returns a random user agent.
|
||||
*
|
||||
* @param {string} type mobile | desktop
|
||||
*/
|
||||
function getRandomUserAgent(type) {
|
||||
switch (type) {
|
||||
case 'mobile':
|
||||
@@ -13,6 +20,11 @@ function getRandomUserAgent(type) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an authentication token from a cookies' sid.
|
||||
*
|
||||
* @param {string} sid Sid extracted from cookies
|
||||
*/
|
||||
function generateSidAuth(sid) {
|
||||
const youtube = 'https://www.youtube.com';
|
||||
const timestamp = Math.floor(new Date().getTime() / 1000);
|
||||
@@ -25,6 +37,14 @@ function generateSidAuth(sid) {
|
||||
return ['SAPISIDHASH', [timestamp, gen_hash].join('_')].join(' ');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Gets a string between two delimiters.
|
||||
*
|
||||
* @param {string} data The data.
|
||||
* @param {string} start_string Start string.
|
||||
* @param {string} end_string End string.
|
||||
*/
|
||||
function getStringBetweenStrings(data, start_string, end_string) {
|
||||
const regex = new RegExp(`${escapeStringRegexp(start_string)}(.*?)${escapeStringRegexp(end_string)}`, "s");
|
||||
const match = data.match(regex);
|
||||
@@ -35,12 +55,120 @@ function escapeStringRegexp(string) {
|
||||
return string.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&").replace(/-/g, "\\x2d");
|
||||
}
|
||||
|
||||
function createFunction(input, raw_code) { // I hate this
|
||||
return new Function(input, raw_code);
|
||||
/**
|
||||
* Converts time (h:m:s) to seconds.
|
||||
*
|
||||
* @param {string} time
|
||||
* @returns {string} seconds
|
||||
*/
|
||||
function timeToSeconds(time) {
|
||||
let params = time.split(':');
|
||||
return parseInt(({
|
||||
3: +params[0] * 3600 + +params[1] * 60 + +params[2],
|
||||
2: +params[0] * 60 + +params[1],
|
||||
1: +params[0]
|
||||
})[params.length]);
|
||||
}
|
||||
|
||||
function encodeId(id) {
|
||||
return encodeURI(new Buffer.from(`` + id + `*`).toString('base64').replace('==', '') + 'BQBw==');
|
||||
/**
|
||||
* Converts strings in camelCase to snake_case.
|
||||
*
|
||||
* @param {string} string The string in camelCase.
|
||||
*/
|
||||
function camelToSnake(string) {
|
||||
return string[0].toLowerCase() + string.slice(1, string.length).replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
|
||||
}
|
||||
|
||||
module.exports = { getRandomUserAgent, generateSidAuth, getStringBetweenStrings, createFunction, encodeId };
|
||||
/**
|
||||
* Encodes notification preferences protobuf.
|
||||
*
|
||||
* @param {string} channel_id
|
||||
* @param {string} index
|
||||
*/
|
||||
function encodeNotificationPref(channel_id, index) {
|
||||
const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/proto/youtube.proto`));
|
||||
|
||||
const buf = youtube_proto.NotificationPreferences.encode({
|
||||
channel_id,
|
||||
pref_id: {
|
||||
index
|
||||
},
|
||||
number_0: 0,
|
||||
number_1: 4
|
||||
});
|
||||
|
||||
return encodeURIComponent(Buffer.from(buf).toString('base64'));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Encodes livestream message protobuf.
|
||||
*
|
||||
* @param {string} channel_id
|
||||
* @param {string} video_id
|
||||
*/
|
||||
function encodeMessageParams(channel_id, video_id) {
|
||||
const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/proto/youtube.proto`));
|
||||
|
||||
const buf = youtube_proto.LiveMessageParams.encode({
|
||||
params: {
|
||||
ids: {
|
||||
channel_id,
|
||||
video_id
|
||||
}
|
||||
},
|
||||
number_0: 1,
|
||||
number_1: 4
|
||||
});
|
||||
|
||||
return Buffer.from(encodeURIComponent(Buffer.from(buf).toString('base64'))).toString('base64');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Encodes comment params protobuf.
|
||||
*
|
||||
* @param {string} video_id
|
||||
*/
|
||||
function encodeCommentParams(video_id) {
|
||||
const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/proto/youtube.proto`));
|
||||
|
||||
const buf = youtube_proto.CreateCommentParams.encode({
|
||||
video_id,
|
||||
params: {
|
||||
index: 0
|
||||
},
|
||||
number: 7
|
||||
});
|
||||
|
||||
return encodeURIComponent(Buffer.from(buf).toString('base64'));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Encodes search filter protobuf
|
||||
*
|
||||
* @param {string} period Period in which a video is uploaded: any | hour | day | week | month | year
|
||||
* @param {string} duration The duration of a video: any | short | long
|
||||
* @param {string} order The order of the search results: relevance | rating | age | views
|
||||
*/
|
||||
function encodeFilter(period, duration, order) {
|
||||
const youtube_proto = Proto(Fs.readFileSync(`${__dirname}/proto/youtube.proto`));
|
||||
|
||||
const periods = { 'any': null, 'hour': 1, 'day': 2, 'week': 3, 'month': 4, 'year': 5 };
|
||||
const durations = { 'any': null, 'short' : 1, 'long': 2 };
|
||||
const orders = { 'relevance': null, 'rating': 1, 'age': 2, 'views' : 3 };
|
||||
|
||||
const search_filter_buff = youtube_proto.SearchFilter.encode({
|
||||
number: orders[order],
|
||||
filter: {
|
||||
param_0: periods[period],
|
||||
param_1: (period == 'hour' && order == 'relevance') ? null : 1,
|
||||
param_2: durations[duration]
|
||||
}
|
||||
});
|
||||
|
||||
return encodeURIComponent(Buffer.from(search_filter_buff).toString('base64'));
|
||||
}
|
||||
|
||||
module.exports = { getRandomUserAgent, generateSidAuth, getStringBetweenStrings, camelToSnake, timeToSeconds, encodeMessageParams, encodeCommentParams, encodeNotificationPref, encodeFilter };
|
||||
44
lib/proto/youtube.proto
Normal file
44
lib/proto/youtube.proto
Normal file
@@ -0,0 +1,44 @@
|
||||
syntax = "proto2";
|
||||
package proto;
|
||||
|
||||
message NotificationPreferences {
|
||||
string channel_id = 1;
|
||||
message Preference {
|
||||
int32 index = 1;
|
||||
}
|
||||
Preference pref_id = 2;
|
||||
int32 number_0 = 3;
|
||||
int32 number_1 = 4;
|
||||
}
|
||||
|
||||
message LiveMessageParams {
|
||||
message Params {
|
||||
message Ids {
|
||||
string channel_id = 1;
|
||||
string video_id = 2;
|
||||
}
|
||||
Ids ids = 5;
|
||||
}
|
||||
Params params = 1;
|
||||
int32 number_0 = 2;
|
||||
int32 number_1 = 3;
|
||||
}
|
||||
|
||||
message CreateCommentParams {
|
||||
string video_id = 2;
|
||||
message Params {
|
||||
int32 index = 1;
|
||||
}
|
||||
Params params = 5;
|
||||
int32 number = 10;
|
||||
}
|
||||
|
||||
message SearchFilter {
|
||||
int32 number = 1;
|
||||
message Filter {
|
||||
int32 param_0 = 1;
|
||||
int32 param_1 = 2;
|
||||
int32 param_2 = 3;
|
||||
}
|
||||
Filter filter = 2;
|
||||
}
|
||||
134
package-lock.json
generated
134
package-lock.json
generated
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"name": "youtubei.js",
|
||||
"version": "1.0.5",
|
||||
"version": "1.2.9",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "youtubei.js",
|
||||
"version": "1.0.5",
|
||||
"version": "1.2.9",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^0.21.4",
|
||||
"time-to-seconds": "^1.1.5",
|
||||
"protons": "^2.0.3",
|
||||
"user-agents": "^1.0.778",
|
||||
"uuid": "^8.3.2"
|
||||
}
|
||||
@@ -53,9 +53,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.14.4",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.4.tgz",
|
||||
"integrity": "sha512-zwGkiSXC1MUJG/qmeIFH2HBJx9u0V46QGUe3YR1fXG8bXQxq7fLj0RjLZQ5nubr9qNJUZrH+xUcwXEoXNpfS+g==",
|
||||
"version": "1.14.7",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.7.tgz",
|
||||
"integrity": "sha512-+hbxoLbFMbRKDwohX8GkTataGqO6Jb7jGwpAlwgy2bIz25XtRm7KEzJM76R1WiNT5SwZkX4Y75SwBolkpmE7iQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -76,19 +76,47 @@
|
||||
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
|
||||
"integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8="
|
||||
},
|
||||
"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/multiformats": {
|
||||
"version": "9.6.2",
|
||||
"resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.6.2.tgz",
|
||||
"integrity": "sha512-1dKng7RkBelbEZQQD2zvdzYKgUmtggpWl+GXQBYhnEGGkV6VIYfWgV3VSeyhcUFFEelI5q4D0etCJZ7fbuiamQ=="
|
||||
},
|
||||
"node_modules/protocol-buffers-schema": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz",
|
||||
"integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw=="
|
||||
},
|
||||
"node_modules/protons": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/protons/-/protons-2.0.3.tgz",
|
||||
"integrity": "sha512-j6JikP/H7gNybNinZhAHMN07Vjr1i4lVupg598l4I9gSTjJqOvKnwjzYX2PzvBTSVf2eZ2nWv4vG+mtW8L6tpA==",
|
||||
"dependencies": {
|
||||
"protocol-buffers-schema": "^3.3.1",
|
||||
"signed-varint": "^2.0.1",
|
||||
"uint8arrays": "^3.0.0",
|
||||
"varint": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/signed-varint": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/signed-varint/-/signed-varint-2.0.1.tgz",
|
||||
"integrity": "sha1-UKmYnafJjCxh2tEZvJdHDvhSgSk=",
|
||||
"dependencies": {
|
||||
"varint": "~5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uint8arrays": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.0.0.tgz",
|
||||
"integrity": "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==",
|
||||
"dependencies": {
|
||||
"multiformats": "^9.4.2"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -99,9 +127,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/user-agents": {
|
||||
"version": "1.0.801",
|
||||
"resolved": "https://registry.npmjs.org/user-agents/-/user-agents-1.0.801.tgz",
|
||||
"integrity": "sha512-giB7GP2g71STtQaYbSDpd5T+XzbGr5ni+1NpEbeQnifnFiOIQeQonXOC2kDxGKvubzul6qQb/BwG9LlIQ1zxXA==",
|
||||
"version": "1.0.912",
|
||||
"resolved": "https://registry.npmjs.org/user-agents/-/user-agents-1.0.912.tgz",
|
||||
"integrity": "sha512-vvFMlK3XcaF/ZRF1Ky4IrBASSNXflZys+5ArPRanNeggWtOYeD2k3FD/6wrv9h7lFlvh5KS+X45E/siw26+EJg==",
|
||||
"dependencies": {
|
||||
"dot-json": "^1.2.2",
|
||||
"lodash.clonedeep": "^4.5.0"
|
||||
@@ -114,6 +142,11 @@
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/varint": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/varint/-/varint-5.0.2.tgz",
|
||||
"integrity": "sha512-lKxKYG6H03yCZUpAGOPOsMcGxd1RHCu1iKvEHYDPmTyq2HueGhD73ssNBqqQWfvYs04G9iUFRvmAVLW20Jw6ow=="
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -146,24 +179,56 @@
|
||||
}
|
||||
},
|
||||
"follow-redirects": {
|
||||
"version": "1.14.4",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.4.tgz",
|
||||
"integrity": "sha512-zwGkiSXC1MUJG/qmeIFH2HBJx9u0V46QGUe3YR1fXG8bXQxq7fLj0RjLZQ5nubr9qNJUZrH+xUcwXEoXNpfS+g=="
|
||||
"version": "1.14.7",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.7.tgz",
|
||||
"integrity": "sha512-+hbxoLbFMbRKDwohX8GkTataGqO6Jb7jGwpAlwgy2bIz25XtRm7KEzJM76R1WiNT5SwZkX4Y75SwBolkpmE7iQ=="
|
||||
},
|
||||
"lodash.clonedeep": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
|
||||
"integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8="
|
||||
},
|
||||
"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=="
|
||||
"multiformats": {
|
||||
"version": "9.6.2",
|
||||
"resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.6.2.tgz",
|
||||
"integrity": "sha512-1dKng7RkBelbEZQQD2zvdzYKgUmtggpWl+GXQBYhnEGGkV6VIYfWgV3VSeyhcUFFEelI5q4D0etCJZ7fbuiamQ=="
|
||||
},
|
||||
"protocol-buffers-schema": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz",
|
||||
"integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw=="
|
||||
},
|
||||
"protons": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/protons/-/protons-2.0.3.tgz",
|
||||
"integrity": "sha512-j6JikP/H7gNybNinZhAHMN07Vjr1i4lVupg598l4I9gSTjJqOvKnwjzYX2PzvBTSVf2eZ2nWv4vG+mtW8L6tpA==",
|
||||
"requires": {
|
||||
"protocol-buffers-schema": "^3.3.1",
|
||||
"signed-varint": "^2.0.1",
|
||||
"uint8arrays": "^3.0.0",
|
||||
"varint": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"signed-varint": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/signed-varint/-/signed-varint-2.0.1.tgz",
|
||||
"integrity": "sha1-UKmYnafJjCxh2tEZvJdHDvhSgSk=",
|
||||
"requires": {
|
||||
"varint": "~5.0.0"
|
||||
}
|
||||
},
|
||||
"uint8arrays": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.0.0.tgz",
|
||||
"integrity": "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==",
|
||||
"requires": {
|
||||
"multiformats": "^9.4.2"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -174,9 +239,9 @@
|
||||
}
|
||||
},
|
||||
"user-agents": {
|
||||
"version": "1.0.801",
|
||||
"resolved": "https://registry.npmjs.org/user-agents/-/user-agents-1.0.801.tgz",
|
||||
"integrity": "sha512-giB7GP2g71STtQaYbSDpd5T+XzbGr5ni+1NpEbeQnifnFiOIQeQonXOC2kDxGKvubzul6qQb/BwG9LlIQ1zxXA==",
|
||||
"version": "1.0.912",
|
||||
"resolved": "https://registry.npmjs.org/user-agents/-/user-agents-1.0.912.tgz",
|
||||
"integrity": "sha512-vvFMlK3XcaF/ZRF1Ky4IrBASSNXflZys+5ArPRanNeggWtOYeD2k3FD/6wrv9h7lFlvh5KS+X45E/siw26+EJg==",
|
||||
"requires": {
|
||||
"dot-json": "^1.2.2",
|
||||
"lodash.clonedeep": "^4.5.0"
|
||||
@@ -186,6 +251,11 @@
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="
|
||||
},
|
||||
"varint": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/varint/-/varint-5.0.2.tgz",
|
||||
"integrity": "sha512-lKxKYG6H03yCZUpAGOPOsMcGxd1RHCu1iKvEHYDPmTyq2HueGhD73ssNBqqQWfvYs04G9iUFRvmAVLW20Jw6ow=="
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
10
package.json
10
package.json
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "youtubei.js",
|
||||
"version": "1.0.5",
|
||||
"version": "1.2.9",
|
||||
"description": "An object-oriented library that allows you to search, get detailed info about videos, subscribe, unsubscribe, like, dislike, comment, download videos and much more!",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
"test": "node test"
|
||||
},
|
||||
"author": "LuanRT",
|
||||
"license": "MIT",
|
||||
@@ -14,7 +14,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^0.21.4",
|
||||
"time-to-seconds": "^1.1.5",
|
||||
"protons": "^2.0.3",
|
||||
"user-agents": "^1.0.778",
|
||||
"uuid": "^8.3.2"
|
||||
},
|
||||
@@ -25,15 +25,17 @@
|
||||
"keywords": [
|
||||
"youtube",
|
||||
"youtube-dl",
|
||||
"youtubedl",
|
||||
"innertube",
|
||||
"innertubeapi",
|
||||
"livechat",
|
||||
"api",
|
||||
"search",
|
||||
"like",
|
||||
"dislike",
|
||||
"comment",
|
||||
"downloader",
|
||||
"automation",
|
||||
"comments-section",
|
||||
"youtube-downloader"
|
||||
],
|
||||
"bugs": {
|
||||
|
||||
24
test/constants.js
Normal file
24
test/constants.js
Normal file
@@ -0,0 +1,24 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
test_url: 's=t%3DQ%3DAv2TLJ2sbQFV5msp4j7v71gS1rsXNd6QH2V1KpxGlaOD%3DIC46mVzTVTW_2zttE32HKH7XO1jkyfOJs58avqMLKdvRdgIQRw8JQ0qOA&sp=sig&url=https://r1---sn-hxtxgcg-8qjl.googlevideo.com/videoplayback%3Fexpire%3D1635863482%26ei%3DWveAYdqsB6KPobIPjtWwYA%26ip%3D128.201.98.50%26id%3Do-ABuHwkfRnd4hOQoDKRKn7ZHvuLEPAPKkYhiYKpTwLrY7%26itag%3D18%26source%3Dyoutube%26requiressl%3Dyes%26mh%3DG3%26mm%3D31%252C29%26mn%3Dsn-hxtxgcg-8qjl%252Csn-gpv7dned%26ms%3Dau%252Crdu%26mv%3Dm%26mvi%3D1%26pl%3D24%26initcwndbps%3D397500%26vprv%3D1%26mime%3Dvideo%252Fmp4%26ns%3Dv9CYauI2ycUgrV6wOERCNxsG%26gir%3Dyes%26clen%3D7275579%26ratebypass%3Dyes%26dur%3D218.290%26lmt%3D1540416860737282%26mt%3D1635841731%26fvip%3D4%26fexp%3D24001373%252C24007246%26c%3DWEB%26txp%3D5531432%26n%3DD8yGa-DC5m2Dwv--%26sparams%3Dexpire%252Cei%252Cip%252Cid%252Citag%252Csource%252Crequiressl%252Cvprv%252Cmime%252Cns%252Cgir%252Cclen%252Cratebypass%252Cdur%252Clmt%26lsparams%3Dmh%252Cmm%252Cmn%252Cms%252Cmv%252Cmvi%252Cpl%252Cinitcwndbps%26lsig%3DAG3C_xAwRAIgdS6ux5rh5ulfwh8c6_Kt2cOdyS51OxPlxSUoB5k5x9YCICOgRiuFsZwAqJmxvBrCuq3CKk1S4YeAxEq3zPLvzAvX',
|
||||
expected_url: 'https://r1---sn-hxtxgcg-8qjl.googlevideo.com/videoplayback?expire=1635863482&ei=WveAYdqsB6KPobIPjtWwYA&ip=128.201.98.50&id=o-ABuHwkfRnd4hOQoDKRKn7ZHvuLEPAPKkYhiYKpTwLrY7&itag=18&source=youtube&requiressl=yes&mh=G3&mm=31%2C29&mn=sn-hxtxgcg-8qjl%2Csn-gpv7dned&ms=au%2Crdu&mv=m&mvi=1&pl=24&initcwndbps=397500&vprv=1&mime=video%2Fmp4&ns=v9CYauI2ycUgrV6wOERCNxsG&gir=yes&clen=7275579&ratebypass=yes&dur=218.290&lmt=1540416860737282&mt=1635841731&fvip=4&fexp=24001373%2C24007246&c=WEB&txp=5531432&n=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',
|
||||
original_ntoken: 'PqjqqJjdB9K821VIisj',
|
||||
expected_ntoken: 'AxwyS-osUl1WhMUd1',
|
||||
client_version: '2.20211101.01.00',
|
||||
test_video_id: 'dQw4w9WgXcQ',
|
||||
sig_decipher_sc: `fB={RP:function(a,b){a.splice(0,b)},
|
||||
Td:function(a){a.reverse()},
|
||||
kq:function(a,b){var c=a[0];a[0]=a[b%a.length];a[b%a.length]=c};fB.kq(a,35);fB.RP(a,2);fB.kq(a,46);fB.Td(a,6);`,
|
||||
n_scramble_sc: `var b=a.split(""),c=[-470482026,-691770757,function(d,e){e=(e%d.length+d.length)%d.length;d.splice(-e).reverse().forEach(function(f){d.unshift(f)})},
|
||||
b,258876269,-1426380890,318754300,-68090711,-2064438462,-1886316521,1913911047,1635047330,function(d,e){e=(e%d.length+d.length)%d.length;d.splice(-e).reverse().forEach(function(f){d.unshift(f)})},
|
||||
-1815897225,1940621629,-714586149,-1723898467,null,778601498,2145333248,1245726977,1952270083,268207944,244274044,null,function(d,e){e=(e%d.length+d.length)%d.length;d.splice(0,1,d.splice(e,1,d[0])[0])},
|
||||
null,-762271981,604636391,1087224318,-931565987,-338396815,function(d,e){for(e=(e%d.length+d.length)%d.length;e--;)d.unshift(d.pop())},
|
||||
2126741474,function(d){for(var e=d.length;e;)d.push(d.splice(--e,1)[0])},
|
||||
-1874551858,-1238260579,498106911,1913911047,-1951114300,-504396507,b,344510945,905306344,b,function(d,e){e=(e%d.length+d.length)%d.length;var f=d[0];d[0]=d[e];d[e]=f},
|
||||
909033134,1027812119,1686673079,function(d,e){d.push(e)},
|
||||
-1902376100,function(d,e){e=(e%d.length+d.length)%d.length;d.splice(e,1)},
|
||||
"push",function(d,e){for(var f=64,h=[];++f-h.length-32;)switch(f){case 58:f=96;continue;case 91:f=44;break;case 65:f=47;continue;case 46:f=153;case 123:f-=58;default:h.push(String.fromCharCode(f))}d.forEach(function(l,m,n){this.push(n[m]=h[(h.indexOf(l)-h.indexOf(this[m])+m-32+f--)%h.length])},e.split(""))}];
|
||||
c[17]=c;c[24]=c;c[26]=c;try{c[45](c[17],c[38]),c[12](c[44],c[29]),c[45](c[26],c[0]),c[51](c[41],c[13]),c[12](c[41],c[27]),c[12](c[26],c[11]),c[39](c[17],c[49]),c[9](c[38],c[47]),c[26](c[40],c[0]),c[7](c[8],c[44]),c[14](c[54],c[0]),c[18](c[3],c[25]),c[7](c[33],c[36]),c[15](c[19],c[14]),c[7](c[19],c[9]),c[7](c[6],c[12]),c[41](c[33],c[35]),c[7](c[40],c[5]),c[50](c[42]),c[13](c[14],c[17]),c[6](c[35],c[51]),c[26](c[48],c[50]),c[26](c[35],c[0]),c[6](c[21],c[46]),c[15](c[21],c[42]),c[1](c[2],c[43]),c[15](c[2],
|
||||
c[31]),c[1](c[21],c[25]),c[22](c[30],c[17]),c[15](c[44],c[46]),c[22](c[44],c[11]),c[22](c[23],c[38]),c[1](c[23],c[14]),c[35](c[23],c[44]),c[11](c[53],c[20]),c[9](c[51]),c[31](c[51],c[28]),c[18](c[51],c[35]),c[46](c[53],c[6]),c[52](c[51],c[49]),c[11](c[53],c[15])}catch(d){return"enhanced_except_75MBkOz-_w8_"+a} return b.join("");`
|
||||
};
|
||||
61
test/index.js
Normal file
61
test/index.js
Normal file
@@ -0,0 +1,61 @@
|
||||
'use strict';
|
||||
|
||||
const Fs = require('fs');
|
||||
const Innertube = require('..');
|
||||
const NToken = require('../lib/NToken');
|
||||
const SigDecipher = require('../lib/Sig');
|
||||
const Constants = require('./constants');
|
||||
|
||||
let failed_tests = 0;
|
||||
|
||||
async function performTests() {
|
||||
const youtube = await new Innertube().catch((error) => error);
|
||||
assert(youtube instanceof Error ? false : true, `should retrieve Innertube configuration data`, youtube);
|
||||
|
||||
if (!(youtube instanceof Error)) {
|
||||
const search = await youtube.search('Carl Sagan - Documentary').catch((error) => error);
|
||||
assert(!(search instanceof Error) && search.videos.length >= 1, `should search videos`, search);
|
||||
|
||||
const details = await youtube.getDetails(Constants.test_video_id).catch((error) => error);
|
||||
assert(!(details instanceof Error), `should retrieve details for ${Constants.test_video_id}`, details);
|
||||
|
||||
const comments = await youtube.getComments(Constants.test_video_id).catch((error) => error);
|
||||
assert(!(comments instanceof Error), `should retrieve comments for ${Constants.test_video_id}`, comments);
|
||||
|
||||
const video = await downloadVideo(Constants.test_video_id, youtube).catch((error) => error);
|
||||
assert(!(video instanceof Error), `should download video (${Constants.test_video_id})`, video);
|
||||
}
|
||||
|
||||
|
||||
const n_token = new NToken(Constants.n_scramble_sc).transform(Constants.original_ntoken);
|
||||
assert(n_token == Constants.expected_ntoken, `should transform n token into ${Constants.expected_ntoken}`, n_token);
|
||||
|
||||
const transformed_url = new SigDecipher(Constants.test_url, Constants.client_version, { sig_decipher_sc: Constants.sig_decipher_sc, ntoken_sc: Constants.n_scramble_sc }).decipher();
|
||||
assert(transformed_url == Constants.expected_url, `should correctly decipher signature`, transformed_url);
|
||||
|
||||
if (failed_tests > 0)
|
||||
throw new Error('Some tests have failed');
|
||||
}
|
||||
|
||||
function downloadVideo(id, youtube) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let got_video_info = false;
|
||||
const stream = youtube.download(id, { type: 'videoandaudio' });
|
||||
stream.pipe(Fs.createWriteStream(`./${id}.mp4`));
|
||||
stream.on('end', () => Fs.existsSync(`./${id}.mp4`) && got_video_info && resolve() || reject());
|
||||
stream.on('info', () => got_video_info = true);
|
||||
stream.on('error', (err) => reject(err));
|
||||
});
|
||||
}
|
||||
|
||||
function assert(outcome, description, data) {
|
||||
const pass_fail = outcome ? 'pass' : 'fail';
|
||||
|
||||
console.info(pass_fail, ':', description);
|
||||
!outcome && (failed_tests += 1);
|
||||
!outcome && console.error('Error: ', data);
|
||||
|
||||
return outcome;
|
||||
}
|
||||
|
||||
performTests();
|
||||
Reference in New Issue
Block a user